Der Fehler war 25 Stunden alt. Der Client war die ganze Zeit gesund.

kafka resilience mechanism reliability observability

Ein Produktions-Producer hat den größten Teil eines Tages jede Anfrage abgelehnt. Jedes Gesundheitssignal blieb die gesamte Zeit grün: Restart-Zähler 0, TCP-Sitzungen zu den Kafka-Brokern ESTABLISHED, Liveness- und Readiness-Probes bestanden. Der Fehler, den er zurückgab, war byte-identisch mit einem, den sein darunterliegender Client 25 Stunden zuvor erzeugt hatte, einschließlich des Zeitwerts. Der Client hatte sich binnen Sekunden nach dem Vorfall erholt. Der Wrapper hat es nie bemerkt.

Das Muster, das funktioniert

Wie viele Plattformen betreiben wir Kafka-Clients hinter einer gemeinsamen Wrapper-Bibliothek, und die Antwort des Wrappers auf Brokerverlust ist bewusst grob: Fehler zählen und, sobald eine Schwelle überschritten ist, panisch werden. Der Orchestrator startet den Prozess neu, und der Dienst kommt kalt und sauber zurück.

Es ist leicht, über Crash-to-Recover zu spotten, bis man einen Client in einem halb defekten Zustand debuggt hat, verklemmt in einer Ecke seiner Reconnect-Zustandsmaschine, die kein Test abdeckt und kein Runbook benennt. Der Panic verwandelt einen unbegrenzten Raum solcher Zustände in genau einen Zustand: einen frischen Prozess. Und er komponiert mit Maschinerie, die die Plattform ohnehin hat. Der Wrapper reimplementiert keine Erholung; er delegiert an den Neustart, den einen Erholungspfad, in den bereits jeder investiert hat.

Während des Vorfalls im Zentrum dieses Beitrags hat die Plattform ihre Broker gerollt, und die ausgelasteten Dienste mit diesem Wrapper verhielten sich exakt wie entworfen. Ein Stream-Processor berührt Kafka ständig, also erzeugt ein Broker-Roll mehrfach pro Sekunden Fehler; die Schwelle fällt in Sekunden, der Prozess wird panisch, der Orchestrator ersetzt ihn, und der Dienst ist binnen einer Minute sauber. Über ein Dutzend Dienste auf derselben Bibliotheksversion haben genau das getan, in der Sekunde, in der ihr Broker herunterfuhr. Crash-to-Recover hat sich diese Nacht bezahlt gemacht.

Warum Untätigkeit es bricht

Die Schwelle zählt Fehler, und Fehler entstehen nur, wenn der Dienst Arbeit verrichtet. Das ist der gesamte Defekt.

Ein Stream-Processor produziert Fehler schnell genug, um jede Schwelle zu erreichen. Ein anfragegetriebener Producer, ein Dienst, der Kafka nur berührt, wenn ihn etwas aufruft, und der ungefähr einmal täglich aufgerufen wird, produziert etwa einen Fehler pro Tag. Der Zähler überschreitet nie etwas. Und das Verhalten des Wrappers bei einer Schwelle, die nie fällt, ist nicht „weiter versuchen“. Es ist, den letzten Fehler zu verankern und ihn für jeden weiteren Versuch unbegrenzt zurückzugeben. Wir haben einen dabei 22 Stunden zusehen können, und er endete nur, weil ein Mensch den Pod neu startete.

Erholung proportional zum Traffic ist keine Erholung. Die Dienste, die sie am wenigsten brauchen, bekommen sie; die, die sie am dringendsten brauchen, nie. Der stille Dienst ist genau der Dienst, dessen Versagen unbemerkt bleibt, und der Mechanismus, der ihn retten soll, wird von der einen Sache ratenlimitiert, die ihm fehlt: Traffic.

Zweimal falsch

Der Weg zur richtigen Antwort lief über zwei falsche, beide sind es wert, behalten zu werden.

Die erste war Korrelation im Kostüm der Ursache. Die Broker-Logs zeigten einen Benutzer, der über Wochen flach tausendfach täglich die Authentifizierung verfehlte. Das Audit-Trail pinnte es fest: Ein automatisierter Aufräum-Job hatte die Credential dieses Benutzers gelöscht. Genau eine Credential war gelöscht worden, und genau ein Benutzer scheiterte. Ich habe die Credential wiederhergestellt, das Versiegen der Fehler beobachtet und den Vorfall als ursächlich geklärt gemeldet.

