← Back to list

Graceful shutdown em Node.js: você dropa processos e nem sabe

O deploy que corta requests, perde jobs e duplica mensagens. E como parar de sofrer com isso.

Pedro Zigante · 2026-04-24 14:31 · 0 claps · 16.6 min read
#nodejs #graceful-shutdown #kubernetes #typescript
Open on Medium ↗
Wiki topics: 🌐 · Web Development ☁️ · DevOps & Cloud

Graceful shutdown em Node.js: você dropa processos e nem sabe

Você faz deploy. Um pod morre, outro sobe. Tudo parece ok no log.

Só que em algum lugar, um cliente recebeu 502 Bad Gateway no meio de uma compra. Em outro, um job saiu da fila pra ser processado, o worker morreu antes de terminar, e a fila marcou o job como completo mesmo sem ter sido. Em outro, um consumer de Kafka estava no meio de processar uma mensagem, o container foi morto, a mensagem foi reentregue horas depois por outro consumer e processada em duplicidade.

Esses bugs têm uma coisa em comum: você não vê eles no kubectl logs. Você vê eles no ticket do suporte, três dias depois, quando alguém reclama que a cobrança foi duplicada.

Graceful shutdown é o tema mais subestimado de Node.js em produção. Todo mundo escuta SIGTERM, todo mundo chama process.exit(), e todo mundo acha que tá resolvido. Não tá.

Photo by 2H Media on Unsplash

Photo by 2H Media on Unsplash

Esse artigo é denso de propósito. Vai passar pelos fundamentos, pelos frameworks HTTP (Express e Fastify), pela conexão de banco com pg, pelo worker BullMQ, pelo consumer Kafka com kafkajs, pelo timeout de segurança e pela coordenação com Kubernetes. Cada exemplo é código completo, pronto pra copiar e adaptar. No final tem um template consolidado que você pode literalmente colar no seu projeto e ajustar.

Pega um café. Vai valer.

O que acontece quando seu processo morre

Antes de código, entende o que está acontecendo por baixo.

Quando o orquestrador (Kubernetes, Docker Compose, systemd, PM2) decide que seu processo precisa morrer, ele manda o sinal SIGTERM. Esse sinal é um pedido educado: "por favor, termine". O processo recebe, tem um tempo pra se organizar, e depois o orquestrador manda SIGKILL — que não é um pedido, é uma execução. SIGKILL mata o processo imediatamente, sem chance de nada.

O tempo entre SIGTERM e SIGKILL se chama grace period. No Kubernetes o padrão é 30 segundos. No Docker é 10. Nesse intervalo, seu processo precisa:

  1. Parar de aceitar trabalho novo (requests, jobs, mensagens)
  2. Terminar o trabalho em voo (requests em andamento, jobs sendo processados)
  3. Fechar recursos externos (conexões de banco, de Redis, de Kafka)
  4. Sair do processo com código 0

Se não fizer isso dentro do grace period, o SIGKILL vem e interrompe o que tiver no meio. Resultado: requests cortadas, jobs inconsistentes, mensagens duplicadas.

A pergunta que o artigo inteiro responde é: como fazer esses quatro passos direito pra cada tipo de recurso que seu app tem.

Um detalhe importante antes de começar: process.exit() é uma saída bruta. Ele não espera callbacks assíncronos terminarem. Se você escreve server.close(); process.exit(0), o process.exit() vai disparar antes do server.close() de fato terminar, porque server.close() é assíncrono. Isso é a origem da maior parte dos bugs de shutdown em Node.

A regra é: process.exit() é o último comando, depois de await em tudo que importa.

O shutdown ingênuo

Quase todo projeto Node começa com algo parecido:

// quando chegar SIGTERM, o processo sai imediatamente
process.on('SIGTERM', () => {
  console.log('Recebeu SIGTERM, saindo');
  process.exit(0);
});

Funciona. Sai na hora. O processo morre.

Mas morre no meio de tudo. Request em andamento? Cortada. Job no worker? Interrompido. Transação de banco aberta? Abandonada. Mensagem do Kafka sendo processada? Duplicada quando outro consumer pegar.

O resto do artigo é sobre como fazer o certo em vez disso.

HTTP server com Express

Express é o framework mais comum. Vamos pelo jeito certo.

import express from 'express';
import http from 'node:http';

const app = express();
app.get('/checkout', async (req, res) => {
  // imagina que isso demora 3 segundos
  await processarPagamento(req.body);
  res.json({ ok: true });
});

