Graceful Shutdown em Zig com SIGTERM, SIGINT e Drain de Conexões

Graceful shutdown em Zig é o contrato operacional de receber SIGTERM ou SIGINT, parar de aceitar trabalho novo, esvaziar (ou cancelar com limite) o que já está em andamento e sair com estado previsível. Na prática: uma flag atômica no handler, um loop principal que reage a ela, um timeout de drain e limpeza explícita de sockets, workers, arquivos e métricas.

Esse padrão aparece em todo serviço real — API HTTP, worker de fila, agente local, CLI de longa duração e daemon atrás de systemd. Sem ele, o deploy vira roleta: resposta pela metade, job duplicado, arquivo temporário órfão e log sem causa. Com ele, o stop do orquestrador deixa de ser um acidente e passa a ser um caminho de código testável.

A receita de sinais em Zig cobre a captura básica. Este artigo fecha o ciclo de produção: o que o handler pode fazer, como drenar HTTP e workers, como alinhar timeout com systemd/Docker e como provar o comportamento com testes.

Resposta rápida

MomentoAção correta em Zig
SIGINT / SIGTERM chegagravar flag atômica; não fazer I/O no handler
depois do sinalparar accept / consumo de fila; manter conexões ativas
trabalho em andamentoterminar se for curto; cancelar ou reenfileirar se for longo
recursosfechar listener, flush de logs, liberar locks e arquivos
timeout de drainregistrar pendências e sair; não bloquear para sempre
sucessoexit 0 (ou código combinado com o supervisor)
falha de limpezalogar e sair com código ≠ 0 para o restart policy agir

Se você está montando o servidor do zero, combine este guia com o tutorial de HTTP server e o checklist de HTTP em produção. Para jobs assíncronos, leia também filas e workers em background.

Por que “matar o processo” não basta

Em desenvolvimento, Ctrl+C e o terminal some. Em produção, o mesmo binário precisa sobreviver a:

  • systemctl stop e systemctl restart;
  • rolling update em container com stop_grace_period;
  • balanceador que remove a réplica do pool e depois manda SIGTERM;
  • deploy que sobe a nova versão antes de a antiga terminar;
  • jobs que escrevem em disco, chamam API externa ou atualizam cache.

Se o processo morre no meio de uma resposta HTTP, o cliente pode reenviar e gerar efeito colateral. Se um worker some no meio de um job não idempotente, você pode cobrar duas vezes, enviar dois e-mails ou deixar um arquivo pela metade. Graceful shutdown não elimina esses riscos sozinho — ele cria a janela para o programa decidir o que fazer com o trabalho residual.

Zig favorece esse desenho porque não esconde o processo atrás de um runtime com hooks mágicos. Você escreve o contrato: quais sinais importam, qual flag muda, quem consulta a flag e quanto tempo o drain pode durar.

O contrato mínimo em quatro etapas

Todo graceful shutdown saudável segue a mesma ordem:

  1. Sinal — o SO notifica o processo.
  2. Marca — o handler grava um estado compartilhado seguro.
  3. Drain — o loop principal para de aceitar trabalho novo e conclui o restante com limite de tempo.
  4. Saída — recursos liberados, logs finais, código de saída previsível.

Inverter a ordem é o erro clássico: tentar fechar sockets dentro do handler, logar com alocação, ou esperar “todo mundo terminar” sem timeout. O supervisor (systemd, Docker, Kubernetes) sempre tem um prazo. Se o seu programa não tiver o mesmo prazo, o kernel manda SIGKILL e o graceful vira kill.

Signal handler seguro: só a flag

Em POSIX, o conjunto de funções assíncrono-seguras dentro de um handler é pequeno. Alocar com o allocator do heap, formatar string, escrever em arquivo ou chamar a maior parte da stdlib não é seguro. O padrão portátil em Zig é:

const std = @import("std");
const posix = std.posix;

var shutting_down = std.atomic.Value(bool).init(false);

