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

gitlab debugging migration reliability observability

Einige Wochen nachdem wir unsere selbst gehostete GitLab-Instanz in einen neuen Account auf einen neuen Host verlegt hatten, meldete das Solutions-Team, npm-Installs gegen die Gruppen-Paket-Registry seien langsam geworden. Builds, die 1:40 bis 2:00 dauerten, lagen nun bei etwa 3:30. Ein npm install --verbose mit leerem Cache zeigte die Form: wiederholte 500er, jeweils gefolgt von einem späteren Erfolg. Das Timing passte zur Migration, und die Regression war konsistent, also kam der Bericht als “langsamer seit dem Umzug” herein. Ein vernünftiger Bericht. Und auf eine spezifische, lehrreiche Weise falsch.

Ein konstantes zusätzliches Intervall ist ein Client-Retry, kein Ressourcenproblem

Das Erste, was ich stoppte, war die fehlschlagende Anfrage selbst: 500, nach 45 Millisekunden. Jede einzelne.

Das ist die ganze Diagnose in einer Zahl. Ein Ressourcenproblem, eine langsame Disk, eine ausgehungerte CPU, eine unterdimensionierte Instanz, erzeugt Latenz, die unter Last schwankt und sich allmählich verschlechtert. Ein konstantes zusätzliches Intervall ist etwas anderes: eine feste Pause, die der Client zwischen den Versuchen einlegt. npms Fetch-Retry-Policy läuft einen Backoff-Fahrplan ab, grob T, T+10 Sekunden, T+70 Sekunden, bevor ein Endpoint aufgegeben wird. Addiert man diese Pausen zu den Anfragen, die wiederholt werden mussten, landet man fast exakt bei den gemeldeten zusätzlichen 110 Sekunden. Der Server war nie langsam. Die Builds waren langsam, weil der Client zwischen den Fehlschlägen höflich wartete.

Ich hätte eine Stunde auf gp3-IOPS und Instanzgrößen verwenden können und nichts gefunden, weil Disk und CPU in Ordnung waren. Die Lektion gilt über npm hinaus: Wenn sich “langsamer seit X” auf ein konstantes Intervall statt auf eine verlagerte Verteilung auflösen lässt, hört man auf, den Server zu messen, und liest stattdessen die Retry-Konfiguration des Clients. Erst Statuscodes, dann Timings, dann Dashboards.

Eine Zeile von 63.000

Jeder 500er trug dieselbe Exception:

RuntimeError: Object Storage is not enabled for Packages::Npm::MetadataCacheUploader

GitLab cached die npm-Metadaten jedes Pakets, das Packument, in einer Tabelle namens packages_npm_metadata_caches. Wie alles, was GitLab über CarrierWave speichert, trägt jede Zeile eine Store-Spalte: 1 heißt lokale Disk, 2 heißt Objektspeicher. Auf dieser Instanz war Object Storage global deaktiviert. Eine Zeile in dieser Tabelle behauptete file_store = 2, also fragte jede Anfrage nach den Metadaten dieses Pakets bei CarrierWave ein Backend an, das nicht existierte, und die Anfrage starb mit einem 500.

Wir hatten Object Storage einmal versucht, im Juli, und waren zurückgerollt. Der Rollback war auf eine bestimmte Weise gründlich und auf eine andere blind, das ist der nächste Abschnitt. Relevant hier ist das Audit: Ich fragte information_schema nach jeder Tabelle mit einer Store-Spalte, prüfte alle, 62 Tabellen mit rund 63.000 Zeilen, und fand exakt eine Zeile, die auf den Remote-Store zeigte. Diese einzelne Zeile war der gesamte Vorfall. Die 554 eigentlichen Paketdateien lagen alle lokal, weshalb Tarball-Downloads durchgehend 200 in 60 Millisekunden lieferten: Die Registry war wirklich gesund. Kaputt war nur der Lese-Pfad des Metadata-Caches, und nur für die Pakete, deren gecachte Zeile umgeschaltet worden war.

Der Check existierte; die Liste nicht

Der unangenehme Teil ist, dass dieser Fehler bereits dokumentiert war. Die Rollback-Prozedur für den Object-Storage-Versuch, im Juli geschrieben, endet mit der korrekten Assertion: Prüfe, dass Object Storage deaktiviert ist, und frage nach null Zeilen mit file_store = 2. Der Check existierte. Es war sogar der richtige Check.

Er scheiterte daran, dass der Schritt darüber eine handgeschriebene Liste von Modellnamen abarbeitete. Wer die Runbook schrieb, zählte die Tabellen auf, an die er sich als Datei-Speicher erinnerte: Uploads, Artifacts, Package Files und so weiter. Packages::PackageFile stand auf der Liste. Packages::Npm::MetadataCache nicht, weil im Juli niemand den Metadata-Cache als Datei-Store auf dem Schirm hatte, also wurde er nie geprüft, also überlebte seine eine umgeschaltete Zeile den Rollback, zeigend auf einen Bucket, der gerade gelöscht werden sollte.