// criando o server manualmente pra conseguir chamar .close() depois
const server = http.createServer(app);
server.listen(3000, () => console.log('API na 3000'));

// flag pra evitar que o shutdown rode duas vezes
// (pode chegar SIGTERM e SIGINT juntos em alguns cenários)
let shuttingDown = false;
async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`Recebeu ${signal}, iniciando shutdown`);

  // server.close() é assíncrono mas usa callback
  // envolvemos numa Promise pra poder usar await
  await new Promise<void>((resolve, reject) => {
    server.close((err) => {
      if (err) reject(err);
      else resolve();
    });
  });

  console.log('HTTP server fechou, todas as requests em voo terminaram');
}

// escutamos SIGTERM (orquestrador pedindo pra sair)
// e SIGINT (Ctrl+C no terminal)
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

O coração disso é o server.close(). Ele tem comportamento que parece mágica na primeira vez que você entende:

  • Para de aceitar conexões novas imediatamente
  • Espera todas as conexões ativas terminarem naturalmente
  • Só depois chama o callback

Ou seja, request que está a meio caminho de responder vai até o fim. Request nova que chega depois do close() recebe connection refused — o load balancer precisa saber disso, e vamos falar sobre isso na seção de Kubernetes.

A armadilha do keep-alive

server.close() só fecha conexões HTTP com requests ativas, não conexões keep-alive ociosas. Se um cliente tem uma conexão HTTP keep-alive aberta mas sem request ativa, essa conexão segura o server aberto por tempo indeterminado. Em Node 18.2+, existe um método específico pra isso:

server.close();
// força o fechamento de conexões keep-alive ociosas
// sem isso, shutdown pode travar por minutos esperando clientes que nunca vão mandar nada
server.closeIdleConnections();

Sem isso, seu shutdown pode ficar pendurado esperando keep-alives ociosos que nunca vão mandar nada.

Timeout de request no Express

Express por si só não tem timeout de request. Se você tem um endpoint que pode demorar 60 segundos e o grace period é 30, seu shutdown será interrompido por SIGKILL. Duas saídas:

// opção A: timeout global no server (25s, menor que o grace period de 30s)
server.setTimeout(25_000);

A outra opção é aceitar que algumas requests longas vão ser cortadas e fazê-las idempotentes, mas isso depende do seu caso.

HTTP server com Fastify

Fastify tem um método nativo, fastify.close(), mais ergonômico que o Express:

import Fastify from 'fastify';

const app = Fastify({ logger: true });
app.post('/checkout', async (req, reply) => {
  await processarPagamento(req.body);
  return { ok: true };
});

await app.listen({ port: 3000, host: '0.0.0.0' });

let shuttingDown = false;
async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;
  app.log.info({ signal }, 'Iniciando shutdown');

  // app.close() no Fastify já faz:
  // 1. para de aceitar requests novas
  // 2. espera requests em voo terminarem
  // 3. fecha conexões keep-alive ociosas
  // 4. executa hooks onClose registrados
  await app.close();
  app.log.info('Fastify fechou, requests em voo terminaram');
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

app.close() faz o equivalente do server.close() + closeIdleConnections() automaticamente, e executa os hooks onClose que você tiver registrado. Isso é útil porque permite acoplar limpeza de recursos diretamente no ciclo de vida do Fastify:

// esse hook roda automaticamente quando app.close() é chamado
// útil pra fechar recursos que vivem no escopo da app
app.addHook('onClose', async (instance) => {
  instance.log.info('Hook onClose disparado');
});

Fastify tem plugin oficial @fastify/graceful-shutdown que automatiza grande parte disso. Eu particularmente prefiro o shutdown manual porque dá controle explícito sobre a ordem de fechamento dos recursos, e essa ordem importa muito, como você vai ver nas próximas seções. O plugin é bom pra casos simples (só HTTP), mas vira limitante quando você tem banco, worker e consumer pra fechar numa sequência específica.

Conexão de banco com pg

Aqui começa o pulo do gato. A ordem importa.

Imagina que uma request chegou, começou a rodar, fez uma query e está esperando a resposta do banco. Nesse exato momento, o SIGTERM chega. Se você fechar o pool do pg antes do server.close() terminar, a query em voo retorna erro, a request responde 500, o cliente vê erro. Shutdown ruim.

