Heroku Scheduler durch Cron-Jobs auf deinem Server ersetzen
Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig
Herokus eigene Dokumentation für Scheduler sagt es offen: “Scheduler job execution is expected but not guaranteed.” Dieselbe Seite sagt, dass in sehr seltenen Fällen ein Job ausgelassen oder zweimal ausgeführt werden kann, und die einzigen verfügbaren Intervalle sind alle 10 Minuten, jede Stunde und jeden Tag. Für alles, dessen Fehlen du merken würdest, einen nächtlichen Report, eine Abo-Verlängerungsprüfung, einen Aufräum-Task, der eine Tabelle davon abhält, endlos zu wachsen, ist das keine gute Grundlage. Herokus eigene Doku empfiehlt stattdessen einen eigenen Clock-Prozess für alles Kritische.
Diese Anleitung ersetzt Scheduler durch schlichtes cron auf einem Server, den du kontrollierst: wie man den Job schreibt, wie man verhindert, dass sich zwei Läufe überlappen, wie man die Zeitzone richtig hinbekommt, und wo die Logs eigentlich landen.
Was du brauchst
- Einen Server, auf dem deine App bereits läuft, oder einen frischen, wenn du konsolidierst. Ein Hetzner CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, 5,99 €/Monat exkl. MwSt.) reicht für eine Web-App plus ihre geplanten Jobs.
- SSH-Zugang mit einem Benutzer, der seine eigene Crontab bearbeiten kann, oder Root, falls der Job als Service-Account laufen muss.
- Eine Liste dessen, was Scheduler aktuell ausführt. Öffne das Scheduler-Dashboard mit
heroku addons:open scheduler -a your-app-nameund schreibe dir Befehl, Intervall und nächste Laufzeit jedes Jobs auf, bevor du anfängst.
Schritt 1: Den Job als schlichten Befehl oder Skript schreiben
Was Scheduler aktuell ausführt, sei es rails runner SomeJob.perform, python manage.py cleanup oder ein rohes SQL-Skript, es muss etwas sein, das du als einzelnen Shell-Befehl aufrufen kannst, ohne dass Herokus Dyno-Umgebung dir implizit etwas abnimmt. Verpacke es in ein kleines Skript, wenn es mehr als eine Zeile ist, damit Cron ein Ding hat, das es aufruft, und du einen Ort hast, an dem du Logging hinzufügst:
#!/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
Mache es ausführbar:
chmod +x /opt/myapp/bin/nightly-report.sh
set -euo pipefail zählt hier mehr als in den meisten Skripten: Cron wiederholt einen fehlgeschlagenen Job nicht für dich, und ohne das kann ein Schritt, der mittendrin fehlschlägt, den Rest des Skripts still weiterlaufen und trotzdem Erfolg melden lassen. set -a vor source exportiert jede Variable, die die Datei definiert, sodass der Befehl, den du ausführst, sie tatsächlich sieht; ein schlichtes source würde sie nur innerhalb der eigenen Shell des Skripts setzen.
Führe es einmal von Hand aus, bevor du es planst:
/opt/myapp/bin/nightly-report.sh
tail -n 20 /var/log/myapp/nightly-report.log
Du solltest die normale Ausgabe des Jobs im Log sehen, und echo $? direkt nach dem Skript sollte 0 ausgeben. Erstelle zuerst /var/log/myapp (sudo mkdir -p /var/log/myapp && sudo chown $USER /var/log/myapp), falls es nicht existiert.
Schritt 2: Umgebungsvariablen explizit laden
Das ist der Schritt, bei dem fast jeder beim Wechsel von Heroku hängen bleibt. Scheduler-Jobs laufen in einem Einmal-Dyno, der automatisch deinen vollständigen Config-Var-Satz erbt. Cron läuft mit einer minimalen Umgebung, meist nur PATH, HOME, SHELL und was auch immer die Crontab selbst setzt, nichts aus der .env deiner App oder dem Login-Profil deiner Shell.
Lade deine Umgebungsdatei explizit im Skript, wie oben gezeigt, oder setze Variablen oben in der Crontab selbst, eine NAME=value pro Zeile:
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
Beides funktioniert. Der Skript-Ansatz lässt sich leichter zusammen mit deiner App in Versionskontrolle halten; der Crontab-Ansatz hält Secrets aus einer Datei heraus, die mit deinem Code deployt wird. Wähle einen und verlasse dich nicht darauf, dass Cron irgendetwas aus einer Login-Shell übernimmt, denn das tut es nicht.
Schritt 3: Die Zeitzone richtig setzen
Herokus Doku merkt an, dass die nächste Laufzeit täglicher Scheduler-Jobs in UTC angegeben ist, und sagt dir, den UTC-Offset selbst zu addieren, wenn du einen Job zu einer bestimmten lokalen Zeit willst. Hast du das gemacht, ist dein aktueller Zeitplan bereits in UTC geschrieben. System-Cron läuft dagegen in der Zeitzone, auf die der Server selbst eingestellt ist, was auf einem frischen Cloud-Server meist UTC ist, aber überprüfenswert.
Prüfe, welche Zeit der Server zu haben glaubt:
timedatectl
Du solltest eine Zeile Time zone: sehen. Steht dort Etc/UTC (UTC, +0000), kannst du deine Scheduler-Zeiten unverändert übernehmen: Ein täglicher Job um 03:00 auf Heroku wird zu 0 3 * * *.
Wenn du Zeitpläne lieber in lokaler Zeit schreibst, ändere die Zeitzone des Servers und starte Cron neu, damit die Änderung übernommen wird:
sudo timedatectl set-timezone America/Toronto
sudo systemctl restart cron
Behalte im Kopf, dass das die Uhr für alles auf dem Server ändert, einschließlich Log-Zeitstempel, und dass lokale Zeit Sommerzeitverschiebungen hat: Ein Job, der zwischen 02:00 und 03:00 geplant ist, kann in den Nächten der Zeitumstellung ausgelassen oder doppelt ausgeführt werden. Bei UTC zu bleiben vermeidet beides, weshalb viele Teams ihre Server auf UTC lassen und im Kopf umrechnen.
Schritt 4: Den Crontab-Eintrag mit Locking hinzufügen
Installiere die Crontab für den Benutzer, dem der Job gehören soll:
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 ist die direkte Antwort auf Schedulers “Läufe können sich überlappen”-Problem, nur dass du es hier kontrollierst, statt zu hoffen, dass es nicht passiert. Es nimmt ein exklusives Lock auf die angegebene Datei, bevor es den Befehl ausführt, und -n bedeutet, dass es sofort aufgibt statt einen zweiten Lauf einzureihen, wenn ein vorheriger Lauf desselben Jobs das Lock noch hält. Ein Zehn-Minuten-Sync-Job, der gelegentlich zwölf Minuten braucht, überspringt so einfach diesen einen überlappenden Lauf, statt zwei Kopien von sich selbst gegen dieselben Datenbankzeilen zu stapeln.
Bestätige, dass die Crontab installiert ist:
crontab -l
Du solltest beide Zeilen genau so aufgelistet sehen, wie eingegeben.
Schritt 5: Logs an einen Ort bringen, an dem du wirklich nachsiehst
Crons Standardverhalten, die Job-Ausgabe über sendmail an den lokalen Benutzer zu mailen, bringt auf einem Server ohne konfigurierten Mail-Transport-Agenten nichts, was bei den meisten der Fall ist. Ohne Umleitung verschwindet die Ausgabe einfach.
Das Skript aus Schritt 1 leitet bereits in eine Logdatei um, was die einfachste Lösung ist. Für alles, was du zusammengefasst statt über Einzeldateien verteilt haben willst, leite durch logger, um es ans System-Journal zu schicken:
0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /opt/myapp/bin/nightly-report.sh 2>&1 | logger -t myapp-nightly-report
Verfolge es dann mit:
journalctl -t myapp-nightly-report -n 50
Du solltest die Ausgabe des Jobs zeitlich passend zum erwarteten Zeitplan sehen, ohne Vermischung mit anderen Jobs.
Schritt 6: Wenn deine App in Docker Compose läuft
Wenn deine App mit Docker Compose statt direkt auf dem Host deployt wird, behalte Cron auf dem Host und lass jeden Eintrag einen kurzlebigen Container aus dem eigenen Service der App starten:
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 startet einen frischen Container aus dem Image des web-Service, mit demselben env_file und derselben Umgebung, die der Service definiert, führt den einen Befehl aus, und --rm entfernt den Container danach. -T schaltet das Pseudo-Terminal aus, das Cron nicht hat. Weil der Job im selben Image wie der Web-Prozess läuft, nutzt er immer den Code deines neuesten Deploys.
Prüfe zuerst den Pfad mit which docker; /usr/bin/docker ist, wo Dockers eigene Pakete es auf Ubuntu und Debian installieren.
crond in einem eigenen Container laufen zu lassen, ist schwieriger, als es aussieht: Der Cron-Daemon startet Jobs mit einer sauberen Umgebung, sodass die Variablen, die Compose an den Container übergibt, die Jobs nicht erreichen, und das crond-Binary und sein Crontab-Speicherort unterscheiden sich zwischen Alpine- und Debian-basierten Images. Host-Cron plus docker compose run vermeidet beide Probleme.
Die Abkürzung: mit Railyard
Railyards Cron-Feature deckt Skript, Umgebung und Logs für dich ab. Füge einen Cron-Eintrag mit einem Zeitplan-Ausdruck wie 0 3 * * * und einem Befehl hinzu, und Railyard führt ihn in einem Einmal-Container aus dem eigenen Image deiner App aus, mit den Umgebungsvariablen der App bereits angehängt, sodass es keine Crontab auf dem Server zu bearbeiten und keine .env zu sourcen gibt. Zeitpläne werden in UTC ausgewertet, derselben Konvention wie Herokus Scheduler-Tagesjobs, sodass Zeiten, die du für Heroku geschrieben hast, unverändert übernommen werden. Jeder Lauf erscheint im Run-Verlauf mit Exit-Code und Ausgabe, sodass “ist der Job von letzter Nacht wirklich gelaufen” eine Seite ist, die du nachsiehst, statt eine Logdatei, an die du dich erinnern musst.
Eine Sache, die Railyard nicht tut: einen Lauf überspringen, während der vorherige noch läuft. Wenn ein Job länger als sein Intervall dauern kann, mach ihn sicher für doppelte Ausführung, oder gib ihm ein längeres Intervall.
Es sitzt neben dem Workers-Tab, der den entsprechenden Job für langlaufende Background-Prozesse wie Sidekiq oder Celery erledigt, statt geplante; siehe Workers and scheduled jobs. Beide verwenden dasselbe Image wie dein Web-Prozess, es gibt also nichts zusätzlich zu bauen oder synchron zu halten.
Troubleshooting
Der Job läuft zur falschen Zeit. Fast immer ein Zeitzonen-Mismatch zwischen dem, was du als Server-Einstellung angenommen hast, und dem, was timedatectl tatsächlich meldet, besonders direkt nach dem Wechsel von Herokus reinem UTC-Scheduler. Prüfe zuerst die Zeitzone des Servers und konvertiere dann entweder den Zeitplan auf die Serverzeitzone oder ändere die Serverzeitzone wie in Schritt 3 gezeigt.
Der Job scheint zweimal zu laufen oder sich selbst zu stören. Das ist Schedulers “selten, aber kommt vor”-Überlappungsproblem, das jetzt auf deiner eigenen Infrastruktur auftaucht, meist weil ein Job gelegentlich länger als sein Intervall braucht. Verpacke den Befehl wie in Schritt 4 gezeigt in flock -n; ein ausgelassener überlappender Lauf ist fast immer sicherer als zwei Kopien, die gleichzeitig gegen dieselben Daten laufen.
Nichts erscheint in irgendeinem Log, und du kannst nicht sagen, ob der Job überhaupt gelaufen ist. Crons standardmäßige mailbasierte Ausgabe geht auf einem Server ohne konfigurierten MTA nirgendwohin. Leite die Ausgabe explizit in eine Datei oder zu logger um, und verifiziere, dass deine Umleitung tatsächlich gegriffen hat, indem du genau den Befehl, den Cron ausführen würde, einmal von Hand ausführst.
Das Skript funktioniert, wenn du es von Hand ausführst, schlägt aber unter Cron fehl. Das ist fast immer die fehlende Umgebung aus Schritt 2. Führe es mit einer abgespeckten Umgebung aus, um das Problem lokal nachzustellen: env -i /bin/bash -c '/opt/myapp/bin/nightly-report.sh' schlägt auf dieselbe Weise fehl wie Crons minimale Umgebung, was es deutlich schneller macht, die fehlende Variable zu finden, als auf dem Server selbst zu debuggen.
Permission denied beim Ausführen des Skripts. Bestätige, dass es ausführbar ist (chmod +x) und dass der Benutzer, dessen Crontab es enthält, tatsächlich jede Datei und jedes Verzeichnis besitzt oder lesen kann, das das Skript anfasst, einschließlich des übergeordneten Verzeichnisses der Logdatei, falls dieses Verzeichnis noch nicht existiert.
FAQ
Kann Cron Jobs häufiger ausführen als Herokus Zehn-Minuten-Minimum bei Scheduler? Ja. Standard-Cron-Syntax geht nur bis auf einmal pro Minute hinunter, aber du kannst weiter gehen, indem ein einmal-pro-Minute-Eintrag ein kurzes Skript startet, das mit einem Sleep schleift, falls du wirklich Sub-Minuten-Scheduling brauchst, was selten ist für irgendetwas, das zuvor überhaupt auf Scheduler lief.
Was ist das Äquivalent zu heroku run für einen Einmal-Job? SSH einloggen und den Befehl direkt ausführen; es gibt keinen Dyno, der erst hochgefahren werden muss. Wenn der Job genau dieselbe Umgebung wie deine laufende App braucht, führe ihn im eigenen Container der App mit docker compose exec web <command> aus, statt nach Host-Level-Tooling zu greifen, das möglicherweise eine andere Umgebung sieht.
Überlebt Cron einen Server-Reboot? Ja, cron oder crond ist ein Systemdienst, der auf jeder gängigen Linux-Distribution beim Booten startet, und Crontabs werden auf der Festplatte gespeichert, nicht im Speicher. Bestätige mit systemctl is-enabled cron (oder crond, je nach Distribution), falls du nach einem frischen Server-Setup sichergehen willst.
Wie behandle ich Secrets, die ein geplanter Job braucht, ohne sie im Klartext in die Crontab-Datei zu schreiben? Halte sie in der .env-Datei, die das Skript sourct, mit restriktiven Dateirechten (chmod 600). Die Crontab jedes Benutzers ist bereits privat für diesen Benutzer, aber Secrets in einer .env-Datei zu halten bedeutet, dass die App und jeder geplante Job dieselben Werte lesen, und ein Secret zu rotieren ist eine Bearbeitung statt mehrerer.
Was passiert, wenn der Server mitten im Job neu startet? Dasselbe, was passiert, wenn der Prozess auf andere Weise beendet wird: Was auch immer der Job gerade tat, stoppt, unfertig. Idempotente Jobs (sicher, zweimal zu laufen, oder in der Lage, dort weiterzumachen, wo sie aufgehört haben) vertragen das problemlos; Jobs, die das nicht sind, müssen unabhängig davon idempotent gemacht werden, was sie plant, da derselbe Fehlermodus auch unter Scheduler existiert.
Weg von Scheduler zu kommen ist vor allem eine Frage, das explizit zu machen, was Heroku implizit für dich erledigt hat: die Umgebung, die Zeitzone, und was passiert, wenn sich zwei Läufe überlappen. Sobald diese drei Dinge geklärt sind, lässt sich Cron einfacher nachvollziehen als ein gehosteter Scheduler es je war, weil man aus einer Textdatei genau ablesen kann, was es tun wird, statt einem Dashboard zu vertrauen.
Warum Leute sich dafür Railyard entscheiden
- Dein eigenes Cloud-Konto. Deploye auf DigitalOcean, Hetzner, Vultr, AWS, OVHcloud oder jedem Linux-Server per SSH, direkt von deinem Anbieter abgerechnet, ohne Aufschlag.
- Ein fester Preis pro Team. Free ist für immer kostenlos: ein Server plus ein Datenbankserver, drei Apps, keine Karte. Pro kostet 19 $ im Monat für bis zu fünf Server und unbegrenzte Apps. Team kostet 49 $ im Monat mit unbegrenzten Servern. Keine Kosten pro App, pro Deployment, pro Sitz oder pro Gigabyte.
- Templates mit einem Klick. Deploye Open-Source-Apps, wie Datenbanken, Analytics und Dev-Tools, mit einem Klick auf deinem eigenen Server.
- Verwaltete Datenbanken. Postgres, MySQL und Redis mit geplanten, wiederherstellungsgeprüften Backups und Point-in-Time-Recovery.
- Push-to-Deploy. Deploye von GitHub, GitLab oder der CLI, mit automatischem HTTPS und Releases ohne Ausfallzeit.