Blog

Graceful shutdown que ninguém testou: deploy que corta requisição no meio e derruba job em andamento

Uma plataforma de cobrança fazia seis deploys por dia, e cada um gerava entre 30 e 80 respostas 502 no balanceador. O time tinha um nome para isso: ruído de deploy. O cliente que recebia o 502 no meio de um pagamento tentava de novo e quase sempre dava certo, então ninguém priorizou. Até a tarde em que um deploy pegou o job de conciliação no meio: ele tinha reservado um lote de 1.200 boletos, marcado todos como em processamento e conciliado 400 quando o processo recebeu SIGKILL. Os outros 800 ficaram presos em processando, e nenhum outro worker os pegava porque o estado dizia que alguém já estava cuidando. A descoberta veio dois dias depois, pelo suporte. O código tinha um handler de SIGTERM. Ninguém nunca tinha verificado se ele rodava. Este artigo mostra o que realmente acontece entre o SIGTERM e o SIGKILL, os três jeitos comuns de morrer errado, a sequência que drena HTTP sem cortar requisição, como parar um worker sem perder o job, como encaixar tudo no orçamento de tempo do orquestrador e como testar para que o encerramento deixe de ser o caminho de código que só roda em produção.

2026-10-09 / Arquitetura / 16 min

01

O que acontece entre o SIGTERM e o SIGKILL

Quando um pod entra em Terminating, o Kubernetes dispara duas coisas ao mesmo tempo. O kubelet executa o preStop, se houver, e envia SIGTERM ao processo principal do container. Em paralelo, o plano de controle tira o pod dos EndpointSlices, e cada componente que encaminha tráfego, kube-proxy, ingress, service mesh, um ALB fora do cluster, aplica essa remoção no seu próprio ritmo. As duas trilhas não se esperam. É comum o SIGTERM chegar antes de o último balanceador parar de mandar requisições, e um processo que fecha o servidor no instante do sinal recusa conexões que ainda estão sendo roteadas para ele.

t=0s   kubectl rollout / scale down: o pod entra em Terminating
        |
        |-- (a) kubelet: preStop, depois SIGTERM para o PID 1 do container
        '-- (b) controle: tira o pod dos EndpointSlices
                 '-- kube-proxy, ingress, service mesh e ALB aplicam a remocao
                     em momentos diferentes, segundos depois   <- requisicoes ainda chegam

        (a) e (b) correm em paralelo: nada garante que (b) termina antes de (a)

t=30s  terminationGracePeriodSeconds vence (o preStop conta dentro dele)
        '-- SIGKILL: sem handler, sem finally, sem flush. O que estava no meio morre no meio.

O segundo fato é o prazo. terminationGracePeriodSeconds, 30 segundos por padrão, conta a partir do início do término e inclui o tempo do preStop. Quando vence, o processo recebe SIGKILL, que não pode ser tratado: não roda finally, não roda handler, não faz flush de log. Tudo o que estava no meio fica no meio. ECS, Nomad, systemd e o docker stop seguem o mesmo contrato com nomes diferentes: um sinal educado, um prazo e um sinal que não negocia.

  • O SIGTERM é um aviso de que o tráfego vai parar, não uma confirmação de que já parou.
  • O prazo é do encerramento inteiro: atraso de propagação, drenagem, jobs, fechamento de recursos e flush cabem no mesmo orçamento.
  • O SIGKILL vai acontecer em algum momento, em algum deploy. O sistema precisa estar correto mesmo assim, e o encerramento gracioso só reduz a frequência.

02

Os três jeitos comuns de morrer errado

Quase todo encerramento quebrado cai em um de três padrões, e cada um deixa uma assinatura diferente nas métricas. Reconhecer a assinatura economiza a investigação.

PadrãoAssinaturaCausa típicaCorreção
Sai na horaRajada de 502 e connection reset no balanceador a cada deploy, logo no início do rolloutNenhum handler, ou handler que chama server.close() e process.exit() imediatamenteAtraso de propagação antes de fechar, e drenagem das requisições em andamento
Nunca trata o sinalTodo pod leva exatamente 30 s para morrer; jobs e requisições longas terminam cortados por SIGKILLCMD em forma shell ou npm start: o sinal fica no sh ou no npm e não chega ao node; node como PID 1 sem handler ignora SIGTERMCMD em forma exec com node direto, handler explícito, --init ou tini para colher zumbis
Fecha sem esperar o keep-alive502 esporádicos mesmo com drenagem, concentrados em quem usa conexões persistentesO balanceador reutiliza uma conexão que o servidor acabou de fechar; keepAliveTimeout do Node menor que o idle timeout do balanceadorConnection: close durante a drenagem, closeIdleConnections() e keepAliveTimeout maior que o idle timeout do balanceador

