O Docker resolve o problema de empacotar e executar uma aplicação em um único container. O Docker Compose resolve o problema de orquestrar múltiplos containers em uma única máquina. Mas quando a aplicação cresce além de uma máquina — quando são necessários dezenas de instâncias distribuídas em múltiplos servidores, com failover automático, atualizações sem downtime e escala dinâmica baseada em demanda — uma nova categoria de problema emerge: a orquestração de containers em escala.
O Kubernetes — frequentemente abreviado como K8s — é o sistema de orquestração de containers que se tornou o padrão da indústria para gerenciar cargas de trabalho containerizadas em ambientes de produção. Originado no Google, que orquestrou containers em escala por mais de uma década antes de abrir o projeto, o Kubernetes foi doado à Cloud Native Computing Foundation em 2014 e desde então se tornou o projeto de infraestrutura de código aberto mais ativo do mundo.
A proposta central do Kubernetes é declarativa: o engenheiro descreve o estado desejado do sistema — "quero cinco réplicas desta aplicação, cada uma com 512MB de memória, acessíveis pela porta 80" — e o Kubernetes trabalha continuamente para garantir que o estado real corresponde ao declarado. Se um container morre, o Kubernetes inicia um novo. Se um nó falha, as cargas de trabalho são reagendadas em nós saudáveis. Se o tráfego aumenta, novas réplicas são criadas automaticamente.
Arquitetura do Kubernetes
Um cluster Kubernetes é composto por dois tipos de máquinas com responsabilidades distintas.
O Plano de Controle
O plano de controle é o cérebro do cluster — o conjunto de componentes que gerencia o estado desejado e toma decisões sobre o cluster.
kube-apiserver — o único ponto de entrada para todas as operações no cluster. Toda comunicação — do kubectl, dos outros componentes do plano de controle, dos agentes nos nós — passa pela API. É stateless e pode ser escalado horizontalmente.
etcd — o banco de dados distribuído de chave-valor que armazena todo o estado do cluster. É o componente mais crítico — perder o etcd sem backup significa perder o cluster inteiro. Em produção, o etcd é executado em um cluster de três ou cinco instâncias para tolerância a falhas.
kube-scheduler — observa pods recém-criados que ainda não têm um nó atribuído e seleciona o nó mais adequado baseado em recursos disponíveis, afinidades, taints e tolerations.
kube-controller-manager — executa os controladores que implementam a lógica de reconciliação. O controlador de réplicas garante que o número correto de pods está em execução. O controlador de endpoints atualiza a lista de IPs nos Services. O controlador de jobs garante que jobs completam com sucesso.
Os Nós Worker
Os nós worker são as máquinas que executam as cargas de trabalho — os containers da aplicação.
kubelet — o agente que roda em cada nó. Recebe especificações de pods do kube-apiserver e garante que os containers descritos estão rodando e saudáveis. Reporta o estado dos containers de volta ao plano de controle.
kube-proxy — mantém as regras de rede em cada nó, implementando o conceito de Services — abstrações que expõem um conjunto de pods como um endpoint de rede estável.
Container Runtime — o software que executa os containers. O Kubernetes suporta qualquer runtime compatível com a CRI — Container Runtime Interface. O containerd é o runtime padrão na maioria das distribuições modernas.
┌─────────────────────────────────────────────────────────────┐
│ PLANO DE CONTROLE │
│ │
│ ┌──────────────┐ ┌──────┐ ┌────────────┐ ┌──────────┐ │
│ │ kube-apiserver│ │ etcd │ │ scheduler │ │controller│ │
│ └──────────────┘ └──────┘ └────────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │
┌──────┴──────┐ ┌─────┴──────┐
│ NÓ 1 │ │ NÓ 2 │
│ │ │ │
│ kubelet │ │ kubelet │
│ kube-proxy │ │ kube-proxy │
│ │ │ │
│ ┌─────────┐ │ │ ┌────────┐ │
│ │ Pod A │ │ │ │ Pod B │ │
│ │ Pod C │ │ │ │ Pod D │ │
│ └─────────┘ │ │ └────────┘ │
└─────────────┘ └────────────┘
Instalando um Cluster Local com kind
Para aprendizado e desenvolvimento local, o kind (Kubernetes in Docker) cria um cluster Kubernetes completo usando containers Docker como nós — sem precisar de máquinas virtuais:
# Instala o kind
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
# Instala o kubectl
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl
# Cria um cluster com configuração personalizada
cat > kind-config.yml << 'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
- role: worker
EOF
kind create cluster --config kind-config.yml --name meu-cluster
# Verifica que o cluster está funcionando
kubectl cluster-info
kubectl get nodes
Os Objetos Fundamentais do Kubernetes
O Kubernetes é operado inteiramente através de objetos — recursos declarados em arquivos YAML que descrevem o estado desejado do sistema. Cada objeto tem quatro campos obrigatórios: apiVersion, kind, metadata e spec.
Pod: A Unidade Atômica
O Pod é o objeto mais básico do Kubernetes — um grupo de um ou mais containers que compartilham o mesmo namespace de rede e podem compartilhar volumes. Containers dentro de um pod se comunicam via localhost.
Na prática, pods raramente são criados diretamente. Eles são gerenciados por objetos de nível superior — Deployments, StatefulSets, Jobs — que garantem que o número correto de pods está sempre em execução.
# pod.yml — apenas para entender a estrutura; use Deployments em produção
apiVersion: v1
kind: Pod
metadata:
name: minha-api
namespace: default
labels:
app: minha-api
versao: "1.5.0"
spec:
containers:
- name: api
image: ghcr.io/minha-empresa/minha-api:1.5.0
ports:
- containerPort: 3000
protocol: TCP
env:
- name: NODE_ENV
value: production
- name: PORT
value: "3000"
resources:
requests: # Mínimo garantido pelo scheduler
memory: "128Mi"
cpu: "100m" # 100 millicores = 0.1 CPU
limits: # Máximo permitido
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
restartPolicy: Always
Deployment: Gerenciando Réplicas com Atualizações Controladas
O Deployment é o objeto mais usado para cargas de trabalho stateless. Ele gerencia um ReplicaSet — que por sua vez gerencia os pods — e orquestra atualizações com estratégias de rollout configuráveis:
# deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
name: minha-api
namespace: producao
labels:
app: minha-api
annotations:
kubernetes.io/change-cause: "Deploy versão 1.5.0 — feature: checkout redesign"
spec:
replicas: 3
selector:
matchLabels:
app: minha-api
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # Máximo de pods extras durante atualização
maxUnavailable: 0 # Zero pods indisponíveis — zero downtime
template:
metadata:
labels:
app: minha-api
versao: "1.5.0"
spec:
# Distribui pods entre nós diferentes para resiliência
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: minha-api
# Graceful shutdown — aguarda conexões ativas terminarem
terminationGracePeriodSeconds: 30
containers:
- name: api
image: ghcr.io/minha-empresa/minha-api:1.5.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: production
- name: PORT
value: "3000"
# Injeta variáveis de ambiente a partir de Secrets
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: minha-api-secrets
key: database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: minha-api-secrets
key: redis-url
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "512Mi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Garante que o processo recebe SIGTERM ao terminar
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
# Contexto de segurança — sem root
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
# Volume para arquivos temporários (filesystem read-only)
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
# Pull de imagem privada
imagePullSecrets:
- name: ghcr-credentials
Service: Expondo Pods com IP Estável
Pods têm IPs efêmeros — quando um pod morre e é recriado, recebe um IP diferente. O Service resolve esse problema fornecendo um IP e nome DNS estáveis para um conjunto de pods selecionados por labels:
# service.yml
apiVersion: v1
kind: Service
metadata:
name: minha-api
namespace: producao
spec:
selector:
app: minha-api # Seleciona pods com este label
ports:
- name: http
port: 80 # Porta do Service
targetPort: 3000 # Porta do container
protocol: TCP
type: ClusterIP # Acessível apenas dentro do cluster
Os tipos de Service disponíveis:
ClusterIP — padrão. Acessível apenas dentro do cluster. Usado para comunicação interna entre serviços.
NodePort — expõe o Service em uma porta estática em cada nó do cluster. Acessível externamente via IP_DO_NO:NODEPORT. Raramente usado em produção diretamente.
LoadBalancer — solicita ao cloud provider a criação de um load balancer externo. Na AWS, cria um Network Load Balancer ou Classic Load Balancer. É a forma mais comum de expor serviços em clusters gerenciados como EKS.
ConfigMap e Secret: Configuração Separada do Código
# configmap.yml
apiVersion: v1
kind: ConfigMap
metadata:
name: minha-api-config
namespace: producao
data:
NODE_ENV: "production"
LOG_LEVEL: "info"
PORT: "3000"
# Arquivo de configuração completo como entrada no ConfigMap
nginx.conf: |
server {
listen 80;
location / {
proxy_pass http://localhost:3000;
}
}
# secret.yml
apiVersion: v1
kind: Secret
metadata:
name: minha-api-secrets
namespace: producao
type: Opaque
# Valores em base64 — não é criptografia, apenas encoding
# Em produção, use External Secrets Operator ou Sealed Secrets
data:
database-url: cG9zdGdyZXNxbDovL... # base64 da string
redis-url: cmVkaXM6Ly9...
jwt-secret: c2VjcmV0b211aXRvc2VndXJv...
Ingress: Roteamento HTTP de Entrada
O Ingress define regras de roteamento HTTP/HTTPS para o tráfego de entrada no cluster. Requer um Ingress Controller — um pod que implementa as regras definidas pelo Ingress:
# ingress.yml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minha-api
namespace: producao
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
cert-manager.io/cluster-issuer: "letsencrypt-producao"
spec:
ingressClassName: nginx
tls:
- hosts:
- api.empresa.com
secretName: api-empresa-tls
rules:
- host: api.empresa.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minha-api
port:
number: 80
Comandos Essenciais do kubectl
# ── Contextos e clusters ──────────────────────────
kubectl config get-contexts # Lista clusters configurados
kubectl config use-context meu-cluster # Muda de cluster
# ── Inspecionando recursos ────────────────────────
kubectl get pods -n producao # Lista pods no namespace producao
kubectl get pods -A # Lista pods em todos os namespaces
kubectl get all -n producao # Todos os recursos do namespace
kubectl describe pod minha-api-abc -n producao # Detalhes de um pod
kubectl logs minha-api-abc -n producao # Logs de um pod
kubectl logs minha-api-abc -n producao -f # Logs em tempo real
kubectl logs minha-api-abc -n producao --previous # Logs da instância anterior
# ── Aplicando configurações ───────────────────────
kubectl apply -f deployment.yml # Cria ou atualiza recursos
kubectl apply -f ./manifests/ # Aplica todos os YAMLs de um diretório
kubectl delete -f deployment.yml # Remove recursos descritos no arquivo
# ── Operações em pods ─────────────────────────────
kubectl exec -it minha-api-abc -n producao -- /bin/sh # Shell interativo
kubectl port-forward pod/minha-api-abc 8080:3000 # Port forward local
# ── Rollout e atualizações ────────────────────────
kubectl rollout status deployment/minha-api -n producao
kubectl rollout history deployment/minha-api -n producao
kubectl rollout undo deployment/minha-api -n producao # Rollback
kubectl set image deployment/minha-api api=ghcr.io/empresa/api:1.6.0 -n producao
# ── Escalando ─────────────────────────────────────
kubectl scale deployment/minha-api --replicas=5 -n producao
# ── Diagnóstico ───────────────────────────────────
kubectl top pods -n producao # Uso de CPU e memória dos pods
kubectl top nodes # Uso de CPU e memória dos nós
kubectl get events -n producao --sort-by='.lastTimestamp'
Horizontal Pod Autoscaler
O HPA escala automaticamente o número de réplicas baseado em métricas — CPU, memória ou métricas customizadas via Prometheus Adapter:
# hpa.yml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: minha-api
namespace: producao
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: minha-api
minReplicas: 3
maxReplicas: 20
metrics:
# Escala por CPU
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
# Escala por memória
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
# Escala por métrica customizada do Prometheus
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "100"
behavior:
scaleUp:
stabilizationWindowSeconds: 60 # Aguarda 60s antes de escalar para cima
policies:
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # Aguarda 5min antes de escalar para baixo
policies:
- type: Pods
value: 1
periodSeconds: 60
Namespaces: Isolamento Lógico no Cluster
Os Namespaces dividem um cluster em ambientes virtuais isolados. É uma forma eficiente de separar equipes, ambientes ou projetos sem precisar de clusters separados:
# Cria os namespaces para diferentes ambientes
kubectl create namespace producao
kubectl create namespace staging
kubectl create namespace monitoring
# Define o namespace padrão para o contexto atual
kubectl config set-context --current --namespace=producao
# network-policy.yml — isola o namespace de produção
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: isolamento-producao
namespace: producao
spec:
podSelector: {} # Aplica a todos os pods do namespace
policyTypes:
- Ingress
- Egress
ingress:
# Permite tráfego apenas do Ingress Controller
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
egress:
# Permite saída para DNS
- ports:
- protocol: UDP
port: 53
# Permite saída para o namespace de banco de dados
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: databases
O Que Vem a Seguir
O próximo artigo aprofunda o Kubernetes em produção com o Amazon EKS — o serviço gerenciado de Kubernetes da AWS. Serão cobertos o provisionamento do cluster via Terraform, a gestão de node groups com Karpenter para auto scaling eficiente e as práticas de segurança essenciais para clusters EKS em produção.
Referências para Aprofundamento
Documentação oficial
- Kubernetes Documentation — kubernetes.io — Documentação oficial completa do Kubernetes, incluindo conceitos, tutoriais e referência da API. O ponto de partida obrigatório para qualquer estudo sério de K8s.
- kubectl Cheat Sheet — kubernetes.io — Referência rápida dos comandos kubectl mais usados, organizada por categoria de operação.
Aprendizado prático
- kind Documentation — kind.sigs.k8s.io — Documentação do kind para criação de clusters locais, incluindo configurações avançadas de multi-node e networking.
- Killercoda Kubernetes Playgrounds — killercoda.com — Ambiente interativo online para praticar Kubernetes sem instalação local, com cenários guiados e cluster real no navegador.
Livros e cursos de referência
- Kubernetes: Up and Running — O'Reilly — Livro de referência escrito pelos criadores do Kubernetes, cobrindo desde os fundamentos até operações avançadas em produção.
- CNCF Landscape — landscape.cncf.io — Mapa interativo do ecossistema Cloud Native, mostrando todas as ferramentas e projetos organizados por categoria. Essencial para entender o ecossistema ao redor do Kubernetes.
Exercícios
Exercício 1
No deployment.yml, livenessProbe e readinessProbe apontam para o mesmo caminho /health. Qual a diferença de comportamento entre as duas sondas quando falham? E o que acontece com as três réplicas se esse /health for implementado fazendo um SELECT 1 no banco e o banco ficar indisponível por dois minutos?
Ver resposta
✓ Resposta: As consequências são bem diferentes. Quando o readinessProbe falha, o pod é removido dos endpoints do Service — para de receber tráfego, mas continua rodando. É reversível: assim que a sonda voltar a passar, o pod volta a receber requisições. Quando o livenessProbe falha, o kubelet mata o container e o reinicia. É destrutivo e não pergunta se o problema era do pod.
Com /health consultando o banco, a indisponibilidade de dois minutos derruba a aplicação inteira. O liveness tem periodSeconds: 15 e failureThreshold: 3 — cerca de 45 segundos de falhas e o kubelet reinicia o container. Como o banco está fora para todos os pods ao mesmo tempo, as três réplicas entram em restart simultâneo, sem nenhuma delas ter defeito. Pior: cada reinício zera pool de conexões e cache em memória, então quando o banco voltar, os pods estarão em CrashLoopBackOff, com o kubelet esperando intervalos crescentes antes de tentar de novo — a aplicação demora a se recuperar bem depois do banco já estar de pé.
A regra prática: o liveness só deve verificar o próprio processo (o event loop responde? a thread principal travou?), sem tocar em dependências externas. Dependências pertencem ao readiness, onde a resposta correta é "não me mande tráfego agora", não "me reinicie". Na prática são dois endpoints distintos — /health/live e /health/ready.
Exercício 2
O container declara lifecycle.preStop executando sleep 5, e o pod tem terminationGracePeriodSeconds: 30. Dormir cinco segundos antes de encerrar parece desperdício — que problema esse sleep resolve? E quanto tempo, no total, o Kubernetes espera antes de matar o container à força?
Ver resposta
✓ Resposta: Ele resolve uma corrida entre dois processos assíncronos. Quando um pod é marcado para terminação, duas coisas acontecem em paralelo: o kubelet inicia o encerramento do container, e o controlador de endpoints remove o pod do Service — o que ainda precisa se propagar até as regras de rede de cada nó (kube-proxy) e até o Ingress Controller. A segunda é bem mais lenta.
Sem o preStop, o container recebe SIGTERM e começa a recusar conexões enquanto vários nós ainda o têm na lista de destinos. O resultado são erros 502/504 intermitentes durante cada rollout — o sintoma clássico de deploy "com zero downtime" que ainda assim derruba requisições. O sleep 5 segura o container vivo e servindo normalmente durante a janela de propagação; só depois o SIGTERM é enviado.
Sobre o tempo total: o terminationGracePeriodSeconds engloba o preStop — não é somado a ele. A contagem é: 5 s de preStop, depois SIGTERM, e o processo tem os 25 s restantes para encerrar conexões ativas. Passados os 30 s, vem o SIGKILL. Vale notar que o hook não é gratuito: um preStop maior que o grace period consome todo o tempo e o container é morto à força sem nunca ter tratado o SIGTERM.
Exercício 3
O container declara requests de 256Mi/200m e limits de 512Mi/1000m. Descreva o que acontece com o pod em cada caso: (a) o processo tenta usar 900m de CPU; (b) o processo tenta alocar 600Mi de memória.
Ver resposta
✓ Resposta: (a) Nada acontece — 900m está abaixo do limite de 1000m, então o processo usa livremente. O requests de 200m não é um teto: é o que o scheduler reserva ao escolher o nó, e o piso garantido em caso de disputa. Se o processo pedisse 1500m, seria throttled: o kernel simplesmente lhe daria menos fatias de CPU. O processo continua vivo, apenas mais lento — o sintoma aparece como latência alta sem nenhum erro, e é notoriamente difícil de diagnosticar sem olhar as métricas de throttling.
(b) O container é morto na hora, com OOMKilled. Aqui está a assimetria que importa: CPU é compressível, memória não é. Não existe "usar memória mais devagar" — ao ultrapassar o limite, o cgroup dispara o OOM killer do kernel e o processo morre sem chance de tratar nada. Com restartPolicy: Always, o container reinicia; se o consumo for estrutural (não um pico), o ciclo se repete até CrashLoopBackOff.
Duas consequências práticas: o kubectl describe pod mostra Last State: Terminated / Reason: OOMKilled, e kubectl logs --previous é o que recupera os logs da instância morta — o comando está listado no artigo justamente para esse caso. Para aplicações Node.js e JVM, ajustar o limite não basta: o runtime precisa saber do teto (--max-old-space-size, -XX:MaxRAMPercentage), ou tentará crescer até bater no cgroup.
Exercício 4
O securityContext define readOnlyRootFilesystem: true. Logo abaixo, o mesmo container monta um volume emptyDir em /tmp. Por que essa montagem é necessária, e o que aconteceria com ela se o pod fosse reiniciado?
Ver resposta
✓ Resposta: Com readOnlyRootFilesystem: true, todo o sistema de arquivos do container fica somente-leitura — qualquer escrita retorna EROFS: read-only file system. O problema é que muita coisa escreve em /tmp sem que o código da aplicação peça: upload de arquivos em frameworks web, cache de compilação, sockets Unix, bibliotecas de imagem, o próprio runtime. A aplicação quebraria com erros aparentemente sem relação com segurança. O emptyDir devolve uma única fatia gravável, sem abrir mão do resto.
É essa a razão da flag existir: um invasor que consiga execução remota não consegue persistir nada — não escreve binário, não altera arquivo de configuração, não injeta código no diretório da aplicação. Combinada com runAsNonRoot, allowPrivilegeEscalation: false e capabilities.drop: ["ALL"], reduz bastante o que se pode fazer depois de entrar.
Quanto ao reinício: o emptyDir tem o ciclo de vida do pod, não do container. Se o container reinicia (por OOM ou falha de liveness), o pod continua o mesmo e o conteúdo de /tmp sobrevive. Se o pod é removido — rollout, reagendamento em outro nó, escala para baixo — o volume é apagado definitivamente. Por isso emptyDir nunca serve para dado que precisa durar: para isso existem PersistentVolumeClaim e StatefulSets.
Exercício 5
O deployment.yml declara replicas: 3 e o hpa.yml declara minReplicas: 3, maxReplicas: 20 para o mesmo Deployment. Numa terça de pico o HPA escalou para 12 réplicas; nesse momento alguém faz merge de uma mudança de variável de ambiente e o pipeline roda kubectl apply -f deployment.yml. O que acontece com as 12 réplicas?
Ver resposta
✓ Resposta: O cluster cai para 3 réplicas no meio do pico. O apply envia o manifesto com replicas: 3, o Deployment é atualizado, e nove pods são encerrados de uma vez. O HPA percebe a sobrecarga no ciclo seguinte e começa a escalar de volta, mas o behavior.scaleUp deste HPA impõe stabilizationWindowSeconds: 60 e no máximo 4 pods por minuto — voltar de 3 para 12 leva mais de dois minutos, com a aplicação degradada ou fora do ar durante a subida.
A causa é ter dois controladores donos do mesmo campo. A correção é omitir replicas do manifesto quando existe um HPA: o campo passa a ser gerenciado apenas pelo autoscaler, e o apply deixa de sobrescrevê-lo. O minReplicas: 3 do HPA já cumpre o papel de garantir o piso de três réplicas.
Duas notas sobre isso. Se o Deployment for criado sem HPA e sem replicas, o padrão é 1 — por isso a ordem importa, e o HPA deve existir antes. E se a equipe usa GitOps com reconciliação contínua (Argo CD, Flux), o problema é ainda maior: sem essa omissão, a ferramenta enxerga as 12 réplicas como drift e reverte para 3 sozinha, repetidamente, sem que ninguém tenha feito deploy.
Repare que o scaleDown foi configurado deliberadamente mais conservador — stabilizationWindowSeconds: 300 e 1 pod por minuto — justamente para evitar remoção brusca de capacidade. Um apply com replicas fixo passa por cima de toda essa proteção.