136 Millionen PUTs für 17 GiB Daten

clickhouse s3 finops observability mechanism

Die S3-Zeile in einem unserer AWS-Accounts hatte das ganze Jahr unter einem Euro im Monat gelegen. Dann waren es 733 $ in neun Tagen. Das ist der Account, in dem unser Observability-Stack läuft, und der ClickHouse-Cluster über diesem Bucket referenzierte dort 17 GiB Daten.

PostenKostenMenge
PUT-, COPY-, POST-, LIST-Requests662 $136 Millionen
Storage58 $2,6 TB-Monate
GET-Requests15 $38 Millionen

Storage machte acht Prozent der Rechnung aus. Das war kein Storage-Problem, und die Schätzung, nach der der Cold Tier 66 $ im Monat kosten würde, hatte die falsche Einheit bepreist.

Was ein Part auf S3 kostet

Eine ClickHouse-MergeTree-Tabelle speichert ihre Daten als Parts, und ein Part ist ein Verzeichnis. Darin liegen pro Spalte eine Datendatei und eine Mark-Datei, dazu eine Handvoll Metadatendateien. Auf einer lokalen Disk ist das ein Verzeichnislisting, über das niemand nachdenkt. Auf einer S3-Disk ist jede dieser Dateien ein Objekt, und jedes Mal, wenn ClickHouse einen Part schreibt, ist jede Datei ein PUT.

Unsere Traces-Tabelle hat 79 Spalten. Ein Part sind etwa 160 Objekte. Schreibt man die Spans eines Tages als ein paar hundert ganze Parts, ist die Request-Zahl ein Rundungsfehler. Schreibt man dieselben Bytes als 90 Millionen Fragmente, ist sie die ganze Rechnung. Die Daten sind in beiden Fällen identisch; wie oft man S3 gebeten hat, sie anzunehmen, ist es nicht.

Genau dabei hilft die Preisseite nicht. Sie nennt Cent pro GB-Monat, und ein Tier mit 2,6 TB kostet zu diesem Satz 66 $. Abgerechnet werden aber Operationen, und die Zahl der Operationen ist eine Eigenschaft davon, wie die Engine schreibt, nicht wie viel sie schreibt. Bei uns sorgten zwei Einstellungen dafür, dass sie schlecht schrieb, und keine davon war in der Values-Datei zu sehen.

Defekt eins: der Batch, der nie voll wurde

Die OpenTelemetry-Collectors, die ClickHouse befüllen, nutzen einen Batch-Processor, und das Chart lässt ihn bei 50.000 Zeilen oder nach einer Sekunde flushen, je nachdem, was zuerst eintritt. Bei unserem Volumen trat die Zeilengrenze nie zuerst ein. Der Timeout tat es, jede Sekunde, auf jedem Collector, also setzte jeder Collector ein INSERT pro Sekunde ab, und jedes INSERT wurde zu einem Part.

Gemessen auf einem Shard: 854.554 Parts an einem Tag erzeugt, im Schnitt 9 KiB groß. Den Timeout auf 30 Sekunden anzuheben, brachte den durchschnittlichen Part auf 14,5 MiB. Dieselben Daten, rund 1.600-mal weniger Objekte. Ein Nebeneffekt, der benannt werden sollte: Die kleinen Parts hatten außerdem das Insert-Limit für Parts pro Partition ausgelöst, sodass der Ingest aus demselben Grund, aus dem die Rechnung hoch war, immer wieder Batches abgelehnt hatte. Es war ein Verfügbarkeitsdefekt, der zufällig auch ein Kostendefekt war.

Zwei Dinge machten das schwer zu sehen. Erstens ist ein Chart-Default in einem Values-Diff nicht von „nicht gesetzt“ zu unterscheiden. Niemand hatte irgendwo timeout: 1s geschrieben, also zeigte es kein Diff je an, und es überlebte jeden Chart-Bump. Das Zweite ist schlimmer. Unsere eigenen Konfigurationsnotizen führten die Ein-Sekunden-Einstellung als bewusste Entscheidung, abgeglichen mit einer niedrigeren Umgebung, und taten den engeren Wert auf der Produktionsinstanz als Altlast ab. Eine falsche Entscheidung, die aufgeschrieben wurde, ist schwerer zu sehen als eine, die es nicht wurde, weil die Notiz die Frage beantwortet, bevor jemand sie stellt.

