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:
| Recurso | Mínimo (com bursting) | Mínimo (sem bursting) |
|---|---|---|
| CPU | 50m | 250m |
| Memória | 52 MiB | 512 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 definirlimitsigual arequests, o pod seráGuaranteedQoS — 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 rootDOTNET_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 avaliadaslivenessProbe: verifica se o processo está vivo. Se falhar 3 vezes, o Kubernetes mata e reinicia o containerreadinessProbe: 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 quebradosrollout 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
- Sempre declare requests explicitamente — os defaults do Autopilot (0.5 vCPU + 2 GiB) são generosos demais para a maioria das APIs .NET
- Use CronJobs para scale down em ambientes não-produtivos
- Considere Spot pods para workloads tolerantes a interrupção (batch jobs, workers)
- Monitore com
kubectl describe pod— verifique se o Autopilot não está inflando seus requests - 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
envsubstno CI/CD - Rolling update com
maxUnavailable: 0para zero downtime - HPA configurado se a carga é variável
-
terminationGracePeriodSecondsconfigurado (30s é um bom default para APIs) - Métricas Prometheus expostas via
/metrics - Logging estruturado (JSON) para integração com Cloud Logging
-
TZ=America/Sao_Pauloconfigurado 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.