Programação Por Equipe DevBras

Capa do artigo: SIGTERM no Docker: desligue sem cortar o pedido

SIGTERM no Docker: desligue sem cortar o pedido

SIGTERM no Docker e no Kubernetes: como fazer desligamento gracioso, terminar os pedidos em curso e ajustar o stop_grace_period antes do SIGKILL.

O deploy derruba o processo no meio de um POST de pedido. O cliente vê erro, tenta de novo e agora existem dois pedidos, ou nenhum, ou um pela metade, porque o pagamento foi registrado e o estoque não. A resposta curta: o SIGTERM é um aviso, não uma queda de fio. Ao recebê-lo, a aplicação deve sinalizar que não está mais pronta, parar de aceitar conexões novas, terminar os pedidos em curso dentro de um prazo, fechar conexões com banco e fila e só então sair. E o orquestrador precisa esperar mais do que esse prazo antes de mandar o SIGKILL.

Como o Docker e o Kubernetes desligam um container

O docker stop envia o sinal de parada (SIGTERM, a menos que a imagem defina outro com STOPSIGNAL) para o processo principal do container. Se o processo não sair, depois de 10 segundos por padrão o Docker envia SIGKILL, que não pode ser tratado: o processo morre na hora, com o que estiver pela metade. O prazo muda com docker stop --timeout 30 ou, no Compose, com stop_grace_period.

No Kubernetes o roteiro é parecido, e está descrito na documentação de encerramento de pods. O pod recebe SIGTERM, tem terminationGracePeriodSeconds (30 segundos por padrão) e então leva SIGKILL. Ao mesmo tempo, o pod é retirado dos endpoints do Service. Essas duas coisas acontecem em paralelo, então por alguns instantes ainda pode chegar tráfego novo depois do sinal. É por isso que a aplicação não deve fechar a porta no primeiro milissegundo.

O problema do PID 1

Dentro do container, o processo principal é o PID 1. O kernel trata o PID 1 de forma especial: sinais para os quais ele não instalou tratador são ignorados, em vez de executar a ação padrão de encerrar. Uma aplicação que nunca tratou SIGTERM, rodando como PID 1, simplesmente não reage, e o Docker espera os 10 segundos e mata.

Pior ainda é a forma shell do CMD. Com CMD npm start, quem vira PID 1 é um shell, que pode não repassar o sinal para o seu processo. Use a forma exec, CMD ["node", "server.js"], e trate o sinal no código. Se quiser um processo init pequeno na frente para repassar sinais e recolher processos filhos, o Docker tem a opção --init, e o Compose, init: true.

Como implementar o desligamento gracioso

  1. Marque a instância como não pronta. A rota de prontidão passa a devolver 503, e o balanceador para de mandar tráfego. Essa rota precisa dizer a verdade sobre o estado da aplicação, assunto do post healthcheck que mente.
  2. Espere alguns segundos ainda atendendo. É o tempo de o balanceador perceber a mudança, ou de o Kubernetes tirar o pod dos endpoints.
  3. Feche o servidor para conexões novas. Os pedidos em curso continuam até o fim.
  4. Feche os recursos. Pool do banco, conexão com fila, arquivos abertos.
  5. Saia com código zero. Se o prazo interno estourar, saia com erro e registre o motivo no log.

Exemplo em Node.js com Express e PostgreSQL:

const express = require('express');
const { Pool } = require('pg');

const app = express();
const pool = new Pool();
let desligando = false;

app.get('/readyz', (req, res) => {
  res.status(desligando ? 503 : 200).end();
});

app.post('/pedidos', express.json(), async (req, res) => {
  let cliente;
  try {
    cliente = await pool.connect();
    await cliente.query('BEGIN');
    const { rows } = await cliente.query(
      'INSERT INTO pedidos (cliente_id, total_centavos) VALUES ($1, $2) RETURNING id',
      [req.body.clienteId, req.body.totalCentavos]
    );
    await cliente.query(
      'UPDATE estoque SET quantidade = quantidade - 1 WHERE produto_id = $1',
      [req.body.produtoId]
    );
    await cliente.query('COMMIT');
    res.status(201).json({ id: rows[0].id });
  } catch (err) {
    if (cliente) await cliente.query('ROLLBACK').catch(() => {});
    console.error('falha ao gravar pedido:', err.message);
    res.status(500).json({ erro: 'falha ao gravar o pedido' });
  } finally {
    if (cliente) cliente.release();
  }
});

const server = app.listen(3000);