Es war nicht der Vorfall. Der scheiternde Dienst authentifizierte sich als ein völlig anderer Benutzer, was eine einzige Lektüre seiner Deployment-Konfiguration gezeigt hätte, bevor jemand etwas anfasste. Die Wiederherstellung war harmlos und hat einen tatsächlich defekten Client repariert, aber es war ein anderer Client. „Nur eine Sache ist fehlgeschlagen, und nur eine Sache hat sich geändert“ ist eine Eigenschaft Ihrer Suche, nicht des Systems. Einzigartigkeit ist ein Anlass zu verifizieren, kein Beweis.

Ein Detail hat die erste Runde aktiv in die Irre geführt: Der Dienst, dessen Logs alle lasen, war nicht der Dienst, der nach Kafka produzierte. Er hatte den Fehler des Producers im Körper einer HTTP-500-Antwort erhalten und als seinen eigenen geloggt, also trug ein Java-Log die Fehlermeldung einer C-Bibliothek. Der Melder und der Akteur waren verschiedene Dienste, und das Log, das alle durchsuchten, beantwortete eine andere Frage als die gestellte.

Die zweite falsche Antwort war die Version. Der gesunde Dienst lief auf einer neueren Client-Bibliothek als der festsitzende, also wirkte ein Versionssprung wie die Lösung, und ein Ticket wurde erstellt. Dann kam eine Flotteninventur: die eingebetteten Go-Build-Infos aus /proc/1/exe jedes Pods greppen, und man weiß exakt, welche Bibliotheksversion jeder Dienst fährt. Dutzende Dienste auf dem Wrapper, die meisten auf derselben Version wie der festsitzende, und über ein Dutzend dieser diesselbe-Version-Dienste waren während des Rolls panisch geworden und hatten sich selbst geheilt. Die Version trennt nichts.

Keine Richtung des üblichen Arguments überlebt das. „Ein anderer Dienst auf dieser Version war gesund“ entlastet nichts, und „der gesunde läuft auf neuerer“ überführt nichts. Teilen Sie die Flotte nach Traffic-Profil, bevor Sie nach einem Versionsdiff greifen. Wer das falsch macht, schickt jemanden zu einem Upgrade, das das Problem nicht beheben kann, und es war der Widerspruch eines Kollegen, mit einem Crash-Fenster aus den Logs des Clients, den meine Geschichte nicht unterbringen konnte, der es zutage förderte.

Das Beweisstück in den Ziffern

Das entscheidende Beweisstück stand die ganze Zeit im Fehlerstring.

Der Fehler des festsitzenden Producers war byte-identisch mit einem, den der darunterliegende Client während des Broker-Rolls erzeugt hatte, und zwar identisch bis zum Zeitwert, dem Fragment „nach N Millisekunden“. Der darunterliegende Client hatte seitdem nichts geloggt, weil er sich binnen Minuten normal neu verbunden hatte. Der Wrapper hatte das Fehlerobjekt gepuffert und einen Tag lang wiederverwendet.

Identische Zeitwerte können keine zwei unabhängigen Ereignisse sein. Eine verstrichene Zeit ist eine Messung, und zwei Messungen stimmen nicht auf die Ziffer überein. Sobald das sitzt, lautet die Frage nicht mehr „warum schlägt es fortwährend fehl“, sondern „warum wird der Fehler nie geräumt“, und diese Frage hat eine Antwort. Wenn eine Fehlermeldung eine Dauer oder einen Zähler trägt, differenzieren Sie diese Ziffern über die Vorkommen. Gleiche Ziffern bedeuten einen gepufferten Fehler, der wiederserviert wird. Es kostet nichts und dreht die Untersuchung um.

Wie ein verankerter Prozess aussieht

Nichts auf dem Standard-Dashboard unterscheidet festsitzend von untätig.

Der Restart-Zähler steht auf 0, weil der Prozess nach eigener Logik nie gerettet werden musste. Die Sockets zu den Brokern sind ESTABLISHED, weil der Transport sich tatsächlich erholt hatte; Socket-Zustand ist Evidenz von einer Schicht unter der Behauptung, und ich hatte die Sache einmal auf diese Evidenz hin wiederhergestellt genannt, was verfrüht war. Liveness und Readiness bestehen, weil keine der Probes einen Produce ausübt. Die Logs zeigen nichts, weil ein stiller Dienst nichts loggt, ob er defekt ist oder nur still. Und App-seitige Retries bekommen denselben gepufferten Fehler wenige Sekunden später, weshalb „fügen Sie Retries hinzu“ der falsche Reflex ist. Aus einem Cache kommen Sie nicht durch Retries heraus.

Was es schließt

Drei Fixes, in fallendem Wert.

Räumen Sie den Fehlerzustand bei Erfolg. Ein erfolgreicher Produce oder eine erfolgreiche Reconnect-Anbindung sollte den gepufferten Fehler pensionieren. Das Versäumnis genau dessen ist der gesamte Bug; alles andere ist Milderung.

