Health Check em Zig: Liveness, Readiness e /healthz em Produção

Health check em Zig é o contrato operacional que diz a um orquestrador, load balancer ou script de deploy se o processo está vivo e se ele pode receber tráfego agora. Na prática: duas rotas baratas — /healthz (liveness) e /readyz (readiness) — com respostas previsíveis, dependências explícitas e integração com graceful shutdown.

Sem esse contrato, o deploy vira achismo. O Nginx continua mandando request para uma réplica que ainda não abriu a porta. O Kubernetes reinicia um pod só porque o banco falhou por dois segundos. O Docker marca o container como saudável enquanto a API já não aceita conexões. Zig não esconde isso atrás de um framework: você decide o que cada probe significa e o que cada status HTTP comunica.

Este artigo fecha o buraco entre menções espalhadas no HTTP em produção, observabilidade, cloud native e systemd. O objetivo é um desenho reutilizável: o que medir, o que nunca medir, como modelar estado em Zig e como provar o comportamento com testes e smoke scripts.

Resposta rápida

PerguntaLiveness (/healthz)Readiness (/readyz)
O que responde?o processo ainda reage?posso receber tráfego agora?
Dependência externa?não (ou só se for realmente o processo)sim, se for obrigatória
Durante graceful shutdownpode continuar 200deve virar 503 imediatamente
Falha típicaevent loop travado, deadlock, OOM iminentebanco fora, fila obrigatória, config incompleta
Ação do orquestradorreiniciar o processotirar do balanceador, sem matar ainda
Custo alvomicrosegundos a poucos msbaixo, com timeout curto
Status saudável200200
Status ruim503503

Se você está montando a API do zero, combine este guia com o tutorial de HTTP server, o checklist de proxy reverso e o desenho de API REST completa em Zig.

Por que dois endpoints e não um

A confusão mais comum é tratar “saúde” como um booleano único. Em produção existem pelo menos duas perguntas distintas:

  1. O processo está vivo? Se a resposta for não, reiniciar pode ajudar.
  2. O processo está pronto para tráfego? Se a resposta for não, tire a réplica do pool — mas não mate o binário se ele ainda está drenando trabalho útil.

Exemplos em que um único /health quebra o deploy:

  • No shutdown, você devolve 503 para parar o tráfego. Se isso for liveness, o kubelet pode matar o pod antes do drain de conexões.
  • O banco falha por 3 segundos. Se isso for liveness, o processo reinicia em cascata e piora a tempestade.
  • O serviço sobe, abre a porta, mas ainda carrega cache quente. Sem readiness separado, o balanceador manda request cedo demais.

A regra operacional é simples: liveness protege o processo; readiness protege o usuário.

O contrato mínimo de um health check em Zig

Todo health check saudável em Zig deve ser:

  1. Barato — sem alocar buffers grandes, sem varrer disco, sem serializar relatórios enormes.
  2. Determinístico — o mesmo estado interno deve produzir o mesmo status.
  3. Seguro — sem segredos, sem stack trace completo, sem dados de cliente.
  4. Observável — logs e métricas de falha de readiness, sem poluir cada probe bem-sucedido em alta frequência.
  5. Alinhado ao shutdown — readiness cai no SIGTERM antes do exit.

Um esqueleto conceitual:

const std = @import("std");

pub const HealthState = struct {
    ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
    shutting_down: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
    // opcional: contadores, timestamp do último check de dependência, etc.
};

pub fn handleHealthz(state: *const HealthState) u16 {
    // Liveness: o handler só roda se o loop de HTTP ainda despacha trabalho.
    // Se você chegou aqui, o processo "respira".
    _ = state;
    return 200;
}

pub fn handleReadyz(state: *const HealthState, deps_ok: bool) u16 {
    if (state.shutting_down.load(.acquire)) return 503;
    if (!state.ready.load(.acquire)) return 503;
    if (!deps_ok) return 503;
    return 200;
}

