Se você está acostumado com GKE Standard e resolveu migrar para o Autopilot — ou está começando do zero — prepare-se: o Autopilot tem regras próprias que podem transformar um deploy simples em horas de troubleshooting. Este guia reúne tudo que aprendemos rodando APIs .NET em produção no GKE Autopilot, incluindo as armadilhas que a documentação oficial não deixa óbvias.

GKE Autopilot vs Standard: o que muda na prática

No GKE Standard, você gerencia node pools, escolhe machine types, configura auto-scaling de nós e lida com patches de OS. No Autopilot, o Google assume tudo isso. Você declara os pods, e o GKE provisiona a infraestrutura automaticamente.

Parece ótimo, mas tem consequências diretas:

  • Você não tem acesso SSH aos nós
  • Não pode criar DaemonSets arbitrários (apenas os permitidos)
  • O Autopilot modifica seus resource requests se estiverem fora dos limites
  • Você paga por pod (recursos solicitados), não por nó
  • Não existe privileged: true — o Autopilot bloqueia containers privilegiados
  • O armazenamento temporário (ephemeral storage) tem limites restritos (máximo 10 GiB por padrão)

A mudança de mentalidade é sutil mas importante: no Standard, você otimiza nós. No Autopilot, você otimiza pods.

Resource Requests: a parte que mais pega

Este é o ponto onde 90% dos problemas acontecem. O Autopilot tem regras rígidas de recursos que você precisa entender antes de fazer o primeiro kubectl apply.

Mínimos obrigatórios

Para a classe de computação padrão (uso geral), os mínimos são:

RecursoMínimo (com bursting)Mínimo (sem bursting)
CPU50m250m
Memória52 MiB512 MiB

Se o seu cluster suporta bursting (versões mais recentes do GKE), os mínimos são bem menores. Sem bursting, o Autopilot arredonda CPU para o múltiplo de 250m mais próximo.

Proporção CPU:Memória

O Autopilot exige que a proporção CPU:Memória fique entre 1:1 e 1:6.5 na classe de uso geral. Se você pedir 1 vCPU e 100Mi de memória (proporção ~1:0.1), o Autopilot vai aumentar automaticamente a memória para pelo menos 1 GiB.

Isso significa que um Deployment assim:

resources:
  requests:
    cpu: "500m"
    memory: "100Mi"

Será silenciosamente modificado para algo como:

resources:
  requests:
    cpu: "500m"
    memory: "500Mi" # Autopilot ajustou para manter a proporção mínima 1:1

Você descobre isso ao rodar kubectl describe pod e ver os valores diferentes do que declarou.

Defaults quando você não declara nada

Se não especificar requests, o Autopilot aplica os defaults:

  • CPU: 0.5 vCPU
  • Memória: 2 GiB
  • Armazenamento temporário: 1 GiB

Para uma API .NET simples, 0.5 vCPU e 2 GiB pode ser excessivo (e caro). Sempre declare seus requests explicitamente.

Recomendação para APIs .NET

Para uma API .NET típica em produção:

resources:
  requests:
    cpu: "250m"
    memory: "256Mi"
  limits:
    cpu: "500m"
    memory: "512Mi"

Definir limits maior que requests permite bursting — o pod pode ultrapassar temporariamente os requests se houver capacidade disponível no nó. Isso é particularmente útil para o startup do .NET, que consome mais CPU durante o JIT warmup.

Dica: Em clusters com bursting habilitado, se você não definir limits, o pod pode usar burst livremente. Se definir limits igual a requests, o pod será Guaranteed QoS — mais estável, mas sem burst.

Dockerfile otimizado para Autopilot

Um Dockerfile otimizado faz diferença real de custo e performance no Autopilot, porque você paga por recursos solicitados.

# === BUILD ===
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

