Os hooks de shutdown rodavam duas vezes - quando o SIGTERM tem dois donos
A cada deploy, um erro P2028 do Prisma caía no Sentry. Fui corrigir a ordem de shutdown e descobri que o enableShutdownHooks do NestJS e a biblioteca de graceful shutdown estavam, os dois, capturando o SIGTERM - fazendo os hooks de shutdown rodarem duas vezes a cada desligamento.
Resumo
Quando o SIGTERM chegava durante um deploy, o banco fechava antes dos jobs que ainda estavam em andamento, gerando o erro P2028 do Prisma. A causa era o shutdown do worker e o shutdown do banco estarem na mesma fase do ciclo de vida - a correção foi drenar o worker numa fase anterior, o beforeApplicationShutdown, para acertar a ordem. Só que, durante a revisão, um problema mais profundo apareceu. O enableShutdownHooks() e o http-graceful-shutdown estavam, os dois, capturando o SIGTERM, fazendo os hooks de shutdown rodarem duas vezes antes mesmo do dreno do HTTP terminar. Só não virou incidente porque os hooks eram idempotent - sobrou apenas um cheiro de log. A solução foi apagar uma linha (enableShutdownHooks) para deixar um único dono do sinal, e validar isso na prática enviando um SIGTERM de verdade.
Nesta página
A cada deploy, o mesmo erro caía no Sentry. Transaction already closed. É o erro que o Prisma lança quando tenta rodar uma query depois que a transação já foi fechada - código P2028. Não havia impacto nenhum para o usuário: o retry do BullMQ fazia outra tentativa pegar o job logo em seguida, e os logs de conclusão seguiam sem interrupção. Ainda assim, era uma estrutura que reaparecia silenciosamente toda vez que um deploy coincidia com tráfego real. Foi correndo atrás desse erro que descobri que os hooks de shutdown do nosso servidor, na verdade, estavam rodando duas vezes a cada desligamento.
Ato 1: o banco morre antes do job
Cruzando o horário do erro com os logs do CloudWatch, a linha do tempo batia certinho. O deploy rolling manda um SIGTERM para a task antiga. Seis segundos depois, o app.close() começa a limpeza e o engine do Prisma fecha a transação. Logo em seguida, o updateMany de um job de asset-processing que ainda estava rodando executa em cima de uma transação já fechada e falha com P2028. Um usuário estava fazendo upload contínuo de áudio, então havia um job ativo bem no momento do deploy, e foi a query desse job que morreu.
A causa era a ordem do shutdown. Ao desligar, o NestJS chama os hooks de ciclo de vida numa ordem definida. O problema é que fechar o worker do BullMQ e desconectar o Prisma estavam na mesma fase de shutdown (onApplicationShutdown). Dentro da mesma fase, é a ordem de registro dos módulos que decide a ordem de execução - e, por azar, o Prisma desconectava primeiro, deixando sem destino a query do worker que ainda estava processando um job.
A direção do conserto era clara: fechar o worker numa fase anterior. Os hooks de shutdown do NestJS têm uma ordem, e o beforeApplicationShutdown termina antes do onApplicationShutdown de qualquer módulo. Criei um serviço que encontra todos os workers e os drena (espera os jobs ativos terminarem) nessa fase mais cedo.
@Injectable()
export class WorkerDrainService implements BeforeApplicationShutdown {
constructor(private readonly discovery: DiscoveryService) {}
async beforeApplicationShutdown() {
// Encontra todos os WorkerHost registrados e espera os jobs ativos terminarem.
// Essa fase termina antes do onApplicationShutdown, onde o Prisma desconecta.
const workers = this.discovery
.getProviders()
.filter((p) => p.instance instanceof WorkerHost);
await Promise.all(workers.map((w) => w.instance.worker.close()));
}
}
Como Worker.close() devolve a mesma promise se chamado de novo, não conflita com nenhum código que já fechasse workers, e em ambientes sem Redis simplesmente não faz nada. Agora, mesmo quando um deploy coincide com um job, é o job que termina primeiro e só depois o banco é fechado. Adicionei testes unitários e abri o PR. Até aqui, era só uma correção comum de ordem.
Ato 2: o SIGTERM tinha dois donos
A virada veio na revisão. Um revisor examinando essa correção passou o olho por todo o caminho de shutdown e fez uma pergunta simples: afinal, quem está tratando o SIGTERM?
Conferindo, havia dois donos. No main.ts existiam estas duas linhas, separadas:
// ① Delega o sinal de shutdown ao próprio Nest (chamado sem argumento, registra SIGTERM e SIGINT)
app.enableShutdownHooks();
// ② Delega o shutdown também à biblioteca http-graceful-shutdown
setupGracefulShutdown(server, {
onShutdown: async () => {
await app.close(); // aqui roda todo o conjunto de hooks do ciclo de vida
},
});
Os dois capturam o SIGTERM. Quando o sinal chega, o Node chama todos os listeners registrados, em ordem, sem esperar um terminar para chamar o outro. Então, no instante em que um SIGTERM real chega, acontece o seguinte: o listener registrado pelo Nest dispara imediatamente, sem sequer esperar o dreno do HTTP, a mesma sequência de shutdown que o app.close() executaria. Em paralelo, o http-graceful-shutdown drena as conexões HTTP do seu próprio jeito e, no onShutdown, chama o app.close() de novo. Os hooks de shutdown acabam rodando duas vezes.
Só o raciocínio não bastava para ter certeza, então voltei a ler os logs de shutdown de produção. A evidência estava lá. Vinte milissegundos depois do horário registrado do SIGTERM - antes mesmo do dreno do HTTP terminar - o onModuleDestroy já tinha começado, e os logs de shutdown do SpeechService e do QueueMonitorService apareciam duplicados, cada um duas vezes. A única razão para isso não ter virado incidente é que os hooks de cada serviço foram escritos para serem seguros mesmo se chamados duas vezes (idempotent). Graças a esse cuidado defensivo, a "execução dupla" ficou só como cheiro de log em vez de virar incidente - e foi justamente esse cheiro que levou a encontrar o bug.
Apagando uma linha
A correção foi apagar uma linha.
// main.ts
- app.enableShutdownHooks();
Sem o enableShutdownHooks(), o dono do SIGTERM passa a ser um só: o http-graceful-shutdown. Não se perde nada, porque o app.close() chamado pelo onShutdown dessa biblioteca já executa todo o conjunto de hooks do ciclo de vida de qualquer forma. A única diferença é que os hooks agora rodam uma vez só, e depois que o dreno do HTTP termina.
Quanto menor a correção, mais necessário é medir na prática. Enviei um SIGTERM de verdade para o servidor já buildado e conferi a sequência com os próprios olhos.
Received SIGTERM → health check 503 → HTTP connections closed
→ shutdown hooks run only once, after HTTP close
→ Drained 56/56 BullMQ workers in 211ms → shutdown completed → clean exit
O que em produção rodava duas vezes antes do dreno do HTTP agora roda uma única vez, depois do dreno. Como essa validação já incluía a correção do dreno de workers do Ato 1, acabei confirmando também o comportamento combinado das duas correções juntas.
Registrei também uma armadilha à parte: em NODE_ENV=development, o http-graceful-shutdown opera em modo de desligamento imediato e nem entra no caminho graceful. Por isso, essa validação não se reproduz em modo de desenvolvimento. Só consegui medir na prática depois de configurar NODE_ENV=staging com segredos fictícios, forçando o mesmo caminho que roda em produção. Ao validar um caminho de shutdown, o primeiro passo é garantir que o ambiente de validação realmente passa por esse caminho.
O que ficou desse código de shutdown
A lição desta história vai além do palco estreito da sequência de shutdown.
Um sinal só deve ter um dono. Se você adicionou uma biblioteca de graceful shutdown, não deixe ligado ao mesmo tempo o registro automático de hooks de shutdown que o framework oferece. No instante em que os dois capturam o mesmo SIGTERM, a sequência de shutdown roda duas vezes, silenciosamente. Ao adotar uma ferramenta nova, o primeiro passo é entender de qual sinal ou evento ela vai se tornar dona, e verificar se já não existe código capturando a mesma coisa.
Shutdown também tem ordem. O código que limpa algo precisa rodar depois que tudo que depende daquilo já terminou. O banco só deve fechar depois que os jobs que o usam terminarem, e para isso os jobs precisam ser drenados numa fase anterior. As fases separadas nos hooks de shutdown do framework não são enfeite - existem justamente para expressar essa ordem.
Código escrito defensivamente compra tempo. Se os hooks de shutdown não tivessem sido escritos para serem seguros mesmo chamados duas vezes, esse bug teria aparecido primeiro como um incidente de dados, não como um cheiro de log. Foi só porque os hooks foram escritos de forma idempotent, aguentando a execução dupla, que conseguimos encontrar o problema no log em vez de num incidente. Se os hooks tivessem sido escritos assumindo que rodariam uma única vez, a história teria sido bem pior.
Perguntas frequentes
Os hooks de ciclo de vida (onModuleDestroy, etc.) do meu app NestJS estão rodando duas vezes no shutdown. Por quê?
É bem provável que haja dois donos tratando o SIGTERM. O app.enableShutdownHooks() faz o próprio Nest registrar os listeners de sinal de shutdown; se você também usa uma biblioteca de graceful shutdown (como o http-graceful-shutdown), ela também captura o SIGTERM e chama app.close() dentro do onShutdown. Como o Node chama todos os listeners registrados para um mesmo sinal, a sequência de shutdown acaba rodando duas vezes. A solução é deixar apenas um dos dois como dono do sinal.
Se eu uso uma biblioteca de graceful shutdown, não devo chamar o enableShutdownHooks()?
Se a biblioteca já chama app.close() dentro do callback onShutdown, o enableShutdownHooks() é redundante, porque o app.close() já executa todos os hooks do ciclo de vida. Um sinal só deve ter um dono. Deixando a biblioteca como dona do sinal e removendo o enableShutdownHooks(), os hooks passam a rodar uma única vez - e só depois que o dreno do HTTP termina.
Artigos relacionados
- 💻 Dev
O desconto que você não estava usando - por que Spot e Graviton são baratos
A fatura da AWS estourou o orçamento por dois meses seguidos. Ao procurar recursos para cortar, descobri que a verdadeira alavanca não era 'quanto usar', mas 'como comprar'. Por que Spot e Graviton são baratos, e por que existe um custo que parece desperdício mas não pode ser desligado (RDS Proxy) - a história do 'porquê' por trás de cada número da fatura.
- 💻 Dev
É só desligar no staging, não é? - o alerta barulhento que escondia o bug de verdade
Todo dia, o mesmo alerta de 404 do Google Play aparecia no staging. Desligar a fonte deixa tudo quieto, mas isso e corrigir o modo de falha são duas coisas diferentes. O 404 é um erro permanente que não melhora com retry, e mesmo assim estava sendo agrupado com os 5xx e retentado sem parar.
- 💻 Dev
Criando um blog MDX do zero com Next.js - como este blog foi feito
O processo de criar um blog que roda só com arquivos markdown, sem CMS, usando o App Router do Next.js 16. Estrutura de pastas, configuração do MDX e SEO, tudo organizado com o fluxo de código real.