fn onSignal(sig: c_int) callconv(.C) void {
    _ = sig;
    shutting_down.store(true, .seq_cst);
}

fn installShutdownHandlers() !void {
    const act = posix.Sigaction{
        .handler = .{ .handler = onSignal },
        .mask = posix.empty_sigset,
        .flags = 0,
    };
    try posix.sigaction(posix.SIG.INT, &act, null);
    try posix.sigaction(posix.SIG.TERM, &act, null);
}

Pontos de design:

  • um handler, dois sinaisSIGINT e SIGTERM pedem o mesmo fluxo;
  • std.atomic.Value(bool) — leitura/escrita sem data race entre o handler e as threads;
  • sem log no handler — o loop principal loga service_stop reason=signal quando vê a flag;
  • sem std.process.exit no handler — sair ali pula o drain.

Se você precisa de SIGHUP para reload de configuração, use outra flag. Misturar reload e stop no mesmo booleano gera restart confuso e perda de trabalho.

Loop principal que reage ao sinal

O handler só avisa. Quem age é o loop:

pub fn main() !void {
    try installShutdownHandlers();

    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var app = try App.init(allocator);
    defer app.deinit();

    const drain_ns: u64 = 15 * std.time.ns_per_s;
    var drain_deadline: ?i128 = null;

    while (true) {
        if (shutting_down.load(.seq_cst)) {
            if (drain_deadline == null) {
                std.log.info("service_stop reason=signal starting_drain", .{});
                try app.stopAccepting();
                drain_deadline = std.time.nanoTimestamp() + drain_ns;
            }

            if (app.activeWork() == 0) break;

            const now = std.time.nanoTimestamp();
            if (now >= drain_deadline.?) {
                std.log.warn("drain_timeout active_work={d}", .{app.activeWork()});
                try app.forceCancel();
                break;
            }
        }

        try app.pollOnce();
    }

    std.log.info("service_exit code=0", .{});
}

Esse esqueleto serve para HTTP, TCP genérico e workers. A API de App pode ser a que o seu projeto já tem: o importante é separar aceitar trabalho novo de trabalho em voo.

Drain de servidor HTTP

Para um servidor HTTP, graceful shutdown significa:

  1. parar de aceitar conexões novas no listener;
  2. continuar lendo e respondendo requisições já aceitas;
  3. fechar conexões ociosas;
  4. cancelar com critério as que ainda não terminaram quando o timeout estoura;
  5. só então fechar o socket de escuta de forma definitiva e sair.

Na borda, o reverse proxy (Nginx, Caddy, Traefik) e o balanceador devem parar de enviar tráfego antes ou junto com o SIGTERM. Se o proxy ainda manda requests enquanto o binário já recusa accept, o cliente vê erro que parece falha da aplicação. Coordene:

  • health check que passa a falhar assim que a flag sobe (/ready → 503);
  • liveness separado de readiness;
  • timeout de stop do supervisor ≥ timeout de drain do processo.

Um endpoint de readiness simples:

fn handleReady(shutting_down_flag: *const std.atomic.Value(bool), response: anytype) !void {
    if (shutting_down_flag.load(.seq_cst)) {
        // 503: tire a réplica do balanceador, mas deixe liveness OK se o processo ainda respira
        try response.status(.service_unavailable);
        try response.body("not ready\n");
        return;
    }
    try response.status(.ok);
    try response.body("ready\n");
}

O detalhe de implementação de std.http.Server muda entre versões do Zig; o contrato de readiness + drain não muda. Confira a referência de std.http.Server e o guia de proxy reverso e load balancing para a parte de borda.

Workers e filas: o que fazer com o job atual

Graceful shutdown em worker é mais delicado que em HTTP porque o “request” pode durar minutos. Defina uma política por tipo de job:

Tipo de jobNo shutdown
curto e idempotente (segundos)terminar
longo e idempotentecancelar e reenfileirar
não idempotente (pagamento, e-mail)preferir terminar se já começou; senão não puxar
batch com checkpointsalvar progresso e sair

