Mein Pod war gesund. Redis nieste. Mein /health-Endpoint prüfte Redis, fing den Timeout ab und antwortete 503. Der Kubelet las daraus „der Prozess ist kaputt“ und startete ihn neu. Der Prozess war nicht kaputt. Er stand nur neben etwas Kaputtem.
Mit zehn Repliken prüfen alle zehn /health-Endpoints Redis. Redis hakt für zwei Sekunden, und Kubernetes erschießt den ganzen Schwarm, ein Health-Poll nach dem anderen. Das ist nicht Kubernetes, das überreagiert. Das ist dein Health-Endpoint, der petzt.
Also habe ich go-health gebaut — drei Probes, die den Unterschied kennen zwischen kaputt sein und neben etwas Kaputtem stehen.
Drei Fragen, drei Endpoints
Kubernetes stellt deinem Pod drei Fragen. Liveness — „lebst du noch?“ Readiness — „kannst du Traffic bedienen?“ Startup — „bist du mit dem Booten fertig?“ Die meisten Codebasen beantworten alle drei mit demselben Handler. Das ist, als würde man „bist du tot?“ und „ist die Datenbank erreichbar?“ mit demselben Achselzucken beantworten.
go-health trennt sie:
probe := health.New(injector,
health.WithCriticalServices("database", "redis"),
health.WithVersion("1.0.0"),
)
mux := http.NewServeMux()
probe.RegisterRoutes(mux, health.DefaultRoutes())
/healthz, /readyz, /startupz. Liveness fasst deine Abhängigkeiten nie an — sie antwortet in Mikrosekunden, immer 200, denn „lebe ich noch“ ist keine Frage über Redis.
Readiness prüft, was du als kritisch markiert hast. Startup prüft einmal, rastet auf 200 ein und fragt nie wieder — Booten ist eine Phase, kein Lebenszustand. Und Kubernetes kann so hart pollen, wie es will; deine Abhängigkeiten sehen höchstens eine Prüfung pro Sekunde.
Warn ist kein Todesurteil
Hier der Teil, auf den ich am stolzesten bin. metrics-exporter stirbt. Nichts, was du auslieferst, hängt davon ab. Der alte /health sagt 503, der Pod startet neu — und jetzt ist der Exporter immer noch kaputt und dir fehlt auch noch ein Pod. Eine Beerdigung für eine Komponente, die niemand benutzt.
go-health antwortet stattdessen 200 mit "status": "warn" im Body:
{
"status": "warn",
"checks": {
"database": { "status": "pass" },
"metrics-exporter": { "status": "warn", "error": "connection refused" }
}
}
Der Pod bleibt in Rotation. Nur Services, die du ausdrücklich als kritisch markiert hast, dürfen dich umbringen. Dein Health-Endpoint wechselt vom Informanten zum Diplomaten.
Meinungen, fest verdrahtet
- Nur GET. Ein HEAD-Request bekommt ein 405 mit dem Body
health probes only accept GET. Keine Fehlermeldung. Eine Tatsache. - Höfliches Herunterfahren.
Shutdown()stellt Readiness sofort auf 503, damit Load Balancer keinen Traffic mehr schicken, während Liveness auf 200 bleibt, damit Kubernetes dich nicht mitten im Drain neu startet. Sterben mit Anstand. - Du kannst deine Bootzeit fälschen.
WithBootTimeüberschreibt den Zeitstempel für die Uptime-Berechnung. Nützlich für Tests. Auch nützlich fürs Ego. - Das Kubelet kann deine Abhängigkeiten nicht bombardieren. Antworten liegen hinter einem Atomic Pointer und werden einmal pro Sekunde aufgefrischt. Zehn Polls pro Minute kosten zehn Memory-Lesevorgänge, nicht zehn Ausflüge zu Postgres.
Was ich absichtlich nicht gebaut habe
- Logging. Die Library importiert kein Logging-Package. „Eine Library darf keine Logging-Entscheidungen für die Host-Anwendung treffen.“ Deine Logs, deine Regeln.
- Vom Client einstellbare Timeouts. Irgendwann fragt jemand nach
?timeout=50msals Query-Parameter. Das ist ein DoS-Verstärker mit Query-String. Im Repo gibt es ein Design-Dokument, das erklärt, warum nicht; die Kurzfassung ist: nein. - Ein UI. go-health bleibt eine einzige Abhängigkeit (samber/do) und spricht JSON. Wer nur Probes will, zahlt null Kosten für Pixel, die er nie sieht.
Die Statusseite, die deine Binary verdient
Weil grünes JSON keine Persönlichkeit hat, habe ich zusätzlich go-health-dashboard gebaut. Dasselbe Probe, zwei Zeilen mehr, und dein Service liefert eine Live-Statusseite aus:
dash := dashboard.New(probe,
dashboard.WithTitle("My Service"),
)
dash.RegisterRoutes(mux)
Content Negotiation macht die Trennung: das Kubelet bekommt JSON, ein Browser bekommt HTML — grüne, gelbe und rote Karten nach Schwere gruppiert, per SSE aktualisiert, sobald ein Check kippt. Trend-Sparklines, Dark Mode, ein Prometheus-Endpoint und ein grünes Herz als Favicon. Das Banner sagt „All Systems Operational“ — und zum ersten Mal in deinem Leben ist das keine Lüge.
Die Example-App liefert ein fake redis mit, das alle fünfzehn Sekunden flappt — du kannst zusehen, wie das Badge hin- und herspringt wie ein Herzmonitor mit Bindungsproblemen.
Ein Design-Entscheid, zu dem ich stehe: Unbekannte Status werden als Fail geplottet. Die Trendlinie fällt bei allem, was nicht nachweislich gesund ist. Optimismus ist was für Landingpages.
Und ja — das Dashboard läuft auf go-datastar und go-sse, aus dem letzten Post. Ich baue ein Universum, einen Health-Endpoint nach dem anderen.
Wo es steht
go-health ist bei v0.0.2 mit einer Runtime-Abhängigkeit. Das Dashboard ist bei v0.3.1 und braucht GOEXPERIMENT=jsonv2 zum Bauen — experimentelles Go, ich weiß; ich habe Frieden damit geschlossen. Im README steht immer noch „v0.0.1 alpha“, weil ich v0.0.2 schneller ausgeliefert habe als meine eigene Doku — eine Library, die die Gesundheit von allem meldet, nur nicht von sich selbst. Beide haben ein FEATURES.md, „generiert durch das Studium des tatsächlichen Codes, nicht der Marketing-Versprechen“ — die einzige Art von Feature-Liste, die ich zu schreiben weiß.
Lies den Code. Deine Pods waren immer gesund. Dein Health-Endpoint hatte nur ein großes Maul.