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
| Momento | Ação correta em Zig |
|---|---|
SIGINT / SIGTERM chega | gravar flag atômica; não fazer I/O no handler |
| depois do sinal | parar accept / consumo de fila; manter conexões ativas |
| trabalho em andamento | terminar se for curto; cancelar ou reenfileirar se for longo |
| recursos | fechar listener, flush de logs, liberar locks e arquivos |
| timeout de drain | registrar pendências e sair; não bloquear para sempre |
| sucesso | exit 0 (ou código combinado com o supervisor) |
| falha de limpeza | logar 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 stopesystemctl 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:
- Sinal — o SO notifica o processo.
- Marca — o handler grava um estado compartilhado seguro.
- Drain — o loop principal para de aceitar trabalho novo e conclui o restante com limite de tempo.
- 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 sinais —
SIGINTeSIGTERMpedem 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=signalquando vê a flag; - sem
std.process.exitno 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:
- parar de aceitar conexões novas no listener;
- continuar lendo e respondendo requisições já aceitas;
- fechar conexões ociosas;
- cancelar com critério as que ainda não terminaram quando o timeout estoura;
- 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 job | No shutdown |
|---|---|
| curto e idempotente (segundos) | terminar |
| longo e idempotente | cancelar e reenfileirar |
| não idempotente (pagamento, e-mail) | preferir terminar se já começou; senão não puxar |
| batch com checkpoint | salvar 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:
- parar de aceitar conexões / jobs;
- esperar ou cancelar trabalho em voo (com timeout);
- fechar listener e conexões restantes;
- flush de buffers de log e métricas;
- liberar locks de arquivo e PID file;
- fechar conexões de banco, Redis, sockets Unix;
deinitde 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
popprecisa 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
SIGTERMpode 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):
- spawn do binário com
std.process.Child(veja executar processos); - enviar carga (HTTP ou jobs);
kill -TERM <pid>;- 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
-
SIGINTeSIGTERMinstalados 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
SIGTERMde 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.