Substitua o Heroku Scheduler por cron jobs no seu servidor
Siga isso no seu próprio servidor. Comece grátis, sem cartão
A própria documentação do Heroku pro Scheduler diz a parte quieta em voz alta: “a execução de jobs do Scheduler é esperada, mas não garantida.” A mesma página diz que, em casos muito raros, um job pode ser pulado ou pode rodar duas vezes, e as únicas frequências disponíveis são a cada 10 minutos, a cada hora e a cada dia. Pra qualquer coisa cuja falta você notaria — um relatório noturno, uma checagem de renovação de assinatura, uma tarefa de limpeza que impede uma tabela de crescer pra sempre — isso não é uma base muito boa. A própria documentação do Heroku recomenda um processo clock customizado em vez disso pra qualquer coisa crítica.
Este guia substitui o Scheduler pelo cron puro num servidor que você controla: como escrever o job, como impedir que duas execuções se sobreponham, como acertar o fuso horário, e pra onde os logs realmente vão.
O que você vai precisar
- Um servidor com a sua aplicação já rodando nele, ou um novo se você estiver consolidando. Um Hetzner CX23 (2 vCPU, 4 GB de RAM, 40 GB NVMe, €5,99/mês sem IVA) é suficiente pra uma aplicação web mais os jobs agendados dela.
- Acesso SSH com um usuário que consiga editar a própria crontab, ou root se o job precisar rodar como uma conta de serviço.
- Uma lista do que o Scheduler está rodando atualmente. Abra o painel do Scheduler com
heroku addons:open scheduler -a your-app-namee anote o comando, a frequência e o próximo horário de execução de cada job antes de começar.
Passo 1: escreva o job como um comando ou script simples
O que quer que o Scheduler esteja rodando hoje, seja rails runner SomeJob.perform, python manage.py cleanup, ou um script SQL puro, precisa ser algo que você consiga invocar como um único comando de shell sem o ambiente de dyno do Heroku fazendo nada implícito pra você. Envolva isso num script pequeno se for mais de uma linha, pra o cron ter uma coisa só pra chamar e você ter um lugar só pra adicionar logging:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/myapp
set -a
source /opt/myapp/.env
set +a
bundle exec rails runner "NightlyReportJob.perform_now" >> /var/log/myapp/nightly-report.log 2>&1
Torne ele executável:
chmod +x /opt/myapp/bin/nightly-report.sh
set -euo pipefail importa mais aqui do que na maioria dos scripts: o cron não vai tentar de novo um job que falhou pra você, e sem isso uma etapa que falha no meio do caminho pode deixar o resto do script quietamente rodando mesmo assim e reportando sucesso. set -a antes do source exporta toda variável que o arquivo define, então o comando que você roda de fato vê elas; um source puro só definiria elas dentro do próprio shell do script.
Rode ele uma vez manualmente antes de agendar:
/opt/myapp/bin/nightly-report.sh
tail -n 20 /var/log/myapp/nightly-report.log
Você deve ver a saída normal do job no log, e echo $? logo depois do script deve imprimir 0. Crie /var/log/myapp primeiro (sudo mkdir -p /var/log/myapp && sudo chown $USER /var/log/myapp) se ele não existir.
Passo 2: carregue variáveis de ambiente explicitamente
Essa é a etapa que pega quase todo mundo saindo do Heroku. Os jobs do Scheduler rodam dentro de um dyno avulso que herda o conjunto completo das suas config vars automaticamente. O cron roda com um ambiente mínimo, geralmente só PATH, HOME, SHELL e o que a própria crontab definir, nada do .env da sua aplicação ou do perfil de login do seu shell.
Carregue o seu arquivo de ambiente explicitamente dentro do script, como mostrado acima, ou defina variáveis no topo da própria crontab, um NAME=value por linha:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DATABASE_URL=postgres://user:pass@localhost:5432/myapp_production
Qualquer um dos dois funciona. A abordagem do script é mais fácil de manter em controle de versão junto com a sua aplicação; a abordagem da crontab mantém secrets fora de um arquivo que é implantado junto com o seu código. Escolha um e não dependa do cron pegar qualquer coisa de um shell de login, porque ele não vai.
Passo 3: acerte o fuso horário corretamente
A documentação do Heroku nota que o próximo horário de execução pros jobs diários do Scheduler está em UTC, e te diz pra adicionar o offset de UTC você mesmo se quiser um job num determinado horário local. Se você fez isso, o seu agendamento atual já está escrito em UTC. O cron de sistema, por outro lado, roda no fuso horário que o próprio servidor estiver configurado, que geralmente é UTC num servidor de nuvem novo mas vale checar.
Cheque o que o servidor pensa que é o horário:
timedatectl
Você deve ver uma linha Time zone:. Se disser Etc/UTC (UTC, +0000), você pode copiar os seus horários do Scheduler sem mudar nada: um job diário às 03:00 no Heroku se torna 0 3 * * *.
Se você preferir escrever agendamentos em horário local, mude o fuso horário do servidor e reinicie o cron pra ele pegar a mudança:
sudo timedatectl set-timezone America/Toronto
sudo systemctl restart cron
Tenha em mente que isso muda o relógio pra tudo no servidor, incluindo timestamps de log, e que o horário local tem mudanças de horário de verão: um job agendado entre 02:00 e 03:00 pode ser pulado ou rodar duas vezes nas noites em que os relógios mudam. Ficar em UTC evita as duas coisas, que é por isso que muitas equipes mantêm os servidores em UTC e convertem de cabeça.
Passo 4: adicione a entrada na crontab, com locking
Instale a crontab pro usuário que deveria ser dono do job:
crontab -e
*/10 * * * * /usr/bin/flock -n /tmp/myapp-sync.lock /opt/myapp/bin/sync-job.sh
0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /opt/myapp/bin/nightly-report.sh
flock -n é a resposta direta pro problema de “execuções podem se sobrepor” do Scheduler, só que aqui você controla isso em vez de esperar que não aconteça. Ele tira um lock exclusivo no arquivo dado antes de rodar o comando, e -n significa que ele desiste imediatamente em vez de colocar uma segunda execução na fila, se uma execução anterior do mesmo job ainda está segurando o lock. Um job de sincronização de dez minutos que ocasionalmente leva doze minutos vai simplesmente pular essa execução sobreposta em vez de empilhar duas cópias de si mesmo contra as mesmas linhas do banco de dados.
Confirme que a crontab está instalada:
crontab -l
Você deve ver as duas linhas listadas exatamente como foram digitadas.
Passo 5: coloque os logs em algum lugar que você de fato vai olhar
O comportamento padrão do cron, mandar a saída do job por email pro usuário local via sendmail, não faz nada útil num servidor sem um agente de transporte de email configurado, que é a maioria deles. Sem redirecionamento, a saída simplesmente desaparece.
O script do Passo 1 já redireciona pra um arquivo de log, que é o jeito mais simples de resolver. Pra qualquer coisa que você queira agregada em vez de espalhada por arquivos de log por job, use pipe através do logger pra mandar pro journal do sistema:
0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /opt/myapp/bin/nightly-report.sh 2>&1 | logger -t myapp-nightly-report
Depois acompanhe com:
journalctl -t myapp-nightly-report -n 50
Você deve ver a saída do job com timestamp no horário que você espera, sem nada de outros jobs misturado.
Passo 6: se a sua aplicação roda em Docker Compose
Se a sua aplicação é implantada com Docker Compose em vez de diretamente no host, mantenha o cron no host e faça cada entrada iniciar um container de curta duração a partir do próprio serviço da aplicação:
0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /usr/bin/docker compose -f /opt/myapp/compose.yaml run --rm -T web bin/rails runner "NightlyReportJob.perform_now" >> /var/log/myapp/nightly-report.log 2>&1
docker compose run inicia um container novo a partir da imagem do serviço web, com o mesmo env_file e ambiente que o serviço define, roda o comando único, e --rm remove o container depois. -T desativa o pseudo-terminal, que o cron não tem. Porque o job roda na mesma imagem do processo web, ele sempre usa o código do seu deploy mais recente.
Cheque o caminho com which docker primeiro; /usr/bin/docker é onde os próprios pacotes do Docker instalam ele no Ubuntu e no Debian.
Rodar o crond dentro de um container próprio é mais difícil do que parece: o daemon do cron inicia os jobs com um ambiente limpo, então as variáveis que o Compose passa pro container não chegam nos jobs, e o binário crond e a localização da crontab dele diferem entre imagens baseadas em Alpine e em Debian. Cron no host mais docker compose run evita os dois problemas.
O caminho mais rápido: faça isso com a Railyard
A funcionalidade de Cron da Railyard cobre o script, o ambiente e os logs pra você. Adicione uma entrada de cron com uma expressão de agendamento como 0 3 * * * e um comando, e a Railyard roda ele num container avulso a partir da própria imagem da sua aplicação, com as variáveis de ambiente da aplicação já anexadas, então não existe crontab pra editar no servidor e nenhum .env pra carregar. Os agendamentos são avaliados em UTC, a mesma convenção dos jobs diários do Heroku Scheduler, então os horários que você escreveu pro Heroku vêm sem mudar nada. Toda execução aparece no histórico de execução com o código de saída e a saída dela, então “o job de ontem à noite de fato rodou” é uma página que você checa em vez de um arquivo de log que você tem que lembrar de ir olhar.
Uma coisa que a Railyard não faz é pular uma execução enquanto a anterior ainda está rodando. Se um job pode levar mais tempo do que o intervalo dele, torne ele seguro pra rodar duas vezes, ou dê a ele um intervalo mais longo.
Isso fica do lado da aba Workers, que faz o trabalho equivalente pra processos de longa duração em segundo plano como Sidekiq ou Celery em vez dos agendados; veja Workers and scheduled jobs. Os dois usam a mesma imagem que o seu processo web roda, então não existe nada extra pra construir ou manter sincronizado.
Resolução de problemas
O job roda no horário errado. Quase sempre uma incompatibilidade de fuso horário entre o que você assumiu que o servidor estava configurado e o que timedatectl de fato reporta, especialmente logo depois de migrar do Scheduler só-UTC do Heroku. Cheque o fuso horário do servidor primeiro, depois converta o agendamento pro fuso horário do servidor ou mude o fuso horário do servidor como mostrado no Passo 3.
O job parece rodar duas vezes, ou interfere consigo mesmo. Esse é o problema de sobreposição “raramente, mas acontece” do Scheduler aparecendo na sua própria infraestrutura em vez disso, geralmente porque um job ocasionalmente leva mais tempo do que o intervalo dele. Envolva o comando em flock -n como mostrado no Passo 4; uma execução sobreposta pulada é quase sempre mais segura do que duas cópias rodando contra os mesmos dados ao mesmo tempo.
Nada aparece em nenhum log, e você não consegue dizer se o job rodou ou não. A saída padrão baseada em email do cron não vai pra lugar nenhum num servidor sem um MTA configurado. Redirecione a saída explicitamente pra um arquivo ou pro logger, e verifique que o seu redirecionamento de fato disparou rodando exatamente o comando que o cron rodaria, manualmente, uma vez.
O script funciona quando você roda manualmente, mas falha sob o cron. Isso é quase sempre o ambiente faltando do Passo 2. Rode ele com um ambiente reduzido pra reproduzir o problema localmente: env -i /bin/bash -c '/opt/myapp/bin/nightly-report.sh' vai falhar do mesmo jeito que o ambiente mínimo do cron falha, o que torna muito mais rápido encontrar a variável faltante do que depurar no próprio servidor.
Permissão negada rodando o script. Confirme que ele é executável (chmod +x) e que o usuário em cuja crontab ele está de fato é dono ou consegue ler todo arquivo e diretório que o script toca, incluindo o diretório pai do arquivo de log se esse diretório ainda não existir.
Perguntas frequentes
O cron consegue rodar jobs com mais frequência do que o mínimo de dez minutos do Heroku Scheduler? Sim. A sintaxe padrão do cron só vai até uma vez por minuto, mas você pode ir além fazendo uma entrada de uma vez por minuto disparar um script curto que faz loop com um sleep, se você genuinamente precisar de agendamento de sub-minuto, o que é raro pra qualquer coisa que antes estava rodando no Scheduler.
Qual é o equivalente do heroku run pra um job avulso? Entre via SSH e rode o comando direto; não existe dyno pra subir antes. Se o job precisa do exato mesmo ambiente da sua aplicação rodando, rode ele dentro do próprio container da aplicação com docker compose exec web <command> em vez de recorrer a ferramentas no nível do host que podem ver um ambiente diferente.
O cron sobrevive a um reboot do servidor? Sim, cron ou crond é um serviço de sistema que inicia no boot em toda distribuição Linux mainstream, e as crontabs são armazenadas no disco, não na memória. Confirme que está habilitado com systemctl is-enabled cron (ou crond, dependendo da distribuição) se você quiser ter certeza depois de configurar um servidor novo.
Como eu lido com secrets que um job agendado precisa, sem colocar eles no arquivo da crontab em texto puro? Mantenha eles no arquivo .env que o script carrega, com permissões de arquivo restritivas (chmod 600). A crontab de cada usuário já é privada pra esse usuário, mas manter secrets num arquivo .env só significa que a aplicação e todo job agendado leem os mesmos valores, e rotacionar um secret é uma edição em vez de várias.
O que acontece se o servidor reiniciar no meio de um job? A mesma coisa que acontece se o processo for matado de qualquer outra forma: o que o job estava fazendo para, inacabado. Jobs idempotentes (seguros de rodar duas vezes, ou capazes de continuar de onde pararam) toleram isso bem; jobs que não são precisam ser tornados idempotentes independente do que está agendando eles, já que o mesmo modo de falha existe sob o Scheduler também.
Sair do Scheduler é principalmente uma questão de tornar explícito o que o Heroku estava fazendo por você implicitamente: o ambiente, o fuso horário, e o que acontece se duas execuções se sobrepõem. Depois que essas três coisas estão resolvidas, o cron é mais simples de raciocinar sobre do que um agendador hospedado algum dia foi, porque você consegue ler exatamente o que ele vai fazer a partir de um arquivo de texto em vez de confiar num painel.
Por que escolhem o Railyard para isso
- Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
- Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
- Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
- Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
- Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.