Webentwicklung

Graceful Shutdown für langlebige PHP-Worker - Den aktuellen Job vor dem Beenden abschließen

Graceful Shutdown für langlebige PHP-Worker - Den aktuellen Job vor dem Beenden abschließen

Ein Queue-Worker hat gerade die Hälfte einer Rechnung versendet, ein Thumbnail erzeugt oder einen Suchindex aktualisiert, als ein Deployment ihn zum Beenden auffordert. Ein sofortiger Abbruch kann einen Job zurücklassen, nachdem einige Auswirkungen bereits eingetreten sind. Wird die Aufforderung ignoriert, kann sich das Deployment verzögern, bis der Supervisor die Geduld verliert. Wie sieht ein kontrollierter Mittelweg für einen kleinen PHP-CLI-Worker aus?

Die hilfreiche Antwort ist enger gefasst als "niemals einen Job verlieren". Ein Prozess kann bei einer normalen Beendigungsanforderung kooperieren, aber Abstürze, Stromausfälle oder eine erzwungene Beendigung nicht unmöglich machen. Graceful Shutdown ist daher ein Protokoll: die Anforderung empfangen, keine neue Arbeit mehr annehmen, eine definierte Job-Grenze erreichen und vor Ablauf einer endlichen Frist beenden. Queue-Wiederherstellung und Idempotenz müssen weiterhin die Fälle abdecken, in denen dieses Protokoll nicht abgeschlossen werden kann.

Die Grenze vor der Signalbehandlung festlegen

Angenommen, ein Worker reserviert wiederholt einen Job, führt dessen Auswirkungen aus und bestätigt den Abschluss. Der sicherste Punkt zum Beenden liegt normalerweise zwischen Jobs, nicht mitten in einem Job. Ein Beendigungssignal sollte ändern, was die Schleife als Nächstes tut. Es sollte den Signal-Handler nicht zu einem zweiten Ausführungspfad machen, der Datenbankverbindungen schließt, den Queue-Status ändert und mehrere Log-Einträge schreibt.

In diesem Artikel bedeutet ein kontrolliertes Beenden:

  • keinen weiteren Job reservieren, nachdem das Beenden angefordert wurde;
  • den gerade ausgeführten Job seinen normalen Erfolgs- oder Fehlerpfad erreichen lassen;
  • Arbeit nur gemäß den festgelegten Regeln der Queue bestätigen;
  • innerhalb der Stoppfrist des Supervisors beenden.

Diese Definition macht bereits eine wichtige Grenze sichtbar. Wenn ein Job unbegrenzt bei einem Netzwerkaufruf blockieren kann, kann "den aktuellen Job abschließen" auch "niemals beenden" bedeuten. Graceful Shutdown erfordert begrenzte Arbeit: Timeouts für Verbindungen und Requests, soweit praktikabel eine maximale Jobdauer und eine Supervisor-Frist, die die Realität statt bloßer Hoffnung abbildet.

Den Handler das Beenden anfordern lassen

Die PHP-Dokumentation zu Process Control beschreibt PCNTL als Prozesssteuerung im Unix-Stil und warnt vor der Verwendung in einer Webserver-Umgebung. Die Extension ist außerdem unter Windows nicht verfügbar. Dieses Muster ist für einen langlebigen PHP-CLI-Prozess auf einem Unix-ähnlichen System gedacht, nicht für einen gewöhnlichen PHP-FPM-Request.

Der Kernmechanismus kann klein bleiben:

<?php

declare(strict_types=1);

if (PHP_SAPI !== 'cli' || !extension_loaded('pcntl')) {
    fwrite(STDERR, "This worker requires PHP CLI with PCNTL.\n");
    exit(1);
}

$stopRequested = false;

pcntl_async_signals(true);

$requestStop = static function () use (&$stopRequested): void {
    $stopRequested = true;
};

pcntl_signal(SIGTERM, $requestStop);
pcntl_signal(SIGINT, $requestStop);

while (!$stopRequested) {
    $job = reserveNextJob();

    if ($job === null) {
        sleep(1);
        continue;
    }

    if ($stopRequested) {
        releaseJob($job);
        break;
    }

    try {
        processJob($job);
        acknowledgeJob($job);
    } catch (Throwable $error) {
        recordJobFailure($job, $error);
    }
}

fwrite(STDOUT, "Worker stopped cleanly.\n");

reserveNextJob(), processJob() und die Queue-Funktionen sind bewusst Platzhalter. Ihre korrekte Implementierung hängt davon ab, ob die Queue MariaDB, Redis, RabbitMQ oder etwas anderes verwendet. Die Aussage des Beispiels betrifft nur den Kontrollfluss: Der Handler setzt ein Flag, die aktive Iteration behält ihren gewöhnlichen Erfolgs- oder Fehlerpfad bei und die Schleife prüft das Flag, bevor sie weitere Arbeit reserviert.