A ordem correta é:

  1. Para de aceitar requests novas (server.close() ou app.close())
  2. Espera as requests em voo terminarem — elas vão consumir conexões do pool naturalmente
  3. Só depois fecha o pool do banco
import { Pool } from 'pg';

const pool = new Pool({
  host: 'localhost',
  port: 5432,
  user: 'postgres',
  password: 'postgres',
  database: 'app',
  max: 10,
});

// exemplo de rota usando o pool
app.get('/users/:id', async (req, reply) => {
  const result = await pool.query(
    'SELECT * FROM users WHERE id = $1',
    [req.params.id]
  );
  return result.rows[0];
});

async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;
  app.log.info({ signal }, 'Iniciando shutdown');

  // 1. fecha o HTTP primeiro pra drenar requests em voo
  //    as requests em andamento vão continuar usando o pool normalmente
  await app.close();
  app.log.info('HTTP fechou');

  // 2. agora que não tem mais request ativa, fecha o banco
  //    pool.end() espera queries em voo terminarem antes de fechar
  await pool.end();
  app.log.info('Pool do pg fechou');
}

pool.end() no pg tem comportamento que vale memorizar:

  • Não aceita queries novas (chamadas depois do end() rejeitam com erro)
  • Espera queries em voo terminarem
  • Devolve todas as conexões ociosas pro Postgres
  • Retorna uma promise que resolve quando tudo fechou

Exatamente o que você quer.

Transações abertas travando o shutdown

Se você tem uma transação de longa duração aberta (BEGIN sem COMMIT ou ROLLBACK), pool.end() espera ela terminar. Se ela nunca termina — porque a request que a iniciou travou, por exemplo — o shutdown fica pendurado até o SIGKILL.

A proteção é usar try/finally com ROLLBACK implícito sempre que usar transações manuais:

// padrão seguro pra transações manuais
const client = await pool.connect();
try {
  await client.query('BEGIN');
  // ... suas operações aqui ...
  await client.query('COMMIT');
} catch (err) {
  // se qualquer operação falhou, dá rollback pra não deixar transação aberta
  await client.query('ROLLBACK');
  throw err;
} finally {
  // IMPORTANTE: devolve a conexão pro pool sempre
  // sem isso, a conexão fica travada e o shutdown trava com ela
  client.release();
}

Sem o client.release() no finally, a conexão fica travada e o shutdown também.

Conexões de longa duração fora de requests

Se você tem algo que usa pool.connect() fora do ciclo de uma request — um LISTEN/NOTIFY em background, por exemplo — precisa fechar essa conexão explicitamente no shutdown. Senão o pool não fecha.

// no startup, você pega uma conexão dedicada
const listenClient = await pool.connect();
await listenClient.query('LISTEN my_channel');

// no shutdown, ANTES do pool.end(), devolve essa conexão
listenClient.release();
await pool.end();

Worker BullMQ

Worker tem semântica diferente de HTTP. Não é request-response, é um loop infinito pegando jobs da fila.

O desafio: quando o SIGTERM chega, o worker pode estar em três estados diferentes:

  1. Ocioso — fácil, fecha e sai
  2. No meio de um job — precisa esperar o job terminar antes de fechar
  3. Tentando pegar um job novo — precisa parar de pegar imediatamente

BullMQ tem worker.close() que cobre os três casos:

import { Worker } from 'bullmq';

const worker = new Worker(
  'my-jobs',
  async (job) => {
    console.log(`Processando job ${job.id}`);
    await processarJob(job.data);
    console.log(`Job ${job.id} terminou`);
  },
  {
    connection: { host: 'localhost', port: 6379 },
    concurrency: 5,
  }
);

async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`Recebeu ${signal}, fechando worker`);

  // worker.close() faz tudo que precisa:
  // 1. para de pegar jobs novos
  // 2. espera jobs em processamento terminarem
  // 3. fecha conexão com Redis
  await worker.close();
  console.log('Worker fechou, jobs em voo terminaram');
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Jobs que não respeitam cancelamento

Se o grace period do orquestrador é 30s e um job pode demorar 60s, o worker.close() vai esperar o job terminar. O SIGKILL chega no meio, mata o processo, e o job volta pra fila como "stalled" depois que o lock expirar. Dependendo da configuração, ele será reprocessado — o que pode ser bom (retry automático) ou ruim (duplicação de efeito colateral).

Duas estratégias:

