Infra

Upgrade do AKS sem derrubar a aplicação: max surge, PDB e janela de manutenção

O upgrade do cluster foi agendado para a madrugada de domingo, correu sem nenhum erro e o portal mostrou “Succeeded” na manhã seguinte. Mesmo assim, o monitoramento registrou alguns minutos de indisponibilidade da API principal por volta das duas da manhã. A investigação foi curta: as três réplicas da aplicação estavam no mesmo nó, e quando o AKS drenou esse nó, as três saíram juntas. Não havia PodDisruptionBudget, então nada disse ao Kubernetes que aquilo não podia acontecer.

O AKS fez exatamente o que foi configurado para fazer. O problema é que ninguém tinha configurado nada.

O que acontece com cada nó durante o upgrade

O upgrade de um node pool é um rolling upgrade, sempre no mesmo ciclo:

Surge. O AKS cria nós extras, já na versão nova, conforme o max surge do pool.

Cordon e drain. Um nó antigo é marcado como não agendável e os pods são removidos pela Eviction API, a mesma do kubectl drain. Com max surge maior que 1, o AKS drena tantos nós ao mesmo tempo quanto o número de nós de surge.

Soak (opcional). Uma espera entre o drain e o reimage, para você olhar o painel antes de seguir.

Reimage e repetição. O nó drenado recebe a versão nova e passa a ser o buffer do próximo lote. No fim, os nós de surge que sobraram são removidos e o pool volta ao tamanho original.

O ponto que a abertura ilustra: o surge garante capacidade, não disponibilidade. Quem decide quantos pods podem sair ao mesmo tempo é o PDB. Sem ele, a Eviction API não tem restrição nenhuma.

Max surge, max unavailable e soak

O padrão de max surge é 1: um nó extra por vez, o mais lento. Para produção, a Microsoft recomenda 33%. O valor aceita inteiro ou porcentagem (arredondada para cima), e 100% drena o pool inteiro de uma vez, o que só faz sentido em teste.

Três detalhes que aparecem tarde demais. A configuração é persistente e vale para os próximos upgrades de versão e de imagem. Cada nó de surge consome quota de computação e, com Azure CNI, IPs da sub-rede ((nós + maxSurge) * (1 + maxPods), na conta da documentação). E em pools com zonas, a zona dos nós de surge não é conhecida antes: use um surge múltiplo de três.

Sem quota para surge, existe o max unavailable. Ele não cria nós: drena até N nós existentes e empurra os pods para o resto do pool. O padrão é 0, não vale para pools de sistema e aumenta o conflito com PDB, porque sobra menos espaço. Combinar os dois, com fallback do surge para max unavailable, ainda é preview.

O node soak tem padrão 0 e vai até 30 minutos. Mantenha curto: ele aumenta a duração total do upgrade.

az aks get-upgrades \
  --resource-group rg-plataforma \
  --name aks-producao \
  --output table

az aks nodepool update \
  --resource-group rg-plataforma \
  --cluster-name aks-producao \
  --name npaplicacao \
  --max-surge 33% \
  --drain-timeout 45 \
  --node-soak-duration 5 \
  --undrainable-node-behavior Cordon

O PDB que protege

Para a aplicação da abertura, bastariam dois ajustes: um PDB e uma regra que espalhe as réplicas entre nós.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: pdb-api-pedidos
  namespace: pedidos
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: api-pedidos
---
# trecho do Deployment (spec.template.spec)
topologySpreadConstraints:
- maxSkew: 1
  topologyKey: kubernetes.io/hostname
  whenUnsatisfiable: ScheduleAnyway
  labelSelector:
    matchLabels:
      app: api-pedidos

Com maxUnavailable: 1 e três réplicas, o drain só consegue remover uma réplica por vez. A próxima eviction espera a substituta ficar pronta em outro nó. O topologySpreadConstraints faz o scheduler espalhar as réplicas entre nós. Com ScheduleAnyway isso é preferência, não garantia; se o cluster tem nós suficientes e você quer a regra obrigatória, use DoNotSchedule. Com as réplicas espalhadas, o drain de um único nó deixa de ser um evento de indisponibilidade.

O PDB que trava o upgrade

O mesmo mecanismo, mal configurado, para o upgrade. Um PDB com maxUnavailable: 0, ou com minAvailable igual ao número de réplicas, nunca permite uma eviction. O drain espera até o drain timeout, que tem padrão de 30 minutos e aceita de 5 minutos a 24 horas. Se o tempo acaba e ainda há pods, a operação para, e qualquer PUT seguinte retoma o upgrade.

O que acontece com o nó bloqueado depende do undrainable node behavior. No padrão, Schedule, o AKS apaga o nó bloqueado e cria um substituto. Com Cordon, que a documentação recomenda, o nó fica isolado com o rótulo kubernetes.azure.com/upgrade-status=Quarantined e o upgrade continua nos demais. O nó em quarentena fica na versão antiga e a resolução é sua: ajustar o PDB, remover o pod ou apagar o nó. O parâmetro --max-blocked-nodes, que define quantos nós bloqueados são tolerados, só existe na extensão aks-preview.

Existe também o --enable-force-upgrade no az aks upgrade, que ignora os PDBs. Eu trato como último recurso, porque ele pode drenar todos os pods de uma vez, que é exatamente o incidente da abertura.

Versão do Kubernetes e imagem do nó são upgrades diferentes

O cluster tem dois canais de atualização automática, independentes.