Die zweite Prüfung nach reserveNextJob() schließt eine weitere praktische Lücke. Ein Signal kann eintreffen, während der Reservierungsaufruf wartet. Liefert der Aufruf anschließend einen Job zurück, wurde dieser noch nicht begonnen. Das Beispiel gibt ihn deshalb frei und beendet sich. Eine echte Queue muss releaseJob() sicher machen oder eine Lease verwenden, durch die eine verlassene Reservierung wieder verfügbar wird. Es gibt weiterhin keine magische Anweisung, die Signalzustellung und Queue-Status zu einem atomaren Ereignis macht. Das Wiederherstellungsmodell der Queue bleibt unverzichtbar.

Die offizielle pcntl_async_signals()-Dokumentation besagt, dass true die asynchrone Signalbehandlung aktiviert. Die pcntl_signal()-Dokumentation nennt den asynchronen Dispatch als empfohlene Dispatch-Methode. Sie weist außerdem darauf hin, dass blockierende Funktionen wie sleep() unterbrochen werden können. Allgemeiner dokumentiert Linux, dass einige blockierte Aufrufe nach einem Handler neu gestartet werden, während andere abhängig von Interface und Signal-Handler-Flags vorzeitig mit EINTR zurückkehren. Ein Worker mit Sockets, Subprozessen oder einer Client-Bibliothek muss anhand dieser tatsächlich verwendeten Aufrufe getestet werden. Das boolesche Flag macht nicht jede Abhängigkeit sofort unterbrechbar.

Verstehen, was systemd beim Stoppen tut

Ein minimaler Service kann den Worker als Hauptprozess ausführen:

[Unit]
Description=Example PHP queue worker
After=network.target

[Service]
Type=exec
User=app-worker
WorkingDirectory=/srv/example-app
ExecStart=/usr/bin/php /srv/example-app/bin/worker.php
Restart=on-failure
RestartSec=5s
TimeoutStopSec=45s

[Install]
WantedBy=multi-user.target

Die Pfade, das Konto, die Restart-Policy und die Frist von 45 Sekunden sind Beispiele, keine universellen Standardwerte. Das Konto muss existieren und genau den Zugriff auf Queue, Anwendung und Dateisystem besitzen, den der Worker benötigt. Die Frist sollte länger sein als die vorgesehene Zeit, um einen begrenzten Job abzuschließen oder sicher fehlschlagen zu lassen, und dennoch Deployment und Shutdown eine echte Obergrenze setzen.

Das offizielle systemd.kill-Handbuch besagt, dass das erste Stoppsignal standardmäßig SIGTERM ist. Beim normalen Stoppvorgang wartet systemd darauf, dass der Service beendet wird. Die TimeoutStopSec=-Dokumentation erklärt, dass ein nach Ablauf der Frist noch laufender Service standardmäßig mit SIGKILL zwangsweise beendet wird.

Daraus ergibt sich eine konkrete Reihenfolge:

  1. systemctl stop oder ein Neustart veranlasst systemd, SIGTERM zu senden.
  2. Der PHP-Handler setzt $stopRequested auf true.
  3. Der aktive Job folgt seinem normalen Erfolgs- oder Fehlerpfad.
  4. Die Schleife erkennt das Flag vor der Reservierung eines weiteren Jobs und endet.
  5. Überschreitet der Prozess die Frist, eskaliert systemd normalerweise zu SIGKILL.

Das Linux-Handbuch signal(7) besagt, dass SIGKILL nicht abgefangen, blockiert oder ignoriert werden kann. Kein Shutdown-Handler kann nach diesem Signal aufräumen. Das Abschalten des finalen Kill-Signals von systemd würde das zugrunde liegende Problem nicht lösen. Stattdessen kann dadurch ein Service zurückbleiben, der sich nicht beenden lässt. Die bessere Frage lautet, warum der Worker seine Grenze nicht vor Ablauf der Frist erreichen konnte.

Queue-Korrektheit und Prozesslebensdauer trennen

Das Abschließen einer PHP-Funktion ist für sich genommen kein Beleg dafür, dass ein Job genau einmal verarbeitet wurde. Angenommen, ein Job löst über eine externe API eine Zahlung aus und bestätigt danach seinen Queue-Eintrag. Der Prozess könnte verschwinden, nachdem die Zahlung erfolgreich war, aber bevor die Bestätigung festgeschrieben wurde. Bei der Wiederherstellung kann die Queue den Job erneut bereitstellen. Die Reihenfolge umzukehren ist nicht sicherer: Eine vorzeitige Bestätigung kann die Arbeit verlieren, wenn der Prozess vor dem externen Effekt stirbt.