COPY *.sln .
COPY src/MeuProjeto.Api/*.csproj src/MeuProjeto.Api/
RUN dotnet restore

COPY src/ src/
RUN dotnet publish src/MeuProjeto.Api -c Release -o /app/publish --no-restore

# === RUNTIME ===
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime
WORKDIR /app

ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_EnableDiagnostics=0
ENV TZ=America/Sao_Paulo

COPY --from=build /app/publish .

EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "MeuProjeto.Api.dll"]

Pontos-chave:

  • aspnet:10.0-noble-chiseled: imagem distroless do Ubuntu. Sem shell, sem package manager, sem superfície de ataque. Imagem final com ~80MB vs ~200MB da imagem padrão
  • Porta 8080: o Autopilot bloqueia portas privilegiadas (< 1024). Não tente usar a porta 80
  • USER $APP_UID: rodar como non-root é obrigatório no Autopilot — ele bloqueia containers que tentam rodar como root
  • DOTNET_EnableDiagnostics=0: desabilita diagnósticos que consomem recursos desnecessários em produção

Deployment completo para o Autopilot

Aqui está um Deployment completo com todas as configurações necessárias para produção no Autopilot:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: meuprojeto-api-deployment
  namespace: meuprojeto-app
  labels:
    app: meuprojeto-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: meuprojeto-api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: meuprojeto-api
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8080"
        prometheus.io/path: "/metrics"
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: meuprojeto-api
          image: us-central1-docker.pkg.dev/meu-projeto/app-docker-repo/meuprojeto-api:latest
          envFrom:
            - secretRef:
                name: meuprojeto-api-deploy-secret
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          ports:
            - name: http-port
              containerPort: 8080
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: "Production"
            - name: TZ
              value: "America/Sao_Paulo"
            - name: LANG
              value: "pt_BR.UTF-8"
          startupProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
            failureThreshold: 30
            timeoutSeconds: 5
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            periodSeconds: 15
            failureThreshold: 3
            timeoutSeconds: 5
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            periodSeconds: 10
            failureThreshold: 3
            timeoutSeconds: 5
            successThreshold: 1
---
apiVersion: v1
kind: Service
metadata:
  name: meuprojeto-api-svc
  namespace: meuprojeto-app
spec:
  selector:
    app: meuprojeto-api
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
  type: ClusterIP

Por que três probes?

Esse é um pattern que funciona muito bem com .NET no Autopilot:

  • startupProbe: dá até 150 segundos (30 × 5s) para a aplicação iniciar. APIs .NET com Entity Framework e migrations podem demorar na primeira request. Enquanto a startup probe não passar, as outras probes não são avaliadas
  • livenessProbe: verifica se o processo está vivo. Se falhar 3 vezes, o Kubernetes mata e reinicia o container
  • readinessProbe: verifica se o pod está pronto para receber tráfego. Se falhar, o pod é removido do Service (para de receber requests) mas não é reiniciado

No ASP.NET, configure os health checks assim:

// Program.cs
builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy())
    .AddNpgSql(connectionString, name: "postgres")
    .AddRedis(redisConnection, name: "redis");

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = check => check.Name == "self"
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = _ => true // Verifica todas as dependências
});

A separação é intencional: o /health/live responde quase instantaneamente (só verifica se o processo está rodando), enquanto o /health/ready verifica banco, cache e outras dependências. Se o banco cair, o pod para de receber tráfego mas não é reiniciado — porque o problema não é no pod.

Ingress: TLS e DNS automáticos

No Autopilot, o Ingress NGINX funciona normalmente. Use cert-manager para TLS automático e external-dns para DNS:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: meuprojeto-api-ingress
  namespace: meuprojeto-app
  annotations:
    kubernetes.io/ingress.class: "nginx"
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - api.meuprojeto.com.br
      secretName: api-meuprojeto-tls
  rules:
    - host: api.meuprojeto.com.br
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: meuprojeto-api-svc
                port:
                  number: 8080

Atenção com o Ingress NGINX no Autopilot: o controller do nginx roda como DaemonSet, e no Autopilot os DaemonSets têm limits bem restritos de recursos. Monitore o uso de memória do controller se tiver muitas regras de Ingress.

Secrets: nunca no repositório

O Kubernetes Secrets não é criptografia — é encoding Base64. Mas é a forma padrão de injetar configuração sensível nos pods. A melhor prática é usar envsubst no CI/CD para substituir placeholders:

# secret.yaml (template — nunca commit os valores reais)
apiVersion: v1
kind: Secret
metadata:
  name: meuprojeto-api-deploy-secret
  namespace: meuprojeto-app
type: Opaque
stringData:
  ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING}"
  Redis__ConnectionString: "${REDIS_CONNECTION}"
  JWT__Secret: "${JWT_SECRET}"

No GitHub Actions:

- name: Substituir secrets
  env:
    DB_CONNECTION_STRING: ${{ secrets.DB_CONNECTION_STRING }}
    REDIS_CONNECTION: ${{ secrets.REDIS_CONNECTION }}
    JWT_SECRET: ${{ secrets.JWT_SECRET }}
  run: envsubst < secret.yaml | kubectl apply -f -

Assim, o arquivo de secret no repositório contém apenas placeholders (${VAR}), e os valores reais são injetados no momento do deploy via GitHub Secrets.

Scaling: HPA e CronJobs

HorizontalPodAutoscaler

O Autopilot provisiona nós automaticamente quando novos pods são criados. Combine isso com HPA:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: meuprojeto-api-hpa
  namespace: meuprojeto-app
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: meuprojeto-api-deployment
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80

Com o Autopilot, o HPA cria novos pods → o Autopilot provisiona novos nós se necessário → tudo automatizado. Mas atenção: o provisionamento de novos nós pode levar de 15 a 90 segundos, dependendo da classe de computação. Na plataforma de computação otimizada para contêineres (padrão desde GKE 1.32.3), os nós podem ser redimensionados dinamicamente, o que reduz significativamente o tempo de provisionamento.

CronJobs para Scale Down fora do horário

Para ambientes de desenvolvimento ou staging, use CronJobs para escalar os deployments para zero fora do horário comercial:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: scaledown-meuprojeto-job
  namespace: default
spec:
  schedule: "0 22 * * 1-5" # 22:00 UTC (19:00 BRT) de seg a sex
  successfulJobsHistoryLimit: 0
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: scale-deployment
              image: bitnami/kubectl:latest
              resources:
                requests:
                  cpu: 50m
                  memory: 50Mi
                limits:
                  cpu: 100m
                  memory: 100Mi
              command:
                - /bin/sh
                - -c
                - |
                  kubectl scale deployment "meuprojeto-api-deployment" --replicas=0 -n "meuprojeto-app"
          restartPolicy: OnFailure
          serviceAccountName: scale-deployments-sa
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: scaleup-meuprojeto-job
  namespace: default
spec:
  schedule: "0 11 * * 1-5" # 11:00 UTC (08:00 BRT) de seg a sex
  successfulJobsHistoryLimit: 0
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: scale-deployment
              image: bitnami/kubectl:latest
              resources:
                requests:
                  cpu: 50m
                  memory: 50Mi
                limits:
                  cpu: 100m
                  memory: 100Mi
              command:
                - /bin/sh
                - -c
                - |
                  kubectl scale deployment "meuprojeto-api-deployment" --replicas=2 -n "meuprojeto-app"
          restartPolicy: OnFailure
          serviceAccountName: scale-deployments-sa

Importante: CronJobs no Autopilot também precisam declarar resource requests. Se não declarar, o Autopilot aplica os defaults (0.5 vCPU + 2 GiB) — caro para um job que roda por 2 segundos.

Você vai precisar de uma ServiceAccount com permissão para escalar deployments:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: scale-deployments-sa
  namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: scale-deployments-role
rules:
  - apiGroups: ["apps"]
    resources: ["deployments", "deployments/scale"]
    verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: scale-deployments-binding
subjects:
  - kind: ServiceAccount
    name: scale-deployments-sa
    namespace: default
roleRef:
  kind: ClusterRole
  name: scale-deployments-role
  apiGroup: rbac.authorization.k8s.io

CI/CD com GitHub Actions

Uma pipeline típica de CI/CD para deploy no GKE Autopilot:

name: Deploy API - PRD
on:
  push:
    paths:
      - "prd/apps/meuprojeto/api/**"
    branches:
      - main
  workflow_dispatch:

env:
  GKE_CLUSTER: ${{ secrets.GKE_CLUSTER_PRD }}
  GKE_ZONE: us-central1
  K8S_OVERLAY_DIR: prd/apps/meuprojeto/api

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

    steps:
      - uses: actions/checkout@v4

      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.GKE_CLUSTER }}
          location: ${{ env.GKE_ZONE }}

      - name: Substituir imagem no deployment
        run: |
          sed -i "s|gcr.io/PROJECT_ID/IMAGE:TAG|${{ secrets.REGISTRY_URL }}:${{ github.sha }}|g" \
            $K8S_OVERLAY_DIR/deployment.yaml

      - name: Substituir secrets
        env:
          DB_CONNECTION_STRING: ${{ secrets.DB_CONNECTION_STRING }}
          REDIS_CONNECTION: ${{ secrets.REDIS_CONNECTION }}
        run: envsubst < $K8S_OVERLAY_DIR/secret.yaml > /tmp/secret.yaml

      - name: Validar manifests
        run: |
          kubectl kustomize $K8S_OVERLAY_DIR | kubeval --strict

      - name: Aplicar manifests
        run: |
          cp /tmp/secret.yaml $K8S_OVERLAY_DIR/secret.yaml
          kubectl kustomize $K8S_OVERLAY_DIR | kubectl apply -f -

      - name: Verificar rollout
        run: |
          kubectl rollout status deployment/meuprojeto-api-deployment \
            -n meuprojeto-app --timeout=300s

Destaques:

  • Workload Identity Federation (WIF): em vez de chaves JSON de service account, use WIF para autenticação sem credenciais estáticas. Mais seguro e sem risco de leak de chaves
  • kubeval: valida os manifests antes de aplicar. Evita deploys quebrados
  • rollout status: espera o deploy terminar. Se falhar (por exemplo, o pod não inicia), a pipeline falha

Custos: a conta que você precisa fazer

O modelo de cobrança do Autopilot é por pod (recursos solicitados), não por nó. Isso muda completamente a forma de pensar sobre custos.

Exemplo prático

Suponha uma API .NET com:

  • Requests: 250m CPU, 256Mi memória
  • 2 réplicas em produção
  • Rodando 24/7 (720 horas/mês)

Preço do Autopilot (uso geral, us-central1, maio 2026):

  • CPU: ~$31.50/vCPU/mês
  • Memória: ~$3.48/GiB/mês

Custo mensal:

  • CPU: 0.25 vCPU × 2 pods × $31.50 = $15.75
  • Memória: 0.25 GiB × 2 pods × $3.48 = $1.74
  • Total: ~$17.49/mês por essa API

Parece barato, mas multiplique por 10 microserviços e some o Ingress Controller, cert-manager, prometheus, e os defaults inflados que o Autopilot aplica, e a conta sobe rápido.

Dicas para reduzir custos

  1. Sempre declare requests explicitamente — os defaults do Autopilot (0.5 vCPU + 2 GiB) são generosos demais para a maioria das APIs .NET
  2. Use CronJobs para scale down em ambientes não-produtivos
  3. Considere Spot pods para workloads tolerantes a interrupção (batch jobs, workers)
  4. Monitore com kubectl describe pod — verifique se o Autopilot não está inflando seus requests
  5. Considere a classe Scale-Out para microserviços leves que precisam escalar horizontalmente — usa máquinas T2A (Arm) com melhor custo-benefício

Armadilhas comuns no Autopilot

1. “Meu pod não inicia e não tem erro”

Provavelmente o Autopilot está provisionando um novo nó. No Autopilot, se não houver nó disponível, o pod fica Pending até o nó ser criado. Isso pode levar de 15 a 90 segundos. Verifique com:

kubectl get events -n meuprojeto-app --sort-by='.lastTimestamp'

2. “Meus resources requests mudaram sozinhos”

O Autopilot ajustou para respeitar os mínimos ou a proporção CPU:Memória. Compare o manifesto declarado com o que está rodando:

kubectl get pod <pod-name> -n meuprojeto-app -o yaml | grep -A 10 resources

3. “PodDisruptionBudget não está funcionando”

No Autopilot, o GKE pode remover pods durante upgrades automáticos de nós. Para proteger pods críticos, use a anotação de extended duration:

metadata:
  annotations:
    cluster-autoscaler.kubernetes.io/safe-to-evict: "false"

Isso protege o pod contra remoção por upgrades automáticos e scale-down por até 7 dias.

4. “Não consigo rodar o Ingress NGINX Controller”

O Autopilot tem restrições para DaemonSets. Se precisar de Ingress NGINX, instale via Helm com as configurações adequadas para Autopilot. Alternativamente, considere o GKE Gateway API (nativo do Google Cloud) ou o Cloud Load Balancing diretamente.

5. “Meu container é morto com OOMKilled”

Aplicações .NET podem consumir mais memória do que o esperado, especialmente com EF Core e caches. Defina limits.memory com margem confortável acima dos requests, e considere configurar o GC do .NET:

ENV DOTNET_GCConserveMemory=9
ENV DOTNET_GCHeapHardLimit=0x1E000000  # ~480MB

6. “A startup probe está matando meu pod”

APIs .NET com muitas migrations ou seed data podem demorar mais que o esperado para iniciar. Aumente o failureThreshold da startupProbe:

startupProbe:
  httpGet:
    path: /health/live
    port: 8080
  initialDelaySeconds: 15
  periodSeconds: 10
  failureThreshold: 30 # 15 + (30 × 10) = até 315 segundos para iniciar
  timeoutSeconds: 10

Monitoramento com Prometheus

O Autopilot já vem com Google Cloud Managed Service para Prometheus habilitado. Mas se você quer métricas da aplicação .NET, adicione o pacote prometheus-net.AspNetCore:

dotnet add package prometheus-net.AspNetCore
// Program.cs
app.MapMetrics(); // Expõe /metrics para o Prometheus

E as annotations no Deployment:

annotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "8080"
  prometheus.io/path: "/metrics"

Kustomize: organizando os manifests

Para organizar tudo, use Kustomize (já integrado no kubectl):

# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: meuprojeto-app

resources:
  - deployment.yaml
  - secret.yaml
  - ingress.yaml

Estrutura de diretórios por ambiente:

infra/
├── dev/
│   └── apps/
│       └── meuprojeto/
│           └── api/
│               ├── deployment.yaml
│               ├── secret.yaml
│               ├── ingress.yaml
│               └── kustomization.yaml
└── prd/
    └── apps/
        └── meuprojeto/
            └── api/
                ├── deployment.yaml
                ├── secret.yaml
                ├── ingress.yaml
                └── kustomization.yaml

Cada diretório é self-contained — contém todos os manifests necessários para aquele ambiente. Sem base/overlay. Parece redundante, mas é muito mais simples de debugar quando algo dá errado em produção.

Checklist de produção

Antes de ir para produção no GKE Autopilot com .NET, verifique:

  • Dockerfile usa imagem chiseled/distroless e roda como non-root
  • Porta da aplicação é >= 1024 (recomendado: 8080)
  • Resource requests declarados explicitamente em todos os containers
  • Resource limits definidos (iguais ou maiores que requests)
  • startupProbe configurada com timeout suficiente para o startup do .NET
  • livenessProbe e readinessProbe em endpoints separados
  • Health checks no ASP.NET verificando dependências no /health/ready
  • Secrets usando placeholders com envsubst no CI/CD
  • Rolling update com maxUnavailable: 0 para zero downtime
  • HPA configurado se a carga é variável
  • terminationGracePeriodSeconds configurado (30s é um bom default para APIs)
  • Métricas Prometheus expostas via /metrics
  • Logging estruturado (JSON) para integração com Cloud Logging
  • TZ=America/Sao_Paulo configurado se a aplicação depende de timezone local

Conclusão

O GKE Autopilot remove a complexidade de gerenciar nós, mas introduz restrições que exigem planejamento. Para workloads .NET em produção, a combinação de Dockerfile otimizado, probes bem configuradas, resources requests explícitos e CI/CD automatizado é o que separa um deploy estável de uma madrugada de troubleshooting.

O investimento de tempo para entender as regras do Autopilot compensa rapidamente: menos operação, upgrades automáticos de segurança e um modelo de custo previsível. A chave é respeitar as restrições em vez de lutar contra elas.