Skip to content
Health Checks, die lügen: sechs Arten, wie Ihr Dienst „bereit“ sagt, wenn er es nicht ist
← ← Zurück zu Gedanken Cloud

Health Checks, die lügen: sechs Arten, wie Ihr Dienst „bereit“ sagt, wenn er es nicht ist

Ein Health Check ist ein Vertrag zwischen Ihrer Anwendung und dem, was ihr Traffic zuweist. Der Router fragt „kannst du einen Request annehmen?“, und die Anwendung antwortet. Ist die Antwort falsch, tut der Router genau das, was ihm gesagt wurde: Er schickt Nutzer zu einer Instanz, die sie im Stich lässt, oder er hört auf, Nutzer zu Instanzen zu schicken, die in Ordnung waren. Beides sind Ausfälle, und beide wurden von einem Health Check verursacht, der technisch funktionierte.

Uns haben sechs verschiedene Spielarten davon gebissen, auf App Runner, ECS, Lambda hinter einem API-Gateway und einem Kubernetes-Cluster. Das hier ist der Katalog, wie jede von außen aussieht, und die Health-Route, bei der wir gelandet sind.

Zwei Fragen, nicht eine

Die Wurzel der meisten dieser Probleme ist, dass „gesund“ zwei verschiedene Fragen in einen Endpunkt presst.

Liveness: Lebt der Prozess und sollte man ihn in Ruhe lassen? Die Antwort ist nur dann „nein“, wenn der Prozess festhängt: Deadlock, Speicher voll, Endlosschleife. Die richtige Reaktion auf einen Liveness-Fehler ist, die Instanz zu beenden und zu ersetzen.

Readiness: Kann diese Instanz jetzt gerade Traffic annehmen? Die Antwort ist „nein“, solange sie noch startet, Caches wärmt, auf einen Connection Pool wartet oder, vorübergehend, überlastet ist. Die richtige Reaktion ist, nicht mehr zu ihr zu routen und kurz darauf erneut zu prüfen. Nicht, sie zu beenden.

Kubernetes macht diese beiden Probes explizit. App Runner und ECS geben Ihnen einen Health Check und nutzen ihn für beide Zwecke, was in Ordnung ist, solange Sie wissen, dass ein fehlschlagender Check die Instanz ersetzt, und den Check so gestalten, dass er nur fehlschlägt, wenn Ersetzen die richtige Antwort ist.

Die sechs Lügen

1. TCP ist offen, also sind wir bereit. Der Standard auf App Runner und auf ECS-Target-Groups ist ein TCP-Check: Nimmt der Port eine Verbindung an, ist die Instanz gesund. Node öffnet seinen Port in der ersten Sekunde des Starts, bevor das Framework Routen geladen hat, bevor der Datenbank-Pool existiert, bevor Caches warm sind. Die Instanz kommt in Rotation und bedient die ersten dreißig Sekunden Traffic langsam oder mit Fehlern. Wir haben darüber auf App Runner geschrieben: Die Lösung ist ein HTTP-Check auf einer Route, die 503 liefert, bis der Start tatsächlich abgeschlossen ist.

2. Ein statisches 200. Der gegenteilige Fehler. app.get('/health', (req, res) => res.send('ok')). Es schlägt nie fehl. Eine Instanz, deren Datenbank-Pool erschöpft ist, deren Event Loop sekundenlang blockiert, deren Platte voll ist, meldet ewig gesund, und der Router schickt ihr weiter Traffic, während die Instanz daneben, die in Ordnung ist, ebenfalls ihren Anteil bekommt. Das ist der Health Check, den die meisten Codebasen haben, weil er der erste war, den jemand geschrieben hat.

3. Der tiefe Check, der beim Ausfall eines anderen scheitert. Die Überkorrektur von 2: Die Health-Route pingt Datenbank, Cache, Queue und eine Drittanbieter-API und liefert 503, wenn eines davon scheitert. Dann hat die Drittanbieter-API eine schlechte Stunde, jede Instanz meldet ungesund, die Plattform ersetzt alle, die Ersatzinstanzen melden ebenfalls ungesund, und Sie haben ein beeinträchtigtes Feature in einen Totalausfall eines Dienstes verwandelt, der jeden Request hätte bedienen können, der diese API nicht brauchte. Readiness darf nicht von Dingen abhängen, die die Instanz durch einen Neustart nicht beheben kann.