Ein Check, der existiert, aber das Ding nicht abdeckt, das kaputt geht, ist schlimmer als kein Check, weil er ein Vertrauen erkauft, das er nicht verdient hat. Die Korrektur ist mechanisch: Assertions über Abdeckung müssen aus dem Schema enumerieren, nicht aus dem Gedächtnis. Eine information_schema-Query findet jede Kandidaten-Tabelle, unabhängig davon, woran sich irgendwer beim Schreiben des Runbooks erinnerte. Wenn ein Verifikationsschritt eine literale Liste von Tabellennamen enthält, ist diese Liste eine Vermutung und sollte auch so behandelt werden.

Die Datierung des Blindgängers

Die Zurechnung des Ausfalls brauchte drei Anläufe, und die ersten beiden waren in entgegengesetzte Richtungen falsch.

Die Zeile lag sieben Wochen vor dem September-Umzug, und in keinem der beiden Accounts existierte ein Object-Storage-Bucket, also lautete meine erste Antwort “wahrscheinlich schon vor dem Umzug kaputt”. Dann lieferte der Background-Job die vollständige Zeile, mit einem last_downloaded_at drei Minuten nach updated_at. Dieser Timestamp wird nur auf dem Erfolgspfad geschrieben, der Cache war also nachweislich irgendwann ausgeliefert worden, und ich korrigierte zu “der Umzug ist wohl doch der Auslöser”.

Das Journal entschied. Das Object-Storage-Experiment und sein Revert sind auf den 24. Juli datiert. Der letzte erfolgreiche Read der Zeile war der 22. Juli, zwei Tage früher, als sie noch lokal war. Die Sequenz also: Die Juli-Migration schaltete die Zeile auf remote, der Rollback übersah sie, der Bucket wurde gelöscht, und die Zeile lag sieben Wochen inert, weil eine file_store = 2-Zeile nur detoniert, wenn etwas genau dieses Objekt liest. Niemand zog dieses Paket bis zu dieser Woche. Der September-Umzug war unschuldig; er trug nur eine Datenbank, die den Blindgänger bereits enthielt. Die Korrelation des Teams war ehrlich und falsch, und ich hatte sie verstärkt, bevor ich prüfte.

Ein Detail verdient einen eigenen Satz: Die Store-Umschaltung lief über update_column, das Timestamps überspringt. Das updated_at der Zeile zeigt also auf unzusammenhängende Aktivität und führt jeden in die Irre, der die Änderung allein aus der Zeile datieren will. Wenn ein Timestamp zählt, prüfe zuerst, wie die Spalte geschrieben wurde, bevor du ihm traust.

Der gefährliche Teil war das Löschen

Der Fix war eine Zeile, also war der Fix ein Delete. Dieser Delete hatte seine eigene Falle: CarrierWave-Modelle feuern bei destroy einen Removal-Callback, und dieser Callback versucht, die Datei aus ihrem konfigurierten Backend zu löschen, genau die Operation, die den Fehler wirft, den wir beseitigen wollten. Die Zeile musste mit delete_all entfernt werden, das Callbacks komplett überspringt.

Drei Schichten Backup gingen voran: ein RDS-Snapshot, der tatsächlich die Zeile hält, Snapshots beider EBS-Volumes und ein Dump der Zeile selbst mit einem fertigen INSERT-Statement. Alles verifiziert, bevor etwas angefasst wurde. Die API liest den Cache über metadata_cache&.file, ein fehlender Row schließt also auf den Regenerationspfad kurz: Das Packument wird aus den Paketdateien neu gebaut und frisch gespeichert.

Die Verifikation war der befriedigende Teil. Der regenerierte Cache landete bei file_store = 1 auf lokaler Disk, mit exakt 6.786 Bytes, identisch in der Größe zum Juli-Cache, was gute Evidenz ist, dass der Inhalt rundreiste statt nur aufzuhören zu fehlschlagen. Instanz-weit danach: null 5xx, null Zeilen im Remote-Store, Builds wieder unter zwei Minuten.

Was man mitnimmt

Zwei Regeln haben das hier überlebt. Wenn eine Regression als konstantes zusätzliches Intervall auftaucht, lies zuerst das Retry-Verhalten des Clients, bevor du die Ressourcen des Servers anfasst; ein 500er nach 45 Millisekunden gefolgt von zwei Minuten Verzögerung ist der Client auf seinem Backoff, nicht der kämpfende Server. Und wenn ein Verifikationsschritt Abdeckung aus dem Gedächtnis enumeriert, ersetze die Liste durch eine Schema-Query; in der Tabelle, an die sich niemand erinnerte, sitzt bereits der nächste Vorfall.

$ cat KAFKA .md
· 8 Min. Lesezeit

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

Eine Kafka-Client-Bibliothek, die sich erholt, indem sie Fehler zählt und ab einer Schwelle panisch wird, ist Erholung proportional zum Traffic: Stream-Processoren erreichen die Schwelle in Sekunden, stille anfragegetriebene Producer nie, also verharren sie beim letzten Fehler und servieren ihn unbegrenzt, während jedes Gesundheitssignal grün bleibt. Der Hinweis steckt in den Ziffern des Fehlers selbst: Ein identischer Zeitwert über mehrere Vorkommen ist ein gepuffertes Ereignis, kein wiederkehrendes Versagen.

kafka resilience mechanism reliability observability
$ 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
$ 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