Aceitar que jobs longos voltam pra fila. Funciona se os jobs forem idempotentes.

Passar um AbortSignal pro job e respeitar ele. BullMQ expõe isso:

const worker = new Worker(
  'my-jobs',
  async (job, token) => {
    // token.abortSignal vira "aborted" quando o worker está fechando
    // você pode checar e abortar seu processamento
    const signal = token.abortSignal;

if (signal?.aborted) {
      throw new Error('Worker está fechando, abortando job');
    }

    // operações que respeitam AbortSignal (como fetch) recebem o signal
    // e cancelam automaticamente quando o worker fecha
    const response = await fetch('https://api.externa', { signal });
  },
  { connection: { host: 'localhost', port: 6379 } }
);

Você pode também passar um argumento pro close() indicando se quer forçar:

// espera jobs em voo terminarem naturalmente
await worker.close(false);

// força fechamento imediato, mata jobs em voo (vão pra "stalled")
await worker.close(true);

A decisão depende do seu job. Jobs curtos e idempotentes, pode forçar. Jobs longos com efeitos colaterais, espera e torna eles canceláveis via AbortSignal.

Múltiplos recursos do BullMQ no mesmo processo

Se você usa Queue, Worker e QueueEvents no mesmo processo, todos mantêm conexão com Redis. Precisa fechar todos:

// fecha os três em paralelo
await Promise.all([
  worker.close(),
  queue.close(),
  queueEvents.close(),
]);

Esquecer um faz o processo não sair, porque a conexão Redis segue viva segurando o event loop.

Consumer Kafka com kafkajs

Kafka é onde a maior parte dos bugs de duplicação acontece. Entender o porquê muda como você escreve shutdown.

O ciclo de um consumer é:

  1. Conecta ao broker e entra no consumer group
  2. Recebe mensagens em batches
  3. Processa cada mensagem
  4. Faz commit do offset (sinaliza “processei até aqui”)
  5. Repete

O bug: se o processo morre entre o passo 3 e o passo 4 — ou seja, processou a mensagem mas não commitou o offset — quando o consumer group rebalancear (outro consumer pegar essa partição), a mensagem vai ser reentregue. Processamento duplicado.

A solução é garantir que o processamento da mensagem atual termina e o offset é commitado antes de desconectar do broker.

import { Kafka } from 'kafkajs';

const kafka = new Kafka({
  clientId: 'my-service',
  brokers: ['localhost:9092'],
});

const consumer = kafka.consumer({ groupId: 'my-group' });
await consumer.connect();
await consumer.subscribe({ topic: 'payment-events', fromBeginning: false });
await consumer.run({
  // desliga o commit automático pra controlarmos nós mesmos
  // sem isso, offsets são commitados em intervalos de 5s
  // e mensagens recém-processadas podem não estar commitadas no shutdown
  autoCommit: false,
  eachMessage: async ({ topic, partition, message }) => {
    await processarEvento(JSON.parse(message.value!.toString()));
    // só commita DEPOIS que o processamento terminou com sucesso
    // se o processamento lançar erro, não chega aqui e a mensagem é reprocessada
    await consumer.commitOffsets([
      {
        topic,
        partition,
        // o próximo offset a processar é o atual + 1
        offset: (Number(message.offset) + 1).toString(),
      },
    ]);
  },
});

async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`Recebeu ${signal}, fechando consumer`);

  // consumer.disconnect() no kafkajs:
  // 1. termina a mensagem atual (não interrompe eachMessage no meio)
  // 2. sai do consumer group (libera partições pra outros consumers)
  // 3. fecha a conexão com o broker
  await consumer.disconnect();
  console.log('Consumer desconectou, mensagem em voo terminou');
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Três pontos críticos:

**autoCommit: false.** Por padrão, kafkajs commita offsets em intervalos de 5 segundos. Isso significa que mensagens processadas recentemente podem não ter sido commitadas ainda quando o processo morre. Commit manual depois de cada processamento garante que o shutdown preserva o progresso.

**consumer.disconnect() respeita a mensagem em voo.** Ele não interrompe o eachMessage no meio. Espera terminar, commita, e sai. Esse é o comportamento que você quer.

Sair do consumer group rápido. Quando o consumer se desconecta, o broker dispara um rebalance. Outro consumer pega as partições e continua. Se você demorar pra desconectar, as partições ficam “órfãs” até o session.timeout.ms (padrão 30s no Kafka) e ninguém consome nelas nesse período.

