Todo time que coloca software em produção acaba criando cron jobs e rotinas recorrentes. Limpar arquivos temporários, reconciliar dados, chamar uma API externa, gerar relatórios, compactar logs, atualizar um cache local, enviar métricas, rodar uma verificação de saúde ou processar uma fila pequena. Em muitos projetos isso começa como um shell script no cron. Depois aparecem retries, logs, lock para evitar duas execuções simultâneas, configuração por ambiente e um jeito confiável de desligar o processo.
Zig é uma boa opção para esse tipo de ferramenta porque gera binário único, inicia rápido, consome pouca memória e deixa explícito onde tempo, alocação e erro entram no desenho. Você não precisa transformar um job local em uma plataforma distribuída. Muitas vezes o melhor caminho é um executável pequeno, testável e operado por cron, systemd timer, Kubernetes CronJob ou uma CLI interna.
Este guia mostra como desenhar cron jobs em Zig para rotinas locais e operacionais: quando delegar o relógio ao sistema, quando manter um loop com timers, como evitar sobreposição, como registrar resultado, como lidar com retry e como conectar isso aos guias de ferramentas internas em Zig para DevOps, filas e workers em background, configuração segura com segredos e observabilidade em Zig.
Primeiro: cron externo ou scheduler dentro do processo?
A decisão mais importante não é a sintaxe de tempo. É quem manda no ciclo de vida.
| Opção | Melhor uso | Vantagem | Cuidado |
|---|---|---|---|
cron do sistema | uma tarefa isolada por horário | simples, conhecido, fácil de auditar | logs e ambiente podem ser pobres |
| systemd timer | servidor Linux próprio | controle de usuário, logs no journal, lock por unidade | exige conhecer systemd |
| Kubernetes CronJob | tarefa em cluster | isolamento, histórico, recursos, secrets | cuidado com concorrência e deadline |
| loop interno em Zig | daemon local, polling, várias rotinas | lógica centralizada e testável | precisa tratar shutdown, drift e falhas |
Para uma rotina diária que roda e termina, prefira agendamento externo. Deixe o Zig cuidar do trabalho: validar config, executar, logar e sair com código correto. Para um agente local que precisa acordar a cada poucos segundos, observar diretórios ou coordenar várias tarefas curtas, um loop interno pode fazer sentido.
A regra prática: se o processo não precisa ficar vivo, não invente daemon. Um binário chamado pelo cron é mais fácil de operar que um serviço permanente com bug de memória, timer e sinal.
Esqueleto de job idempotente
Um job recorrente deve ser seguro para rodar de novo. Isso não significa que ele nunca altera estado. Significa que uma repetição depois de falha não duplica efeitos perigosos.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const status = gpa.deinit();
if (status == .leak) std.log.err("vazamento de memória detectado", .{});
}
const allocator = gpa.allocator();
const started_ns = std.time.nanoTimestamp();
const result = runJob(allocator) catch |err| {
std.log.err("job falhou: {}", .{err});
std.process.exit(1);
};
const elapsed_ms = @divTrunc(std.time.nanoTimestamp() - started_ns, std.time.ns_per_ms);
std.log.info("job concluído: itens={d} duração_ms={d}", .{ result.items, elapsed_ms });
}
const JobResult = struct {
items: usize,
};
fn runJob(allocator: std.mem.Allocator) !JobResult {
_ = allocator;
// 1. carregar configuração
// 2. buscar trabalho pendente
// 3. processar com limites
// 4. persistir marcador de progresso
return .{ .items = 0 };
}
Mesmo esse esqueleto simples já tem decisões importantes: alocador explícito, erro vira exit code, tempo de execução aparece no log e a função principal do job pode ser testada separadamente.
Evite duas execuções simultâneas
O problema clássico de cron é sobreposição. Uma tarefa agendada a cada cinco minutos demora oito minutos. A próxima começa antes da anterior terminar. Dependendo da rotina, isso duplica chamadas externas, prende arquivos, corrompe relatório ou disputa banco.
A defesa mínima é um lock. Em Linux, você pode delegar isso ao flock no próprio cron:
*/5 * * * * flock -n /tmp/zig-sync.lock /opt/jobs/zig-sync
Quando o lock precisa ser portátil ou fazer parte do binário, use arquivo de lock com criação exclusiva. O detalhe é não tratar lock como segurança perfeita: se o processo morre, o arquivo pode sobrar. Por isso o lock deve registrar PID, horário e, se fizer sentido, expirar com cuidado.
fn acquireLock(path: []const u8) !std.fs.File {
return std.fs.cwd().createFile(path, .{
.read = true,
.exclusive = true,
}) catch |err| switch (err) {
error.PathAlreadyExists => return error.JobAlreadyRunning,
else => return err,
};
}
Em systemd, prefira modelar a unidade para não sobrepor. Em Kubernetes CronJob, configure concurrencyPolicy: Forbid quando a rotina não pode rodar em paralelo. O ponto é o mesmo: a proteção deve existir fora da lógica de negócio.
Timers internos sem drift invisível
Se você precisa de um daemon simples, evite somar atrasos ao intervalo. Um loop ingênuo faz trabalho, dorme 60 segundos, faz trabalho, dorme 60 segundos. Se o trabalho demora 20 segundos, o ciclo real vira 80 segundos. Às vezes isso é aceitável. Às vezes cria drift acumulado.
Para polling comum, a forma simples é suficiente:
fn runLoop(allocator: std.mem.Allocator) !void {
while (true) {
const started = std.time.milliTimestamp();
try runOnce(allocator);
const elapsed = std.time.milliTimestamp() - started;
std.log.info("ciclo concluído em {d}ms", .{elapsed});
std.time.sleep(60 * std.time.ns_per_s);
}
}
Para horários fixos, calcule o próximo instante-alvo e durma até ele. Isso evita que um atraso pequeno mova todos os ciclos seguintes.
fn sleepUntil(next_ms: i64) void {
const now = std.time.milliTimestamp();
if (next_ms <= now) return;
const delta_ms: u64 = @intCast(next_ms - now);
std.time.sleep(delta_ms * std.time.ns_per_ms);
}
Em produção, registre quando o job começou atrasado. Atraso recorrente é sinal de que o intervalo está agressivo demais, a tarefa ficou lenta ou o host está saturado.
Retry com limite e backoff
Jobs recorrentes falham por motivos normais: DNS, API externa, arquivo temporariamente indisponível, banco reiniciando, rate limit. O erro ruim não é falhar. É repetir agressivamente até piorar o incidente.
Use retry curto dentro da execução e deixe o próximo agendamento tentar de novo depois. Um padrão simples:
fn withRetry(comptime F: type, func: F) !void {
var attempt: u8 = 0;
while (attempt < 3) : (attempt += 1) {
func() catch |err| {
if (attempt == 2) return err;
const delay_ms: u64 = 250 * (@as(u64, attempt) + 1);
std.log.warn("tentativa {d} falhou: {}; aguardando {d}ms", .{ attempt + 1, err, delay_ms });
std.time.sleep(delay_ms * std.time.ns_per_ms);
continue;
};
return;
}
}
Para tarefas que chamam HTTP, combine isso com limites de tempo. Um retry sem timeout não é retry; é uma fila de threads presas. O guia de circuit breaker, timeout e retry em Zig aprofunda essa parte para clientes HTTP e workers.
Estado: marcador pequeno, não banco improvisado
Um job diário muitas vezes precisa lembrar o último item processado. Não grave estado como texto solto se a tarefa tem consequência de negócio. Use um formato simples e validável: JSON pequeno, SQLite local ou a própria tabela do sistema que você está reconciliando.
Para estado local mínimo:
const State = struct {
last_id: u64,
last_run_unix: i64,
};
fn loadState(allocator: std.mem.Allocator, path: []const u8) !State {
const data = std.fs.cwd().readFileAlloc(allocator, path, 64 * 1024) catch |err| switch (err) {
error.FileNotFound => return .{ .last_id = 0, .last_run_unix = 0 },
else => return err,
};
defer allocator.free(data);
return try std.json.parseFromSliceLeaky(State, allocator, data, .{});
}
Para estado com múltiplas linhas, retries por item e auditoria, SQLite é mais honesto. O artigo sobre Zig e SQLite para ferramentas locais mostra por que um banco embutido combina bem com CLIs operacionais: transações, índices e inspeção manual quando algo dá errado.
Logs que ajudam no dia seguinte
Logs de cron costumam ser esquecidos até o dia em que você precisa deles. Faça o binário imprimir eventos estruturados o suficiente para responder quatro perguntas:
- quando começou e terminou;
- quantos itens tentou, processou, pulou e falhou;
- qual configuração relevante estava ativa, sem vazar segredo;
- qual erro causou exit code diferente de zero.
Evite logar tokens, URLs assinadas, payloads sensíveis e dados pessoais. Para segredos, registre apenas o nome lógico da configuração, como API_TOKEN presente, não o valor. Isso segue a mesma disciplina do guia de configuração segura com segredos e env vars.
Se a rotina roda em systemd, std.log indo para stdout/stderr já cai no journal. Em Kubernetes CronJob, stdout vira log do pod. Em cron clássico, redirecione para arquivo rotacionado ou para logger.
15 * * * * /opt/jobs/reconciliar >> /var/log/reconciliar.log 2>&1
Checklist antes de colocar em produção
Antes de confiar em um cron job em Zig, revise:
- a tarefa é idempotente ou tem marcador claro de progresso;
- existe lock ou política de concorrência;
- erro fatal sai com código diferente de zero;
- retry tem limite e backoff;
- chamadas externas têm timeout;
- logs mostram contadores e duração;
- segredos vêm de ambiente/secret manager, não de arquivo commitado;
- o binário aceita
--dry-runse a rotina altera estado importante; - o deploy usa usuário sem privilégios excessivos;
- o próximo operador consegue rodar a tarefa manualmente.
Também vale manter um comando de verificação:
zig build -Doptimize=ReleaseSafe
./zig-out/bin/reconciliar --dry-run
ReleaseSafe costuma ser um bom padrão para ferramentas operacionais: performance boa, mas com checks que ajudam a pegar bugs. Para jobs extremamente sensíveis a performance, meça antes de mudar para ReleaseFast.
Onde Zig brilha nesse problema
Um scheduler pequeno não precisa de runtime grande. Zig brilha quando você quer entender exatamente o que o binário faz: quais arquivos abre, quanto aloca, quais erros propaga e quando dorme. Isso é especialmente útil em automação de infraestrutura, migração de dados, agentes locais e ferramentas que rodam em muitos ambientes.
Para times que já usam Go em operações, a comparação natural é com CLIs e workers escritos em Go. Go continua excelente para serviços com muita concorrência e ecossistema amplo; há bons materiais de referência em Golang Brasil. Zig entra quando binário mínimo, cross-compilation, controle de memória e dependência reduzida são parte do valor.
Comece simples: um binário chamado por cron, com lock, logs e exit code. Se ele crescer, extraia a lógica para funções testáveis, adicione estado transacional e só então considere um daemon. O melhor agendador é aquele que executa a rotina certa, no horário certo, sem virar mais um sistema para operar.