O segundo padrão engana porque o código parece certo. Com CMD npm start, quem recebe o SIGTERM é o npm, e com CMD em forma shell é o /bin/sh, que não repassa o sinal ao filho. Pior: se o node for o PID 1 e não registrar handler, o kernel simplesmente ignora o SIGTERM, porque o PID 1 não recebe a ação padrão de sinais. Em todos os casos, o processo continua atendendo até o SIGKILL, 30 segundos depois. Os deploys ficam lentos e, no fim, cortam do mesmo jeito.

03

A sequência que drena HTTP sem cortar requisição

A ordem importa mais que qualquer detalhe. Primeiro, o processo avisa que não quer mais tráfego: a readiness passa a responder 503 enquanto a liveness continua 200, porque o processo está vivo e só está saindo. No Kubernetes o pod em término já sai dos endpoints sem depender da readiness, mas balanceadores com health check próprio, como um ALB apontando para IPs, um upstream de nginx ou um Consul, dependem dela. Segundo, o processo continua atendendo normalmente por alguns segundos, o atraso de propagação, para que a remoção chegue a todos. Só então fecha o servidor, derruba conexões keep-alive ociosas, espera as requisições em andamento com um prazo e, se o prazo estourar, corta o que sobrou e informa quantas foram cortadas.

import http from 'node:http';
import { setTimeout as esperar } from 'node:timers/promises';

export function criarServidorGracioso(handler, opcoes = {}) {
  const { atrasoPropagacaoMs = 5000, prazoDrenagemMs = 15000 } = opcoes;
  let pronto = true;
  let ativas = 0;
  let aoZerar = null;

  const server = http.createServer(async (req, res) => {
    if (req.url === '/healthz/live') {
      res.writeHead(200).end('ok'); // o processo esta vivo, mesmo encerrando
      return;
    }
    if (req.url === '/healthz/ready') {
      res.writeHead(pronto ? 200 : 503).end(pronto ? 'ok' : 'encerrando');
      return;
    }

    ativas++;
    // Durante a drenagem, cada resposta fecha a conexao em vez de devolve-la ao pool
    if (!pronto) res.setHeader('Connection', 'close');
    res.on('close', () => {
      ativas--;
      if (ativas === 0 && aoZerar) aoZerar();
    });

    try {
      await handler(req, res);
    } catch {
      if (!res.headersSent) res.writeHead(500);
      res.end();
    }
  });

  async function drenar() {
    pronto = false; // 1. readiness passa a 503
    await esperar(atrasoPropagacaoMs); // 2. tempo para a remocao chegar a todos os balanceadores

    const fechado = new Promise((resolve) => server.close(resolve)); // 3. para de aceitar conexoes
    server.closeIdleConnections(); // 4. derruba o keep-alive que nao tem requisicao

    const semAtivas = ativas === 0 ? Promise.resolve() : new Promise((r) => (aoZerar = r));
    const resultado = await Promise.race([
      semAtivas.then(() => 'drenado'),
      esperar(prazoDrenagemMs, 'prazo', { ref: false }),
    ]);

    const cortadas = ativas;
    if (resultado === 'prazo') server.closeAllConnections(); // 5. prazo estourou: corta o resto
    else server.closeIdleConnections(); // conexoes que ficaram ociosas durante a drenagem
    await fechado;
    return { cortadas };
  }

  return { server, drenar };
}
  • server.close() só para de aceitar conexões novas. Conexões keep-alive já abertas continuam podendo trazer requisições; por isso closeIdleConnections() logo em seguida e Connection: close em toda resposta durante a drenagem.
  • O contador usa o evento close da resposta, que dispara tanto quando a resposta termina quanto quando o cliente desiste. Usar finish deixaria o contador preso em requisições abortadas pelo cliente.
  • O número de requisições cortadas é a métrica que prova que a drenagem funciona. Registre-o no log final; um valor diferente de zero em deploy normal significa prazo curto ou requisição que não deveria ser síncrona.
  • O atraso pode ficar no processo, como no código, ou num preStop com sleep, que versões recentes do Kubernetes aceitam nativamente. Escolha um dos dois: os dois somados só gastam orçamento.

04

Parar o worker sem perder o job

Job em background tem um problema que requisição HTTP não tem: ele pode durar mais que o prazo inteiro. A conciliação de 1.200 boletos levava quatro minutos e nunca caberia em 30 segundos. O worker precisa de três comportamentos. Ao receber o pedido de parada, deixa de reservar jobs novos. Se o job atual termina dentro do prazo, ótimo. Se não termina, interrompe num ponto seguro, um checkpoint entre etapas, e devolve o job para a fila, para que outra instância continue.

import { setTimeout as esperar } from 'node:timers/promises';