Processamento em batch com eachBatch

Se você usa eachBatch em vez de eachMessage, recebe um batch inteiro e precisa commitar periodicamente via resolveOffset enquanto processa:

await consumer.run({
  eachBatchAutoResolve: false,
  eachBatch: async ({ batch, resolveOffset, heartbeat, isRunning, isStale }) => {
    for (const message of batch.messages) {
      // para de processar se o consumer está desconectando
      // isso evita continuar processando mensagens durante o shutdown
      if (!isRunning() || isStale()) break;

      await processarEvento(JSON.parse(message.value!.toString()));
      // marca essa mensagem como processada
      resolveOffset(message.offset);
      // avisa o broker que continuamos vivos
      // importante se o processamento for demorado
      await heartbeat();
    }
  },
});

O isRunning() retorna false quando o consumer recebe ordem de desconectar. Quebrar o loop ali garante que não processa mensagens novas depois do shutdown começar.

Heartbeat em processamento longo

Consumer Kafka precisa mandar heartbeat pro broker periodicamente. Se uma mensagem demora mais que session.timeout.ms (padrão 30s) pra processar, o broker acha que o consumer morreu e rebalanceia. A mensagem é reentregue — duplicação.

Em eachBatch você pode chamar heartbeat() manualmente durante o processamento. Em eachMessage, kafkajs cuida disso automaticamente. Mas se sua mensagem demora minutos, considere eachBatch ou quebre o processamento em pedaços menores.

Timeout e force kill

Todos os exemplos acima assumem que o shutdown vai terminar em tempo hábil. E se não terminar? Worker com job eterno, consumer com mensagem travada, pool de banco esperando transação que não volta.

Você precisa de um timeout de segurança. Depois dele, desiste e sai.

// 25s é menor que os 30s de grace period do Kubernetes
// assim saímos "por conta própria" antes do SIGKILL chegar
const SHUTDOWN_TIMEOUT_MS = 25_000;

async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`Recebeu ${signal}, iniciando shutdown`);

  // se o shutdown travar, esse timeout dispara e força a saída
  const timeout = setTimeout(() => {
    console.error('Shutdown demorou demais, forçando saída');
    // código 1 sinaliza que foi forçado (ajuda na observabilidade)
    process.exit(1);
  }, SHUTDOWN_TIMEOUT_MS);

  try {
    await app.close();
    await worker.close();
    await consumer.disconnect();
    await pool.end();

    // IMPORTANTE: limpa o timeout quando o shutdown termina com sucesso
    // sem isso, o setTimeout mantém o event loop vivo e o processo não sai
    clearTimeout(timeout);
    console.log('Shutdown limpo');
    process.exit(0);
  } catch (err) {
    console.error({ err }, 'Erro no shutdown');
    clearTimeout(timeout);
    process.exit(1);
  }
}

Três detalhes que valem atenção:

Timeout menor que o grace period. Se o K8s manda SIGKILL em 30s, seu timeout precisa ser menor, tipo 25s. Assim você sai "limpo" com código 1 em vez de ser morto pelo orquestrador.

**process.exit(1) em vez de 0.** Código 1 sinaliza que o shutdown foi forçado. Bom pra observabilidade, dashboards podem alertar sobre shutdowns com código diferente de 0.

**clearTimeout no sucesso.** Sem isso, o setTimeout mantém o event loop vivo depois do shutdown, e o processo não sai. Detalhe sutil que morde muita gente.

Coordenando com Kubernetes

Tudo que vimos até aqui funciona em qualquer ambiente. Mas se você roda em Kubernetes, tem uma camada a mais que muda o jogo: o load balancer.

Quando um pod é deletado:

  1. K8s marca o pod como “Terminating”
  2. K8s manda SIGTERM pro container
  3. Em paralelo, o endpoint do pod é removido do Service

O problema: a remoção do endpoint não é instantânea. Há um intervalo (geralmente 1 a 5 segundos) entre o SIGTERM chegar e o load balancer parar de mandar tráfego pro pod. Durante esse intervalo, se seu app já fechou o HTTP server, as requests novas recebem connection refused.

A solução padrão combina preStop hook com readiness probe:

# deployment.yaml
spec:
  containers:
    - name: api
      lifecycle:
        preStop:
          # preStop roda ANTES do SIGTERM chegar
          # sleep dá tempo pro load balancer perceber que o pod está saindo
          exec:
            command: ['sleep', '5']
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        # checa a cada 2s pra pegar mudança de status rapidamente
        periodSeconds: 2

