Resposta rápida: para intervalos fixos, use time.Ticker da biblioteca padrão — sem dependência. Para agendas em formato cron (0 3 * * *), use robfig/cron/v3 ou go-co-op/gocron. Em produção, quatro decisões importam mais do que a biblioteca escolhida: timeout por execução com context, proteção contra execuções sobrepostas, exclusão entre réplicas (lock distribuído ou CronJob do Kubernetes) e fuso horário explícito, porque contêineres rodam em UTC. Feche com log estruturado, métrica de duração e parada limpa no shutdown.
Este guia completa a malha de produção que já cobre worker pools, filas com SQS e graceful shutdown: aqui o gatilho não é uma mensagem, é o relógio.
Qual abordagem escolher
| Abordagem | Use quando | Cuidado principal |
|---|---|---|
time.Ticker | Intervalo fixo simples (“a cada 5 min”) | Você mesmo controla overlap e parada |
robfig/cron/v3 | Expressões cron, várias agendas no processo | Overlap por padrão; fuso precisa ser explícito |
go-co-op/gocron | API fluente, jobs dinâmicos, singleton mode | Mais superfície de API para manter |
| CronJob do Kubernetes | Tarefa rara, pesada ou isolada | Cold start e imagem separada |
| Fila + agendador externo | Retry, backoff e histórico obrigatórios | Mais infraestrutura para operar |
time.Ticker: o básico que resolve a maioria dos casos
Antes de instalar qualquer coisa, verifique se o requisito real é apenas “rodar a cada X”. Um ticker com context resolve isso em menos de 30 linhas e morre junto com a aplicação.
package job
import (
"context"
"log/slog"
"time"
)
// Run executa fn a cada interval até o contexto ser cancelado.
func Run(ctx context.Context, interval time.Duration, fn func(context.Context) error) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("job encerrado", "motivo", ctx.Err())
return
case <-ticker.C:
// timeout por execução: nunca deixe um job travado segurar o próximo ciclo
runCtx, cancel := context.WithTimeout(ctx, interval-time.Second)
start := time.Now()
err := fn(runCtx)
cancel()
slog.Info("job executado",
"duracao_ms", time.Since(start).Milliseconds(),
"erro", err)
}
}
}
Três detalhes que costumam faltar em exemplos de blog:
defer ticker.Stop()evita vazamento do timer interno.- O
selectcomctx.Done()primeiro garante que a parada é imediata — sem isso o processo espera o próximo tick para morrer, e seu deploy fica lento. - O
context.WithTimeoutpor execução é o que impede que uma query travada empilhe goroutines. O mesmo raciocínio dos timeouts com context vale aqui.
Um ticker não dispara imediatamente no start. Se você quer a primeira execução na subida do serviço, chame fn uma vez antes do laço.
robfig/cron: expressões cron de verdade
Quando a regra é “todo dia às 3h” ou “toda segunda-feira”, o ticker vira aritmética frágil. É aí que entra github.com/robfig/cron/v3.
package main
import (
"context"
"log/slog"
"time"
"github.com/robfig/cron/v3"
)
func newScheduler(ctx context.Context) (*cron.Cron, error) {
loc, err := time.LoadLocation("America/Sao_Paulo")
if err != nil {
return nil, err
}
c := cron.New(
cron.WithLocation(loc),
cron.WithChain(
cron.SkipIfStillRunning(cron.DefaultLogger), // sem execuções sobrepostas
cron.Recover(cron.DefaultLogger), // panic não derruba o scheduler
),
)
// Todo dia às 03:00 no horário de Brasília.
if _, err := c.AddFunc("0 3 * * *", func() {
runCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if err := gerarRelatorioDiario(runCtx); err != nil {
slog.Error("relatorio diario falhou", "erro", err)
}
}); err != nil {
return nil, err
}
return c, nil
}
Pontos que separam um cron de brinquedo de um cron de produção:
cron.Recover: sem ele, umpanicdentro do job derruba a goroutine do agendador e todas as tarefas param silenciosamente.cron.SkipIfStillRunning: por padrão o robfig dispara execuções concorrentes. Se o job de 3h começou a demorar 70 minutos, você terá duas cópias competindo pela mesma tabela.DelayIfStillRunningé a alternativa quando pular é inaceitável.cron.WithLocation: sem isso, a agenda segue o fuso do processo — que em contêiner é UTC. Se a imagem forscratchoudistrolesssem tzdata, importe_ "time/tzdata"para embutir o banco de fusos no binário.WithSeconds: o parser padrão do v3 usa cinco campos (sem segundos). Expressões copiadas de sistemas com seis campos falham noAddFunc; usecron.New(cron.WithSeconds())se realmente precisar da granularidade.
O gocron cobre o mesmo terreno com uma API mais fluente e WithSingletonMode para evitar sobreposição. A escolha entre os dois é preferência de estilo; as armadilhas de produção são idênticas.
O problema real: várias réplicas
Esta é a parte que mais aparece em incidente e menos aparece em tutorial. Um scheduler embutido roda em cada pod. Com três réplicas, o e-mail de cobrança sai três vezes.
A saída mais barata em stacks que já usam PostgreSQL é um advisory lock, que é liberado automaticamente quando a conexão cai:
func comLockDistribuido(ctx context.Context, pool *pgxpool.Pool, key int64, fn func(context.Context) error) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
var obtido bool
if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&obtido); err != nil {
return err
}
if !obtido {
slog.Info("job ja esta rodando em outra replica", "key", key)
return nil // outra réplica venceu a corrida: sair é o comportamento correto
}
defer conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", key)
return fn(ctx)
}
Detalhes que importam: use pg_try_advisory_lock (não bloqueante) em vez de pg_advisory_lock, ou todas as réplicas ficam enfileiradas esperando a vez e executam em sequência — exatamente o que você queria evitar. E o unlock precisa acontecer mesmo com o contexto já cancelado, daí o context.WithoutCancel. Se sua stack usa Redis, o equivalente é SET chave valor NX PX <ttl> com TTL maior que a duração esperada do job.
Alternativas válidas: um CronJob do Kubernetes chamando um binário de execução única (ganha retry, histórico e exclusão de graça), ou eleição de líder via Lease. Vale a pena comparar com o desenho de outbox quando o job serve para publicar eventos — ali o gatilho por relógio é só um detalhe de implementação.
Idempotência não é opcional
Todo job agendado vai rodar duas vezes algum dia — por retry, por rolling update no meio da janela, por relógio que voltou no horário de verão. Trate cada execução como potencialmente repetida:
- Marque a janela processada (
UPDATE ... WHERE processado_em IS NULL) em vez de assumir “roda uma vez por dia”. - Use uma chave natural de deduplicação, como fazemos em idempotência, retry e DLQ.
- Prefira
SELECT ... FOR UPDATE SKIP LOCKEDpara lotes, o mesmo padrão descrito em transações no PostgreSQL. - Se o job apenas recalcula um cache caro, singleflight reduz o dano de execuções concorrentes.
Shutdown limpo
O scheduler precisa parar antes do processo morrer, senão o deploy interrompe o job na metade. robfig/cron devolve um contexto que fecha quando as execuções em andamento terminam:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
c.Start()
<-ctx.Done()
shutdownCtx := c.Stop() // para de agendar novas execuções
select {
case <-shutdownCtx.Done():
slog.Info("jobs finalizados")
case <-time.After(30 * time.Second):
slog.Warn("timeout aguardando jobs em execucao")
}
Lembre que o terminationGracePeriodSeconds do Kubernetes precisa ser maior que esse timeout, ou o SIGKILL chega antes. O raciocínio completo está no guia de graceful shutdown e nas health checks.
Observabilidade mínima
Job agendado falha em silêncio por semanas se ninguém instrumentar. O conjunto mínimo:
- Log estruturado por execução com nome do job, duração e erro, via slog.
- Métrica de duração e de contagem de falhas, expostas como no tutorial de Prometheus.
- Alerta de ausência: o alerta mais valioso não é “o job falhou”, é “o job não rodou nas últimas 26 horas”. Um
heartbeatgravado ao fim de cada execução resolve. - Trace por execução quando o job chama vários serviços, usando OpenTelemetry.
Testando jobs agendados
Nunca teste a agenda com time.Sleep. Extraia a função de trabalho e teste-a diretamente com testes de tabela; para a lógica dependente de relógio, injete um func() time.Time em vez de chamar time.Now() no meio do código. Para o parser de cron, teste apenas que a expressão é aceita e que o próximo horário calculado é o esperado — isso é uma asserção determinística e rápida.
Checklist de produção
- Agenda com fuso explícito (
WithLocationouCRON_TZ) etzdatadisponível -
context.WithTimeoutpor execução, menor que o intervalo - Proteção contra sobreposição (
SkipIfStillRunningou singleton mode) -
Recoverpara que um panic não mate o agendador - Exclusão entre réplicas: advisory lock, Redis NX ou CronJob
- Job idempotente, seguro para rodar duas vezes
- Log, métrica de duração e alerta de ausência
-
Stop()no shutdown com timeout coerente com o grace period - Agenda configurável por variável de ambiente, não hardcoded
Perguntas frequentes
Preciso de uma biblioteca de cron em Go ou o time.Ticker basta?
Se a tarefa roda em intervalo fixo, time.Ticker basta e evita uma dependência. Bibliotecas valem a pena quando você precisa de expressões cron reais, de várias agendas no mesmo processo ou de configuração vinda de arquivo/banco.
Como evitar que o mesmo job rode em várias réplicas no Kubernetes?
Use um lock distribuído (advisory lock no PostgreSQL, SET NX no Redis), um CronJob do Kubernetes chamando um binário de execução única, ou eleição de líder via Lease. Não confie em “só teremos uma réplica”: o rolling update sobe um pod novo antes de derrubar o antigo.
O que acontece se um job demorar mais que o intervalo do agendamento?
Por padrão, o robfig/cron dispara execuções sobrepostas. Envolva o job com cron.SkipIfStillRunning (ou DelayIfStillRunning) e defina um timeout de contexto menor que o intervalo.
Como lidar com fuso horário em cron no Go?
Contêineres rodam em UTC, então 0 3 * * * dispara às 3h UTC — meia-noite em São Paulo. Carregue time.LoadLocation("America/Sao_Paulo") e passe cron.WithLocation, ou use o prefixo CRON_TZ=. Garanta tzdata na imagem ou importe _ "time/tzdata".
Cron dentro da aplicação ou CronJob do Kubernetes?
Frequência é o critério prático: minutos ou menos, scheduler embutido reaproveitando pool e cache; horas ou mais, CronJob do Kubernetes, que dá exclusão, retry e histórico sem código. Ver também o tutorial de Kubernetes com Go.
Próximos passos
- Worker pool em Go — quando o gatilho é a fila, não o relógio
- Graceful shutdown — parar sem perder trabalho em andamento
- Idempotência, retry e DLQ — sobreviver à execução dupla
- Configuração com Viper — agenda sem recompilar
- Vagas Go no Brasil — times que operam esse tipo de rotina em produção
Última atualização: agosto de 2026.