export function criarWorker({ fila, processar, intervaloMs = 200 }) {
  const controle = new AbortController();
  let parando = false;

  const rodando = (async () => {
    while (!parando) {
      const job = await fila.reservar();
      if (!job) {
        await esperar(intervaloMs, undefined, { ref: false });
        continue;
      }
      try {
        await processar(job, controle.signal);
        await fila.concluir(job);
      } catch (erro) {
        // Abortado no encerramento ou falha real: volta para a fila e outra instancia retoma
        const motivo = controle.signal.aborted ? 'encerramento' : String(erro.message);
        await fila.devolver(job, motivo);
        if (!controle.signal.aborted) await esperar(intervaloMs, undefined, { ref: false });
      }
    }
  })();

  async function parar(prazoMs) {
    parando = true; // 1. nao reserva mais nada
    const terminou = await Promise.race([
      rodando.then(() => true),
      esperar(prazoMs, false, { ref: false }),
    ]);
    if (!terminou) {
      controle.abort(); // 2. prazo estourou: o job para no proximo checkpoint
      await rodando;
    }
    return { jobInterrompido: !terminou };
  }

  return { parar };
}

// O job coopera: verifica o sinal ENTRE etapas, nunca no meio de uma
async function conciliarLote(job, signal) {
  for (const boleto of job.boletos) {
    signal.throwIfAborted();
    await conciliarBoleto(boleto); // idempotente: refazer um boleto ja conciliado nao muda nada
  }
}

O checkpoint só funciona se o job cooperar e se cada etapa for idempotente: interromper depois do boleto 400 e reprocessar o lote em outra instância não pode conciliar o boleto 399 duas vezes. Quebrar o lote em unidades pequenas resolve as duas coisas, porque cada unidade termina rápido e cada uma é refeita sem efeito colateral. O bug do incidente não era só o SIGKILL. Era o estado processando gravado no banco sem dono e sem expiração. Um lease com prazo, em que o job reservado volta a ficar disponível se o worker não renovar a reserva, torna o SIGKILL inofensivo: o job reaparece sozinho alguns minutos depois. BullMQ, SQS e RabbitMQ oferecem isso como stalled job, visibility timeout e mensagem sem ack, respectivamente.

05

Encaixar tudo no orçamento de tempo

O processo inteiro tem um prazo, e cada etapa precisa de uma fatia dele. O erro clássico é configurar a drenagem para 30 segundos com terminationGracePeriodSeconds também em 30: o SIGKILL chega antes do flush do log que diria o que deu errado. A regra é que o processo sempre saia sozinho, com código de saída e log, antes do orquestrador precisar matá-lo.

EtapaFatiaObservação
Atraso de propagação0 a 5 sReadiness em 503, ainda atendendo. Se usar preStop, ele consome esta fatia
Drenagem HTTPaté 15 sRequisições em andamento terminam; o que passar disso é cortado e contado
Workeraté 15 s, em paraleloComeça no SIGTERM e aproveita o atraso de propagação
Fechar broker e pool1 a 2 sDepois da drenagem: requisições e jobs ainda usam o banco
Trava de segurança25 sprocess.exit(1) com log se qualquer etapa travar
Margem até o SIGKILL5 sFlush de log, métricas e atraso de agendamento do kubelet
const PRAZO_TOTAL_MS = 25_000; // abaixo dos 30 s de terminationGracePeriodSeconds

let encerrando = false;
async function encerrar(sinal) {
  if (encerrando) return; // segundo sinal nao reinicia a drenagem
  encerrando = true;
  log.info({ sinal }, 'encerramento iniciado');

  // Trava de seguranca: se algo travar, sai com erro registrado antes do SIGKILL
  setTimeout(() => {
    log.error('prazo total estourado, saindo com trabalho pendente');
    process.exit(1);
  }, PRAZO_TOTAL_MS).unref();

  // HTTP e worker drenam em paralelo; o worker aproveita o atraso de propagacao
  const [http, jobs] = await Promise.all([app.drenar(), worker.parar(15_000)]);
  await broker.close();
  await pool.end(); // so depois: requisicoes e jobs drenando ainda usam o banco
  log.info({ ...http, ...jobs }, 'encerramento concluido');
  process.exit(0);
}

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

O pool fecha por último porque requisições e jobs drenando ainda fazem consultas; fechá-lo antes transforma uma requisição que terminaria com 200 em um 500. A guarda contra o segundo sinal evita que um Ctrl+C repetido ou um SIGTERM duplicado reinicie a drenagem. E o lado da imagem precisa entregar o sinal ao processo certo, com probes que distinguem readiness de liveness.

# Dockerfile: forma exec, node como processo que recebe o sinal
FROM node:22-alpine
WORKDIR /app
COPY . .
# Nao use CMD npm start nem a forma shell (CMD node src/main.js): o sinal para no npm ou no sh
CMD ["node", "src/main.js"]