No código, você expõe o endpoint /ready e marca como "not ready" assim que o shutdown começa:

// flag que indica se o app está pronto pra receber tráfego
let ready = true;

app.get('/ready', (req, reply) => {
  if (!ready) {
    // 503 faz o readiness probe falhar
    // Kubernetes remove o pod do load balancer
    reply.code(503);
    return { ready: false };
  }
  return { ready: true };
});

async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;

  // PRIMEIRA COISA: marca como not ready
  // isso faz o próximo readiness probe falhar
  // e o Kubernetes para de mandar tráfego pro pod
  ready = false;
  app.log.info('Marcado como not ready, aguardando load balancer perceber');
  // espera um pouco pro readiness probe pegar a mudança
  // 3s é um valor seguro pra periodSeconds: 2 do deployment
  await new Promise((r) => setTimeout(r, 3000));
  // AGORA sim fecha os recursos, sem risco de request nova chegando
  await app.close();
  await pool.end();
}

Com essa configuração, a sequência fica:

  1. preStop começa (sleep 5)
  2. Readiness probe pega o próximo tick, vê 503, marca o pod como not ready
  3. Load balancer para de mandar tráfego pro pod
  4. preStop termina
  5. SIGTERM chega
  6. Seu código fecha recursos tranquilamente, sem request nova batendo

Se você não controla o deployment (plataforma gerenciada, por exemplo), dá pra fazer sem preStop — só com o timeout no shutdown. Funciona, fica menos elegante.

Template final

Juntando tudo. Esse é o template pra colar no seu projeto e adaptar:

import Fastify from 'fastify';
import { Pool } from 'pg';
import { Worker } from 'bullmq';
import { Kafka } from 'kafkajs';

// 25s é menor que os 30s de grace period padrão do Kubernetes
const SHUTDOWN_TIMEOUT_MS = 25_000;

// ============================================================
// Setup dos recursos
// ============================================================
const app = Fastify({ logger: true });

const pool = new Pool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  max: 10,
});

const worker = new Worker(
  'my-jobs',
  async (job) => {
    await processarJob(job.data);
  },
  { connection: { host: 'localhost', port: 6379 } }
);

const kafka = new Kafka({
  clientId: 'my-service',
  brokers: ['localhost:9092'],
});

const consumer = kafka.consumer({ groupId: 'my-group' });

// ============================================================
// Readiness probe pro Kubernetes
// ============================================================
let ready = true;
app.get('/ready', async (req, reply) => {
  if (!ready) {
    reply.code(503);
    return { ready: false };
  }
  return { ready: true };
});

// ============================================================
// Rotas
// ============================================================
app.post('/checkout', async (req, reply) => {
  const result = await pool.query('INSERT INTO orders (...) VALUES (...) RETURNING id');
  return { id: result.rows[0].id };
});

// ============================================================
// Startup
// ============================================================
await consumer.connect();
await consumer.subscribe({ topic: 'payment-events', fromBeginning: false });
await consumer.run({
  autoCommit: false,
  eachMessage: async ({ topic, partition, message }) => {
    await processarEvento(JSON.parse(message.value!.toString()));
    await consumer.commitOffsets([
      { topic, partition, offset: (Number(message.offset) + 1).toString() },
    ]);
  },
});
await app.listen({ port: 3000, host: '0.0.0.0' });

// ============================================================
// Shutdown coordenado
// ============================================================
let shuttingDown = false;
async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;
  app.log.info({ signal }, 'Iniciando shutdown');

  // timeout de segurança: se travar, força saída antes do SIGKILL
  const timeout = setTimeout(() => {
    app.log.error('Shutdown timeout, forçando saída');
    process.exit(1);
  }, SHUTDOWN_TIMEOUT_MS);

  try {
    // 1. Marca como not ready pra tirar o pod do load balancer
    //    Isso precisa ser PRIMEIRO pra parar de receber tráfego novo
    ready = false;
    app.log.info('Marcado como not ready');
    await new Promise((r) => setTimeout(r, 3000));

    // 2. Fecha HTTP primeiro pra drenar requests em voo
    //    Requests em andamento continuam usando pool/worker/consumer
    await app.close();
    app.log.info('HTTP fechou');

    // 3. Fecha worker pra drenar jobs em voo
    //    Jobs podem ainda precisar do pool de banco
    await worker.close();
    app.log.info('Worker fechou');

    // 4. Desconecta consumer
    //    Termina mensagem em voo, commita offset, sai do group
    await consumer.disconnect();
    app.log.info('Consumer desconectou');

    // 5. Fecha banco POR ÚLTIMO
    //    Ninguém mais está usando ele nesse ponto
    await pool.end();
    app.log.info('Pool do pg fechou');

    clearTimeout(timeout);
    app.log.info('Shutdown limpo');
    process.exit(0);
  } catch (err) {
    app.log.error({ err }, 'Erro no shutdown');
    clearTimeout(timeout);
    process.exit(1);
  }
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

