Graceful shutdown: desligar também faz parte do serviço
Todo deploy derruba pods, e se o seu serviço não sabe sair com calma, cada deploy vira uma rajada de erros
Um time percebe que, a cada deploy, o dashboard mostra um pequeno pico de erros 502 e 503. Dura uns poucos segundos, some sozinho e ninguém dá muita atenção, porque “deploy é assim mesmo”. Até que o serviço passa a ter dez deploys por dia, o pico aparece no SLO, e alguém descobre que parte desses erros eram pagamentos que ficaram pela metade. O problema não estava no código novo. Estava na forma como o código antigo era desligado.
Num ambiente com Kubernetes, pods morrem o tempo todo: deploy, autoscaling reduzindo réplicas, nó sendo drenado para manutenção. Desligar é uma operação tão rotineira quanto subir, e merece o mesmo cuidado.
O que acontece quando um pod é encerrado
Quando o Kubernetes decide encerrar um pod, várias coisas acontecem quase ao mesmo tempo:
- O pod é marcado como
Terminatinge começa a ser removido dos endpoints do Service. - Se existir um
preStophook, ele é executado. - O container recebe um
SIGTERM. - O Kubernetes espera até o
terminationGracePeriodSeconds(30 segundos por padrão). Se o processo ainda estiver vivo, recebe umSIGKILL.
O detalhe que pega muita gente é que os passos 1 e 3 não são sequenciais. A remoção dos endpoints precisa se propagar para o kube-proxy, para o ingress e para os balanceadores, e isso leva um tempo. Enquanto isso, o SIGTERM pode já ter chegado. Se a aplicação para de aceitar conexões imediatamente, as requisições que ainda estão sendo roteadas para ela batem numa porta fechada.
O SIGTERM precisa chegar na JVM
Antes de qualquer outra coisa, vale conferir se o sinal está chegando. Um Dockerfile com CMD em formato shell executa a aplicação através de /bin/sh -c, e o shell vira o processo principal do container. Muitos shells não repassam o SIGTERM para o processo filho, então a JVM nunca fica sabendo que deveria desligar e morre com SIGKILL depois de 30 segundos.
# o shell recebe o SIGTERM, a JVM não
CMD java -jar app.jar
# a JVM é o processo principal e recebe o sinal
ENTRYPOINT ["java", "-jar", "app.jar"]
Se o pod sempre leva exatamente o grace period inteiro para morrer, essa é a primeira suspeita.
O sleep que parece gambiarra
A forma mais comum de resolver a corrida com os endpoints é um preStop que simplesmente espera alguns segundos antes do SIGTERM ser enviado:
spec:
terminationGracePeriodSeconds: 45
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sleep", "10"]
Durante esses dez segundos, a aplicação continua atendendo normalmente enquanto o resto da infraestrutura para de mandar tráfego para ela. Parece gambiarra, mas é a solução recomendada na prática. Duas observações: o tempo do preStop conta dentro do terminationGracePeriodSeconds, então o total precisa caber ali, e o exec depende de existir um binário sleep na imagem, o que não é verdade em imagens distroless. Para esse caso existe a ação sleep nativa no preStop, habilitada por padrão desde o Kubernetes 1.30 e estável desde a 1.34, que é executada pelo próprio kubelet:
lifecycle:
preStop:
sleep:
seconds: 10
O que a aplicação faz ao receber o sinal
Depois do SIGTERM, a aplicação deveria seguir uma ordem:
- Parar de aceitar requisições e conexões novas.
- Terminar as requisições que já estão em andamento, dentro de um prazo.
- Parar de consumir mensagens de filas e confirmar o que já foi processado.
- Fechar conexões com banco, cache e outros recursos.
- Sair.
No Spring Boot, a primeira parte vem quase pronta. Com o shutdown gracioso ligado, o servidor web para de aceitar requisições novas e espera as que estão em andamento terminarem, até um limite por fase. Nas versões mais recentes isso já é o padrão, mas vale deixar explícito:
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s
O Spring também muda o estado de readiness para REFUSING_TRAFFIC assim que o shutdown começa. Só que, como o servidor HTTP para de aceitar requisições novas nesse momento, as probes de readiness também deixam de ser respondidas, e a plataforma não tem como ler esse estado a tempo. Por isso o preStop continua necessário: é ele que cobre a janela em que o tráfego ainda está chegando. A conta de tempo precisa fechar: preStop de 10 segundos mais até 20 segundos de shutdown da aplicação cabe com folga num grace period de 45.
Consumidores de fila são o caso mais delicado
Requisições HTTP têm um fim claro. Um consumidor de Kafka ou de uma fila qualquer está sempre no meio de alguma coisa. Se o processo morre depois de processar uma mensagem e antes de confirmar o offset, a mensagem vai ser entregue de novo para outro consumidor.
No Kafka, a forma segura de interromper um loop de consumo é chamar wakeup() a partir de outra thread, tratar a WakeupException no loop e fechar o consumer no final:
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
consumer.wakeup();
try {
consumerThread.join(15_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
try {
while (true) {
ConsumerRecords<String, Pedido> records = consumer.poll(Duration.ofMillis(500));
records.forEach(this::processar);
consumer.commitSync();
}
} catch (WakeupException e) {
// esperado no shutdown
} finally {
consumer.close();
}
Na configuração padrão, o close() também avisa o grupo de consumidores que este membro está saindo, o que faz o rebalanceamento acontecer na hora em vez de esperar o timeout de sessão. Mesmo assim, nenhum shutdown é perfeito: um SIGKILL, uma falha de hardware ou um OOM pulam tudo isso. Por isso o processamento precisa ser idempotente. O graceful shutdown reduz quantas mensagens são reprocessadas, mas não elimina o reprocessamento.
Trabalho em background e shutdown hooks
Executores próprios, jobs agendados e tarefas assíncronas também precisam ser encerrados. Um ExecutorService criado à mão não para sozinho: chame shutdown(), espere com awaitTermination por um tempo limitado e, se não terminar, use shutdownNow(). Shutdown hooks da JVM rodam em paralelo e sem ordem garantida, então prefira deixar o framework coordenar a ordem de encerramento em vez de registrar vários hooks independentes.
Testando de verdade
O jeito mais simples de saber se o shutdown funciona é provocar. Rode um teste de carga contra o serviço e, no meio dele, faça um kubectl rollout restart no deployment. Se aparecerem erros, alguma etapa está faltando. Também vale olhar quanto tempo os pods levam para terminar: se todos levam o grace period inteiro, o sinal não está chegando ou alguma coisa está travando a saída.
Graceful shutdown não tem nada de sofisticado. São alguns segundos de espera, uma propriedade de configuração, um loop de consumo bem escrito e um Dockerfile que repassa sinais. Mas é o que separa um deploy invisível de um deploy que todo mundo aprende a temer.