O problema que ninguém vê até virar incidente
Você faz um deploy. O orquestrador envia SIGTERM para o container antigo. A aplicação morre. Alguns requests que estavam sendo processados recebem um erro 502. O cliente tenta de novo, às vezes funciona, às vezes não. O banco ficou com uma transação aberta.
Isso não é hipotético. É o comportamento padrão de um servidor Node.js sem graceful shutdown.
Graceful shutdown é o processo de encerrar a aplicação de forma controlada: parar de aceitar novos requests, aguardar os que estão em andamento terminarem, fechar conexões com banco e filas, e só então sair.
O ciclo de vida de um deploy com Kubernetes
Quando o Kubernetes precisa derrubar um pod, ele:
- Remove o pod do endpoint do Service (novos requests param de chegar)
- Envia
SIGTERMpara o processo principal - Aguarda
terminationGracePeriodSeconds(padrão: 30s) - Se o processo ainda estiver vivo, envia
SIGKILL
O problema: o passo 1 e o passo 2 não são atômicos. Pode haver um delay de alguns segundos entre remover o endpoint e o sinal chegar. Durante esse intervalo, requests ainda chegam — e se a aplicação morrer logo ao receber SIGTERM, esses requests falham.
Implementação básica
import express from 'express';
import { Server } from 'http';
const app = express();
const server: Server = app.listen(3000);
let isShuttingDown = false;
app.use((req, res, next) => {
if (isShuttingDown) {
res.setHeader('Connection', 'close');
return res.status(503).json({ error: 'Servidor encerrando, tente novamente.' });
}
next();
});
async function shutdown(signal: string) {
console.log(`Recebido ${signal}, iniciando graceful shutdown...`);
isShuttingDown = true;
// Para de aceitar novas conexões
server.close(async () => {
console.log('Servidor HTTP fechado.');
// Fecha recursos externos
await closeDatabase();
await closeMessageQueue();
console.log('Shutdown concluído.');
process.exit(0);
});
// Força saída após timeout (segurança)
setTimeout(() => {
console.error('Timeout no shutdown, forçando saída.');
process.exit(1);
}, 25_000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));O server.close() para de aceitar novas conexões TCP mas aguarda as existentes terminarem. Só depois o callback é chamado.
O problema das conexões keep-alive
HTTP/1.1 usa keep-alive por padrão: a conexão TCP fica aberta entre requests. Isso significa que server.close() pode nunca chamar o callback se houver clientes com conexões persistentes abertas — mesmo que não haja requests ativos.
// Rastreia conexões abertas para poder fechá-las manualmente
const connections = new Set<import('net').Socket>();
server.on('connection', (socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
});
async function shutdown(signal: string) {
isShuttingDown = true;
// Sinaliza que a conexão deve ser fechada após o request atual
connections.forEach((socket) => {
socket.setKeepAlive(false);
});
server.close(async () => {
await closeDatabase();
process.exit(0);
});
// Se mesmo assim ainda tiver conexões abertas após 10s, força o fechamento
setTimeout(() => {
connections.forEach((socket) => socket.destroy());
}, 10_000);
setTimeout(() => process.exit(1), 25_000);
}Integrando com banco de dados
O Prisma, TypeORM e Mongoose têm métodos de desconexão que precisam ser chamados no shutdown.
// Prisma
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function closeDatabase() {
await prisma.$disconnect();
console.log('Banco desconectado.');
}
// TypeORM
import { DataSource } from 'typeorm';
async function closeDatabase(dataSource: DataSource) {
if (dataSource.isInitialized) {
await dataSource.destroy();
}
}Fechar o pool de conexões garante que transações abertas sejam finalizadas (ou revertidas pelo banco após timeout) e que as conexões sejam devolvidas corretamente.
Integrando com filas (RabbitMQ / BullMQ)
Consumidores de fila têm um estado particularmente crítico: uma mensagem pode estar sendo processada, com o ack ainda pendente. Se o processo morrer antes do ack, a mensagem volta para a fila e será reprocessada.
Com RabbitMQ:
import amqp from 'amqplib';
let channel: amqp.Channel;
let connection: amqp.Connection;
async function closeMessageQueue() {
// Cancela o consumer: para de receber novas mensagens
await channel.cancel(consumerTag);
// Aguarda a mensagem em processamento terminar
// (o consumer deve sinalizar quando estiver livre)
await waitForActiveConsumer();
await channel.close();
await connection.close();
}Com BullMQ:
import { Worker } from 'bullmq';
const worker = new Worker('fila', async (job) => { /* ... */ });
async function closeMessageQueue() {
await worker.close(); // Aguarda o job atual terminar antes de fechar
}O worker.close() do BullMQ já é graceful por padrão: ele espera o job em andamento completar.
Graceful shutdown no NestJS
O NestJS tem suporte nativo:
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // Habilita SIGTERM/SIGINT
await app.listen(3000);
}Com enableShutdownHooks(), o NestJS intercepta os sinais e chama o lifecycle hook onApplicationShutdown nos módulos que o implementam:
@Injectable()
export class DatabaseService implements OnApplicationShutdown {
async onApplicationShutdown(signal: string) {
console.log(`Fechando banco de dados (${signal})...`);
await this.dataSource.destroy();
}
}Isso é bem mais limpo do que registrar handlers process.on manualmente espalhados pelo código.
Health checks durante o shutdown
Um detalhe que faz diferença: o readiness probe do Kubernetes continua sendo chamado durante o shutdown. Se o probe ainda retornar 200, o Kubernetes pode continuar mandando tráfego enquanto a aplicação está encerrando.
let isReady = true;
let isShuttingDown = false;
app.get('/health/ready', (req, res) => {
if (isShuttingDown) {
return res.status(503).json({ status: 'shutting_down' });
}
res.json({ status: 'ok' });
});
process.on('SIGTERM', () => {
isReady = false; // Para o tráfego novo
isShuttingDown = true;
// ... resto do shutdown
});Com terminationGracePeriodSeconds: 30 no Kubernetes e um preStop hook de alguns segundos, você garante que o load balancer parou de enviar tráfego antes da aplicação começar a se fechar.
lifecycle:
preStop:
exec:
command: ["sleep", "5"]Checklist de graceful shutdown
- [ ] Captura
SIGTERMeSIGINT - [ ] Seta flag
isShuttingDownantes de qualquer coisa - [ ] Retorna 503 para requests que chegarem durante o shutdown
- [ ] Chama
server.close()para parar novas conexões - [ ] Lida com conexões keep-alive abertas
- [ ] Fecha pool do banco de dados
- [ ] Aguarda consumers de fila terminarem a mensagem atual
- [ ] Tem timeout de segurança que força
process.exit(1) - [ ] Readiness probe retorna 503 durante o shutdown
O timeout de segurança é obrigatório. Sem ele, um bug no shutdown pode travar o processo indefinidamente — e o Kubernetes vai esperar o terminationGracePeriodSeconds inteiro antes de enviar SIGKILL.