O worker precisa consultar a flag entre jobs e, se possível, em pontos de cancelamento internos:

fn workerLoop(queue: *JobQueue, flag: *const std.atomic.Value(bool)) !void {
    while (!flag.load(.seq_cst)) {
        const job = queue.popOrWait(100 * std.time.ns_per_ms) catch continue;
        try processJob(job, flag);
        queue.ack(job);
    }
    std.log.info("worker_exit drained=true", .{});
}

fn processJob(job: Job, flag: *const std.atomic.Value(bool)) !void {
    // checkpoints: se o shutdown subir no meio, persista estado e devolva o job
    if (flag.load(.seq_cst)) return error.Shutdown;
    try job.step1();
    if (flag.load(.seq_cst)) return error.Shutdown;
    try job.step2();
}

Sem idempotência e sem ack explícito, “cancelar e reenfileirar” vira duplicata. O artigo de filas e workers e o de circuit breaker, timeout e retry fecham a resiliência do lado da dependência externa.

Timeout: alinhe processo e supervisor

O graceful shutdown só é real se o prazo do programa e o prazo do supervisor combinam.

Exemplos:

# systemd
TimeoutStopSec=20
KillSignal=SIGTERM
FinalKillSignal=SIGKILL
# Docker Compose
stop_grace_period: 20s
# Kubernetes
terminationGracePeriodSeconds: 30

Regra prática: drain interno < TimeoutStopSec. Se o systemd espera 20s, o seu drain_ns deve ser 10–15s, deixando margem para flush de logs e deinit. Se o programa esperar 60s e o supervisor matar em 10s, o código de drain nunca roda até o fim.

Também registre o motivo da saída:

  • reason=signal + drain completo;
  • reason=drain_timeout;
  • reason=fatal_error (outro caminho, não confundir com stop).

Isso vira ouro em logs e Prometheus quando alguém pergunta “por que o job reprocessou no deploy das 3h”.

O que liberar no deinit

Uma checklist típica de limpeza, na ordem:

  1. parar de aceitar conexões / jobs;
  2. esperar ou cancelar trabalho em voo (com timeout);
  3. fechar listener e conexões restantes;
  4. flush de buffers de log e métricas;
  5. liberar locks de arquivo e PID file;
  6. fechar conexões de banco, Redis, sockets Unix;
  7. deinit de allocators e detectar leak em debug.

Zig deixa leaks e handles visíveis. Use GeneralPurposeAllocator em desenvolvimento com detecção de leak no defer, e não engula erros de deinit sem log. Para arquivos temporários e escrita atômica, o mesmo cuidado do download com checksum vale no stop: não renomeie um parcial como se fosse final.

Multi-thread e corridas comuns

Com várias threads, a flag atômica resolve o “foi pedido stop?”, mas não resolve sozinha:

  • condition variable / fila bloqueante — o worker dormindo em pop precisa de wake no shutdown (timeout no wait ou sinalização explícita);
  • accept bloqueante — prefira accept com timeout, event loop ou feche o listener para destravar o accept;
  • contador de trabalho ativo — use atomic ou mutex para activeWork() não mentir no drain;
  • double signal — segundo SIGTERM pode acelerar o force cancel; documente se isso é desejado.

Evite mutex dentro do signal handler. O handler só toca na atomic; quem pega lock é o caminho normal do programa.

Testes que realmente provam o shutdown

Teste unitário da flag e do timeout:

test "drain encerra quando active_work zera" {
    var app = TestApp{ .active = 1 };
    app.requestShutdown();
    app.active = 0;
    try std.testing.expect(app.shouldExit());
}

test "drain_timeout força cancelamento" {
    var app = TestApp{ .active = 3, .now_ms = 0, .deadline_ms = 100 };
    app.requestShutdown();
    app.now_ms = 150;
    try std.testing.expect(app.shouldForceCancel());
}

