Symfony Messenger Queues überwachen: Dead Letter Queues, Health Checks und Alerts, die wirklich auslösen
Eine Symfony Messenger Queue scheitert nicht so, wie ein Web-Request scheitert. Es gibt keinen 500er-Status, keinen Stack-Trace im Error-Tracker, kein offensichtliches Signal, dass etwas nicht stimmt. Nachrichten hören einfach auf, sich zu bewegen. Ein Worker-Prozess stirbt, und nichts startet ihn neu. Ein Webhook-Handler wirft bei jedem Retry eine Exception, und dieselbe Nachricht wird endlos neu verarbeitet, frisst dabei still CPU, während die Nachrichten dahinter warten. Man merkt es, wenn ein Kunde fragt, warum seine Rechnung nie angekommen ist, drei Tage nachdem sich die Queue gestaut hat.
Symfony Messenger Monitoring ist kein Nice-to-have, sobald Queues irgendetwas tragen, worauf ein Nutzer angewiesen ist: E-Mails, Rechnungserstellung, Webhook-Zustellung, Report-Exporte. Dieser Beitrag deckt die Teile ab, die Queues beobachtbar machen: die Metriken, die Messenger bereitstellt, wie man sie mit Prometheus abgreift, die Konfiguration von Dead Letter Queues, einen Health-Check-Endpunkt für die eigene Deployment-Plattform, die Grafana-Panels, die sich lohnen, die Alert-Schwellenwerte, die Probleme früh abfangen, und die Recovery-Schritte für den Fall, dass tatsächlich etwas kaputtgeht.
Warum Queues still versagen
Ein Queue-Consumer läuft als langlebiger Prozess, meist unter Supervisor oder systemd, und zieht in einer Schleife Nachrichten von einem Transport (Doctrine, Redis oder AMQP). Drei Dinge gehen typischerweise schief, und keines davon taucht in einem normalen Application-Log-Dashboard auf.
Der Consumer-Prozess stürzt ab, und der Process-Manager startet ihn nicht neu. Nachrichten kommen weiterhin auf dem Transport an, aber niemand ist da, um sie abzuholen, also klettert die Queue-Tiefe, bis jemand fragt, wo sein Report bleibt.
Eine bestimmte Nachricht bringt den Handler bei jedem Retry zum Werfen. Messengers Standard-Retry-Strategie versucht eine fehlschlagende Nachricht eine feste Anzahl Mal, bevor sie an den Failure-Transport geht, aber wenn das Failure-Transport-Routing nicht konfiguriert ist, wird diese Nachricht unbegrenzt erneut versucht und neu eingereiht, und frisst Worker-Kapazität, die andere Nachrichten brauchen.
Die Fehlerrate für eine Nachrichtenklasse steigt allmählich. Nichts stürzt ab, aber ein wachsender Anteil eines bestimmten Job-Typs schlägt beim ersten Versuch fehl, meist weil eine nachgelagerte API ihr Response-Format geändert hat oder ein Datenbank-Constraint anfängt, eine Untermenge von Datensätzen abzulehnen. Diese Art von Degradation ist ohne Metriken pro Klasse fast unsichtbar.
Symfony Messenger Monitoring mit Prometheus-Metriken
Symfonys MonologBridge und die Messenger-Komponente stellen Prometheus-Metriken nicht von Haus aus bereit, deshalb ist der übliche Ansatz die Bibliothek promphp/prometheus_client_php kombiniert mit Messengers Event-Dispatcher. Drei Metriken sind am wichtigsten:
Nachrichtenanzahl pro Transport und Nachrichtenklasse, als Counter, inkrementiert in einem Listener auf WorkerMessageHandledEvent und WorkerMessageFailedEvent. Mit Transportname und Nachrichtenklasse labeln, damit man sieht, zu welcher Queue und welchem Job-Typ ein Ausschlag gehört.
Verarbeitungszeit pro Nachrichtenklasse, als Histogram. Den Handler-Dispatch mit einem Timer umschließen und die Dauer bei WorkerMessageHandledEvent festhalten. Ein Histogram, kein Gauge, weil man Perzentile braucht: p50 zeigt den Normalfall, p99 zeigt, wann ein Handler unter Last anfängt zu stocken, bevor er ganz ausfällt.
Fehlerrate pro Nachrichtenklasse, abgeleitet aus dem obigen Counter, indem man in der Grafana-Query die fehlgeschlagene Anzahl durch die Gesamtanzahl teilt, statt sie als eigene Metrik zu führen. Sie als zur Query-Zeit berechnetes Verhältnis zu halten bedeutet, dass man nicht zwei separate Counter pflegen muss, die auseinanderdriften können.
Ein minimaler Event-Subscriber sieht so aus:
final class MessengerMetricsSubscriber implements EventSubscriberInterface
{
public function __construct(private CollectorRegistry $registry) {}
public static function getSubscribedEvents(): array
{
return [
WorkerMessageHandledEvent::class => 'onHandled',
WorkerMessageFailedEvent::class => 'onFailed',
];
}
public function onHandled(WorkerMessageHandledEvent $event): void
{
$envelope = $event->getEnvelope();
$this->registry->getOrRegisterCounter(
'app', 'messenger_messages_total', 'Messages processed',
['transport', 'class', 'status']
)->inc([
$event->getReceiverName(),
$envelope->getMessage()::class,
'success',
]);
}
public function onFailed(WorkerMessageFailedEvent $event): void
{
$envelope = $event->getEnvelope();
$this->registry->getOrRegisterCounter(
'app', 'messenger_messages_total', 'Messages processed',
['transport', 'class', 'status']
)->inc([
$event->getReceiverName(),
$envelope->getMessage()::class,
$event->willRetry() ? 'retry' : 'failed',
]);
}
}
Diese über einen /metrics-Endpunkt bereitstellen (typischerweise hinter Netzwerk-Zugriffskontrolle, nicht öffentlich) und einen Prometheus-Scrape-Job hinzufügen:
scrape_configs:
- job_name: 'symfony-messenger'
scrape_interval: 15s
static_configs:
- targets: ['app:9100']
Man braucht zusätzlich die Queue-Tiefe, die die obigen Metriken nicht direkt liefern, da sie Nachrichten erst zählen, nachdem ein Worker sie abgeholt hat. Die Tiefe kommt vom Transport selbst: Redis stellt LLEN auf der zugrunde liegenden Liste bereit, Doctrines Messenger-Tabelle lässt sich mit SELECT COUNT(*) FROM messenger_messages WHERE queue_name = ? AND delivered_at IS NULL zählen, und AMQP-Queues melden ihre Nachrichtenanzahl über die RabbitMQ-Management-API. Den jeweils genutzten Transport nach einem Zeitplan abfragen (ein Cron-getriggerter Command reicht in dieser Größenordnung völlig) und den Wert in ein Gauge schreiben.
Dead Letter Queues, die Fehler tatsächlich abfangen
Messengers Failure-Transport ist der eingebaute Dead-Letter-Mechanismus, und die Standard-framework.yaml-Konfiguration lässt ihn entweder ganz weg oder leitet alles ohne Granularität pro Transport in einen einzigen failed-Transport. Für alles, was über ein Spielzeugprojekt hinausgeht, konfiguriert man einen dedizierten Failure-Transport pro Queue, die zählt:
framework:
messenger:
failure_transport: failed
transports:
async_invoices:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
failed:
dsn: 'doctrine://default?queue_name=failed'
Die Retry-Strategie oben gibt einer Nachricht drei Versuche mit exponentiellem Backoff (1 s, 2 s, 4 s), bevor sie im failed-Transport landet. Dort angekommen, verbraucht sie keine Worker-Kapazität mehr und wartet, bis jemand sie sich ansieht, was genau der Sinn der Sache ist: eine Dead Letter Queue ist kein Ort, an dem Nachrichten verschwinden, sondern ein Wartebereich für Nachrichten, die ein Mensch prüfen muss.
Nachrichten im Failed-Transport nicht unbeobachtet sammeln lassen. Die Tiefe genauso verfolgen wie bei jeder anderen Queue und darauf alerten, denn eine wachsende Failed-Queue bedeutet fast immer, dass ein Bug ausgeliefert wurde, nicht dass die Nachrichten selbst unwichtig sind.
Ein Health-Check-Endpunkt für die eigene Deployment-Plattform
Coolify, Kamal und die meisten Container-Orchestratoren unterstützen HTTP-Health-Checks, und ein Messenger-bewusster Endpunkt ist mehr wert als der Standard-/health, der nur bestätigt, dass PHP-FPM antwortet. Einen Endpunkt bauen, der die Queue-Tiefe gegen einen Schwellenwert prüft und bei Überschreitung einen Unhealthy-Status zurückgibt:
#[Route('/health/queues', methods: ['GET'])]
public function queueHealth(QueueDepthChecker $checker): JsonResponse
{
$depths = $checker->getDepths();
$threshold = 5000;
$unhealthy = array_filter($depths, fn($d) => $d > $threshold);
if ($unhealthy !== []) {
return $this->json(['status' => 'unhealthy', 'queues' => $unhealthy], 503);
}
return $this->json(['status' => 'ok', 'queues' => $depths]);
}
Diesen Endpunkt getrennt vom allgemeinen Application-Health-Check mit dem eigenen Uptime-Monitor überwachen. Ein 503 hier sollte die Web-Container nicht neu starten, da nicht der Web-Prozess der Grund für den Stau ist, aber es sollte diejenigen benachrichtigen, die für den Queue-Betrieb zuständig sind.
Grafana-Panels, die sich lohnen
Vier Panels decken das meiste ab, was man im Alltag braucht: eine Zeitreihe der Queue-Tiefe pro Transport, damit ein langsamer Anstieg sichtbar wird, bevor er zum Vorfall wird; ein gestapeltes Balkendiagramm des Nachrichtendurchsatzes (Erfolg, Retry, Fehlgeschlagen) pro Nachrichtenklasse über die letzte Stunde; eine Heatmap der Verarbeitungszeit-Perzentile pro Klasse, die einen Handler sichtbar macht, der unter Last allmählich langsamer wird; und ein Single-Stat-Panel mit der Tiefe des Failed-Transports und einem roten Schwellenwert bei der Zahl, die für einen bedeutet: "das muss sich heute noch jemand ansehen."
Das Dashboard bei diesen vier Panels belassen. Ein Monitoring-Dashboard, das sich niemand ansieht, weil es vierzig Panels hat, ist schlimmer als gar kein Dashboard, weil es den falschen Eindruck erweckt, dass jemand hinschaut.
Alert-Regeln, die auslösen, bevor Nutzer es merken
Drei Alert-Regeln decken die weiter oben beschriebenen Fehlermodi ab. Queue-Tiefe über dem normalen Basiswert für fünf Minuten fängt den Fall eines feststeckenden Consumers ab, da ein gesunder Consumer-Pool eine Queue kontinuierlich leert und fünf Minuten anhaltenden Wachstums bedeuten, dass irgendetwas aufgehört hat zu konsumieren. Fehlerrate über 1 % für eine Nachrichtenklasse über ein 15-Minuten-Fenster fängt allmähliche Degradation ab, ohne bei einem einzelnen kurzen Ausreißer auszulösen. Consumer-Prozessanzahl, die auf null fällt, geprüft über das eigene Health-Signal des Process-Managers oder eine Prometheus-up-Metrik am Metrics-Port des Workers, fängt den Absturzfall direkt ab, statt auf sein nachgelagertes Symptom zu warten.
Diese als Prometheus-Alerting-Regeln schreiben und über Alertmanager an das eigene Paging-Tool weiterleiten:
groups:
- name: messenger
rules:
- alert: QueueDepthHigh
expr: messenger_queue_depth > 5000
for: 5m
labels:
severity: warning
- alert: MessengerFailureRateHigh
expr: |
rate(app_messenger_messages_total{status="failed"}[15m])
/ rate(app_messenger_messages_total[15m]) > 0.01
for: 15m
labels:
severity: warning
- alert: MessengerWorkersDown
expr: up{job="messenger-worker"} == 0
for: 2m
labels:
severity: critical
Von einer fehlgeschlagenen Queue erholen
Landen Nachrichten tatsächlich im Failed-Transport, übernehmen Symfonys Konsolenbefehle den Recovery-Workflow. bin/console messenger:failed:show listet auf, was wartet, messenger:failed:show <id> -vv liefert den vollständigen Exception-Trace für eine Nachricht, und messenger:failed:retry <id> reiht sie zurück in ihren ursprünglichen Transport ein, sobald die Ursache des Fehlers behoben ist. Für einen Bulk-Retry nach dem Deployment eines Fixes verarbeitet messenger:failed:retry --all alles im Failure-Transport erneut, wobei es sich lohnt, vorher die Fehlerursachen zu prüfen, da ein Batch-Retry nach einem unvollständigen Fix die Failed-Queue nur mit denselben Nachrichten wieder auffüllt.
Ist ein fehlerhaftes Deployment die Ursache und schlagen Nachrichten gerade in großem Umfang fehl, ist es oft schneller, den betroffenen Consumer komplett zu pausieren (das Supervisor-Programm stoppen oder das Worker-Deployment auf null skalieren), statt ihn weiter gute Nachrichten in fehlgeschlagene verwandeln zu lassen, während man zurückrollt. Nachrichten stauen sich während der Pause auf dem Transport und werden verarbeitet, sobald eine korrigierte Version deployt ist und der Consumer neu startet, was sicherer ist als ein Mass-Retry gegen Code, der den Bug noch enthält.
Queue-Monitoring ist einer der Bereiche, in denen ein wenig Instrumentierung im Vorfeld eine unverhältnismäßig große Menge On-Call-Stress später spart. Läuft die eigene Symfony-Anwendung bereits produktiv mit Messenger und ist davon noch nichts vorhanden, lohnt sich ein Audit, bevor einen der nächste stille Stau zuerst findet. Wolf-Tech baut diese Art von Observability in individuelle Symfony-Anwendungen ein und prüft bestehende Setups im Rahmen einer umfassenderen Code-Qualitätsprüfung. Erreichbar unter hello@wolf-tech.io oder über wolf-tech.io für eine zweite Meinung zum eigenen Queue-Setup.

