Zum Inhalt springen
Deutsch

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.

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 ist

Drei Dinge passieren, wenn SIGTERM ankommt:

  1. Die Runtime ruft coordinatedShutdown.run(new ProcessTerminateReason('SIGTERM')).
  2. Die Phasen laufen in kanonischer Reihenfolge. Innerhalb jeder Phase laufen alle registrierten Tasks parallel; die Phase wartet auf alle (oder auf ihre Timeouts).
  3. Die Pipeline endet mit dem eingebauten actor-system-terminate-Task, der system.terminate() für dich aufruft.

Ein Teil dessen, was dazwischen passiert, ist bereits verdrahtet; der Rest ist das, was du hinzugefügt hast.

In Ausführungsreihenfolge gelistet. Verdrahtet markiert die Phasen, die das Framework selbst füllt — alles andere ist leer, bis du etwas registrierst.

#Phasen-NameVerdrahtetTypische Tasks
1before-service-unbind—Letzte Ankündigungen, bevor der Server keine Verbindungen mehr akzeptiert.
2service-unbindsystem.http(...).bind(...), DevToolsHTTP-Server / gRPC-Server / WebSocket-Listener stoppen, neue Verbindungen anzunehmen.
3service-requests-done—Auf das Ende laufender Requests warten; den Rest abbrechen.
4service-stopBroker-Actors (MQTT, Kafka, NATS, AMQP, …)Client-Verbindungen schließen, Sockets freigeben.
5before-cluster-shutdown—Optionale Pre-Cluster-Leave-Hooks.
6cluster-sharding-shutdown-region—Der Sharding-Region sagen, Entitäten zu übergeben.
7cluster-leaveCluster.join(...)Ein Cluster.leave() ausgeben — Leaving-Status gossipen.
8cluster-exiting—Warten, bis der Cluster den Leave bestätigt.
9cluster-exiting-done—Cluster-Übergang als abgeschlossen bestätigen.
10cluster-shutdown—Cluster-Transports abbauen.
11before-actor-system-terminate—Letzte Chance für App-Level-Cleanup (Journals flushen, Broker schließen).
12actor-system-terminateder eingebaute TerminatorDer eingebaute system.terminate()-Task.

Du musst nicht jede Phase verwenden. Leere Phasen sind No-Ops; nur Phasen mit registrierten Tasks tun etwas.

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, ...); // ✓ typisiert
coordinatedShutdown.addTask('service-unbind', ...); // ✗ Stringly-typed, kein Auto-Complete

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.

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:

KlasseWann
ProcessTerminateReason(signal)SIGTERM/SIGINT, aus runUntilTerminated() oder installProcessHooks().
ActorSystemTerminateReasonÜbergib sie selbst, wenn der Trigger ein gewöhnlicher Shutdown ist.
ClusterLeavingReasonWas shutdown() von Cluster.bootstrap übergibt.
ClusterDowningReasonÜbergib sie, wenn du die Pipeline startest, weil der Cluster diesen Knoten herausgezwungen hat.
UnknownReasonDer Default, wenn run() ohne Argument aufgerufen wird.

Du kannst Reason für app-spezifische Trigger subklassen (AdminEndpointReason, HotReloadReason, etc.).

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.

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-Budget

Oder 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
});

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:

BudgetKeyDefault
Die gesamte Phase actor-system-terminateactor-ts.coordinated-shutdown.default-phase-timeout5 s
Der Drain darinactor-ts.system.shutdown-drain-timeout2 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.

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 hat

Beides 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.

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 0

Das 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.

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 hat system.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 in terminate() statt in einer Phase und deckt damit auch ein Programm ab, das terminate() 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.

Die CoordinatedShutdown-API-Referenz deckt addTask, addPhase, run und den vollständigen Phasen-Konstanten-Set ab.