DevOps, CI/CD e contêineres
Padrões de Kubernetes: probes, recursos, escalabilidade, armazenamento e segurança
Avançado11 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Um Deployment e um Service bastam para rodar sua aplicação, mas não a fazem sobreviver a uma release ruim, a um vizinho barulhento ou à queda de um nó. Este guia cobre os padrões que separam um cluster de demonstração de um cluster de produção: probes que dizem a verdade, requests que tornam o agendamento previsível, rollouts seguros, autoscaling, estado persistente, privilégio mínimo e uma forma sensata de empacotar os manifestos por ambiente.
O que você vai aprender
- Configurar probes de liveness, readiness e startup que reflitam o estado real da aplicação
- Definir requests e limits de forma consciente e prever a classe de QoS e o comportamento de despejo resultantes
- Controlar rollouts com as configurações de surge, disruption budgets e rollback
- Escalar workloads horizontalmente com base em CPU e em métricas customizadas
- Executar workloads stateful com StatefulSets, PersistentVolumeClaims e StorageClasses
- Aplicar RBAC, security contexts e network policies, e empacotar manifestos com Helm ou Kustomize
Entre "roda no cluster" e "roda em produção" existe uma série de decisões fáceis de adiar: como o cluster decide que seu contêiner está saudável, o que acontece quando ele usa mais memória do que o esperado, como conter uma release ruim, o que quebra quando um nó é drenado para manutenção. Pule essas decisões e os sintomas são conhecidos: reinícios nos picos de tráfego, deploys que perdem requisições, um serviço guloso degradando tudo o que roda no mesmo nó.
Este guia percorre essas decisões como um conjunto de padrões, cada um com o trecho de manifesto que o implementa. Ele pressupõe que você já conhece os objetos do guia de fundamentos de Kubernetes e tem como alvo clusters da linha 1.3x.
Probes que dizem a verdade
O Kubernetes faz três perguntas diferentes a cada contêiner, e confundi-las causa mais incidentes em produção do que qualquer outro erro de configuração.
A liveness pergunta: este processo travou sem chance de recuperação? Uma falha reinicia o contêiner. A readiness pergunta: esta instância consegue atender tráfego agora? Uma falha tira o Pod dos endpoints do Service, mas o mantém rodando. A startup pergunta: o processo terminou de inicializar? Enquanto ela falha, liveness e readiness ficam suspensas, o que permite que uma aplicação de inicialização lenta evite um loop de reinícios sem afrouxar os tempos de liveness pelo resto da vida da aplicação.
startupProbe:
httpGet: { path: /actuator/health/liveness, port: http }
failureThreshold: 30
periodSeconds: 5 # allows up to 150s to boot
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: http }
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: http }
periodSeconds: 5
failureThreshold: 2
As regras que decorrem dessas definições:
- O endpoint de liveness não deve verificar dependências. Ele deve retornar 200 se o processo ainda consegue atender requisições. Um endpoint que faz ping no banco de dados transforma qualquer soluço do banco em uma tempestade de reinícios no cluster inteiro.
- O endpoint de readiness deve verificar as dependências de que a instância realmente precisa e deve começar a reportar falha assim que a aplicação recebe
SIGTERM, para que o tráfego seja drenado antes de o processo encerrar. - Os tempos das probes precisam combinar com a aplicação. Um serviço JVM que leva 90 segundos para aquecer precisa de uma startup probe, não de um
initialDelaySecondsde 120 segundos na liveness.
Os frameworks ajudam nisso: o Spring Boot expõe grupos separados de liveness e readiness pelo Actuator, como descrito no guia de observabilidade, e a maioria dos ecossistemas tem algo equivalente.
Combine a readiness com um graceful shutdown. Quando um Pod é excluído, o Kubernetes o remove dos endpoints e envia SIGTERM praticamente ao mesmo tempo, então requisições ainda podem chegar por um ou dois segundos. Um sleep no hook preStop cobre essa janela:
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 5"] }
terminationGracePeriodSeconds: 45
Requests, limits e o que o scheduler faz com eles
Uma request é o que o scheduler reserva; um limit é o que o runtime impõe. O scheduler só coloca um Pod em um nó cuja capacidade não reservada cubra a soma das requests dos seus contêineres — o uso real não entra nessa decisão.
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
memory: "512Mi"
Os dois recursos se comportam de maneira completamente diferente. CPU é comprimível: ultrapassar o limit faz o kernel aplicar throttling ao cgroup, adicionando latência em rajadas que aparecem como misteriosos picos de latência de cauda. Memória é incomprimível: ultrapassar o limit significa que o contêiner é morto com código de saída 137 e OOMKilled no status. Essa assimetria explica o conselho comum: sempre defina limits de memória, iguale as requests de memória a eles em tudo o que for importante, defina as requests de CPU com cuidado e desconfie de limits de CPU.
Juntos, requests e limits determinam a classe de QoS do Pod, que decide quem morre primeiro quando um nó fica sem memória:
| Classe de QoS | Condição | Ordem de despejo |
|---|---|---|
Guaranteed | Todos os contêineres têm requests iguais aos limits, tanto de CPU quanto de memória | Por último |
Burstable | Pelo menos uma request definida, mas diferente dos limits | No meio |
BestEffort | Nenhuma request nem limit | Primeiro |
Dê aos workloads de produção a classe Guaranteed ou uma Burstable bem ajustada; deixe BestEffort para o que você não se importa de perder.
Dois objetos no nível do namespace mantêm essa disciplina em um cluster compartilhado. Um LimitRange fornece valores padrão e tetos para que nenhum Pod seja criado sem recursos, e um ResourceQuota limita o consumo total do namespace:
apiVersion: v1
kind: ResourceQuota
metadata: { name: team-quota, namespace: staging }
spec:
hard:
requests.cpu: "20"
requests.memory: 40Gi
limits.memory: 60Gi
pods: "80"
Em workloads JVM, lembre-se de que o contêiner precisa conhecer o limit. JVMs modernas leem os limites do cgroup automaticamente, mas defina -XX:MaxRAMPercentage=75 para que o heap deixe espaço para o metaspace, as pilhas das threads e os buffers nativos — um heap dimensionado em 100 % do limit de memória é um OOMKilled esperando para acontecer.
Rollouts confiáveis
A strategy de um Deployment define como ele substitui os Pods:
spec:
minReadySeconds: 15
progressDeadlineSeconds: 600
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # may create one extra Pod above replicas
maxUnavailable: 0 # never drop below the desired count
maxUnavailable: 0 com maxSurge: 1 é a configuração conservadora: a capacidade nunca cai, ao custo de um rollout mais lento e dos recursos de um Pod extra. minReadySeconds impede que um Pod que passa na readiness e depois quebra seja contado como sucesso, e é isso que faz uma release ruim travar em vez de se propagar.
progressDeadlineSeconds marca um rollout travado como falho, mas não faz o rollback. Deixe isso a cargo do seu pipeline:
kubectl apply -f k8s/
if ! kubectl rollout status deploy/shop-api --timeout=10m; then
kubectl rollout undo deploy/shop-api
exit 1
fi
Um PodDisruptionBudget protege a disponibilidade durante interrupções voluntárias — drenagem de nós, upgrades do cluster, redução feita pelo autoscaler. Ele não faz nada contra um nó que cai, mas impede que um administrador despeje todas as suas réplicas de uma vez:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: shop-api }
spec:
minAvailable: 2
selector:
matchLabels: { app: shop-api }
Defina minAvailable abaixo do número de réplicas; caso contrário, o budget bloqueia completamente as drenagens e alguém vai apagá-lo às 3 da manhã. As estratégias blue-green e canary, e os trade-offs entre elas, são tratadas no guia de pipelines de entrega; no Kubernetes, elas costumam ser implementadas com um controller de progressive delivery, e não na mão.
Escalabilidade
O HorizontalPodAutoscaler ajusta o número de réplicas de um Deployment em direção a uma meta de métrica. Ele exige o metrics-server instalado e requests definidas — a utilização de CPU é expressa como porcentagem da request, então um Pod sem request não tem denominador.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: shop-api }
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: shop-api
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- { type: Percent, value: 25, periodSeconds: 60 }
scaleUp:
policies:
- { type: Percent, value: 100, periodSeconds: 30 }
É o bloco behavior que deixa o autoscaling tranquilo: escalar rápido quando a carga chega e reduzir devagar, com uma janela de estabilização de cinco minutos, para que uma queda passageira não remova capacidade de que você vai precisar de novo um minuto depois. Para workloads em que a CPU não é o gargalo — consumidores de filas, por exemplo —, escale com base em uma métrica customizada ou externa, como a profundidade da fila; o KEDA é a forma usual de conectar esse tipo de métrica.
Dois complementos: o VerticalPodAutoscaler recomenda ou aplica requests e limits melhores, e é útil no modo de recomendação mesmo que você nunca o deixe agir; o Cluster Autoscaler (ou o Karpenter, na AWS) adiciona e remove nós quando há Pods que não podem ser agendados. Escalar Pods horizontalmente não adianta nada se o cluster não tem onde colocá-los.
Fazendo o tráfego entrar
Um Service LoadBalancer por aplicação significa um load balancer de nuvem e um IP público por aplicação. O Ingress consolida tudo isso: um único controller — ingress-nginx, Traefik, HAProxy ou um nativo do provedor de nuvem — atrás de um só load balancer, roteando por host e path e terminando o TLS.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
ingressClassName: nginx
tls:
- hosts: [shop.example.com]
secretName: shop-tls
rules:
- host: shop.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: shop-api
port: { name: http }
O ponto fraco do Ingress é que tudo o que vai além do roteamento por host e path fica em annotations específicas de cada controller, e os manifestos deixam de ser portáveis. A Gateway API é a sucessora: GatewayClass, Gateway e HTTPRoute são recursos tipados de primeira classe, com suporte a correspondência por cabeçalhos, divisão de tráfego por peso e delegação entre namespaces, e uma separação clara entre o time de plataforma, dono do Gateway, e os times de aplicação, donos das rotas. É o caminho para clusters novos; o Ingress continua suportado e onipresente.
Workloads stateful
O armazenamento é expresso por três objetos. Um PersistentVolumeClaim é um pedido de armazenamento; uma StorageClass descreve um tipo de armazenamento e o provisioner que o cria sob demanda; um PersistentVolume é o armazenamento resultante. Em um cluster na nuvem, você escreve claims que referenciam uma classe, e o resto é automático.
Um StatefulSet difere de um Deployment em pontos que importam para bancos de dados e brokers: os Pods recebem nomes ordinais estáveis (kafka-0, kafka-1), um nome DNS estável por meio de um Service headless, seu próprio PersistentVolumeClaim criado a partir de um template e mantido entre reinícios, e rollouts ordenados, um de cada vez.
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres }
spec:
serviceName: postgres-headless
replicas: 3
selector: { matchLabels: { app: postgres } }
template:
metadata: { labels: { app: postgres } }
spec:
terminationGracePeriodSeconds: 60
containers:
- name: postgres
image: postgres:16
volumeMounts:
- { name: data, mountPath: /var/lib/postgresql/data }
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd
resources: { requests: { storage: 100Gi } }
Repare no ReadWriteOnce: a maioria dos armazenamentos em bloco só pode ser montada por um nó de cada vez, então dois Pods não conseguem compartilhar um volume desse tipo. Acesso compartilhado de leitura e escrita exige uma classe de armazenamento de arquivos com suporte a ReadWriteMany.
Um StatefulSet dá identidade e armazenamento. Ele não dá replicação, failover nem backups — isso vem do software de banco de dados e, na prática, de um operator: um controller que codifica o conhecimento operacional de um sistema específico, observando recursos customizados e cuidando de backups, failovers e upgrades de versão. Rodar um banco de dados sério no Kubernetes significa rodar o operator dele, ou usar um serviço gerenciado e apontar o cluster para ele.
Processamento em lote
Um Job executa Pods até que um número especificado deles termine com sucesso. Um CronJob cria Jobs segundo um agendamento.
apiVersion: batch/v1
kind: CronJob
metadata: { name: nightly-reconcile }
spec:
schedule: "17 2 * * *"
timeZone: "Europe/Paris"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: OnFailure
containers:
- name: job
image: registry.example.com/acme/reconcile:1.4.2
concurrencyPolicy: Forbid impede que uma execução lenta se sobreponha ao próximo agendamento — o padrão Allow já causou muito processamento duplicado. ttlSecondsAfterFinished limpa os Jobs concluídos; sem ele, o namespace acumula milhares de Pods finalizados. E activeDeadlineSeconds impede que um job travado rode para sempre.
Init containers e sidecars
Os init containers rodam até o fim, em ordem, antes de os contêineres da aplicação iniciarem. São o lugar certo para esperar por uma dependência, executar uma migração de schema ou baixar um arquivo para um emptyDir compartilhado.
Os sidecars rodam ao lado da aplicação durante toda a vida do Pod. Desde a 1.29 eles têm suporte nativo: declare-os como init containers com restartPolicy: Always, e o kubelet os inicia antes dos contêineres da aplicação e os encerra depois — o que finalmente resolve o velho problema do log shipper que morria antes de enviar tudo, ou do sidecar que mantinha o Pod de um Job vivo para sempre.
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ["sh", "-c", "until nc -z postgres 5432; do sleep 2; done"]
- name: log-shipper # a native sidecar
image: fluent-bit:3.1
restartPolicy: Always
volumeMounts:
- { name: logs, mountPath: /var/log/app }
As variantes adapter e ambassador da mesma ideia — um contêiner que transforma a saída da aplicação ou que faz proxy das suas chamadas de saída — são o que os service meshes instalam automaticamente.
Privilégio mínimo
Quatro mecanismos, cada um fechando uma porta diferente.
O security context restringe o próprio contêiner:
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
seccompProfile: { type: RuntimeDefault }
Um sistema de arquivos raiz somente leitura obriga você a montar volumes emptyDir onde quer que a aplicação realmente grave, o que já é uma auditoria útil por si só. O Pod Security Admission impõe essas configurações em todo o namespace com um label como pod-security.kubernetes.io/enforce: restricted, de modo que um manifesto que as omite é rejeitado em vez de ficar inseguro sem ninguém perceber.
O RBAC controla o que um sujeito pode fazer pela API. Dê a cada workload seu próprio ServiceAccount e conceda apenas o que ele precisa:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { namespace: staging, name: config-reader }
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { namespace: staging, name: shop-api-config-reader }
subjects:
- kind: ServiceAccount
name: shop-api
roleRef: { kind: Role, name: config-reader, apiGroup: rbac.authorization.k8s.io }
Role e RoleBinding têm escopo de namespace; ClusterRole e ClusterRoleBinding valem para o cluster inteiro. Defina automountServiceAccountToken: false nos workloads que nunca chamam a API. kubectl auth can-i --list --as=system:serviceaccount:staging:shop-api mostra o que um service account consegue fazer de verdade, o que costuma ser mais do que o dono imaginava.
A NetworkPolicy restringe o tráfego entre Pods, que de outra forma é totalmente aberto. O padrão é negar tudo por padrão em um namespace e depois liberar fluxos específicos:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-ingress, namespace: staging }
spec:
podSelector: {}
policyTypes: ["Ingress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-from-ingress, namespace: staging }
spec:
podSelector: { matchLabels: { app: shop-api } }
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } }
ports:
- { protocol: TCP, port: 8080 }
As policies são aplicadas pelo plugin CNI, então confirme que o seu tem suporte a elas — com alguns plugins os objetos são aceitos e ignorados em silêncio, o que é pior do que não tê-los.
Empacotando manifestos
Copiar YAML para cada ambiente não resiste à chegada de um terceiro ambiente. Duas ferramentas dominam.
O Kustomize vem embutido no kubectl. Um diretório base guarda manifestos puros; os overlays aplicam patches sobre eles:
# overlays/prod/kustomization.yaml
resources:
- ../../base
namespace: prod
images:
- name: registry.example.com/acme/shop-api
newTag: "1.4.2"
replicas:
- { name: shop-api, count: 6 }
patches:
- path: resources-patch.yaml
target: { kind: Deployment, name: shop-api }
configMapGenerator:
- name: shop-api-config
files: [application.yaml]
Nada usa templates, então todo arquivo é YAML válido e legível, e kubectl kustomize overlays/prod imprime exatamente o que será aplicado. Os geradores adicionam um hash do conteúdo ao nome dos ConfigMaps, o que faz qualquer mudança de configuração disparar um rollout automaticamente — o problema que a annotation de checksum resolve na mão.
O Helm empacota os manifestos como um chart versionado, com templates Go e um values.yaml. É o formato de distribuição de praticamente todos os componentes de terceiros — helm install ingress-nginx ingress-nginx/ingress-nginx -f values.yaml — e registra as releases, de modo que helm rollback restaura os manifestos anteriores. O custo é que YAML com templates é chato de ler e depurar; rodar helm template antes de helm install deve ser um reflexo.
Seja qual for a escolha, os manifestos pertencem ao Git e devem ser aplicados por um processo automatizado, não a partir de um notebook. Controllers GitOps como Argo CD e Flux levam isso às últimas consequências: o repositório é o estado desejado, o controller reconcilia continuamente o cluster com ele, e os desvios são reportados em vez de descobertos por acaso.
Principais conclusões
- Liveness reinicia, readiness drena, startup protege inicializações lentas — nunca deixe uma liveness probe verificar uma dependência e adicione um atraso em
preStoppara que as conexões sejam drenadas. - Requests orientam o agendamento e limits são impostos; sempre defina limits de memória, desconfie de limits de CPU e saiba em qual classe de QoS seus Pods caem.
- Rolling updates com
maxUnavailable: 0eminReadySecondsfalham com segurança, mas o rollback é trabalho do seu pipeline; um PodDisruptionBudget protege você durante as drenagens. - Faça autoscaling com base na CPU relativa às requests, ajuste o bloco
behaviorpara que a redução seja lenta e lembre-se de que o cluster autoscaler precisa fornecer os nós. - Consolide hoje os pontos de entrada atrás de um controller Ingress e planeje a migração para a Gateway API; mantenha o comportamento específico do controller em annotations fáceis de encontrar depois.
- StatefulSets dão identidade e armazenamento por Pod; replicação, failover e backups vêm do software ou do seu operator, não do Kubernetes.
- Blinde os workloads com um security context restritivo, um ServiceAccount por workload com RBAC mínimo e uma NetworkPolicy de negação padrão que seu CNI realmente aplique.
- Use overlays do Kustomize para os seus próprios serviços e charts do Helm para software de terceiros, mantenha ambos no Git e deixe um controller GitOps aplicá-los.
Fontes e leituras complementares
Perguntas frequentes
- Devo definir limits de CPU?
- Muitas vezes, não. CPU é um recurso comprimível: ultrapassar o limit não mata o contêiner, ele sofre throttling, e um throttling agressivo gera picos de latência difíceis de diagnosticar. Definir uma request de CPU garante uma fatia do processador e orienta o agendamento, que é o que realmente importa. Memória é outra história: não é comprimível, então um limit de memória é essencial para impedir que um contêiner derrube um nó inteiro, e ele deve ser igual à request em workloads críticos.
- Por que meu Pod reinicia mesmo com a aplicação funcionando?
- Geralmente por causa de uma liveness probe rígida demais. Se o endpoint verifica a conexão com o banco de dados, uma consulta lenta ou uma breve indisponibilidade marca o contêiner como morto e o Kubernetes o reinicia — o que não conserta o banco e ainda descarta as requisições em andamento. A liveness deve testar apenas se o processo travou. A saúde das dependências é papel da readiness probe, que tira o Pod do balanceamento de carga sem matá-lo.
- O que acontece de fato durante um rolling update?
- O Deployment cria um novo ReplicaSet e o escala para cima enquanto reduz o antigo, respeitando maxSurge e maxUnavailable. Um Pod novo só conta como disponível depois que sua readiness probe passa e minReadySeconds transcorre. Se os Pods novos nunca ficam prontos, progressDeadlineSeconds acaba marcando o rollout como falho — mas não faz rollback automático, então acompanhe o status do rollout no seu pipeline ou use um controller que faça isso.
- Helm ou Kustomize?
- Kustomize combina com as suas próprias aplicações: você mantém YAML puro e aplica patches específicos de cada ambiente sobre uma base, sem nenhuma linguagem de templates para depurar. Helm combina com software que você distribui ou consome, porque um chart é um pacote versionado com values, dependências e histórico de releases. Muitos times usam os dois — Helm para componentes de terceiros e Kustomize para os próprios serviços — e essa combinação é perfeitamente razoável.