Zum Inhalt springen
Deutsch

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.

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.

CheckArtRegistriert vonSchlägt fehl, wenn
actor-systemLivenessder Registry selbstsystem.terminate() durchgelaufen ist
cluster-membershipReadinessCluster.joindieser Knoten in seiner eigenen Member-Sicht nicht up ist
cluster-transportReadinessCluster.joinder 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 Up aus, er sieht also weiter ready aus, während nichts von dem ankommt, was er sendet. Das zu erkennen bräuchte einen quittierten Roundtrip.

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.

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

ProbeWas sie beantwortetWas 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.

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

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.

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([]); // true
isHealthy([{ name: 'database', status: false }]); // false

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

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.

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.