process.on('SIGTERM', () => {
  console.log('SIGTERM recebido, saindo do rodizio');
  desligando = true;

  setTimeout(() => {
    server.close(async () => {
      await pool.end();
      console.log('pedidos em curso concluidos, saindo');
      process.exit(0);
    });
  }, 5000);

  setTimeout(() => {
    console.error('desligamento passou do limite, saindo assim mesmo');
    process.exit(1);
  }, 25000).unref();
});

O server.close para de aceitar conexões e chama o callback quando as existentes terminam. Conexões keep-alive ociosas podem segurar esse fechamento; o Node fecha as ociosas automaticamente a partir da versão 19, e em versões anteriores existe server.closeIdleConnections(). O segundo timer é a rede de segurança: 25 segundos, abaixo dos 30 que você vai dar ao orquestrador.

A transação faz a outra metade do trabalho. Se mesmo assim o processo morrer no meio, o banco desfaz tudo o que não teve COMMIT quando a conexão cai. O dado fica inteiro: ou o pedido e a baixa de estoque, ou nenhum dos dois.

Configurando o prazo no Compose e no Kubernetes

services:
  api:
    image: registry.exemplo.com.br/loja/api:2.4.0
    init: true
    stop_grace_period: 30s
spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: api
      image: registry.exemplo.com.br/loja/api:2.4.0

A conta tem de fechar: espera para sair do rodízio, mais o pedido mais longo, mais o fechamento de recursos, tem de caber no prazo da aplicação, que tem de caber no prazo do orquestrador.

Workers e consumidores de fila

Para quem consome fila, a lógica é a mesma com outras palavras: ao receber o sinal, pare de buscar mensagens novas, termine a que está em mãos e só confirme o processamento (ack) depois de concluído. Mensagem confirmada antes do fim e processo morto no meio é trabalho perdido. Mensagem não confirmada volta para a fila e outro worker pega, desde que o processamento seja seguro de repetir.

Como testar o desligamento

time docker compose stop api
docker inspect --format '{{.State.ExitCode}}' $(docker compose ps -aq api)

Se o stop leva exatamente o prazo inteiro e o código de saída é 137, o processo foi morto pelo SIGKILL (128 + 9): ele ignorou o SIGTERM. Código 0 é saída limpa pelo seu tratador. Para um teste mais realista, dispare uma requisição lenta, pare o container no meio e confira se ela terminou com sucesso.

Erros comuns

  • CMD na forma shell. O shell vira PID 1, o sinal não chega à aplicação e todo deploy termina em SIGKILL depois de 10 segundos.
  • Sair imediatamente no SIGTERM. Um process.exit() direto no tratador é tão ruim quanto o SIGKILL: corta os pedidos em curso.
  • Prazo da aplicação maior que o do orquestrador. A aplicação ainda espera quando o SIGKILL chega, e o desligamento gracioso nunca termina.
  • Fechar a porta antes de sair do rodízio. O balanceador ainda manda tráfego por alguns segundos e recebe conexão recusada.
  • Escrita em várias etapas sem transação. Qualquer morte no meio deixa dado pela metade, por mais gracioso que seja o desligamento.
  • Confirmar a mensagem da fila antes de processar. O processo cai e o trabalho some sem deixar rastro.

Checklist

  • O CMD está na forma exec, ou há um init repassando sinais.
  • A aplicação trata SIGTERM e registra no log quando o recebe.
  • A prontidão passa a devolver 503 no início do desligamento.
  • Existe um prazo interno, menor que o stop_grace_period ou o terminationGracePeriodSeconds.
  • Escritas com mais de uma etapa estão dentro de transação.
  • Workers confirmam a mensagem só depois de concluir o trabalho.
  • O docker compose stop termina antes do prazo e com código de saída 0.

Deploy sem pedido cortado, em todos os servidores

Desligamento gracioso é o que faz uma atualização de container passar despercebida pelo cliente. Se a nova versão sair com defeito, a outra metade da segurança é voltar rápido, de preferência com um roteiro já testado, como descrevemos em rollback ensaiado. O Dockyard, painel Docker da DevBras, faz a atualização de containers com rollback e reúne stacks de vários servidores num só lugar, com papéis de acesso e login único via Keycloak. Dá para pedir um container grátis para testar pelo suporte@devbras.com.

Quanto tempo o stop espera hoje no seu ambiente, e quanto dura o seu pedido mais longo?

Avalie este artigo

Referência: https://docs.docker.com/engine/reference/commandline/stop/