Defekt zwei: das Flag, das nebenbei die Retention abschaltete

Das Chart setzt prefer_not_to_merge = 1 auf dem S3-Volume der Storage Policy. Die Absicht ist nachvollziehbar. Kalte Daten zu mergen heißt, sie zu lesen und neu zu schreiben, und auf S3 bedeutet jedes Neuschreiben weitere Requests, also sagt der Default: kalte Parts in Ruhe lassen.

In dem ClickHouse-Versionsbereich, den wir betreiben, tut dieses Flag noch etwas Zweites. Löschen per TTL ist als Merge implementiert, und der Merge-Selektor filtert Parts auf Volumes mit diesem Flag heraus, also läuft DELETE TTL dort nie. Parts wanderten nach S3, wurden nie kompaktiert und liefen nie ab. Retention war konstruktionsbedingt unerreichbar.

Die Objektzahl erzählte die Geschichte, sobald wir hinsahen: 6 Millionen, 13 Millionen, 44 Millionen, 90 Millionen, und sie ging kein einziges Mal zurück. Die Abfrage, die es entschied, war ein Einzeiler gegen system.parts, gruppiert nach Disk-Name: ClickHouse referenzierte 17 GiB auf der S3-Disk. Der Bucket hielt 48 TB. Drei Größenordnungen an Objekten, die die Datenbank längst vergessen hatte und die nichts löschen würde.

Noch etwas brachte dieselbe Abfrage ans Licht. Die Traces-Tabelle hatte 185 MiB veralteten Rest auf dem Cold Tier, während Logs und Metriken korrekt getiert waren. Ein Signal hatte also seine kalten Daten vollständig verloren, und der Cold Tier als Ganzes sah gesund aus. Ein Cold Tier kann für eine Tabelle stillschweigend kaputt und für den Rest in Ordnung sein.

690x

Mittendrin gab es eine Diskussion darüber, ob S3 für kalte Daten überhaupt günstiger ist als EBS, und die Zahl, die sie beendete, kam aus dem Cost Explorer. Am schlimmsten Tag wuchs der Bucket um 33 TB. Der reale Ingest an diesem Tag lag unter 50 GiB. Das ist eine Write Amplification von rund 690, und das ist kein Preisproblem. S3 ist pro Byte deutlich günstiger als EBS. Es ist ein Merge-Loop-Problem, und kein Preis pro Byte übersteht eine Multiplikation mit 690.

Die allgemeine Fassung lautet so. Merges sind das, was viele kleine Parts in wenige große verwandelt. Also wird jede Ursache für ausgehungerte Merges in dem Moment, in dem Tiering aktiv ist, direkt zu Request-Kosten: eine Hot Disk ohne Spielraum, in den hinein gemergt werden könnte, ein Merge-unterdrückendes Flag auf dem kalten Volume, eine Batch-Rate, die der Konsolidierung davonläuft. Normalerweise verbuchen wir so etwas als Ingest-Health-Thema. Unter einer getierten Storage Policy sind es Kostenthemen mit derselben Ursache.

Die Folgerung ist eine, gegen die ich einen Monat vorher argumentiert hätte. Kürzen Sie den Spielraum der Hot Disk nicht, um ein längeres Retention-Fenster zu kaufen. gp3 für ein paar Cent pro GB-Monat zurückzugewinnen und dafür Request-Kosten zu riskieren, die zwei Größenordnungen höher liegen, ist ein Tausch, den man jedes Mal verliert.

Der Fix, und der, den niemand committet hat

Zwei Einstellungen haben es geschlossen. Der Batch-Timeout, von einer Sekunde auf dreißig. Und das Merge-Flag, von eins auf null auf dem S3-Volume, damit DELETE TTL überhaupt läuft. Die Kosten dafür, Merges auf kalten Daten zuzulassen, lagen am Ende bei etwa 3 $ im Monat, was nicht das Argument ist. Das Argument ist die Asymmetrie: Mit unterdrückten Merges kostet ein einziger Producer, der über das Hot Window hinausdriftet, Hunderttausende PUTs am Tag, dauerhaft; mit erlaubten Merges sind diese Parts binnen Stunden kompaktiert.

