Geplante Jobs und Cron at Scale: Zuverlässige wiederkehrende Tasks in Symfony und Node
Jedes Team macht irgendwann dieselbe Erfahrung mit Cron: Es funktioniert gut, bis zu dem Moment, in dem es am wichtigsten wäre. Eine nächtliche Abrechnungsübersicht läuft auf einem einzelnen Application-Server, dieser Server wird während eines Rolling Deploys ersetzt, der Job läuft still nicht mehr, und niemand merkt es, bis ein Kunde anruft und fragt, warum seine Rechnung falsch ist. Oder ein schwerer Report-Job dauert länger als sein Intervall, eine zweite Instanz startet, während die erste noch läuft, und beide Instanzen zerschießen gemeinsamen State in der Datenbank.
Cron at Scale bedeutet nicht, mehr Jobs laufen zu lassen. Es bedeutet, eine Scheduling-Schicht zu bauen, die jeden wiederkehrenden Task als etwas behandelt, das scheitern kann und wird - und das System so zu entwerfen, dass Fehler sichtbar und behebbar sind und nie zu doppelter oder fehlender Arbeit führen. Dieser Beitrag zeigt, wie das in Symfony und in Node geht, mit Mustern, die über einen einzelnen Server hinaus tragen.
Warum Single-Server-Cron bricht
Der Standardansatz - ein Crontab-Eintrag, der einen Console-Command oder ein Node-Skript aufruft - hat zwei grundlegende Probleme in jeder Umgebung mit mehr als einer Application-Instanz.
Das erste ist das Vervielfachungsproblem geplanter Tasks. Wenn deine Autoscaling-Gruppe drei identische Server betreibt und jeder dieselbe Crontab hat, feuert jeder geplante Job dreimal gleichzeitig. Datenbankschreibvorgänge, die Exklusivität voraussetzen, laufen jetzt gegeneinander. E-Mails gehen dreifach raus. Reports enthalten Teilergebnisse, weil zwei Instanzen gleichzeitig dieselben Tabellen lesen und in dieselbe Ausgabe schreiben.
Das zweite ist Unsichtbarkeit. Eine fehlschlagende Crontab schreibt nichts weiter als eine Zeile in ein Syslog, das niemand liest. Es gibt keine Dead-Letter-Queue, kein Retry, keinen Alert. Ein kritischer wöchentlicher Cleanup-Job kann an dem Tag aufhören zu funktionieren, an dem ein Deployment die Umgebungsvariablen des Servers ändert, und du erfährst es erst Wochen später, wenn jemand bemerkt, dass die Datenbank nicht aufgeräumt wurde.
Für beide Probleme gibt es gut verstandene Lösungen, aber sie erfordern, sich von rohem Cron zu lösen und geplante Arbeit als echtes Engineering-Thema zu behandeln.
Symfony: Symfony Scheduler und verteiltes Locking
Symfony 6.3 hat eine Scheduler-Komponente ausgeliefert, die sich direkt in Messenger integriert. Statt eines Crontab-Eintrags definierst du eine wiederkehrende Message und ihren Zeitplan in PHP, verschickst sie über Messenger und lässt Worker sie aufnehmen. Damit bekommst du das gesamte Retry-, Dead-Letter- und Observability-Werkzeug, das Messenger für Queue-Jobs ohnehin bereitstellt.
Ein einfacher wiederkehrender Task sieht so aus:
// src/Scheduler/ReportSchedule.php
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('reports')]
final class ReportSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::cron('0 3 * * *', new GenerateNightlyReport()),
);
}
}
Ein messenger:consume scheduler_reports auf einem beliebigen Worker nimmt den Zeitplan auf. Du kannst so viele Worker betreiben, wie du willst - der Transport von Messenger stellt sicher, dass jede Message einmal zugestellt wird.
Der Haken ist At-least-once-Zustellung. Wenn dein Handler nicht idempotent ist und ein Job länger dauert als erwartet oder ein Worker mitten im Lauf abstürzt, kann derselbe Task zweimal laufen. Die Lösung ist, am Anfang jedes Handlers, der gemeinsamen State anfasst, einen verteilten Lock zu erwerben:
use Symfony\Component\Lock\LockFactory;
#[AsMessageHandler]
final class GenerateNightlyReportHandler
{
public function __construct(
private LockFactory $lockFactory,
private ReportGenerator $generator,
) {}
public function __invoke(GenerateNightlyReport $message): void
{
$lock = $this->lockFactory->createLock(
'nightly-report-' . $message->date->format('Y-m-d'),
ttl: 3600,
);
if (!$lock->acquire()) {
return; // Ein anderer Worker hat den Lock bereits
}
try {
$this->generator->generate($message->date);
} finally {
$lock->release();
}
}
}
Der Lock-Key enthält das Datum, sodass zwei Läufe an unterschiedlichen Tagen unabhängig sind, zwei Läufe am selben Tag für denselben Report aber serialisiert werden. Das TTL stellt sicher, dass ein abgestürzter Worker den Lock nicht ewig hält.
Als Lock-Store funktioniert Redis mit dem RedisStore-Adapter für die meisten Teams gut. Wenn der Lock einen Redis-Neustart überleben soll, nutze den CombinedStore mit zwei unabhängigen Redis-Knoten oder weiche auf den Doctrine-Lock-Store aus, der über eine Datenbankzeile und Transaktionen Korrektheit garantiert.
Node.js: node-cron, BullMQ und das Scheduler-Muster
In Node führt der entsprechende Weg über BullMQ. Rohes node-cron hat dasselbe Single-Server-Problem wie Crontab: Jede Instanz plant ihre eigenen Timer, und du bist wieder beim Dreifach-Feuern auf einem Drei-Knoten-Cluster.
Die Repeatable Jobs von BullMQ lösen das, indem sie den Zeitplan-State in Redis ablegen. Du registrierst einen Job einmal, und der interne Scheduler von BullMQ stellt sicher, dass er in jedem Intervall genau einmal feuert, unabhängig davon, wie viele Worker laufen:
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL);
const reportQueue = new Queue('reports', { connection });
// Den Repeatable Job einmal beim Start registrieren
// BullMQ ist hier idempotent: Erneutes Registrieren desselben Jobs mit demselben
// Key aktualisiert den Zeitplan, statt ein Duplikat anzulegen.
await reportQueue.add(
'nightly-report',
{ type: 'nightly' },
{
repeat: { cron: '0 3 * * *' },
jobId: 'nightly-report-singleton', // stabiler Key verhindert Duplikate
}
);
const worker = new Worker(
'reports',
async (job) => {
await generateNightlyReport(job.data);
},
{ connection }
);
BullMQ speichert den nächsten geplanten Ausführungszeitpunkt in einem Redis Sorted Set, das über die Repeat-Konfiguration des Jobs gekeyt ist. Wenn ein Repeatable Job feuert, entfernt BullMQ den aktuellen Eintrag atomar und fügt den nächsten ein. Mehrere Worker, die um den Job konkurrieren, nutzen atomare Redis-Operationen, damit genau einer gewinnt.
Für Tasks, die stärkere Garantien brauchen als Redis bietet - wo der Verlust eines laufenden Jobs durch einen Redis-Neustart inakzeptabel ist - lautet das Muster: BullMQ fürs Scheduling nutzen, die Absicht aber vor dem Lauf in die Datenbank schreiben:
const worker = new Worker(
'reports',
async (job) => {
// Absicht zuerst in die DB schreiben - stirbt der Worker, existiert ein Datensatz
const run = await db.scheduledRun.upsert({
where: { jobKey: job.id! },
create: { jobKey: job.id!, status: 'running', startedAt: new Date() },
update: { status: 'running', startedAt: new Date() },
});
try {
await generateNightlyReport(job.data);
await db.scheduledRun.update({
where: { id: run.id },
data: { status: 'completed', completedAt: new Date() },
});
} catch (error) {
await db.scheduledRun.update({
where: { id: run.id },
data: { status: 'failed', error: String(error) },
});
throw error; // BullMQ das Retry überlassen
}
},
{ connection }
);
Damit hast du einen abfragbaren Audit-Trail über jeden Lauf eines geplanten Jobs, sein Ergebnis und sein Timing - unabhängig davon, was BullMQ in Redis ablegt.
Observability: Die fehlende Schicht
Die häufigste Lücke in der Infrastruktur für geplante Jobs ist Observability. Teams richten einen Job ein, bestätigen einmal in der Entwicklung, dass er läuft, deployen und machen weiter. Drei Monate später stellen sie fest, dass er seit sechs Wochen still fehlschlägt.
Das minimale sinnvolle Observability-Setup hat drei Teile.
Job-Heartbeats sagen deinem Monitoring, dass ein Job erfolgreich gelaufen ist. Ein einfacher Ansatz ist ein Dienst wie Healthchecks.io oder ein eigener Endpoint: Am Ende jedes erfolgreichen Laufs sendest du ein HTTP GET an eine für diesen Job eindeutige URL. Wird die URL länger als das erwartete Intervall plus Karenzzeit nicht aufgerufen, bekommst du einen Alert. Das fängt sowohl fehlgeschlagene Jobs als auch Jobs ab, die gar nicht mehr eingeplant werden.
Strukturiertes Logging mit Laufzeit liefert dir die Daten, um zu verstehen, wie sich die Job-Performance über die Zeit verändert:
// Symfony-Beispiel
$this->logger->info('scheduled_job.completed', [
'job' => 'nightly-report',
'date' => $message->date->format('Y-m-d'),
'duration_ms' => (int) ((hrtime(true) - $startTime) / 1e6),
'records_processed' => $count,
]);
Wenn ein Job, der früher 12 Minuten brauchte, plötzlich 45 Minuten läuft, willst du das wissen, bevor er sein Intervall überschreitet und mit dem nächsten Lauf überlappt.
Monitoring der Dead-Letter-Queue fängt Handler ab, die ihre Retries aufgebraucht haben. In Symfony Messenger konfigurierst du einen failure_transport und alarmierst, wenn dort Messages landen. In BullMQ hörst du auf das failed-Event und schreibst fehlgeschlagene Jobs an ein Monitoring-Ziel:
worker.on('failed', (job, error) => {
logger.error('scheduled_job.failed', {
jobName: job?.name,
jobId: job?.id,
attemptsMade: job?.attemptsMade,
error: error.message,
stack: error.stack,
});
metrics.increment('scheduled_job.failure', { job: job?.name });
});
Lang laufende Jobs ohne Störung behandeln
Geplante Jobs, die sich ihrem Intervall nähern oder es überschreiten, brauchen sorgfältige Behandlung, um Überlappungen zu vermeiden. Der oben beschriebene verteilte Lock ist die richtige Verteidigung, aber du brauchst zusätzlich eine Strategie, wie der Job selbst damit umgeht, mitten im Lauf gestoppt zu werden - insbesondere bei Deployments.
In Symfony erhalten Worker beim Graceful Shutdown ein SIGTERM. Der Messenger-Worker respektiert das standardmäßig: Er beendet die aktuelle Message und stoppt dann. Für Jobs, die große Batches verarbeiten, teilst du die Arbeit in Chunks mit Checkpoints auf und schreibst den Fortschritt zwischen den Chunks in die Datenbank. Wird der Worker beendet und der Job neu eingereiht, kann er ab dem letzten committeten Checkpoint fortsetzen, statt von vorne zu beginnen.
In Node behandeln BullMQ-Worker Graceful Shutdown ebenfalls, aber du musst den Worker explizit schließen und auf laufende Jobs warten:
process.on('SIGTERM', async () => {
await worker.close();
await connection.quit();
process.exit(0);
});
Für Jobs, die tatsächlich länger laufen müssen, als ein Deployment-Fenster erlaubt, lautet das Muster: die Arbeit idempotent mit Checkpoints gestalten und dich auf den verteilten Lock verlassen, damit die neue Worker-Instanz sauber übernimmt, wenn die alte beendet wird.
Weg vom Single-Server-Denken
Der nötige mentale Wechsel besteht darin, geplante Jobs nicht länger als "Skripte, die auf einem Server laufen" zu sehen, sondern als "Messages, die nach Zeitplan erzeugt und von Workern konsumiert werden". Diese Umdeutung macht die richtigen Muster sofort sichtbar: Du brauchst einen dauerhaften Message-Store, idempotente Handler, verteilte Koordination für Exklusivität und Observability für jede Ausführung.
Wenn deine Anwendung an einem Punkt steht, an dem sich diese Komplexität verfrüht anfühlt, ist ein pragmatischer Mittelweg, eine einzelne "Scheduler"-Instanz in deiner Infrastruktur zu bestimmen - ein kleiner, dauerhaft laufender Server oder Container, dessen einzige Aufgabe es ist, geplante Tasks auszulösen. Das umgeht das Duplikatsproblem bei mehreren Instanzen, ohne eine vollständig verteilte Architektur zu erfordern, und ist eine gängige Wahl für Teams, die noch nicht in der Größenordnung arbeiten, in der sich die vollständige Lösung rechnet.
Wenn du die vollständige Lösung brauchst, decken die Werkzeuge in Symfony Messenger mit der Scheduler-Komponente und in BullMQ mit Repeatable Jobs die meisten Produktionsfälle ab. Der Aufwand ist überschaubar, und die Alternative - stille Fehler in Arbeit, auf die sich deine Kunden verlassen - ist ein Kostenfaktor, der sich über die Zeit aufsummiert.
Wenn du prüfen willst, ob deine aktuelle Job-Infrastruktur produktionsreif ist, oder ein neues System entwirfst, das wiederkehrende Arbeit zuverlässig erledigen muss, schreib uns an hello@wolf-tech.io oder besuche wolf-tech.io. Die oben beschriebenen Muster sind genau das, was wir in einem Code-Quality-Review bewerten und im Rahmen von Custom Software Development umsetzen.
FAQ
Kann ich bei nur einem Server weiter Cron nutzen? Ja - Cron auf einem einzelnen dedizierten Server vermeidet das Duplikatsproblem. Das Risiko ist, dass dieser Cron-Server zugleich der Single Point of Failure für alle geplante Arbeit ist. Startet er während einer Wartung neu, können Jobs ausfallen. Das Heartbeat-Monitoring lohnt sich unabhängig von der Anzahl deiner Server.
Wie viel Overhead erzeugt verteiltes Locking in jedem Handler? Für die meisten Jobs ist das Erwerben eines Redis-Locks ein einzelner Netzwerk-Roundtrip, also Mikrosekunden. Der Overhead ist gegenüber der eigentlichen Arbeit vernachlässigbar. Bei sehr hochfrequenten Jobs, die tausende Male pro Minute laufen, solltest du prüfen, ob Scheduling überhaupt das richtige Muster ist oder ob eine ereignisgesteuerte Auslösung besser passt.
Sollte Symfony Scheduler alle meine bestehenden Crontab-Einträge ersetzen? Nicht zwingend alle auf einmal. Den größten Nutzen bringt die Migration kritischer Jobs - Abrechnung, Reports, Datenexporte - auf Scheduler-gestützte Messenger-Tasks. Administrative Cleanup-Tasks, bei denen ein gelegentlich ausgefallener Lauf unkritisch ist, können in der Crontab bleiben, bis eine Migration sinnvoll wird.

