Coordinated Shutdown
system.terminate() stoppt das Actor-System, aber eine
Produktions-App hat normalerweise vor dem Stopp noch Arbeit:
laufende HTTP-Requests drainen, dem Cluster sagen, dass du gehst,
ein Journal flushen, Broker-Verbindungen schließen. Und diese
Schritte haben eine Reihenfolge — verlasse den Cluster bevor du
die Sharding-Region stoppst; stoppe den HTTP-Server bevor die
Actors, die Requests behandeln.
Coordinated Shutdown ist das DSL dafür. Du registrierst Tasks
gegen benannte Phasen; das Framework führt sie in
Abhängigkeitsreihenfolge aus, eine Phase nach der nächsten, jede mit
einem Timeout-Cap. Ein Aufruf von run() aus einem beliebigen
Trigger (SIGTERM, K8s-PreStop-Hook, ein Admin-Endpoint) führt die
ganze Pipeline einmal aus.
Das minimale Beispiel
Abschnitt betitelt „Das minimale Beispiel“import { ActorSystem, CoordinatedShutdownId, Phases, type Reason,} from 'actor-ts';
const system = ActorSystem.create('my-app');const coordinatedShutdown = system.extension(CoordinatedShutdownId);
coordinatedShutdown.addTask(Phases.ServiceUnbind, 'close-http', async (reason) => { await httpServer.close();});
coordinatedShutdown.addTask(Phases.ServiceRequestsDone, 'drain-in-flight', async () => { await waitForInFlightRequests(/* bis zu 10s */);});
await system.runUntilTerminated(); // SIGTERM/SIGINT → die Pipeline → löst auf, wenn sie fertig istDrei Dinge passieren, wenn SIGTERM ankommt:
- Die Runtime ruft
coordinatedShutdown.run(new ProcessTerminateReason('SIGTERM')). - Die Phasen laufen in kanonischer Reihenfolge. Innerhalb jeder Phase laufen alle registrierten Tasks parallel; die Phase wartet auf alle (oder auf ihre Timeouts).
- Die Pipeline endet mit dem eingebauten
actor-system-terminate-Task, dersystem.terminate()für dich aufruft.
Ein Teil dessen, was dazwischen passiert, ist bereits verdrahtet; der Rest ist das, was du hinzugefügt hast.
Die 12 kanonischen Phasen
Abschnitt betitelt „Die 12 kanonischen Phasen“In Ausführungsreihenfolge gelistet. Verdrahtet markiert die Phasen, die das Framework selbst füllt — alles andere ist leer, bis du etwas registrierst.
| # | Phasen-Name | Verdrahtet | Typische Tasks |
|---|---|---|---|
| 1 | before-service-unbind | — | Letzte Ankündigungen, bevor der Server keine Verbindungen mehr akzeptiert. |
| 2 | service-unbind | system.http(...).bind(...), DevTools | HTTP-Server / gRPC-Server / WebSocket-Listener stoppen, neue Verbindungen anzunehmen. |
| 3 | service-requests-done | — | Auf das Ende laufender Requests warten; den Rest abbrechen. |
| 4 | service-stop | Broker-Actors (MQTT, Kafka, NATS, AMQP, …) | Client-Verbindungen schließen, Sockets freigeben. |
| 5 | before-cluster-shutdown | — | Optionale Pre-Cluster-Leave-Hooks. |
| 6 | cluster-sharding-shutdown-region | — | Der Sharding-Region sagen, Entitäten zu übergeben. |
| 7 | cluster-leave | Cluster.join(...) | Ein Cluster.leave() ausgeben — Leaving-Status gossipen. |
| 8 | cluster-exiting | — | Warten, bis der Cluster den Leave bestätigt. |
| 9 | cluster-exiting-done | — | Cluster-Übergang als abgeschlossen bestätigen. |
| 10 | cluster-shutdown | — | Cluster-Transports abbauen. |
| 11 | before-actor-system-terminate | — | Letzte Chance für App-Level-Cleanup (Journals flushen, Broker schließen). |
| 12 | actor-system-terminate | der eingebaute Terminator | Der eingebaute system.terminate()-Task. |
Du musst nicht jede Phase verwenden. Leere Phasen sind No-Ops; nur Phasen mit registrierten Tasks tun etwas.
Die verdrahteten Tasks abwählen
Abschnitt betitelt „Die verdrahteten Tasks abwählen“Setze actor-ts.coordinated-shutdown.auto-register-tasks = false, und
das Framework registriert keinen davon. Die Phasen bleiben, der
eingebaute Terminator bleibt, und jede Ressource oben liegt in deiner
Hand — gedacht für einen Einbettenden, dem der Lebenszyklus dessen
gehört, was er dem System übergeben hat. Es ist ein einzelner
Schalter statt einer pro Subsystem, denn der Grund, danach zu greifen,
lautet immer „mir gehört der Lebenszyklus” und nie „binde den
HTTP-Server ab, aber überlass mir die Broker”.
Die Phases-Konstante exportiert die kanonischen Namen — bevorzuge
sie gegenüber String-Literalen für Autocomplete:
import { Phases } from 'actor-ts';
coordinatedShutdown.addTask(Phases.ServiceUnbind, ...); // ✓ typisiertcoordinatedShutdown.addTask('service-unbind', ...); // ✗ Stringly-typed, kein Auto-CompleteEigene Phasen hinzufügen
Abschnitt betitelt „Eigene Phasen hinzufügen“Für app-spezifische Arbeit, die nicht in eine kanonische Phase passt, deklariere deine eigene:
coordinatedShutdown.addPhase({ name: 'flush-metrics', timeoutMs: 3_000, dependsOn: [Phases.BeforeActorSystemTerminate], recover: true,});
coordinatedShutdown.addTask('flush-metrics', 'push-prometheus', async () => { await metricsRegistry.flush();});Das dependsOn-Feld macht die Ordnung DAG-förmig statt linear —
deine Phase läuft nach before-actor-system-terminate, aber vor
actor-system-terminate (weil letzteres ersteres in seiner eigenen
impliziten Kette hat).
Weil addPhase verlangt, dass jeder dependsOn-Eintrag eine
bereits registrierte Phase benennt, lässt sich ein Zyklus gar
nicht erst bilden. Die Reihenfolge selbst wird durch einen
topologischen Sort berechnet, wenn run() ausgeführt wird —
dieser Sort würde Error: cycle in phase dependencies werfen,
falls je ein Zyklus entstünde.
Task-Semantik
Abschnitt betitelt „Task-Semantik“Jeder Task ist eine Funktion von Reason zu void | Promise<void>:
type ShutdownTask = (reason: Reason) => Promise<void> | void;Der reason lässt einen Task auf den Grund des
Shutdown-Triggers branchen:
coordinatedShutdown.addTask(Phases.BeforeClusterShutdown, 'deregister', async (reason) => { if (reason instanceof ClusterDowningReason) { // Wir wurden gedownt — die Registry hat uns bereits abgeschrieben. return; } await serviceRegistry.deregister(cluster.selfAddress.toString());});Eingebaute Reason-Klassen:
| Klasse | Wann |
|---|---|
ProcessTerminateReason(signal) | SIGTERM/SIGINT, aus runUntilTerminated() oder installProcessHooks(). |
ActorSystemTerminateReason | Übergib sie selbst, wenn der Trigger ein gewöhnlicher Shutdown ist. |
ClusterLeavingReason | Was shutdown() von Cluster.bootstrap übergibt. |
ClusterDowningReason | Übergib sie, wenn du die Pipeline startest, weil der Cluster diesen Knoten herausgezwungen hat. |
UnknownReason | Der Default, wenn run() ohne Argument aufgerufen wird. |
Du kannst Reason für app-spezifische Trigger subklassen
(AdminEndpointReason, HotReloadReason, etc.).
Parallelität innerhalb einer Phase
Abschnitt betitelt „Parallelität innerhalb einer Phase“Alle Tasks in einer Phase laufen gleichzeitig — sie werden
zusammen gestartet, und die Phase wartet auf den letzten (oder
seinen Timeout). Wenn du innerhalb einer Phase
Ordnungs-Anforderungen hast (Task B muss auf Task A warten), packe
sie in unterschiedliche Phasen mit einem dependsOn.
Timeouts
Abschnitt betitelt „Timeouts“Jede Phase hat ein timeoutMs (Default 5 s); jeder Task wird in
einen Timeout-Race gewickelt. Ein Task, der nicht rechtzeitig
fertig wird, wird geloggt und entweder:
- Wiederhergestellt (die Phase fährt fort,
recover: true— der Default). Die nächste Phase startet. - Hält die Pipeline an (
recover: false). Nachfolgende Phasen werden nicht ausgeführt; der Shutdown stoppt mitten im Flug.
Überschreibe pro Phase:
coordinatedShutdown.setPhaseTimeout(Phases.ServiceRequestsDone, 30_000); // 30s Drain-BudgetOder definiere deine eigene mit gewünschtem timeoutMs / recover:
coordinatedShutdown.addPhase({ name: 'aggressive-cleanup', timeoutMs: 1_000, // strikter Cap dependsOn: [Phases.BeforeActorSystemTerminate], recover: false, // Fehler → Halt});Das Drain-Budget steckt in der letzten Phase
Abschnitt betitelt „Das Drain-Budget steckt in der letzten Phase“actor-system-terminate ist ein Task, der system.terminate()
awaited, und terminate() beginnt damit, die Actors unter /user zu
leeren — siehe
Terminieren. Damit sind
zwei Budgets ineinander verschachtelt, und das innere muss das kleinere
sein:
| Budget | Key | Default |
|---|---|---|
Die gesamte Phase actor-system-terminate | actor-ts.coordinated-shutdown.default-phase-timeout | 5 s |
| Der Drain darin | actor-ts.system.shutdown-drain-timeout | 2 s |
Setzt du das Drain-Budget über das Phasen-Timeout, wird die Phase abgebrochen, während der Drain noch läuft — bevor auch nur einem Actor gesagt wurde, dass er stoppen soll. Erhöhe beide zusammen oder zuerst das Phasen-Timeout.
SIGTERM / SIGINT-Hooks
Abschnitt betitelt „SIGTERM / SIGINT-Hooks“Fast jeder Service will dasselbe — Handler installieren, warten, herunterfahren, beenden — also ist genau diese Form ein einziger Aufruf:
await system.runUntilTerminated();// Oder, um zusätzlich auf etwas anderes zu hören:await system.runUntilTerminated(['SIGTERM', 'SIGINT', 'SIGUSR2']);Er installiert die Handler, löst auf, sobald das System unten und
die Pipeline fertig ist, und hängt die Handler auf dem Weg hinaus
wieder ab. Dieser letzte Schritt ist keine Kosmetik: unter Deno hält
ein Signal-Listener die Event-Loop offen und hat kein unref — ein
Programm, das aus irgendeinem anderen Grund herunterfährt (ein
terminate() von innen, ein Admin-Endpoint), würde sich also nie
beenden.
Ein Signal, das die Plattform nicht zustellen kann, wird übersprungen statt registriert. Windows hat unter keiner Runtime ein SIGTERM, und Deno wirft bei einem Signal, das es nicht zustellen kann, statt es zu ignorieren — danach zu fragen ist also nie ein Startfehler.
Der Prozess bleibt am Leben, solange das Promise offen ist — und das
ist eine Zusage, die der Aufruf selbst macht, nicht eine, die er von den
installierten Handlern erbt. Ein Signal-Handler ist für sich genommen
kein Grund für eine Runtime, weiterzulaufen: Node unreft seine
Signal-Handles, ein Service ohne irgendetwas anderes in der Event-Loop —
kein gebundener Port, kein offener Socket, kein referenzierter Timer —
würde sie also in dem Moment leerlaufen lassen, in dem er zu warten
beginnt, und sich beenden, bevor das SIGTERM eintreffen kann, für das
er sich gerade selbst gerüstet hat. Deshalb hält der Aufruf die Loop
selbst offen und lässt in demselben Schritt los, in dem er die Handler
abhängt. Ein Node ohne offene Ressourcen braucht kein eigenes
setInterval, um oben zu bleiben.
Die beiden Hälften bleiben einzeln verfügbar, wenn du das System in einen Host einbettest, dem die Prozess-Lebenszeit gehört:
coordinatedShutdown.installProcessHooks(); // → run(new ProcessTerminateReason(signal))coordinatedShutdown.removeProcessHooks(); // hängt exakt das ab, was es installiert hatBeides zweimal aufzurufen ist harmlos. removeProcessHooks() fasst
nie einen Listener an, den es nicht selbst installiert hat — ein
zweites ActorSystem im selben Prozess, oder das eigene
SIGTERM-Handling der Anwendung, bleibt unberührt.
K8s-PreStop-Integration
Abschnitt betitelt „K8s-PreStop-Integration“In Kubernetes ist die Pod-Shutdown-Sequenz:
1. K8s sendet SIGTERM und beendet die Grace-Period-Uhr des Pods.2. K8s ruft auch den PreStop-Hook (falls konfiguriert), läuft parallel.3. Nach max(Graceful-Shutdown, Grace-Period) sendet K8s SIGKILL.Das Standard-Rezept:
// Bei SIGTERM Coordinated Shutdown laufen lassen:await system.runUntilTerminated();
// PreStop-Hook-Script (in deinem Container-Image):// #!/bin/sh// sleep 10 # gib Upstream-LBs Zeit, diesen Pod zu drainen// exit 0Das sleep in PreStop gibt dem Load Balancer Zeit, diesen Pod aus
der Rotation zu nehmen, bevor das Actor-System anfängt
herunterzufahren — laufende HTTP-Requests sehen also nicht “ich
draine, geh weg.”
Siehe Operations — Kubernetes für das vollständige Deployment-Manifest.
Multi-Trigger-Sicherheit
Abschnitt betitelt „Multi-Trigger-Sicherheit“coordinatedShutdown.run() ist idempotent — es mehrmals aufzurufen, gibt
dasselbe laufende Promise zurück. Unabhängige Trigger, die alle
run aufrufen, lassen die Pipeline nicht erneut laufen. Der erste
Aufruf startet sie; folgende Aufrufe warten auf dieselbe
Vollendung.
Das ist wichtig, weil du in Produktion oft mehrere Shutdown-Pfade hast:
// SIGTERM-Pfad:await system.runUntilTerminated();
// Admin-Endpoint-Pfad:app.post('/shutdown', async (req, res) => { await coordinatedShutdown.run(new AdminEndpointReason()); res.send('ok');});
// Ein Cluster-Downing ist keiner davon — siehe unten.Beide führen am Ende dieselbe Shutdown-Sequenz einmal aus.
Vom Cluster gedownt zu werden startet die Pipeline nicht: keine
Reason-Klasse führt sie für dich aus, und ClusterDowningReason
existiert für ein run(), das du selbst auslöst. Wenn ein
gedownter Knoten sich selbst herunterfahren soll, abonniere das und
sag es:
cluster.subscribe((event) => { if (event instanceof MemberDown && event.member.address.equals(cluster.selfAddress)) { void coordinatedShutdown.run(ClusterDowningReason.instance); }});Was nach coordinatedShutdown.run()-Abschluss läuft
Abschnitt betitelt „Was nach coordinatedShutdown.run()-Abschluss läuft“Bis das Promise resolved:
- Jeder Task in jeder Phase ist entweder erfolgreich oder zeitabgelaufen.
- Der eingebaute
actor-system-terminate-Task hatsystem.terminate()aufgerufen, was jeden Actor gestoppt und den Dispatcher und Scheduler geschlossen hat. - Die Log-Sinks sind geflusht und geschlossen, begrenzt durch
actor-ts.logger.close-timeout(Standard 3 s) — die letzten Records eines bündelnden Sinks liegen also auf der Platte oder auf dem Draht, bevor der Prozess geht. Dieser Flush sitzt interminate()statt in einer Phase und deckt damit auch ein Programm ab, dasterminate()direkt aufruft; siehe Multi-Sink-Logging. - Der Prozess ist frei zu beenden (
process.exit(0)). Nichts mehr zu tun.
Eine vollständige Produktions-Main:
async function main() { const system = ActorSystem.create('my-app');
await system.http(8080).bind(routes); // registriert sein eigenes Unbind // Registriere, was dem Framework nicht gehört...
await system.runUntilTerminated();}
main().catch((err) => { console.error(err); process.exit(1);});Wenn SIGTERM ankommt, läuft die Pipeline, das System terminiert,
runUntilTerminated() löst auf, seine Handler gehen ab, und der
Prozess beendet sich, weil nichts mehr die Loop am Leben hält.
Häufige Fallstricke
Abschnitt betitelt „Häufige Fallstricke“Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Actor-System —
das
terminate(), das am Ende der Pipeline läuft. - Cluster-Überblick —
Cluster.join()registriert dencluster-leave-Task; die übrigen Cluster-Phasen füllst du selbst. - Kubernetes-Deployment — das vollständige PreStop + SIGTERM + Grace-Period-Rezept.
- Persistenz — Migration — Rolling Shutdown für Journal-Migrationen.
Die CoordinatedShutdown-API-Referenz
deckt addTask, addPhase, run und den vollständigen
Phasen-Konstanten-Set ab.
