Zig HTTP Server em Produção: Proxy, Logs, Limites e Health Check

Colocar um servidor HTTP em Zig no ar é simples. Manter esse servidor previsível em produção exige mais disciplina: limite de corpo, logs que ajudam a investigar incidentes, health check claro, reverse proxy configurado, encerramento controlado e uma separação honesta entre o que fica no Zig e o que deve ficar na borda.

Este guia complementa o tutorial de servidor HTTP em Zig e a referência de std.http.Server. A ideia aqui não é construir outro “hello world”, mas mostrar o checklist prático para transformar uma API pequena em um serviço que pode rodar atrás de Nginx, Caddy, Traefik, Cloudflare Tunnel, systemd ou Kubernetes.

O que muda entre tutorial e produção

Em um tutorial, o servidor costuma aceitar uma conexão, ler a requisição e responder. Em produção, cada uma dessas etapas precisa de limites. Se o cliente envia um body de 2 GB, você não quer tentar alocar tudo em memória. Se um crawler abre conexão e nunca termina de enviar headers, você não quer prender um worker para sempre. Se a aplicação cai, você quer saber se foi erro de parsing, falta de memória, rota inexistente, timeout no upstream ou deploy interrompido.

Zig ajuda porque deixa custo e memória explícitos. O mesmo controle que torna Zig interessante para networking com sockets TCP e UDP também obriga o projeto a documentar decisões operacionais. A biblioteca padrão não esconde o servidor atrás de um framework cheio de defaults mágicos. Isso é bom, desde que você trate produção como parte do programa.

Arquitetura recomendada

Para a maioria dos projetos, não exponha o binário Zig diretamente na internet. Rode o processo em uma porta local e coloque um reverse proxy na frente:

internet
  -> Cloudflare / load balancer
  -> Nginx, Caddy ou Traefik
  -> 127.0.0.1:8080
  -> processo Zig

Essa divisão mantém o Zig focado na aplicação. TLS, compressão, HTTP/2, certificados, redirects, headers globais e rate limiting básico ficam melhor na camada de proxy. O servidor Zig cuida de roteamento, validação, regras de negócio, JSON e respostas.

Um server block mínimo no Nginx poderia ficar assim:

