Voltar para o blog
Node.jsBackendDevOpsConfiabilidade

Graceful shutdown no Node.js: encerrando a aplicação sem perder requests

16 de junho de 20269 min de leiturapor Sávio Araújo

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:

  1. Remove o pod do endpoint do Service (novos requests param de chegar)
  2. Envia SIGTERM para o processo principal
  3. Aguarda terminationGracePeriodSeconds (padrão: 30s)
  4. 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 SIGTERM e SIGINT
  • [ ] Seta flag isShuttingDown antes 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.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech