Health Checks
Die Management-Routen exponieren zwei Health-Endpunkte:
GET /health— Liveness. Liefert 200, wenn der Prozess betriebsbereit ist.GET /ready— Readiness. Liefert 200, wenn der Pod bereit ist, Traffic zu empfangen.
Beide aggregieren eine Registry pro ActorSystem, erreichbar über
healthChecksOf(system). Framework-Komponenten registrieren sich
dort beim Start; deine eigenen Checks kommen auf dasselbe Objekt:
import { healthChecksOf, managementRoutes } from 'actor-ts/management';
const health = healthChecksOf(system);
health.addReadiness(async () => { const ok = await database.ping(); return { name: 'database', status: ok, detail: ok ? undefined : 'database unreachable' };});
health.addReadiness(async () => { try { await cache.ping(); return { name: 'cache', status: true }; } catch (e) { return { name: 'cache', status: false, detail: (e as Error).message }; }});
await system.http(8558).bind(managementRoutes(system, cluster));Wenn irgendein Check status: false zurückgibt, liefert der
entsprechende Endpunkt 503 mit einem JSON-Body, der das
Ergebnis jedes Checks auflistet.
Die eingebauten Checks
Abschnitt betitelt „Die eingebauten Checks“Diese registriert das Framework selbst — du bekommst sie, ohne
danach zu fragen, und sie liegen in der Aggregation, die /ready
liest. Der gRPC-Dienst grpc.health.v1.Health
liest die Registry, die du ihm übergibst: reiche
healthChecksOf(system) durch, und beide können nie
unterschiedlicher Meinung darüber sein, was „ready” heißt — eine
frische HealthCheckRegistry an dieser Stelle trennt sie.
| Check | Art | Registriert von | Schlägt fehl, wenn |
|---|---|---|---|
actor-system | Liveness | der Registry selbst | system.terminate() durchgelaufen ist |
cluster-membership | Readiness | Cluster.join | dieser Knoten in seiner eigenen Member-Sicht nicht up ist |
cluster-transport | Readiness | Cluster.join | der Knoten keinen der noch erwarteten Peers erreichen kann |
cluster-transport prüft absichtlich auf vollständige Isolation
und nicht auf „jeder Peer ist erreichbar”. Eine Teilpartition
lässt den Knoten weiter gossippen, konvergieren und routen — ihn
aus dem Load Balancer zu nehmen würde einem Cluster Kapazität
entziehen, der die Lage beherrscht. Vom ganzen Cluster abgeschnitten
zu sein ist der Fall, in dem Weiterbedienen zur Split-Brain-Gefahr
wird — und der Fall, den ein Knoten aus seiner Member-Sicht allein
nicht sehen kann, weil kein Peer den eigenen Datensatz dieses Knotens
herabstufen darf. Ein Ein-Knoten-Cluster erwartet niemanden und
besteht immer.
„Erreichbar” heißt eine offene Verbindung zu einem Peer, den der
Failure Detector noch nicht abgeschrieben hat — und es braucht beide
Hälften. Ein offener Socket allein beweist nichts: Die kanonische
Partition — iptables -j DROP, eine schwarz gelochte Route, ein
hängender Peer — nimmt den Verkehr weg, ohne ein FIN oder ein RST zu
erzeugen. Die Sockets bleiben also aufgebaut, solange der Kernel es
weiter versucht, während überhaupt nichts mehr ausgetauscht wird. Zu
verlangen, dass der Peer auch erreichbar ist, übergibt dieses Urteil
dem Failure Detector — der Komponente, die Stille bemerkt.
Was der Check nicht erkennt, damit du damit planen kannst:
- die Latenz des Failure Detectors selbst — zwischen Beginn der Partition und dem Auslösen des Detectors meldet sich der Knoten weiter als ready. Das ist dasselbe Zeitfenster, in dem der Rest des Clusters reagiert.
- eine Einweg-Partition, bei der dieser Knoten noch empfängt.
Seine Peers sehen weiter
Upaus, er sieht also weiter ready aus, während nichts von dem ankommt, was er sendet. Das zu erkennen bräuchte einen quittierten Roundtrip.
leave() bringt die Checks nicht zum Schweigen
Abschnitt betitelt „leave() bringt die Checks nicht zum Schweigen“cluster.leave() lässt beide Checks registriert und fehlschlagend
zurück — /ready antwortet für den Rest der Prozesslaufzeit mit 503,
und genau das drainiert einen Pod, bevor er stoppt. Nichts im
Framework entfernt einen Readiness-Check auf dem Weg aus dem Betrieb:
Eine leere Aggregation gilt als gesund (siehe unten), Abmelden würde
also „alles besteht” und „es meldet nichts mehr” zur selben Antwort
machen. Ein späteres Cluster.join auf demselben System zieht das
alte Paar zurück und installiert sein eigenes; ein Prozess, der
verlässt und wieder beitritt, erholt sich also normal.
Die Check-Signatur
Abschnitt betitelt „Die Check-Signatur“type HealthCheckFunction = () => Promise<HealthCheckResult> | HealthCheckResult;
type HealthCheckResult = { name: string; // identifies the check in the response status: boolean; // true = healthy detail?: string; // human-readable note, usually on failure};Ein Check kann sync oder async sein. addLiveness /
addReadiness geben jeweils eine Unsubscribe-Funktion zurück —
rufe sie auf, um den Check wieder zu entfernen:
const remove = health.addReadiness(() => ({ name: 'warmup', status: warmedUp }));// ... once warm-up is permanently done:remove();Lang laufende Checks blockieren die Antwort, halte sie also schnell (unter einer Sekunde, idealerweise < 100 ms).
Liveness vs. Readiness
Abschnitt betitelt „Liveness vs. Readiness“| Probe | Was sie beantwortet | Was K8s bei Fehlschlag tut |
|---|---|---|
Liveness (/health) | „Würde ein Neustart dieses Prozesses helfen?” | Pod neu starten. |
Readiness (/ready) | „Soll ein Load Balancer diesem Pod Traffic schicken?” | Kein Routing mehr hierher (Pod läuft weiter). |
Ob ein Check Liveness oder Readiness ist, entscheidet welche
Methode du aufrufst — addLiveness oder addReadiness. Es sind
getrennte Listen; wer denselben Check für beides will, registriert
ihn zweimal.
Der Unterschied ist keine Geschmacksfrage — er entscheidet, was in welche Liste darf:
- Liveness darf von nichts außerhalb dieses Prozesses abhängen. Ein fehlschlagender Liveness-Check lässt den Pod töten; ein Check, der bei einem kurzen Aussetzer einer gemeinsam genutzten Datenbank rot wird, macht aus dem Ausfall einer Abhängigkeit einen flottenweiten Neustart-Sturm — und die Neustarts reparieren nichts. Deshalb ist der einzige Liveness-Check des Frameworks „das Actor-System ist nicht heruntergefahren”.
- Readiness ist genau der richtige Ort für das, was Liveness nicht anfassen darf — die Abhängigkeiten, die ein Request braucht. Sie nimmt den Knoten aus der Rotation und lässt ihn laufen; ein Datenbank-Aussetzer, ein sich füllender Cache oder ein Cluster-Rejoin gehören hierher.
Eine Readiness-Probe, die 200 liefert, obwohl der Knoten nicht erreichen kann, was er braucht, ist schlimmer als gar keine Probe: sie nimmt weiter Traffic an, den sie nicht bedienen kann.
Cluster-Readiness
Abschnitt betitelt „Cluster-Readiness“/ready meldet neben der Check-Liste ein Flag clusterReady. Es
ist das Ergebnis des cluster-membership-Checks selbst, aus der
Aggregation zurückgelesen statt separat berechnet — Flag und Check
können sich also nie widersprechen. Es ist false, bis der lokale
Knoten den Zustand Up erreicht, und immer true auf einem System,
das nie einem Cluster beigetreten ist:
GET /ready→ 503{ "status": "DOWN", "clusterReady": false, "checks": [ { "name": "cluster-membership", "status": false, "detail": "this node is 'joining' in its own member view, not 'up'" }, { "name": "cluster-transport", "status": true } ]}Das prozessinterne Gegenstück ist cluster.awaitReady() /
cluster.isReady() — dieselbe up-only-Mitgliedschaftssemantik, aus
Code awaited statt über HTTP abgefragt (siehe
Cluster-Bootstrap → Auf Readiness warten).
Die beiden sind bewusst nicht gekoppelt: awaitReady schaut allein auf
die Mitgliedschaft, nie auf das Readiness-Aggregat, denn app-registrierte
Checks bestehen unter Umständen erst nach Initialisierung, die nach
dem Bootstrap läuft — von innerhalb Cluster.bootstrap auf sie zu
warten könnte den Start verklemmen. /ready bleibt die Sicht des Load
Balancers.
Mehrere Checks
Abschnitt betitelt „Mehrere Checks“health.addReadiness(databaseCheck);health.addReadiness(cacheCheck);health.addReadiness(downstreamApiCheck);Alle Readiness-Checks laufen parallel, wenn /ready
aufgerufen wird. Die Antwort listet das Ergebnis jedes Checks:
{ "status": "DOWN", "clusterReady": true, "checks": [ { "name": "cluster-membership", "status": true }, { "name": "cluster-transport", "status": true }, { "name": "database", "status": false, "detail": "connection refused" }, { "name": "cache", "status": true }, { "name": "downstream-api", "status": true } ]}Die Aggregation ist UP, wenn der status jedes Checks true
ist.
Eine leere Check-Liste ist UP
Abschnitt betitelt „Eine leere Check-Liste ist UP“Keine Checks zu registrieren ist die Aussage, dass nichts die Probe
verriegelt. Ein System mit leerer Readiness-Liste antwortet also mit
200 — sonst wäre jeder einfache, clusterlose Dienst hinter
managementRoutes sein Leben lang 503. /ready, /health und der
gRPC-Health-Dienst wenden diese Regel über dieselbe exportierte
Funktion an:
import { isHealthy } from 'actor-ts/management';
isHealthy([]); // trueisHealthy([{ name: 'database', status: false }]); // falseSicher ist die Regel nur, weil nichts die Liste leert. Könnte eine
Komponente sich beim Herunterfahren abmelden, wären „gesund” und „es
meldet nichts mehr” nicht mehr unterscheidbar — eine Komponente, die
verstummt, meldet stattdessen status: false und bleibt registriert.
Nutze das Undo von addReadiness zum Ersetzen oder wenn das
beobachtete Objekt abgebaut wird, nie als Störungssignal.
Checks testen
Abschnitt betitelt „Checks testen“import { HealthCheckRegistry } from 'actor-ts/management';
it('readiness fails when the database is down', async () => { const health = new HealthCheckRegistry(); health.addReadiness(async () => ({ name: 'database', status: false, detail: 'mock' }));
const results = await health.checkReadiness(); expect(results).toEqual([{ name: 'database', status: false, detail: 'mock' }]);});Ein blankes new HealthCheckRegistry() ist im Unit-Test genau
richtig — es enthält nur, was der Test hineinlegt, ohne die Checks
des Frameworks. In Produktion führt der Weg immer über
healthChecksOf(system): eine zweite Registry ist eine zweite
Vorstellung von „ready”, und nur eine davon lesen die Endpunkte.
checkLiveness() / checkReadiness() führen die registrierten
Checks aus und geben das HealthCheckResult[] zurück, das die
Endpunkte aggregieren — praktisch, um Checks isoliert zu testen.
Ein Check, der wirft, wird abgefangen und als
{ name: 'unknown', status: false, detail } gemeldet. Der Helfer
isHealthy(results) ist dasselbe „alle bestehen”-Prädikat, das
auch die Endpunkte benutzen.
Timeouts
Abschnitt betitelt „Timeouts“Es gibt kein eingebautes Timeout pro Check — ein hängender
Check blockiert die gesamte /health- (oder /ready-)Antwort.
Für einen Check, der stehen bleiben kann, setze ihn gegen eine
eigene Deadline:
health.addReadiness(async () => { const status = await Promise.race([ slowProbe().then(() => true), new Promise<boolean>((r) => setTimeout(() => r(false), 2_000)), ]); return { name: 'downstream', status };});Ohne Absicherung reißt ein festhängender Check irgendwann das eigene Probe-Timeout von K8s (Standard 10 s) und löst einen Neustart aus. Halte Checks schnell.
Wie geht’s weiter
Abschnitt betitelt „Wie geht’s weiter“- Management-Überblick — das größere Bild.
- HTTP-Endpunkte — die vollständige Endpunkt-Referenz.
- Kubernetes-Deployment — die passende Probe-Konfiguration.
