← Voltar para o blog

Cron e Jobs Agendados em Go: Ticker, robfig/cron e gocron

Como agendar tarefas em Go: time.Ticker, robfig/cron e gocron, fuso horário, lock distribuído entre réplicas, timeout, observabilidade e shutdown.

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

AbordagemUse quandoCuidado principal
time.TickerIntervalo fixo simples (“a cada 5 min”)Você mesmo controla overlap e parada
robfig/cron/v3Expressões cron, várias agendas no processoOverlap por padrão; fuso precisa ser explícito
go-co-op/gocronAPI fluente, jobs dinâmicos, singleton modeMais superfície de API para manter
CronJob do KubernetesTarefa rara, pesada ou isoladaCold start e imagem separada
Fila + agendador externoRetry, backoff e histórico obrigatóriosMais 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:

  1. defer ticker.Stop() evita vazamento do timer interno.
  2. O select com ctx.Done() primeiro garante que a parada é imediata — sem isso o processo espera o próximo tick para morrer, e seu deploy fica lento.
  3. O context.WithTimeout por 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, um panic dentro 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 for scratch ou distroless sem 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 no AddFunc; use cron.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 LOCKED para 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 heartbeat gravado 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 (WithLocation ou CRON_TZ) e tzdata disponível
  • context.WithTimeout por execução, menor que o intervalo
  • Proteção contra sobreposição (SkipIfStillRunning ou singleton mode)
  • Recover para 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

Última atualização: agosto de 2026.