Die geeigneten Kontrollen gehören zum Design des Jobs und der Queue:

  • Reservierung oder Lease: Reservierte Arbeit wird wieder verfügbar, wenn ein Worker verschwindet oder seine Lease abläuft.
  • Bestätigung nach dem definierten Ergebnis: Die Queue markiert den Abschluss erst an der gewählten Grenze.
  • Idempotenz: Das Wiederholen desselben logischen Jobs verdoppelt keinen unumkehrbaren Effekt, häufig mithilfe eines stabilen Operation Keys oder einer Eindeutigkeitsregel.
  • Atomarer lokaler Zustand: Zusammengehörige Datenbankänderungen werden gemeinsam festgeschrieben, sofern eine einzelne Datenbanktransaktion sie tatsächlich abdecken kann.
  • Versuchsdatensätze und Beobachtbarkeit: Betreiber können zwischen ausstehender, aktiver, abgeschlossener, fehlgeschlagener und abgelaufener Arbeit unterscheiden.

Verschiedene Broker stellen unterschiedliche Primitive bereit. Daher kann der Artikel nicht ehrlich einen einzigen Bestätigungsbefehl vorschreiben. Die allgemeine Erkenntnis lautet: Ein Signal koordiniert den Prozess-Shutdown, ist aber kein Transaktionsprotokoll. Graceful Handling verringert vermeidbare Unterbrechungen bei gewöhnlicher Wartung, während Wiederherstellungsmechanismen die dennoch auftretenden Unterbrechungen behandeln.

Die Grenze testen, statt der Konfiguration zu vertrauen

Ein sicherer erster Test verwendet wegwerfbare Arbeit, sichtbare Marker und keine Produktions-Queue. Ein simulierter Job sollte mehrere Sekunden dauern. Der Worker wird gestartet, während des Jobs wird SIGTERM gesendet und anschließend wird geprüft, ob der aktuelle Job endet, ohne dass der nächste beginnt:

php graceful-worker-demo.php &
worker_pid=$!
sleep 1
kill -TERM "$worker_pid"
wait "$worker_pid"

Bei einer lokalen Prüfung für diesen Artikel gab ein PHP-8.4.27-CLI-Prozess mit PCNTL die Start- und Abschlussmarker des aktiven simulierten Jobs und danach den Stoppmarker des Workers aus. Der Startmarker des nächsten Jobs erschien nicht. Dieses Ergebnis bestätigt die kleine Demonstration in der geprüften Umgebung, nicht jeden Queue-Client oder jedes PHP-Deployment.

Nachdem die isolierte Schleife funktioniert, sollte der tatsächliche Service in einer Staging- oder anderweitig wegwerfbaren Queue getestet werden. Nützliche Beobachtungen sind das Journal rund um den Stopp, der Exit-Status des Prozesses, der Reservierungsstatus und die Frage, ob derselbe Job nach einer erzwungenen Beendigung erneut zugestellt wird. Zusätzlich sollte ein Abhängigkeits-Timeout getestet werden, der kürzer als TimeoutStopSec= ist. Ein Happy-Path-Test mit SIGTERM allein zeigt nicht, was geschieht, wenn DNS, eine Datenbank oder ein HTTP-Endpunkt hängen bleibt.

Einige Fehlermuster verdienen besondere Aufmerksamkeit:

  • Ein Aufruf von exit im Handler kann die gewöhnliche Grenze des aktiven Jobs umgehen.
  • Komplexe Aufräumarbeiten im Handler erzeugen konkurrierende Kontrollflüsse.
  • Batch-Reservierungen machen "nach dem aktuellen Job beenden" mehrdeutig, sofern ungestartete Reservierungen nicht freigegeben werden oder ablaufen können.
  • Ein unbegrenzter I/O-Aufruf kann die gesamte Stoppfrist verbrauchen.
  • Eine Frist unterhalb der normalen Jobdauer garantiert regelmäßige erzwungene Beendigungen.
  • Eine Frist, die weit über jeder akzeptablen Jobdauer liegt, kann einen hängenden Worker während eines Deployments verbergen.

Graceful bedeutet kooperativ und begrenzt

Ein kleiner PHP-Worker benötigt kein aufwendiges Shutdown-Framework, um mit systemd zu kooperieren. Er braucht eine klare Job-Grenze, einen minimalen Signal-Handler, eine Schleife, die keine neue Arbeit mehr reserviert, endliche Operationen und eine anhand realer Jobs gewählte Stoppfrist. Diese Bausteine machen routinemäßige Stopps weniger schädlich und besser beobachtbar.

Sie beseitigen nicht die Notwendigkeit von Leases, Wiederholungen, Idempotenz oder Wiederherstellung nach einem harten Fehler. Diese Einschränkung ist kein Mangel des Musters, sondern die Grenze, die sein Versprechen ehrlich hält. Die praktische Designfrage lautet nicht "Kann dieser Worker immer fertig werden?", sondern "Wenn er nicht fertig werden kann, kann die Queue sicher erklären und wiederherstellen, was übrig bleibt?"

References