Das dritte Stück ist das, das jetzt die ganze Verteidigung trägt: ein Hot Window, das lang genug ist, dass Parts auf der lokalen Disk fertig gemergt sind, bevor sie wandern. Vollständig gemergt pendelt sich eine Tagespartition auf einem Shard bei ein paar Parts ein, also bewegt der Cluster etwa 15 Parts am Tag, rund 2.500 PUTs. Ungemergt lag der Churn desselben Tages über den Cluster bei 75.000 Parts, was bei 160 Objekten pro Part 12 Millionen PUTs aus einer einzigen Tabelle sind. Der Unterschied zwischen diesen beiden Zahlen besteht ausschließlich darin, ob das Mergen vor dem Umzug fertig war.

Dann der Teil, bei dem ich ehrlich sein will. Der 30-Sekunden-Batch-Timeout, der die 1.600-fache Reduktion gebracht hatte, war während des Vorfalls von Hand auf das laufende Helm-Release angewendet und nie committet worden. Er saß dort zusammen mit vier weiteren von Hand angewendeten Werten. Als wir vor dem nächsten Eingriff am Cold Tier den Infrastruktur-Diff laufen ließen, meldete das Tool, dass das Release von „failed“ zu „deployed“ wechselt, und sonst nichts, mit einer Zeile, nach der Dutzende Attribute unverändert seien. Es vergleicht gegen seinen eigenen State, nicht gegen den Cluster. Ein routinemäßiges Apply hätte den Fix ohne ein Wort der Warnung zurückgedreht und den Vorfall obendrauf auf das, wofür dieses Apply eigentlich gedacht war, noch einmal abgespielt.

Ein unter Druck live angewendeter Fix ist nicht fertig, bevor die Quelle ihn reproduziert. Die Prüfung ist mechanisch: die Values aus der Quelle rendern, die Values ziehen, die der Cluster tatsächlich hält, und beide strukturell diffen. Wenn die beiden nicht übereinstimmen, existiert das, was Sie gerettet hat, nur im Gedächtnis.

Wo dies verallgemeinert, und die Falle

Jeder getierte Store auf Objektspeicher hat diese Form. Loki-Chunks, Thanos- und Cortex-Blöcke, Iceberg- und Delta-Small-Files vor der Compaction, Druid-Segmente im Deep Storage. Objektspeicher bepreist Operationen, also sind die Kosten eine Funktion davon, wie viele Dinge man schreibt und neu schreibt. Engines auf Basis unveränderlicher Parts existieren, um Dinge neu zu schreiben; genau das ist ein Merge. Setzt man beides zusammen, ist jede Einstellung, die ändert, wie oft die Engine schreibt, eine Kosteneinstellung, ob sie so beschriftet ist oder nicht.

Bevor Sie einen Cold Tier aktivieren, messen Sie Parts pro Tag auf dem Hot Tier. Multiplizieren Sie mit Dateien pro Part. Bepreisen Sie die Requests. Und prüfen Sie dann, sobald er läuft, ob der Cold Tier tatsächlich löscht, denn eine Retention-Einstellung, die nicht ausgeführt werden kann, ist von einer funktionierenden nicht zu unterscheiden, bis der Bucket 60 TB groß ist.

Die Falle ist, einen Cold Tier nach GB-Monaten zu schätzen. Diese Zahl ist immer klein, sie ist immer die auf der Preisseite, und sie ist das Einzige, worum es bei dieser Rechnung nicht ging.

$ 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 KUBERNETES .md
· 7 Min. Lesezeit

Die Constraint war erfüllt. Die Zone war trotzdem leer.

Eine topologySpreadConstraint ist immer nur eine Aussage über die Population, die ihr Selector matcht, und ein vom Operator gesetztes Label kann verwandte Deployments unbemerkt in eine gemeinsame Zählung zusammenfassen. Drei Collector erfüllten jeder für sich ihre harte Zonen-Spread, während ihre Vereinigung eine Zone leer ließ, und die Standard-Reparatur konvergierte jedes Mal auf dieselbe falsche Antwort.

kubernetes scheduling opentelemetry finops mechanism
$ 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