O detalhe de std.http.Server muda entre versões do Zig; o contrato de estado compartilhado + status HTTP não muda. Confira a referência de std.http.Server e o tutorial de servidor HTTP para o wiring da rota.

Corpo da resposta: texto curto ou JSON pequeno

Para probes automáticos, o status HTTP basta. Ainda assim, um corpo mínimo ajuda humanos e scripts:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"status":"ok","role":"ready"}

Em falha:

HTTP/1.1 503 Service Unavailable
Content-Type: application/json
Cache-Control: no-store

{"status":"not_ready","reason":"database"}

Boas práticas do corpo:

  • Cache-Control: no-store — health check nunca deve ser cacheado por proxy intermediário;
  • motivo curto e enumerável (database, queue, config, shutdown);
  • sem PII, sem connection string, sem versão interna sensível;
  • tamanho pequeno o bastante para caber em um log de uma linha.

Se o seu serviço já emite métricas Prometheus, um contador readyz_failures_total{reason="database"} vale mais do que um JSON verboso. Veja observabilidade com logs e Prometheus e OpenTelemetry em Zig.

O que colocar em liveness

Liveness deve responder: o processo ainda é capaz de progresso útil?

Inclua:

  • o fato de o handler HTTP ter sido despachado;
  • opcionalmente, um heartbeat de uma thread de trabalho crítica (atualizado com relógio monotônico);
  • opcionalmente, um limite de memória se o processo já está em estado irrecuperável.

Não inclua:

  • ping em banco de dados;
  • chamada a API externa;
  • leitura de fila remota;
  • verificação de disco de alta latência;
  • qualquer I/O que possa bloquear o worker do probe.

Se o event loop travar de verdade, o probe deixa de responder e o orquestrador age. Esse é o sinal mais honesto de liveness em um servidor HTTP.

O que colocar em readiness

Readiness deve responder: se eu mandar tráfego real agora, este processo consegue cumprir o contrato da API?

Dependências típicas:

DependênciaComo checar com segurançaTimeout sugerido
PostgreSQL / MySQLSELECT 1 ou ping do pool50–200 ms
RedisPING30–100 ms
Fila obrigatóriaconexão + auth, sem consumir job50–200 ms
Arquivo de config / secretexistência + parse já feito no bootlocal, quase zero
Feature flag críticaestado em memória já carregadolocal
Upstream obrigatóriosó se a API for um proxy purocurto e com circuit breaker

Regras práticas:

  1. Só dependências obrigatórias. Se a API degrada sem cache, o cache não deve derrubar readiness.
  2. Timeout curto e explícito. Readiness lento vira indisponibilidade artificial.
  3. Cacheie o resultado por 1–5 segundos se o volume de probes for alto, com invalidação no shutdown.
  4. Nunca bloqueie o event loop inteiro em um check de dependência sem limite.

Para pools e queries, use o guia de banco de dados em Zig e o de PostgreSQL com libpq. Para resiliência de cliente, o circuit breaker com timeout e retry evita que o próprio readiness amplifique uma pane.

Integração com graceful shutdown

O casamento correto entre health e shutdown é o que separa deploy limpo de 502 em cascata:

  1. Processo sobe, carrega config, conecta dependências.
  2. Marca ready = true só quando puder servir.
  3. Orquestrador começa a mandar tráfego.
  4. Chega SIGTERM / SIGINT.
  5. Marca shutting_down = true e ready = false antes de fechar o listener.
  6. /readyz passa a responder 503; balanceador remove a réplica.
  7. Drain de HTTP e workers com timeout.
  8. Exit com código previsível.

Esse fluxo está detalhado no artigo de graceful shutdown com SIGTERM e SIGINT. O erro clássico é o inverso: fechar o socket primeiro e só depois lembrar do readiness — o probe e o balanceador veem falha de conexão em vez de um 503 educado.

Kubernetes, Docker e systemd

Kubernetes

livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10
  timeoutSeconds: 1
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /readyz
    port: 8080
  initialDelaySeconds: 2
  periodSeconds: 5
  timeoutSeconds: 1
  failureThreshold: 2

