Encerrando um Servidor Node de Forma Graciosa
Desligamento gracioso do Node com SIGTERM: ajuste a readiness, drene requisições com server.close, feche recursos em ordem e evite 502.
Um shutdown gracioso no Node trata o SIGTERM em uma ordem fixa: falhar o readiness check, aguardar o load balancer parar de rotear tráfego, drenar as requisições em andamento com server.close(), fechar os recursos na ordem de dependência e, então, encerrar o processo.
Se você já faz a maior parte disso e ainda vê 502s e connection resets em cada rollout, o problema muitas vezes não está no handler em si. Três fatores ao seu redor tendem a causar isso: o sinal nunca chega ao Node, o drain nunca termina, ou o orquestrador mata o pod antes de o drain concluir. Este artigo constrói um handler pequeno, percorre esses três cenários e termina com um teste que você pode executar.
Pontos Principais
- Sem um handler de SIGTERM, o Node encerra imediatamente e cada requisição em andamento termina como um connection reset ou uma resposta truncada.
- Aguarde (
await) oserver.close()antes de fechar o pool do banco de dados; fechar o pool primeiro transforma cada query em andamento em um erro. - Desde o Node 19.0.0, o
server.close()fecha por conta própria as conexões keep-alive ociosas; o antigo travamento do tipo “o callback de close nunca é disparado” é histórico. - Um
CMDem shell form no Dockerfile significa que o shell, e não o Node, recebe o SIGTERM, então nenhum código do handler é executado. - Configure um timer de saída forçada dentro do processo com valor abaixo do
terminationGracePeriodSeconds, para que o processo escolha sua própria saída em vez de receber um SIGKILL.
O Que Acontece Sem um Handler de SIGTERM?
A resposta padrão do Node ao SIGTERM é encerrar o processo, conforme a documentação de eventos de sinal. Tudo o que estava em andamento naquele instante é cortado no meio da resposta: o cliente vê um reset ou um corpo parcial, e o load balancer registra um 502. No session replay, essa falha tem um formato reconhecível: uma ação do usuário que fica girando e depois dá erro, agrupada em uma janela estreita que coincide com um rollout, e não com algo que o usuário fez. Essa visão do lado do cliente é normalmente onde o bug é notado primeiro.
A Ordem Correta do Graceful Shutdown no Node
Um handler correto faz cinco coisas em ordem: alterna uma flag de shutdown para que o readiness falhe, aguarda brevemente para o load balancer reagir, aguarda o server.close() para que as requisições em andamento terminem, fecha os recursos restantes e então encerra. O await nesse close é a parte que importa. Um handler que chama server.close() sem esperar pelo seu callback, limpa os recursos e encerra recriou exatamente o crash que ele foi escrito para evitar.
- Alterne uma flag de shutdown para que a readiness probe retorne 503.
- Aguarde brevemente para o load balancer parar de rotear novo tráfego.
- Aguarde o
server.close()para que as requisições em andamento terminem. - Feche os recursos restantes na ordem de dependência.
- Encerre o processo.
// server.js (Express)
const { setTimeout: sleep } = require('node:timers/promises');
let shuttingDown = false;
app.get('/health/live', (req, res) => res.sendStatus(200));
app.get('/health/ready', (req, res) => res.sendStatus(shuttingDown ? 503 : 200));
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true; // readiness now returns 503
await sleep(LB_WAIT_MS); // matches your preStop sleep
await new Promise((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
);
await closeResources(); // next section
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
Por Que o Drain Pode Travar?
Em qualquer release suportada do Node, o server.close() interrompe novas conexões, fecha sockets keep-alive ociosos e aguarda a conclusão das conexões com uma requisição em andamento. O aviso amplamente repetido de que sockets keep-alive ociosos impedem que o callback de close seja disparado descreve o Node anterior à versão 19.0.0, portanto o travamento não ocorre mais em versões suportadas. Se você precisa rodar runtimes mais antigos, chame server.closeIdleConnections() (adicionado no Node 18.2.0) imediatamente após iniciar o close, e não antes dele, para evitar uma condição de corrida com conexões recém-criadas.
O que ainda trava um drain é trabalho legitimamente ativo: handlers lentos, server-sent events e sockets que sofreram upgrade para outro protocolo. É para isso que existe o timer de saída forçada mostrado adiante.
Feche os Recursos na Ordem de Dependência
Feche primeiro o servidor HTTP, depois os queue workers e background jobs, depois o Redis, e por último o pool do banco de dados. A ordem segue a cadeia de dependências: os request handlers e os jobs usam o Redis e o pool, então fechar o pool enquanto eles ainda estão em execução transforma cada query em andamento em um erro, o que anula o drain que você acabou de aguardar.
async function closeResources() {
await worker.close(); // BullMQ or similar: finish the active job
await redis.quit();
await pool.end(); // pg pool last: nothing queries after this
}
Jobs que rodam por mais tempo que o grace period precisam de checkpointing para poderem retomar, não de drain.
Quais Configurações de Container Quebram o Graceful Shutdown?
O Node precisa ser o processo que realmente recebe o sinal, ou nada do que foi dito acima importa. A referência do Dockerfile é explícita ao dizer que a shell form executa seu comando sob /bin/sh -c, que não repassa sinais para o processo filho, então um CMD em shell form significa que o SIGTERM do docker stop nunca chega ao Node.
# Broken: /bin/sh -c receives SIGTERM, node never does
CMD node server.js
# Correct: node is the signal target
CMD ["node", "server.js"]
Se você precisa de um processo init para colher processos zumbis, o tini encaminha sinais para seu processo filho, então o handler continua sendo executado. Observe que o docker stop escala para SIGKILL após 10 segundos por padrão no Linux, o que é configurável com -t.
No Kubernetes, defina o terminationGracePeriodSeconds (padrão 30) acima do seu tempo total de drain, e adicione um sleep no preStop: a remoção do endpoint é avaliada em paralelo com o SIGTERM e se propaga de forma assíncrona via EndpointSlices, então as requisições continuam chegando por um instante após o sinal. Mantenha a liveness probe passando enquanto a readiness falha; uma liveness probe apontada para o mesmo endpoint que está falhando faz o container ser reiniciado no meio do drain.
terminationGracePeriodSeconds: 30 # > preStop + LB wait + drain + cleanup
lifecycle:
preStop:
exec:
command: ["sleep", "5"] # LB_WAIT_MS should match
Escolha Sua Saída Antes Que o SIGKILL Escolha
Acione um timer de saída forçada no início do handler, configurado abaixo do grace period do orquestrador, para que um drain travado termine com sua linha de log e seu exit code em vez de um SIGKILL. Como último recurso, ele chama server.closeAllConnections() (adicionado no Node 18.2.0), que derruba todas as conexões abertas, incluindo aquelas que ainda estão atendendo uma requisição. Conexões que passaram para outro protocolo sobrevivem a isso, então WebSockets precisam do seu próprio broadcast de close frame.
const forceExit = setTimeout(() => {
server.closeAllConnections();
process.exit(1);
}, GRACE_MS - 2_000); // grace period minus a buffer, never a fixed default
forceExit.unref();
Como Provar Que o Shutdown Funciona?
O teste de aceitação é mecânico: inicie o servidor, dispare uma requisição para uma rota deliberadamente lenta, envie SIGTERM no meio do voo e verifique se a resposta ainda chega com 200 e o processo encerra com 0.
// verify-shutdown.js
const { spawn } = require('node:child_process');
const assert = require('node:assert');
const child = spawn('node', ['server.js'], { stdio: ['ignore', 'pipe', 'inherit'] });
child.stdout.on('data', async (chunk) => {
if (!chunk.toString().includes('listening')) return;
const pending = fetch('http://localhost:3000/slow'); // route awaits ~2s
setTimeout(() => child.kill('SIGTERM'), 100);
const res = await pending;
assert.equal(res.status, 200);
const code = await new Promise((r) => child.on('exit', r));
assert.equal(code, 0);
console.log('graceful shutdown verified');
});
Se isso passa localmente mas os rollouts ainda perdem requisições, os suspeitos restantes estão na camada de container: um CMD em shell form ou um grace period menor que o seu orçamento de drain.
O handler em si tem quarenta linhas; a confiabilidade vem da ordenação e do ambiente ao seu redor. Integre o teste ao CI para que a próxima refatoração não possa reintroduzir silenciosamente o bug de reset-no-deploy que você acabou de corrigir.
Perguntas Frequentes
Um processo Node.js pode capturar ou tratar SIGKILL?
Não. O Node se recusa a registrar um listener para SIGKILL, e o sinal encerra o processo em todas as plataformas, independentemente do que seu código diga, então nenhuma limpeza é executada. Também não é possível escutar o SIGSTOP. É por isso que o timer de saída forçada dentro do processo importa: ele precisa disparar antes de o grace period do orquestrador expirar, para que o processo drene e encerre no SIGTERM em vez de ser morto sem chance de responder.
Qual é a diferença entre closeIdleConnections e closeAllConnections?
Ambos os métodos foram adicionados no Node 18.2.0. O closeIdleConnections fecha apenas os sockets que estão ociosos entre requisições, então qualquer coisa no meio de uma requisição é deixada para terminar. O closeAllConnections é o método bruto: ele derruba todas as conexões abertas, incluindo as que ainda estão atendendo uma requisição, ainda que conexões que passaram para outro protocolo, como WebSocket, sobrevivam a isso. Desde o Node 19.0.0, o server.close limpa as conexões ociosas por conta própria, então o closeIdleConnections só vale a pena se você ainda dá suporte a runtimes mais antigos.
O process.exit aguarda requisições em andamento ou trabalho assíncrono pendente?
Não. O process.exit encerra o processo de imediato e descarta qualquer trabalho assíncrono ainda enfileirado, inclusive saída que não terminou de ser escrita em stdout ou stderr. É por isso que o handler de shutdown aguarda o server.close e a limpeza de recursos antes de chamar o exit; chamar o exit antes disso recria o crash de requisições descartadas que o handler existe para evitar. Fora de um handler, prefira definir process.exitCode e deixar o processo encerrar naturalmente.
Handlers de SIGTERM funcionam no Windows?
Não da mesma forma. O Windows não tem sinais POSIX, e a documentação do Node.js lista o SIGTERM como não suportado lá, mesmo que seu código ainda possa registrar um listener para ele. O Ctrl+C levanta SIGINT em qualquer plataforma, e é por isso que o handler também escuta o SIGINT para desenvolvimento local. Teste o caminho de drain do SIGTERM dentro de um container Linux, onde o docker stop e o Kubernetes realmente entregam o sinal.