4. Der Check trifft einen anderen Codepfad als der Traffic. Die Health-Route ist auf einem separaten internen Port registriert, oder vor dem Middleware-Stack, oder auf einem Pfad, der vom Router ausgenommen ist, durch den echte Requests laufen. Sie besteht, während der echte Pfad kaputt ist: ein schlechter Middleware-Deploy, ein Router, der nicht lädt, eine TLS-Fehlkonfiguration auf dem öffentlichen Port. Der Check muss durch so viel vom echten Pfad gehen wie möglich, ohne echte Arbeit zu tun.

5. Das gecachte Ergebnis. Um den Check billig zu machen, cacht jemand sein Ergebnis für sechzig Sekunden. Eine Instanz, die in Sekunde eins kaputtging, meldet weitere neunundfünfzig gesund, und ein clusterweites Problem ist eine Minute lang unsichtbar, in der jede Instanz im Chor lügt. Cachen Sie die teuren Teilprüfungen, wenn es sein muss, mit kurzer TTL, aber nie das Gesamtergebnis.

6. Der Check, der blockiert wird. Die Health-Route hat keinen User-Agent, kommt aus einem internen IP-Bereich und trägt keinen Auth-Header, sodass die NoUserAgent_HEADER-Regel der WAF, der Rate-Limiter oder die Auth-Middleware sie ablehnt und die Plattform ein 403 als ungesund liest. Jede Instanz wird in einer Schleife ersetzt, bis jemand die WAF-Metriken bemerkt. Health Checks brauchen einen expliziten Bypass in allem, was vor der App sitzt, beschränkt auf die Quelle der Plattform und nur den Health-Pfad.

1 · TCP offen ≠ bereitPort öffnet in Sekunde 1Routen und Pools bereit in Sekunde 40kalte Instanz liefert 30 s Fehler 2 · ein statisches 200res.send('ok'), immerPool erschöpft, Loop blockiert: trotzdem 200kaputte Instanz behält ihren Traffic-Anteil 3 · tiefer Check auf einen Anbieter503, wenn die Drittanbieter-API down istjede Instanz scheitert gleichzeitigein beeinträchtigtes Feature wird Totalausfall 4 · ein anderer Codepfadinterner Port, vor der Middlewareechter Pfad kaputt, Check besteht trotzdemgesund auf dem Papier, kaputt in Produktion 5 · ein gecachtes ErgebnisGesamtergebnis 60 s gecachtgeht in Sekunde 1 kaputt, lügt 59die ganze Flotte lügt im Chor 6 · von WAF oder Auth blockiertkein User-Agent, kein Auth-Header → 403Plattform liest 403 als ungesundgesunde Instanzen in einer Schleife ersetzt

Die Health-Route, bei der wir gelandet sind

Eine Route, /api/health, mit einem Query-Parameter, der die Tiefe wählt, weil der Check der Plattform und der Check eines Menschen verschiedene Dinge wollen.

// app/api/health/route.ts
const startedAt = Date.now();
let warm = false;                       // vom Warm-up-Task auf true gesetzt, nachdem Caches geladen sind

export async function GET(req: Request) {
  const deep = new URL(req.url).searchParams.get('deep') === '1';

  // Readiness: nur Dinge, die ein Neustart beheben würde
  if (!warm) return json({ status: 'starting', uptimeMs: Date.now() - startedAt }, 503);
  if (eventLoopLagMs() > 1000) return json({ status: 'wedged' }, 503);

  const checks: Record<string, string> = { app: 'ok', release: process.env.RELEASE_SHA ?? 'dev' };

  if (deep) {
    // nur informativ: macht die Antwort nie zu einem 503
    checks.db = await timed(() => db.query('select 1'), 500);
    checks.cache = await timed(() => cache.ping(), 200);
    checks.payments = await timed(() => payments.ping(), 800);
  }
  return json({ status: 'ok', ...checks }, 200);
}