Ajuste initialDelaySeconds ao tempo real de boot do binário Zig (quase sempre baixo) e ao tempo de aquecimento de dependências. Para o desenho de deploy em cluster, veja Zig cloud native e Kubernetes operators em Zig.

Docker

healthcheck:
  test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8080/healthz"]
  interval: 10s
  timeout: 2s
  retries: 3
  start_period: 5s

Docker Compose costuma ter um único healthcheck. Se você só puder expor um, prefira liveness no healthcheck do engine e deixe readiness com o load balancer / orchestrator de cima. No código, um modo --health de CLI que sai 0/1 também funciona bem para containers sem HTTP — padrão citado no guia de Docker com Zig.

systemd

Com systemd, o socket ou o serviço pode usar:

curl -fsS http://127.0.0.1:8080/healthz
curl -fsS http://127.0.0.1:8080/readyz

em ExecStartPost, scripts de deploy e checagens de systemctl is-active. O artigo de serviço systemd em produção mostra o encaixe com logs e restart policy.

Nginx / load balancer

O proxy na frente deve usar a rota de readiness (ou um location interno equivalente) para decidir se a réplica entra no upstream. O guia de Nginx, proxy reverso e load balancing cobre max_fails, fail_timeout e a janela perigosa de deploy com várias réplicas.

Implementação de rotas no servidor HTTP

Independentemente da API exata de std.http.Server na sua versão do Zig, o desenho de roteamento costuma ser:

// Pseudocódigo de despacho
if (method == .GET and pathEql(path, "/healthz")) {
    // 200 quase sempre; corpo mínimo
    return writeStatus(response, 200, "{\"status\":\"ok\",\"role\":\"live\"}");
}

if (method == .GET and pathEql(path, "/readyz")) {
    const code = handleReadyz(&app.state, checkDeps(&app));
    if (code == 200) {
        return writeStatus(response, 200, "{\"status\":\"ok\",\"role\":\"ready\"}");
    }
    return writeStatus(response, 503, "{\"status\":\"not_ready\",\"reason\":\"deps\"}");
}

Detalhes que evitam dor:

  • aceite só GET (e, se quiser, HEAD);
  • não conte health check na métrica de “request de negócio” sem label separado;
  • não aplique rate limiting agressivo nas rotas de probe da rede interna;
  • não exija JWT no probe local;
  • se o servidor estiver atrás de mTLS de serviço, configure o probe do orquestrador na mesma rede/confiança.

Dependências opcionais vs obrigatórias

Nem toda falha externa deve derrubar readiness. Classifique no boot:

obrigatória  -> readiness falha
opcional     -> métrica + log + degradação da feature

Exemplos:

  • API de pagamentos em um checkout: se for o único meio de pagamento, é obrigatória; se houver fallback offline, talvez não.
  • Cache Redis de sessão: se a API consegue cair para store primário, readiness pode permanecer OK.
  • Worker de e-mail: o processo HTTP de leitura não precisa falhar readiness porque o SMTP caiu.

Essa classificação também melhora feature flags e rollout: você pode marcar uma dependência nova como opcional até o percentual de tráfego justificar o contrário.

Testes que realmente pegam regressão

Health check sem teste vira documentação. Cubra pelo menos:

  1. Boot frio — antes de ready=true, /readyz é 503 e /healthz é 200.
  2. Pronto — com dependências mockadas OK, ambos 200.
  3. Dependência ruim — readiness 503 com reason estável; liveness intacto.
  4. Shutdown — após sinal (ou flag injetada), readiness cai; liveness permanece coerente até o exit.
  5. Timeout de dependência — se o mock dorme demais, readiness falha por timeout, não trava o teste.
  6. Smoke de processo — sobe o binário em porta efêmera, faz curl real, encerra.

Um smoke mínimo:

./bin/api --port 18080 &
pid=$!
trap 'kill -TERM $pid' EXIT