# deployment.yaml (trecho)
spec:
  terminationGracePeriodSeconds: 30   # o orcamento inteiro, preStop incluido
  containers:
    - name: api
      readinessProbe:
        httpGet: { path: /healthz/ready, port: 3000 }
        periodSeconds: 2
        failureThreshold: 1
      livenessProbe:
        httpGet: { path: /healthz/live, port: 3000 }   # nunca a mesma rota da readiness
        periodSeconds: 10
        failureThreshold: 3

06

Testar o caminho que só roda em produção

O handler de encerramento é executado algumas vezes por dia, sempre em produção e sempre sem ninguém olhando. Por isso ele quebra em silêncio: um refactor troca a ordem do pool.end(), uma dependência nova segura um timer, uma mudança no Dockerfile volta para npm start. Três camadas de teste fecham esse buraco.

  1. Teste unitário da drenagem: suba o servidor numa porta efêmera, dispare uma requisição lenta, chame drenar() no meio dela e verifique que ela termina com 200 e que cortadas é zero. Repita para o prazo estourado, o keep-alive ocioso e o worker que devolve o job.
  2. Teste de sinal real em container: rode a imagem, dispare requisições, envie SIGTERM ao processo e confira que todas terminam e que o código de saída é 0. É o único teste que pega o npm start e o PID 1, e roda em segundos no CI.
  3. Deploy sob carga em staging: mantenha um gerador de carga constante durante um rollout completo e conte as respostas que não são 2xx. A meta é zero. Rode antes de mudar a imagem base, o balanceador ou a configuração de probes.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { setTimeout as esperar } from 'node:timers/promises';
import { criarServidorGracioso } from '../src/servidor.js';

test('requisicao em andamento termina com 200 durante o encerramento', async () => {
  const app = criarServidorGracioso(
    async (req, res) => {
      await esperar(400);
      res.writeHead(200).end('feito');
    },
    { atrasoPropagacaoMs: 50, prazoDrenagemMs: 2000 },
  );
  app.server.listen(0);
  await once(app.server, 'listening');
  const url = `http://127.0.0.1:${app.server.address().port}/pedidos`;

  const resposta = fetch(url);
  await esperar(50); // a requisicao ja esta no meio quando o encerramento comeca
  const drenagem = app.drenar();

  assert.equal((await resposta).status, 200);
  assert.deepEqual(await drenagem, { cortadas: 0 });
});

Em produção, duas métricas mantêm o encerramento honesto: a contagem de 502 e 503 no balanceador agrupada por janela de deploy, e a contagem de requisições e jobs cortados registrada no log final de cada processo. Se os 502 de deploy voltarem a aparecer, a pergunta deixa de ser se é ruído e passa a ser qual das três assinaturas eles têm.

FAQ

Perguntas frequentes

Se o Kubernetes já tira o pod dos endpoints, por que a readiness precisa responder 503?

Porque nem todo tráfego passa pelos endpoints do Kubernetes. Um ALB apontando direto para IPs de pods, um nginx com upstream estático ou um service discovery como Consul decidem pela própria checagem de saúde. A readiness em 503 avisa esses balanceadores e torna o estado do processo visível. O que garante o fim das requisições, nos dois casos, é o atraso de propagação antes de fechar o servidor.

Qual valor usar no terminationGracePeriodSeconds?

O suficiente para a requisição síncrona mais longa aceitável terminar, somado ao atraso de propagação e ao fechamento de recursos, com margem. Para APIs comuns, 30 segundos sobram. Aumentar para minutos para caber um job longo é um erro: deploys e scale down ficam lentos e o job continua vulnerável a SIGKILL por falha de nó. Job longo se resolve com checkpoint, devolução para a fila e lease, não com prazo maior.

Uma requisição longa, como exportação de relatório, deve segurar o encerramento?

Não. Se uma requisição pode passar do prazo de drenagem, ela não deveria ser síncrona: o certo é responder 202, processar em background e entregar o resultado por link ou notificação. A métrica de requisições cortadas aponta exatamente essas rotas. Enquanto elas existirem, o encerramento só escolhe entre cortar o cliente ou atrasar todos os deploys.

Encerramento é código de produção e merece teste de produção

Todo processo morre muitas vezes por dia, a cada deploy, scale down e troca de nó. O SIGTERM avisa que o tráfego vai parar, não que parou, e o prazo até o SIGKILL é do encerramento inteiro. Readiness em 503, atraso de propagação, drenagem com prazo que informa o que cortou, worker que interrompe no checkpoint e devolve o job, recursos fechados por último e uma trava abaixo do prazo do orquestrador transformam o ruído de deploy em zero requisição cortada. Jobs idempotentes com lease garantem que o SIGKILL que ainda vai acontecer não deixe nada preso. E três camadas de teste, unitário, sinal real em container e deploy sob carga, fazem desse caminho algo que se verifica antes da produção, não depois do suporte.