Die Eigenschaften, die zählen:

  • Sie liefert 503 aus genau zwei Gründen, beide durch einen Neustart behoben: Die Instanz ist noch nicht warm, oder der Event Loop hängt. Das ist Readiness und Liveness in einer Route, mit Fehlerbedingungen, die so gewählt sind, dass „diese Instanz ersetzen“ immer die richtige Reaktion auf ein 503 ist.
  • Abhängigkeiten werden gemeldet, nie erzwungen. Mit ?deep=1 prüft die Route Datenbank, Cache und Zahlungsanbieter, jeweils mit Timeout, und legt das Ergebnis in den Body. Ein Mensch oder ein Dashboard liest es. Die Plattform ruft die tiefe Variante nicht auf, sodass ein Anbieterausfall die Flotte nicht ausschalten kann. Ist die Datenbank nicht erreichbar, sagt die Instanz trotzdem 200, weil ein Neustart die Datenbank nicht repariert und die Requests, die sie nicht brauchen (statische Seiten, gecachte Lesezugriffe), weiter funktionieren.
  • Sie läuft durch den echten Pfad. Gleicher Port, gleicher Middleware-Stack, gleicher Router wie Nutzer-Traffic. Die Middleware hat für diesen Pfad einen expliziten frühen Return, der Auth und Rate-Limiter überspringt, sonst nichts, und die WAF hat ein Scope-Down, das ihn für die Health-Check-Quelle der Plattform von der User-Agent-Regel ausnimmt.
  • Nichts wird gecacht. Die Route ist billig: zwei In-Memory-Lesezugriffe für die flache Version. Die tiefe Version macht echtes I/O und wird von Menschen aufgerufen, selten.
  • Sie meldet das Release. Der Body trägt den Git-SHA, das ist, was das Deploy-Skript nach einem Rollout prüft und was man in den Incident-Kanal einfügt.
Was die Route antwortet, und warum BedingungAntwortwarum wärmt noch Caches und Pools503 · startingPlattform wartet vor dem Routing; nichts zu ersetzen Event-Loop-Lag über 1 s503 · wedgedein Neustart ist die Lösung; die Plattform machen lassen Datenbank nicht erreichbar200 · db: failedein Neustart behebt es nicht; gecachte Lesezugriffe laufen Zahlungsanbieter down200 · payments: failedein Feature beeinträchtigt, nicht der ganze Dienst Check kommt ohne Auth oder User-Agentexplizit ausgenommenin der Middleware und in der WAF, auf diesen Pfad beschränkt

Plattform für Plattform

Plattform Was sie unterstützt Was zu konfigurieren ist
App Runner Ein Check, TCP oder HTTP, Fehler = ersetzen HTTP auf /api/health, Intervall 10 s, Unhealthy-Schwelle 3, Timeout 5 s
ECS auf Fargate hinter einem ALB Container-Health-Check (Task-Definition) + Target-Group-Health-Check Beide HTTP auf derselben Route; die Target Group steuert den Traffic, der Container-Check löst das Ersetzen aus
Lambda hinter API Gateway Kein Health Check; jeder Aufruf ist seine eigene Instanz Provisioned Concurrency für das Warm-up-Problem; ein synthetischer Canary, der /api/health?deep=1 aufruft, für das Sichtbarkeitsproblem
Kubernetes Getrennte Liveness- und Readiness-Probes, plus eine Startup-Probe Readiness auf /api/health, Liveness auf derselben mit längerer Periode, Startup-Probe mit großzügiger Fehlerschwelle, damit langsame Starts nicht beendet werden

Auf jeder davon ist die wichtigste Zahl die Unhealthy-Schwelle mal Intervall: So lange bedient eine kaputte Instanz weiter, bevor sie herausgenommen wird. Zehn Sekunden mal drei sind dreißig Sekunden schlechter Requests. Wir nehmen lieber 5 × 2 und zahlen die zusätzlichen Check-Aufrufe, die kostenlos sind.

Die Kurzfassung

Ein Health Check ist ein Versprechen. Lassen Sie ihn nur fehlschlagen, wenn ein Neustart das Heilmittel ist, melden Sie alles andere im Body für Menschen, führen Sie ihn durch den echten Request-Pfad, nehmen Sie ihn explizit von allem aus, was diesen Pfad bewacht, und cachen Sie die Antwort nie. Dann setzen Sie Intervall und Schwelle so, dass eine Lüge, wenn sie doch passiert, zehn Sekunden dauert und nicht eine Minute.

Wenn Ihre Health-Route ein res.send('ok') ist, helfen wir Ihnen, sie zu ersetzen. Es ist ein Nachmittag, und meist der Nachmittag, der das Ticket „zufällige 502 nach dem Deploy“ beendet.