Lassen Sie die Health-Probe den Produce-Pfad ausüben. Die Readiness-Behauptung eines Producers lautet „ich kann produzieren“, also genau das sollte die Probe testen. Ein Producer, der gesund meldet, während er jeden Produce ablehnt, lügt auf der einen Achse, die jemand abfragt.

Machen Sie die Schwelle zeit- wie zählbasiert. Ein stiller Dienst erholt sich dann irgendwann so wie ein ausgelasteter, über die Uhr statt über den Zähler.

Und akzeptieren Sie den wiederkehrenden Auslöser, denn er verschwindet nicht. Eine managed Kafka-Plattform rollt ihre Broker nach einem Wartungsplan, bei uns monatlich, und jedes Roll wiederholt dieses Experiment an jedem Client. Nach jedem Roll seitens des Providers sind die anfragegetriebenen Producer die zu prüfenden, und der einzige verlässliche Check ist, eine Anfrage durch sie zu schieben oder sie neu zu starten. Es gibt eine kleine Menge stiller Producer mit wenig Traffic, die sich vom Festsitzenden nicht ohne eine durchgeschobene Anfrage unterscheiden lassen; sie kommen auf die Liste für das nächste Roll.

Wo dies verallgemeinert, und die Falle

Jeder Erholungsmechanismus mit ratenbasiertem Auslöser hat diesen Fehlermodus. Circuit Breaker brauchen Fehler zum Auslösen. Watchdogs zählen Ereignisse. Verbindungs-Pools validieren beim Auschecken, also validiert ein Pool, den niemand auschecket, nie etwas. Health-Checks, die nur den Happy Path üben, bestehen ewig neben einem Defekten. Wenig Traffic macht jeden davon lautlos zum No-Op: nichts ist deaktiviert, nichts ist fehlkonfiguriert, der Mechanismus wartet schlicht auf Evidenz, die nicht kommt.

Zwei Regeln verlassen diese Seite. Wenn ein Erholungsmechanismus ratenbasiert ist, fragen Sie, was geschieht, wenn der Traffic die Rate nicht erreicht. Und wenn ein Fehler einen Zähler trägt, ist der Zähler Evidenz.

Die Falle ist, Erholung als Eigenschaft des Versagens zu behandeln. Sie ist eine Eigenschaft des Traffics. Ein Client, der sich nur erholt, während er versagt, erholt sich genau dann, wenn er oft genug versagt, und der Dienst, zu still, um oft genug zu versagen, bekommt nie die Gelegenheit. Er wird dort sitzen, auf jedem Dashboard gesund, und den Fehler von gestern servieren.

$ cat GITLAB .md
· 6 Min. Lesezeit

Die Registry antwortete in 45 Millisekunden. Der Build brauchte zwei Minuten länger.

Nach einer Server-Migration wurden Builds gegen eine selbst gehostete Paket-Registry zwei Minuten langsamer, und alle Berichte zeigten auf den Umzug. Jede fehlschlagende Anfrage war ein 500 nach 45 Millisekunden; die gesamte Regression war npms Retry-Backoff. Die Ursache war eine Zeile von 63.000, die noch auf einen Objektspeicher zeigte, den es nicht mehr gab, und der genau dafür geschriebene Check iterierte eine handgeschriebene Tabellenliste und übersah sie.

gitlab debugging migration reliability observability
$ cat CLICKHOUSE .md
· 8 Min. Lesezeit

136 Millionen PUTs für 17 GiB Daten

Objektspeicher rechnet pro Operation ab, und ein ClickHouse-Part auf einer S3-Disk ist nicht ein Objekt, sondern eines pro Spalte. Die Kosten eines Cold Tier sind also eine Funktion davon, wie viele Parts existieren, nicht wie viele Bytes sie halten, und jede Einstellung, die Merges aushungert, wird zu einer Zeile auf der Rechnung. Zwei Chart-Defaults haben genau das getan, und der Fix, der es beendet hat, war nie committet worden.

clickhouse s3 finops observability mechanism
$ cat GIT .md
· 7 Min. Lesezeit

84 Repositories verschwanden. Der Fix war mkdir.

S3 kennt keine leeren Verzeichnisse, und in einem vollständig gepackten git-Repository sind die refs-Verzeichnisse genau das: leer. Eine Datei-für-Datei-Sync hat jedes Objekt intakt übertragen und die zwei Verzeichnisse fallen gelassen, die git braucht, um etwas ein Repository zu nennen, also kamen 84 von 517 unlesbar zurück, während die Datenbank weiterhin Commits für sie verzeichnete. Die Health Checks der Migration blieben die ganze Zeit grün, weil keiner von ihnen je ein Repository öffnet.

git aws s3 gitlab mechanism