// ============================================================
// Erros não tratados
// ============================================================
process.on('uncaughtException', (err) => {
  app.log.fatal({ err }, 'Uncaught exception');
  shutdown('uncaughtException');
});

process.on('unhandledRejection', (reason) => {
  app.log.fatal({ reason }, 'Unhandled rejection');
  shutdown('unhandledRejection');
});

// ============================================================
// Handlers de exemplo
// ============================================================
async function processarJob(data: any) {
  // sua lógica de job aqui
}
async function processarEvento(data: any) {
  // sua lógica de evento aqui
}

A ordem de fechamento (HTTP → worker → consumer → banco) importa. Cada um depende do próximo: HTTP usa o banco, worker usa o banco, consumer usa o banco. Fechar o banco primeiro quebraria tudo que ainda está em voo.

Detalhes que pegam em produção

Três coisas que pegam muita gente e não cabiam nas seções anteriores.

Logs durante shutdown

Se seu logger é assíncrono (Pino com transport, por exemplo), os logs do shutdown podem ser perdidos porque o processo sai antes do buffer esvaziar. Com Fastify + Pino, o app.close() cuida disso. Com Winston ou outros, você pode precisar chamar logger.end() explicitamente antes do process.exit().

Shutdown em containers que usam PID 1

Quando Node roda como PID 1 no container (sem init system como tini ou dumb-init), ele recebe sinais de forma diferente — SIGTERM pode não ser propagado como esperado pra processos filhos. Se você usa child_process.spawn ou similar, os filhos podem virar zumbis.

A solução é usar tini como init do container:

FROM node:20-alpine

# instala o tini pra ser o PID 1 do container
RUN apk add --no-cache tini

# tini vira o PID 1 e repassa sinais pros processos filhos
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

Com isso, o PID 1 vira o tini, que cuida dos sinais. O Node vira PID 2 e recebe SIGTERM normalmente.

Conexões externas (APIs, webhooks)

Se seu app está no meio de uma chamada HTTP pra um serviço externo (gateway de pagamento, provider de email, qualquer integração) quando o shutdown começa, você tem duas opções: esperar terminar ou cancelar com AbortController.

Cancelar é arriscado porque você não sabe se a operação foi executada no outro lado. Geralmente é melhor esperar, desde que o timeout não estoure o grace period.

Fechando

Graceful shutdown não é feature. É requisito. Se você roda Node em produção com tráfego real, jobs, consumers, banco — e não implementou isso direito — está gerando bugs silenciosos todo deploy.

A boa notícia é que o template acima cobre 95% dos casos. Os outros 5% são particularidades do seu stack que você resolve adicionando mais uma etapa na sequência de fechamento.

O teste que recomendo: em ambiente de dev, manda kill -TERM <pid> no processo durante carga simulada. Se alguma request cortou, algum job sumiu, alguma mensagem duplicou, seu shutdown está errado. Itera até nada sumir, nada duplicar, nada cortar.

Ninguém te parabeniza quando shutdown funciona. É o trabalho invisível que evita o ticket de suporte de sexta à noite.

Vale o esforço.


메타데이터
post_id
f80e53b26cd9
slug
graceful-shutdown-em-node-js-voce-dropa-processos-e-nem-sabe-f80e53b26cd9
url
https://medium.com/@zigante.pedro/graceful-shutdown-em-node-js-voce-dropa-processos-e-nem-sabe-f80e53b26cd9
canonical_url
https://medium.com/@zigante.pedro/graceful-shutdown-em-node-js-voce-dropa-processos-e-nem-sabe-f80e53b26cd9
author_url
https://medium.com/@zigante.pedro
status
ok
fetched_at
2026-08-23 21:37:58