Teste de integração (o que pega bug de verdade):

  1. spawn do binário com std.process.Child (veja executar processos);
  2. enviar carga (HTTP ou jobs);
  3. kill -TERM <pid>;
  4. assert em: log de stop, zero novas aceitações, exit code, ausência de arquivo parcial.

Não dependa só de “subi na mão e deu Ctrl+C”. O caminho de SIGTERM do systemd é o que importa em produção.

Checklist de produção

  • SIGINT e SIGTERM instalados com o mesmo handler mínimo.
  • Handler só grava flag atômica; zero I/O/alocação no handler.
  • Loop principal loga o início do drain e o motivo da saída.
  • Listener / consumidor para de aceitar trabalho novo imediatamente.
  • Existe contador confiável de trabalho em voo.
  • Timeout de drain < TimeoutStopSec / stop_grace_period.
  • Readiness falha no shutdown; liveness permanece coerente.
  • Jobs longos têm política de cancel/retry/idempotência.
  • Logs e métricas fazem flush antes do exit.
  • Arquivos temporários não viram artefato final no meio do stop.
  • Teste de integração manda SIGTERM de verdade.
  • Unit file / compose / manifest documenta o prazo de stop.

Erros frequentes

Chamar std.process.exit no handler. Pula defer, pula drain, corrompe estado.

Logar ou formatar no handler. Pode dead lock ou corromper allocator sob sinal.

Esperar “para sempre” o trabalho acabar. O supervisor manda SIGKILL e você perde o log final.

Tratar só SIGINT. Em produção quem manda é SIGTERM.

Health check único. Se liveness e readiness são o mesmo endpoint e você devolve 503 no stop, o orquestrador pode matar a réplica cedo demais ou achar que o pod morreu.

Ignorar workers. O HTTP drena, mas a thread de fila continua puxando job e o processo “nunca termina”.

Conclusão

Graceful shutdown em Zig não é um framework: é um contrato curto e explícito. O SO envia SIGTERM ou SIGINT, o handler marca uma flag atômica, o loop para de aceitar trabalho, o drain roda com timeout e o processo libera recursos antes de sair. Esse fluxo protege deploys, reduz jobs duplicados e torna o stop observável.

Para ir além, encaixe o padrão no serviço systemd, no servidor HTTP de produção, nos workers em background e na observabilidade com logs e Prometheus. Quando o stop vira caminho de código — e não surpresa de madrugada — o binário Zig deixa de ser só rápido e passa a ser operável.

Perguntas frequentes

O que é graceful shutdown em Zig?

É receber SIGTERM ou SIGINT, parar trabalho novo, drenar ou cancelar com limite o que já está em andamento, liberar recursos e sair com código previsível, em vez de matar o processo no meio de uma operação.

Qual a diferença entre SIGTERM e SIGINT?

SIGINT costuma vir de Ctrl+C no terminal. SIGTERM é o sinal padrão de stop do systemd, Docker e Kubernetes. Em produção, trate os dois como pedido de encerramento com o mesmo drain.

Posso alocar ou fazer I/O no signal handler?

Não. O handler deve ser assíncrono-seguro: grave uma flag atômica e deixe o loop principal reagir com logs, fechamento de sockets e flush de arquivos.

Quanto tempo esperar no drain?

Use um timeout menor que o do supervisor — em geral 5 a 30 segundos, alinhado a TimeoutStopSec ou stop_grace_period. Se estourar, registre pendências e encerre.

Graceful shutdown resolve jobs em background sozinho?

Não. Workers precisam consultar a flag, parar de puxar jobs novos e aplicar política explícita (terminar, cancelar ou reenfileirar) para o job atual.

Como testar de ponta a ponta?

Suba o processo, gere carga, envie kill -TERM <pid> e verifique logs de stop, ausência de novas aceitações, limpeza de recursos e exit code. Automatize com std.process.Child em teste de integração.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.