for i in 1 2 3 4 5 6 7 8 9 10; do
  if curl -fsS "http://127.0.0.1:18080/healthz" >/dev/null; then
    break
  fi
  sleep 0.2
done

curl -fsS "http://127.0.0.1:18080/healthz" | grep -q ok
# readiness pode exigir fixtures de dependência no ambiente de CI

Para a cultura de testes em Zig, veja o guia completo de testes e testes baseados em propriedade.

Erros comuns (e como evitar)

Um único endpoint para liveness e readiness. Já coberto: separa as perguntas.

Readiness que faz trabalho pesado. Se o check precisa varrer tabela inteira, você criou uma DoS self-service a cada 5 segundos.

Liveness que depende de rede. Reinício em cascata quando a rede treme.

Sem Cache-Control: no-store. Proxy intermediário devolve health antigo e esconde pane real.

Sem alinhar ao shutdown. Balanceador ainda manda request enquanto o processo já não quer tráfego.

Corpo com segredo. Connection string, token e path interno não pertencem à resposta de probe.

Probe na porta pública sem filtro. Em edge exposta, restrinja por rede ou coloque o probe só no listener interno.

Métricas sem label de rota. Health check alto volume distorce latência p99 da API de negócio.

Ignorar HEAD. Alguns load balancers preferem HEAD; se você rejeita, documenta ou trata igual ao GET sem body.

Checklist de produção

  • /healthz e /readyz existem e respondem rápido.
  • Liveness não consulta dependência externa cara.
  • Readiness só falha por dependência obrigatória ou shutdown.
  • No SIGTERM, readiness vira 503 antes do drain completo.
  • Respostas usam 200 / 503 de forma estável.
  • Cache-Control: no-store (ou equivalente) está presente.
  • Probes do Kubernetes/Docker/Nginx apontam para as rotas certas.
  • Timeouts de dependência são curtos e testados.
  • Métricas e logs de falha de readiness existem sem flood no sucesso.
  • Smoke test de processo cobre health no CI ou no pipeline de deploy.
  • Rede de probe não exige auth de usuário final.
  • Documentação interna lista o que cada reason significa.

Quando health check sozinho não basta

Health check responde “agora”. Ele não substitui:

Pense no health check como o semáforo do cruzamento: necessário, barato, inequívoco. O resto do sistema ainda precisa frear, sinalizar e recuperar.

FAQ na prática

Devo falhar readiness se a latência p99 subiu?

Em geral, não. Latência alta é sinal de capacidade ou dependência lenta; trate com autoscaling, rate limit e circuit breaker. Readiness deve falhar quando o serviço não pode cumprir o contrato, não quando está apenas mais lento que o ideal.

E se eu tiver vários listeners (admin vs público)?

Exponha probes no listener interno/admin. O listener público continua servindo a API. Isso reduz superfície de ataque e evita que um WAF externo interfira no kubelet.

Preciso de /startup além de liveness/readiness?

Em boot longo (raro em Zig puro, comum se você carrega modelos ou migra estado), um startup probe separado evita que o liveness mate o processo ainda inicializando. Se o boot é rápido, readiness com ready=false no começo já resolve.

Health check de worker sem HTTP?

Use um comando --health no mesmo binário, ou um arquivo heartbeat em disco/tmpfs atualizado pelo loop principal, lido por um sidecar. O importante é o mesmo contrato mental: vivo vs pronto.

Próximos passos

  1. Adicione /healthz e /readyz ao servidor com estado atômico compartilhado.
  2. Ligue readiness ao fluxo de graceful shutdown.
  3. Configure probes no Docker, systemd ou Kubernetes.
  4. Emita métricas de falha e encaixe no pipeline de observabilidade.
  5. Só então refine dependências opcionais, cache de check e dashboards.

Zig recompensa esse estilo incremental: cada decisão fica no código, cada probe vira contrato testável, e o deploy deixa de depender de “parece que subiu”. Com health check bem desenhado, o orquestrador para de adivinhar — e o seu binário passa a falar, em HTTP claro, se está vivo e se está pronto.

Continue aprendendo Zig

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