Exit-Code 143 ist eine dieser Zahlen, die man einmal lernt und danach überall sieht. Er bedeutet 128 plus 15, und 15 ist SIGTERM, und SIGTERM bedeutet, dass irgendwer deinen Prozess gebeten hat zu gehen, und dein Prozess keine Ahnung hatte, wie er darauf antworten soll. Ein Medium-Artikel, der diese Woche die Runde macht, beschreibt ein Data-Engineering-Team, das genau daran Tage verloren hat: ein nächtlicher PHP-Batch über rund zwei Millionen Datensätze, üblicherweise zwei bis drei Stunden, verwaltet von einer systemd-Unit, endete plötzlich in der einen Nacht bei 60 Prozent und in der nächsten bei 50 Prozent. Keine Exception. Kein Fatal. Die Logs hören einfach auf, als hätte jemand den Stecker gezogen. Die Antwort stand die ganze Zeit in journalctl, in der Zeile über das erreichte Stopping-Timeout.

graceful-worker.php
<?php
declare(strict_types=1);

pcntl_async_signals(true);
$shouldExit = false;

pcntl_signal(SIGTERM, function () use (&$shouldExit) {
    $shouldExit = true;
});

while (!$shouldExit) {
    // one atomic chunk of work, then a checkpoint
    sleep(1);
}

exit(0);

Der Fix, auf den der Artikel hinausläuft, ist richtig, und ich würde ihn heute noch ausliefern: pcntl_async_signals(true), einen Handler auf SIGTERM registrieren, der ein Boolean umlegt, das Boolean zwischen den Arbeitspaketen prüfen, exit 0. Das sind tatsächlich ein paar Zeilen, es ist verifiziertes Verhalten auf PHP 8.3, und es macht aus einem Rätsel einen Log-Eintrag. Mein Argument ist: Diese Zeilen sind der billigste Teil der Arbeit und genau der Punkt, an dem alle aufhören. Der Handler sagt dir, dass ein Stopp angefordert wurde. Er sagt dir nicht, ob dein Job es überlebt, gestoppt zu werden.

So sieht der Vertrag aus. systemd schickt SIGTERM (oder was auch immer du als KillSignal setzt), wartet TimeoutStopSec, was auf den meisten modernen Installationen standardmäßig 90 Sekunden sind und auf manchen älteren fünf Minuten waren, und schickt dann SIGKILL. Signal 9 ist nicht verhandelbar. PHP lässt dich nicht einmal so tun als ob: Probier pcntl_signal(SIGKILL, ...) und du bekommst einen Fatal Error über das Installieren eines Signal-Handlers für 9, was der Kernel ist, der in deinem Namen ablehnt. Das ganze Spiel besteht also darin, dein Cleanup innerhalb des Gnadenfensters abzuschließen. Und dieses Fenster ist ein Rennen, das du jedes einzelne Mal gewinnen musst, wenn ein Wartungsfenster startet, ein Deploy die Unit neu startet oder RuntimeMaxSec abläuft.

Genau deshalb ist der erste Reflex meistens falsch. Jemand wird gebissen, öffnet die Unit-Datei und dreht TimeoutStopSec auf zehn Minuten hoch. Jetzt ist der schlimmste Fall durch das langsamste Ding begrenzt, das du je gemessen hast, und jeder Server-Reboot in der Flotte wartet darauf. Währenddessen braucht der Batch, der früher zwei Stunden lief, drei Stunden, weil die Tabelle gewachsen ist, und die Zahl, die du im März gewählt hast, ist im September klammheimlich falsch. Du hast den Job nicht fortsetzbar gemacht. Du hast dir ein größeres Fenster gekauft, in dem du Pech haben kannst, und du hast den Shutdown für alles verlangsamt, was sich diese Kiste teilt.

Das ehrliche Gegenargument, und es ist ein starkes, lautet: Die meisten PHP-Teams schreiben nie eine Zeile PCNTL und fahren völlig gut damit. Laravels queue:work behandelt SIGTERM, indem es den aktuellen Job beendet und aussteigt. Symfonys messenger:consume macht dasselbe. Gib ihnen ein vernünftiges TimeoutStopSec, etwa 60 Sekunden für typische Jobs in Message-Größe, und du bist fertig. Für Queue-Traffic stimme ich dem zu. Der Haken ist, dass Framework-Signalbehandlung genau eine Sache verspricht: dass das aktuelle Arbeitspaket abgeschlossen wird. Wenn ein Arbeitspaket eine einzelne Message ist, die drei Zeilen anfasst, wunderbar. Wenn ein Arbeitspaket deine monatliche Abstimmungsschleife ist, die zwei Millionen Datensätze innerhalb eines einzigen Aufrufs liest, wartet der Handler höflich auf etwas, das nicht fertig wird, und SIGKILL kommt planmäßig mit Exit-Code 137.

Die Design-Entscheidung, auf die es wirklich ankommt, ist also: Wie groß ist dein kleinster sicherer Haltepunkt? Zerlege den Batch in Einheiten, die du gerne noch einmal laufen lassen würdest, schreibe nach jeder einen Checkpoint, mach die Einheit so idempotent, dass ein Retry langweilig ist. Dann hört die Gnadenfrist auf, eine Wette zu sein, weil dein Worker nie mehr als ein paar Sekunden von einem legalen Exit entfernt ist. Genau das macht auch RuntimeMaxSec oder einen anwendungsseitigen max-time-Restart angenehm statt beängstigend: Du bekommst einen frischen Prozess nach einem Zeitplan, den du gewählt hast, mit zurückgewonnenem Speicher und nichts in der Schwebe. Kleingedrucktes, das man sich merken sollte: pcntl_async_signals(true) ist keine optionale Deko. Ohne das erfolgt die Zustellung nur an Dispatch-Punkten, eine enge Rechenschleife ohne I/O segelt also glatt an einem SIGTERM vorbei, das sie nie sieht.

Der letzte Punkt: Nichts davon ist beobachtbar, wenn du nicht hinschaust. Exit-Codes sind kostenlose Telemetrie, und fast niemand plottet sie. Eine 143 in Produktion bedeutet, dass ein Prozess zum Stoppen aufgefordert wurde und nicht antworten konnte. Eine 137 außerhalb deiner Deploy-Fenster heißt normalerweise, dass der OOM-Killer schneller war, und dmesg bestätigt es dir. Setz einen Counter auf beides, alarmiere, wenn sich etwas bewegt, und du findest den kaputten Shutdown-Pfad in Woche eins statt nach einem Quartal voller halbfertiger Reports. Schick deinem Worker auf Staging ein kill -TERM, bevor du irgendetwas davon glaubst, denn der Shutdown-Pfad ist der eine Code-Pfad, den niemand übt, bis es darauf ankommt.

Deshalb gebe ich die Frage an dich weiter, denn ich vermute, die Meinungen gehen auseinander. Wo ziehst du die Grenze zwischen TimeoutStopSec hochdrehen und den Job in Chunks refactoren? Wenn deine längste einzelne Arbeitseinheit zwanzig Minuten dauert und ein Rewrite bedeutet, eine Reporting-Pipeline anzufassen, die niemand wieder aufmachen will: Schreibst du sie wirklich um, oder setzt du das Timeout auf dreißig Minuten und überwachst stattdessen die Exit-Codes? Und hat hier jemand das Shutdown-Verhalten in CI unter Test, oder ist das immer noch eine dieser Sachen, die wir alle für eine gute Idee halten und nie machen?