server {
    listen 443 ssl http2;
    server_name api.exemplo.com;

    client_max_body_size 1m;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

O client_max_body_size no proxy é a primeira linha de defesa, mas não deve ser a única. O processo Zig também precisa validar tamanho e formato, porque proxy muda, configuração falha e ambientes de teste às vezes chamam o binário direto.

Health check não é página inicial

Toda API em produção precisa de uma rota de health check. Ela deve ser barata, determinística e pensada para automação. Não misture com a home, não rode consultas caras e não esconda erro grave com 200 OK.

Um padrão simples:

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

fn handleHealth(request: *http.Server.Request) !void {
    try request.respond(
        \\{"status":"ok","service":"zig-api"}
    , .{
        .status = .ok,
        .extra_headers = &.{
            .{ .name = "content-type", .value = "application/json; charset=utf-8" },
            .{ .name = "cache-control", .value = "no-store" },
        },
    });
}

Em Kubernetes, systemd watchdogs, uptime monitors e load balancers, essa rota vira contrato. Se você tem dependências externas obrigatórias, como banco de dados ou fila, considere duas rotas: /healthz para indicar que o processo está vivo e /readyz para indicar que a aplicação está pronta para receber tráfego.

Limite de body e parsing defensivo

APIs REST em Zig costumam usar JSON. O erro comum é ler o corpo inteiro sem limite. Em desenvolvimento isso funciona; em produção vira vetor de consumo de memória.

Defina um limite por rota:

const max_body = 64 * 1024; // 64 KiB

fn readBodyLimited(
    allocator: std.mem.Allocator,
    reader: anytype,
) ![]u8 {
    return try reader.readAllAlloc(allocator, max_body);
}

O limite certo depende da API. Um endpoint de login pode aceitar poucos KiB. Um importador de CSV talvez precise de megabytes, mas aí deve ter autenticação, observabilidade e, idealmente, processamento em streaming. Para JSON comum, limites pequenos melhoram previsibilidade e deixam falhas mais óbvias.

Depois de ler, valide o content-type, trate erro de parse e responda com status adequado. Um 400 Bad Request para JSON inválido é melhor que um 500 genérico. Erros de cliente não devem parecer incidente interno.

fn badRequest(request: *http.Server.Request, message: []const u8) !void {
    try request.respond(message, .{
        .status = .bad_request,
        .extra_headers = &.{
            .{ .name = "content-type", .value = "text/plain; charset=utf-8" },
        },
    });
}

Se a API expõe endpoints públicos, combine isso com autenticação, rate limiting no proxy e logs que permitam encontrar abuso sem registrar dados sensíveis.

Logs úteis para incidentes

Log bom responde quatro perguntas: o que aconteceu, onde, quando e com qual resultado. Em servidor HTTP, registre método, caminho, status, duração e um identificador de request quando existir. Evite logar tokens, senhas, cookies, payloads completos ou dados pessoais.

Um formato de linha simples já resolve muito:

const started = std.time.milliTimestamp();

// ... processa requisição ...

const elapsed_ms = std.time.milliTimestamp() - started;
std.log.info("http method={s} path={s} status={d} duration_ms={d}", .{
    @tagName(request.head.method),
    request.head.target,
    @intFromEnum(status),
    elapsed_ms,
});

Em serviços pequenos, std.log com stdout é suficiente. O supervisor captura a saída e envia para journald, Docker logs, Loki, Vector ou outro pipeline. O importante é não depender de std.debug.print espalhado pelo código. Logs devem ser uma interface operacional, não sobras de depuração.

Timeouts, keep-alive e concorrência

O modelo mais simples aceita uma conexão por vez. Isso é útil para aprender, mas não serve para tráfego real. Em produção, você normalmente quer um destes caminhos:

  • processo atrás de proxy com baixa concorrência e thread pool simples;
  • múltiplos processos Zig balanceados pelo supervisor;
  • arquitetura assíncrona quando o projeto justificar a complexidade.

O ponto prático é medir antes de sofisticar. Para uma API interna, um pool pequeno pode ser suficiente. Para tráfego público, acompanhe latência p95, erros 5xx, conexões simultâneas e uso de memória. Se o gargalo está no banco, trocar o modelo de concorrência do HTTP não resolve.

Timeouts também devem existir em duas camadas. O proxy encerra conexões lentas e protege a borda; o Zig evita prender processamento em chamadas internas demoradas. Quando a aplicação chamar APIs externas com std.http.Client, trate timeout como resultado esperado, não como surpresa.

Shutdown controlado

Deploy sem shutdown controlado derruba requisições no meio. O mínimo aceitável é capturar sinal de encerramento, parar de aceitar novas conexões e deixar requisições em andamento terminarem por um período curto. Em ambientes simples, o supervisor pode reiniciar o processo; em ambientes maiores, readiness probe deve sair do ar antes do processo encerrar.

Mesmo que a primeira versão não implemente graceful shutdown completo, documente o comportamento. Um serviço que responde rápido e só faz operações idempotentes tolera reinício brusco melhor que uma API que processa pagamento, arquivo grande ou tarefa assíncrona. Produção é feita dessas diferenças.

Checklist antes de publicar

Antes de apontar DNS ou incluir o serviço em um load balancer, confirme:

  1. /healthz responde 200 sem cache.
  2. Bodies têm limite no proxy e no Zig.
  3. JSON inválido retorna 400, não 500.
  4. Logs registram método, rota, status e duração.
  5. Segredos entram por variável de ambiente ou secret manager, nunca hardcoded.
  6. O processo roda como usuário sem privilégios.
  7. O binário é compilado em modo release apropriado.
  8. O deploy consegue reiniciar sem intervenção manual.
  9. Métricas ou logs permitem descobrir aumento de erro.
  10. O README do serviço explica porta, variáveis e comando de execução.

Esse checklist parece burocrático, mas é justamente o que separa um exemplo de uma aplicação operável.

Zig, Go e Rust no backend

Para APIs HTTP tradicionais, Go ainda tem o caminho mais maduro: biblioteca padrão estável, deploy simples e uma cultura enorme de servidores. Rust oferece garantias fortes, mas normalmente exige mais escolhas de runtime e ecossistema. Zig fica em um ponto diferente: menos framework pronto, mais controle explícito, ótimo para binários pequenos, integração com C, ferramentas de infraestrutura e serviços onde previsibilidade importa.

Se você está comparando opções, vale ler materiais irmãos como Go para APIs no golang.com.br e a análise de Zig vs Rust. A decisão não precisa ser ideológica. Para um backend CRUD comum, Go pode vencer por produtividade. Para um serviço pequeno, crítico, com dependências nativas e distribuição multiplataforma, Zig pode ser uma escolha excelente.

Próximo passo

Se você ainda está no começo, implemente primeiro o servidor HTTP básico e uma rota JSON simples. Depois volte para este checklist e adicione uma melhoria por vez: health check, limite de body, logs, proxy, shutdown e deploy. Zig recompensa esse estilo incremental porque cada decisão fica visível no código.

Produção não exige um framework grande. Exige contratos claros, limites explícitos e observabilidade suficiente para corrigir problemas sem adivinhar.

Continue aprendendo Zig

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