CanalO que mudaOpções
--auto-upgrade-channelVersão do Kubernetes (plano de controle e pools)none, patch, stable, rapid (node-image é legado)
--node-os-upgrade-channelImagem do sistema operacional dos nósNone, Unmanaged, SecurityPatch, NodeImage

O canal de imagem não altera a versão do Kubernetes. Desde a API 2023-06-01, cluster Standard novo nasce com NodeImage, que troca o VHD dos nós semanalmente, respeitando a janela de manutenção e as configurações de surge. Ou seja: mesmo quem nunca clicou em “upgrade” tem nós sendo drenados toda semana.

Cada canal tem a sua janela. O aksManagedAutoUpgradeSchedule controla o upgrade de versão e o aksManagedNodeOSUpgradeSchedule controla a imagem. A documentação pede pelo menos quatro horas de janela e deixa claro que ela é de melhor esforço: o AKS pode sair dela em manutenção urgente. Configurar a janela também não liga o upgrade automático; ela só define quando ele acontece.

az aks update \
  --resource-group rg-plataforma \
  --name aks-producao \
  --auto-upgrade-channel patch \
  --node-os-upgrade-channel NodeImage

az aks maintenanceconfiguration add \
  --resource-group rg-plataforma \
  --cluster-name aks-producao \
  --name aksManagedAutoUpgradeSchedule \
  --schedule-type Weekly --day-of-week Sunday --interval-weeks 1 \
  --start-time 01:00 --duration 4 --utc-offset=-03:00

az aks maintenanceconfiguration add \
  --resource-group rg-plataforma \
  --cluster-name aks-producao \
  --name aksManagedNodeOSUpgradeSchedule \
  --schedule-type Weekly --day-of-week Saturday --interval-weeks 1 \
  --start-time 01:00 --duration 4 --utc-offset=-03:00

Eu separo as duas janelas em dias diferentes: se algo der errado, sei qual mudança causou.

Antes da versão nova: APIs depreciadas

Em upgrades de versão menor com destino 1.26 ou superior, o AKS bloqueia a operação quando detecta uso recente de uma API depreciada ou removida na versão alvo. A detecção já vem ligada nos clusters Standard e Automatic e considera o uso das últimas 12 horas. O erro traz o subcódigo UpgradeBlockedOnDeprecatedAPIUsage e o nome da API.

Esse bloqueio é proteção, não falha. O caminho é abrir Diagnose and solve problems > Create, Upgrade, Delete, and Scale > Kubernetes API deprecations, migrar manifests e charts, esperar as 12 horas e tentar de novo. Contornar com --enable-force-upgrade só adia o problema até a chamada à API removida falhar em produção.

Três erros que eu vejo com frequência

Confiar no surge para proteger a aplicação. O surge cria capacidade, e só isso. Sem PDB e sem espalhar as réplicas, um único drain derruba o serviço inteiro, como na abertura. Antes de mexer em max surge, eu confiro se toda aplicação crítica tem pelo menos duas réplicas em nós diferentes e um PDB que permita exatamente uma eviction por vez.

Escrever um PDB que ninguém consegue satisfazer. minAvailable: 1 num Deployment com uma réplica, ou maxUnavailable: 0 “por segurança”. O resultado é o drain parado por 30 minutos, o upgrade interrompido e o cluster em estado parcial numa janela que já acabou. O AKS até valida PDBs mal configurados antes do upgrade, mas a correção é no manifest: o PDB precisa permitir pelo menos uma eviction.

Achar que só o upgrade de versão drena nós. O canal NodeImage troca a imagem dos nós toda semana, com o mesmo ciclo de cordon e drain. Quem configura só a janela do upgrade de versão e esquece o aksManagedNodeOSUpgradeSchedule descobre o drain no horário comercial.

Quando a mudança é grande: um node pool novo

Para mudanças de maior risco, como subir para uma versão com muitas mudanças de API ou trocar o tamanho de VM, eu prefiro não fazer rolling upgrade no pool existente. O padrão é o blue-green de node pools: com o plano de controle já na versão alvo, criar um pool novo nessa versão, isolar o antigo e migrar no seu ritmo.

az aks nodepool add \
  --resource-group rg-plataforma \
  --cluster-name aks-producao \
  --name npverde \
  --kubernetes-version <versao-alvo> \
  --node-count 3

kubectl cordon -l kubernetes.azure.com/agentpool=npazul
kubectl drain <no-do-pool-azul> --ignore-daemonsets --delete-emptydir-data

az aks nodepool delete \
  --resource-group rg-plataforma \
  --cluster-name aks-producao \
  --name npazul

O kubectl drain respeita os mesmos PDBs, e o pool antigo serve de rollback até ser apagado: kubectl uncordon nos nós azuis, drain dos nós verdes e remoção do pool verde. O custo é quota em dobro na transição. Há também uma estratégia BlueGreen gerenciada dentro do pool, ainda em preview.

O que fica

Upgrade sem indisponibilidade não é uma configuração do AKS, é uma propriedade da aplicação. Max surge, janela de manutenção e drain timeout decidem quando e com que velocidade os nós saem, mas quem decide se o usuário percebe é o número de réplicas, onde elas estão e o PDB que as protege. Eu não aceitaria ligar o upgrade automático num cluster em que essa conta não foi feita para cada aplicação crítica.

Se o AKS drenasse agora o nó mais carregado do seu cluster, quantas réplicas da sua aplicação principal ficariam de pé?