1 Punkte von GN⁺ 2024-11-14 | 1 Kommentare | Auf WhatsApp teilen
  • Bei der Überprüfung des Kafka-kompatiblen Streaming-Systems Bufstream 0.1.0~0.1.3 wurden zwei Verfügbarkeitsprobleme und drei Sicherheitsprobleme in Bufstream selbst gefunden; mit Stand 0.1.3 sind alle fünf behoben
  • Die Tests basierten auf dem Java Kafka Client 3.8.0 sowie bestehenden Jepsen-Tests für Kafka/Redpanda und verwendeten sicherheitsorientierte Einstellungen wie acks = all, enable.idempotence = true, enable.auto.commit = false und read_committed
  • Zu den Problemen in Bufstream gehörten Stillstände bei Consumer und Producer, eine falsche Antwort mit Offset 0, verlorene Transaction Commits sowie bestätigte Schreibverluste durch einen Bug bei der Filterung der Antwortgröße der Fetch API
  • Im Verlauf der Untersuchung traten auch beim Kafka Java Client und beim Kafka-Transaktionsprotokoll Probleme zutage, darunter unbegrenztes Blockieren von Consumer.close(), unvorhersehbare Consumer-Offsets sowie aborted read·lost write·torn transaction
  • Jepsen kommt zu dem Schluss, dass das Kafka-Transaktionsprotokoll die Reihenfolge von Client-Anfragen und Transaktionsnummern nicht explizit garantiert, wodurch die Transaktionssicherheit von Kafka und Kafka-kompatiblen Systemen bei Verwendung des offiziellen Java-Clients verletzt werden kann

Architektur von Bufstream und Umfang der Überprüfung

  • Kafka ist ein Streaming-System mit replizierten, geshardeten Append-only-Logs, und Bufstream ist eine Kafka-Alternative, die in Cloud-Umgebungen Data Governance und Kosteneffizienz priorisiert
  • Bufstream bietet wie Kafka Topics und Partitions und funktioniert mit Standard-Kafka-Clients
    • Producer hängen Records mit producer.send() an
    • Consumer werden mit consumer.assign() oder consumer.subscribe() an Partitions gebunden und lesen Records mit consumer.poll()
    • Consumer Groups teilen sich die Verarbeitung von Records aus einer Menge von Topics
  • In Verbindung mit Buf Schema Registry können Protocol-Buffer-Records geprüft werden, um Record-Validierung, Field-level Access Control und die Umwandlung von Datenformaten für andere Systeme zu unterstützen
  • Anders als Kafka, das lokale Festplatten und ein eigenes Replikationsprotokoll verwendet, schreibt Bufstream Daten direkt in Object Storage
    • Ziel ist Kostensenkung durch Nutzung der Kostenstruktur des Replikationsverkehrs von Object Storage
    • Bufstream-Nodes können als zustandslose, automatisch skalierte VMs betrieben werden
  • Bufstream besteht aus drei Subsystemen
    • agent: zustandsloser Dienst, der die Kafka API bereitstellt
    • object store: speichert Record-Chunks und stellt sie Readern bereit
    • coordination service: verwendet derzeit etcd und legt fest, welche Chunks committed wurden und in welcher Reihenfolge Records stehen
  • Mit Stand Oktober 2024 war Bufstream nur an einige Kunden ausgerollt, und die Dokumentation bewarb es als „drop-in replacement“ für Apache Kafka sowie als kompatibel mit Kafka Transactions und Exactly-once Semantics, machte jedoch nur wenige konkrete Aussagen zur Sicherheit

Client-Konfiguration und Annahmen zu Transactions

  • Wie bei früheren Tests Kafka-kompatibler Systeme passte Jepsen die Client-Konfiguration an, um ein sichereres Verhalten zu erreichen
  • Producer-Konfiguration

    • Verwendet wird das Standard-acks = all
    • In Bufstream kann acks = 0 Schreibvorgänge bestätigen, ohne auf Storage zu warten, wodurch committed Writes verloren gehen können
    • acks = 1 und acks = all blockieren, bis Bufstream von einer dauerhaft persistenten Speicherung ausgehen kann
    • Um doppelte Appends durch automatische Wiederholungen des Kafka-Producers zu vermeiden, wurde der Standardwert enable.idempotence = true verwendet
  • Consumer-Konfiguration

    • Da es Dokumentationen gibt, wonach Auto-Commit zu Datenverlust führen kann, wurde meist enable.auto.commit = false verwendet
    • Wenn kein committed Offset vorhanden ist, startet das Standard-auto.offset.reset beim neuesten Offset und garantiert daher keine At-least-once Delivery
    • Damit der Consumer das gesamte Log beobachten kann, wurde auto.offset.reset = earliest verwendet
    • Kafka-Transactions bestehen aus einer vom Producer gesendeten Menge von Records und einer Map maximaler Offsets pro vom Consumer gepollter Partition
    • Erst wenn eine Transaction committed wird, sind die gesendeten Records dauerhaft gespeichert und schließlich für read_committed-Consumer sichtbar; auch der committed Offset steigt dann auf mindestens den in der Transaction angegebenen Offset
    • Wird eine Transaction nicht committed, rückt der committed Offset nicht vor, und die Sichtbarkeit der Writes kann von der Consumer-Konfiguration abhängen
    • Wenn ein read_uncommitted-Consumer Werte aus einer abgebrochenen Transaction liest, wird dies als aborted read(G1a) klassifiziert
    • Die Kafka-Dokumentation sagt zwar, dass read_committed G1a verhindert und in gewissem Maß garantiert, dass entweder alle Writes einer Transaction sichtbar sind oder keine, doch in Jepsens Tests von Kafka, Redpanda und Bufstream wurden Write Cycles (ein G0-ähnliches Phänomen) und einige Formen von G1c beobachtet

Testdesign

  • Jepsen testete Bufstream 0.1.0 bis 0.1.3 sowie mehrere Release-Candidate-Builds
  • Der Test-Harness verwendete den Bufstream test harness, die Jepsen testing library und den Java Kafka Client 3.8.0
  • Ausführungsumgebung

    • Es wurden 3 bis 5 Debian-Bookworm-Nodes sowohl in LXC-Containern als auch auf EC2-VMs verwendet
    • Ein Node wurde für etcd, einer für Minio und die übrigen als Bufstream-Agenten verwendet
    • Producer-, Consumer- und Admin-Client wurden zwar jeweils nur mit einem einzelnen Node in bootstrap_servers initialisiert, die Smart-Client-Discovery wurde jedoch nicht unterbunden
  • Zentrale Sicherheitseinstellungen

    • Auto-Commit false
    • acks = all
    • Retries 1.000
    • Idempotence aktiviert
    • Isolation Level read_committed
    • auto_offset_reset = earliest
    • serverseitige automatische Topic-Erstellung deaktiviert
    • Die Fehler-Injektion umfasste Process Pause (SIGSTOP), Crash (SIGKILL), Clock Skew (clock_settime) und Network Partition (iptables)
    • Da Bufstream in Agent, Object Store und Coordination Service getrennt ist, wurde ein neues Jepsen-Werkzeug erstellt, das Fehler gezielt nur in bestimmte Subsysteme injizieren kann
    • So wurden etwa nur Bufstream-Nodes zum Absturz gebracht oder nur der etcd-Koordinator pausiert, wobei diese Kombinationen im Zeitverlauf variiert wurden

Queue-Workload und Abort-Workload

  • Der Queue-Workload analysiert die Sicherheit anhand des Kafka-Datenmodells
    • Jeder logische Prozess führt Producer, Consumer und Admin-Client aus
    • Ein numerischer Schlüssel identifiziert eine bestimmte Topic-Partition
    • Schlüssel werden mit exponentieller Häufigkeit ausgewählt, sodass auf manche Schlüssel häufig und auf andere selten zugegriffen wird
  • Es werden drei grundlegende Operationen verwendet
    • crash: beendet einen logischen Prozess und ersetzt ihn durch einen neuen Client
    • subscribe oder assign: ändert die Menge der Topics oder Partitionen, die der Consumer per poll abfragt
    • txn, poll, send: führt eine Sequenz von poll- oder send-Mikrooperationen aus
  • Im nichttransaktionalen Workload enthält jedes send oder poll genau eine Mikrooperation
  • Im transaktionalen Workload werden mehrere Mikrooperationen in eine Kafka-Transaktion eingebettet
  • Die Analyse erstellt zunächst pro Schlüssel eine Offset-zu-Wert-Zuordnung und sucht dann nach Fehlern
    • Wenn am selben Offset mehrere Werte erscheinen, ist das ein inkonsistenter Offset
    • Wenn derselbe Wert an mehreren Offsets erscheint, ist das ein Duplikatfehler
    • Wenn ein bestätigter Record überhaupt nicht beobachtet wird, gilt er als verloren oder ungesehen
    • Wenn poll einen Wert zurückgibt, der von einer abgebrochenen Operation gesendet wurde, ist das ein abgebrochener Read
    • Es wird auch geprüft, ob eine Transaktion ihre eigenen Writes beobachtet
  • Nach dem Haupttest werden die Störungen behoben und die Phase final reads beginnt
    • Jeder Prozess liest alle Topic-Partitionen ab Offset 0 und führt poll bis zum bekannten höchsten geschriebenen Offset aus
    • Wenn final reads in ein Timeout läuft und bestätigte Records weiterhin nicht beobachtet werden, werden sie als ungesehen klassifiziert
  • Der Abort-Workload wurde zusätzlich eingeführt, um das Verhalten von Poll-Offsets nach einem Transaktionsabbruch zu verfolgen
    • Das Topic ist auf eine einzelne Partition, einen einzelnen Prozess, einen Producer und einen Consumer beschränkt
    • Nachdem eine Transaktion einen Record per poll gelesen hat, wird sie absichtlich abgebrochen; anschließend wird der Poll-Offset als advance, rewind, rewind-further oder other klassifiziert

Fünf in Bufstream entdeckte Probleme

  • Hängende Consumer (#1)

    • Von 0.1.0 bis 0.1.3-rc.8 blieb die Phase der finalen Reads häufig hängen
    • consumer.poll() gab sofort ein leeres Ergebnis zurück, obwohl im Log noch Tausende bestätigte Records vorhanden waren
    • Dieser Zustand hielt von einigen Dutzend Sekunden bis zu über einer Stunde an
    • In einem Test wurden in den ersten 120 Sekunden 691 bestätigte Records gesendet, und beim Start der finalen Reads wurden 40 von keinem Poller beobachtet
    • Danach lieferte consumer.poll() über mehr als eine Stunde keine Ergebnisse zurück, sodass der Test per Timeout abbrach
    • Die Ursache war, dass ein neu gestarteter Bufstream-Node veraltete gecachte Werte für den last stable offset und den high watermark zurückgeben konnte
    • Einige Client-Bibliotheken gingen deshalb davon aus, dass keine späteren Records existierten, und gerieten ins Stocken; Bufstream patchte dies in 0.1.3-rc.6, indem beim Start der Cache aktualisiert wird
  • Hängende Producer und Consumer (#2)

    • Auch in 0.1.3-rc.6 wurden nach Pausen, Abstürzen und Partitionen von Coordinator, Storage und Bufstream-Node weiterhin Probleme mit unsichtbaren Writes beobachtet
    • In manchen Fällen gerieten Clients nach einer Pause des Coordinators in einen Zustand, in dem sie auf InitProducerId warteten und per Timeout scheiterten, obwohl alle Bufstream-Nodes liefen
    • In anderen Fällen schlug listOffsets mit node ... being disconnected oder timed out waiting for a node assignment fehl, und poll wurde zwar abgeschlossen, lieferte aber keine Ergebnisse zurück
    • Wurde ein Bufstream-Node beendet und neu gestartet, verschwand das Problem
    • Die Ursache hing mit etcd-Leases zusammen
    • Der Bufstream-Agent verwendet etcd leases, um aktive Agents zu verfolgen
    • Durch kurze Pausen oder Partitionen löschte etcd Schlüssel, die an die Lease eines Agents gebunden waren, aber das Lösch-Update wurde dem Agent möglicherweise nicht zugestellt
    • Der Agent wusste dann nicht, dass er seine eigene Lease verloren hatte
    • Das Bufstream-Team ergänzte zusätzliche Polling-Logik, und in 0.1.3-rc.8 war das Problem mit unsichtbaren Writes weitgehend behoben
  • Scheinhafte Null-Offsets (#3)

    • Von 0.1.0 bis 0.1.3-rc.2 konnte ein gesendeter Wert den Offset 0 zugewiesen bekommen und anschließend unter einem höheren tatsächlichen Offset erscheinen
    • Das trat sogar dann auf, wenn Offset 0 bereits viel früher vergeben worden war
    • Nur der Sender beobachtete Offset 0, während der Poller einen höheren Offset beobachtete
    • In einem zweiminütigen Test mit einem einzelnen Bufstream-Node und einer Pause des etcd-Prozesses erhielten 6 Writes den Offset 0 und erschienen später unter höheren Offsets
    • Die Ursache war ein fehlendes Feld in der Error-Response von Bufstream
    • Bufstream schickte eine Anfrage zum Commit des Logs an etcd, und obwohl etcd sie verarbeitete, konnte Bufstream wegen einer Pause oder Partition beim Warten auf die Response in ein Timeout laufen
    • Bufstream sendete dem Client einen Error-Code, setzte den Offset des gesendeten Records aber nicht auf das Fehlersignal -1
    • Der Java-Kafka-Client interpretierte dies als erfolgreiche Response mit Offset 0
    • Franz-go, das von der Bufstream-Testsuite verwendet wurde, interpretierte diese Nachricht als Fehler, weshalb das Problem in den Tests nicht sichtbar wurde
    • Bufstream behob das Problem in 0.1.3-rc.6; danach konnte Jepsen es nicht erneut beobachten
  • Verlorene Transaction-Writes (#4)

    • In 0.1.2 trat häufig Write Loss auf, bei dem einige Records aus committeten Transactions verschwanden und nicht erneut beobachtet wurden
    • In einem Test gingen über 100 Sekunden und 6.761 Write-Transactions hinweg 240 Records verloren, die von committeten Transactions geschrieben worden waren
    • Im Beispiel wurde der Wert 141 für Key 5 erfolgreich auf Offset 274 geschrieben zurückgemeldet, aber jedes consumer.poll() übersprang diesen Offset
    • Die Ursache war ein Bug im Mechanismus zur Concurrency-Sicherheit, der in 0.1.2 hinzugefügt wurde
    • Dieser Mechanismus weist jeder Transaction innerhalb einer Producer-Epoch eine eindeutige Nummer zu, um die fehlende Idempotenz des Kafka-Transaction-Protokolls abzumildern
    • Wegen eines Bugs in der Logik zur Nachverfolgung der Transaction-Nummern wurden einige Commits fälschlich ignoriert, wenn mehrere Transactions über mehrere Epochen hinweg committet wurden
    • Transactions, die als committet erschienen, konnten tatsächlich abgebrochen worden sein, oder umgekehrt
    • Jepsen entdeckte diesen Bug, weil das Transaction-Timeout auf eine niedrige Sekunde gesetzt war
    • Bufstream identifizierte das Problem innerhalb weniger Stunden nach dem Release von 0.1.2, verhinderte Kunden-Upgrades, und die Kunden führten kein Upgrade auf 0.1.2 durch
    • Der Fix wurde in 0.1.3-rc2 aufgenommen
  • Verlorene Writes durch serverseitiges Filtering (#5)

    • In 0.1.3-rc.8 trat nach kleinen Störungen wie einer Pause des Bufstream-Prozesses oder Coordinators oder einer Partition zwischen beiden häufig ein kurzes Fenster mit Write Loss auf
    • Data Loss trat unabhängig davon auf, ob Transactions verwendet wurden oder nicht
    • In einem fünfminütigen Test wurden 22 von 16.770 Records bestätigt, konnten aber von keinem Consumer gepollt werden
    • Einige Records waren für Poller eine Zeit lang sichtbar und verschwanden später wieder aus den Poll-Ergebnissen
    • Die Ursache war die Logik zur Begrenzung der Größe von Fetch-API-Responses, die in 0.1.3-rc.8 hinzugefügt wurde, um einen Bug in einer beliebten Kafka-Web-GUI zu umgehen
    • Ein Bug in der Filtering-Logik verbarg Records vor hinterherhinkenden Consumern und sah dadurch wie Write Loss aus
    • Bufstream behob das Problem in 0.1.3-rc.12

Probleme mit dem Kafka-Java-Client und dem Kafka-Protokoll

  • KIP-588: Irreführende ProducerFencedException

    • Während der Tests trat häufig der Fehler ProducerFencedException: There is a newer producer with the same transactionalId which fences the current one. auf
    • Selbst in Tests, in denen alle Producer eine eindeutige Transactional ID erhielten, trat dieser Fehler auf, sodass die Ursachensuche viel Zeit kostete
    • KIP-588 beschreibt, dass auch bei einem Transaction-Timeout eine ProducerFencedException geworfen werden kann
    • Der Kafka-Java-Client verwendet für die meisten Timeouts eine eigene TimeoutException, wirft in diesem Fall jedoch eine ProducerFencedException
    • Obwohl tatsächlich kein kollidierender Producer existiert, besagt die Fehlermeldung, dass es eine zweite Producer-Instanz gibt
    • KIP-588 ist seit zwei Jahren offen, und Jepsen empfiehlt dem Kafka-Team, die Fehlermeldung zu ändern
  • KAFKA-17734: Consumer.close() kann unbegrenzt blockieren

    • Sowohl in den Bufstream- als auch in den Kafka-Tests blieb der Test wegen eines Java-Client-Bugs alle paar Stunden hängen
    • Consumer.close() blockiert standardmäßig bei Network I/O
    • Der Timeout-Parameter von close() sollte unbegrenztes Blockieren verhindern, funktionierte aber nicht
    • Auch die Methode, consumer.wakeup() in einem separaten Thread aufzurufen, um einen bei I/O festhängenden Consumer zu unterbrechen, zeigte keine Wirkung
    • Jepsen ist der Ansicht, dass lang laufende Programme auch bei Netzwerkfehlern Ressourcen wie Client, Connection, Thread und Memory in angemessener Zeit freigeben können sollten, und hat KAFKA-17734 eingereicht
  • KAFKA-17582: Nach einem Fehlschlag einer Transaction sind Consumer-Offets unvorhersehbar

    • Die offizielle Kafka-Dokumentation sagt fast nichts darüber, wie sich Consumer-Offets bei einem Fehlschlag des Transaction-Commit verhalten sollten
    • Die Kafka-Designdokumentation von Confluent besagt, dass bei einem Abort einer Transaction die Consumer-Position auf den vorherigen Wert zurückgeht, doch der tatsächliche Java-Client verhält sich nicht immer so
    • Die Ergebnisse des Abort-Workloads zeigten, dass das Verhalten nach einem Abort selbst in einem gesunden Cluster schwer vorherzusagen war
    • Die meisten Transaction-Paare rückten auf spätere Offsets vor
    • Einige wurden auf frühere Offsets zurückgesetzt
    • Alle Rewinds standen mit Rebalance-Events in Zusammenhang, und bei allen Advances gab es kein Rebalance
    • Laut der Antwort von Kafka ist dieses Verhalten beabsichtigt
    • Der Consumer rückt weiter vor
    • Tritt ein Rebalance auf, kann er anhand des committed Offset auf einen beliebigen Punkt zurückgesetzt werden
    • Nutzer müssen bei einem Transaction-Abort die Consumer-Position manuell zurücksetzen
    • Jepsen hat KAFKA-17582 eröffnet und vorgeschlagen, dieses Verhalten zu dokumentieren und eine Änderung des Standard-Rewinds bei einem Transaction-Abort zu prüfen
    • Auch der Queue-Workload wurde so geändert, dass der Consumer explizit zurückgesetzt wird
  • KAFKA-17754: Write Loss, Aborted Read, Torn Transaction

    • In Bufstream 0.1.0 bis 0.1.3 wurden allein durch Pause des Bufstream-Prozesses, Pause des Coordinators, Crash und Network Partition Aborted Reads, Lost Writes und Atomicity-Verletzungen beobachtet
    • Die Analyse führte zu einem grundlegenden Mangel im Kafka-Transaction-Protokoll
    • Im Beispiel führte der Client mit der eindeutigen Transactional ID jt1234 eine Transaction aus und sendete an EndTxn committed = false, um sie zu aborten, dennoch beobachteten 15 poll()-Aufrufe Writes aus der abgebrochenen Transaction
    • Andere Writes derselben Transaction wurden von keinem Poller beobachtet
    • Betrachtet man Packet Capture und Bufstream-Log gemeinsam, war die Ursache eine verzögerte Commit-Message
    • Ein einige Transactions zuvor gesendetes Commit-EndTxn wurde auf einem Node verspätet verarbeitet
    • Der Client hatte bereits die folgenden Transactions ausgeführt
    • Das verzögerte Commit wurde auf die aktuelle Transaction angewendet, sodass nur der vordere Teil der Transaction committed wurde und der Rest als separate Transaction behandelt und abgebrochen wurde
    • Das Kafka-Protokoll ist so entworfen, dass der Client Requests über mehrere TCP-Connections und an mehrere Nodes senden kann, besitzt jedoch keine Sequence Number, die die Reihenfolge der Requests desselben Clients festlegt
    • Es gibt auch kein Konzept einer Transaction-Nummer, sodass der Server beim Empfang einer Commit- oder Abort-Message nicht erkennen kann, welche Transaction der Client beenden wollte
    • Dadurch werden folgende Situationen möglich
      • Eine scheinbar committede Transaction wird tatsächlich abgebrochen
      • Eine abgebrochene Transaction wird tatsächlich committed
      • Es kommt zu einer Torn Transaction, bei der nur ein Teil der Writes einer Transaction erhalten bleibt und ein anderer Teil verloren geht
    • Der offizielle Java-Kafka-Client behandelt Timeouts als retryable und kann automatisch mehrere EndTxn-Messages senden, sodass Probleme entstehen können, selbst wenn der Nutzer pro Transaction nur einmal Commit oder Abort aufruft
    • Jepsen beobachtete auch in Kafka durch Process Pause Aborted Reads und Torn Transactions und eröffnete KAFKA-17754
    • Kafka-Ingenieure gehen davon aus, dass KIP-890 dieses Problem beheben könnte
    • KIP-890 ändert das Transaction-Protokoll so, dass für jede Transaction die Producer-Epoch erhöht wird
    • Da der Server Messages aus früheren Epochs ablehnt, kann verhindert werden, dass Commit-Messages aus früheren Transactions in spätere Transactions hineinwirken
    • Bufstream fügte in 0.1.3 einen Mechanismus hinzu, der die etcd-Revision als logische Uhr verwendet, um die Häufigkeit zu verringern, verhindert jedoch keine Reorderings zwischen Client und Bufstream
    • Jepsen beobachtete auch in 0.1.3 weiterhin Aborted Reads, Lost Writes und Torn Transactions und geht davon aus, dass eine clientseitige Lösung nötig ist

Zusammenfassung der Gesamtergebnisse

  • Alle fünf Bufstream-spezifischen Probleme wurden behoben
    • #1: Consumer blieben wegen eines nachhinkenden höchsten stabilen Offsets hängen, kein Ausfall erforderlich, behoben in 0.1.3-rc.6
    • #2: Producer/Consumer blieben wegen des Ablaufs einer etcd-Lease hängen, Pause erforderlich, behoben in 0.1.3-rc.8
    • #3: irrtümliche Zero-Offets, Pause erforderlich, behoben in 0.1.3-rc.6
    • #4: verlorene Transaction-Writes, kein Ausfall erforderlich, behoben in 0.1.3-rc.2
    • #5: verlorene Writes wegen serverseitiger Filterung, Pause erforderlich, behoben in 0.1.3-rc.12
  • Kafka-bezogene Probleme bestehen weiterhin
    • KIP-588: falsche Fehlermeldung bei Transaction-Timeout, ungelöst
    • KAFKA-17734: ConsumerClient.close() kann unbegrenzt blockieren, ungelöst
    • KAFKA-17582: Consumer-Offsets nach Fehlschlagen einer Transaction nicht vorhersagbar, ungelöst
    • KAFKA-17754: Write Loss, abgebrochene Reads, zerrissene Transactions, ungelöst
  • Jepsen weist darauf hin, dass experimentelle Sicherheitsprüfungen die Existenz von Bugs nachweisen können, nicht aber deren Abwesenheit
  • Insbesondere wegen KAFKA-17754 hält Jepsen es für schwer zu beurteilen, ob es in Bufstream weitere Fälle von Write Loss gibt

Empfehlungen für Bufstream-Nutzer und den Betrieb

  • Nutzer, die Bufstream-Transactions mit dem offiziellen Java-Kafka-Client verwenden, sollten berücksichtigen, dass Transactions derzeit möglicherweise nicht sicher sind
    • abgebrochene Transactions können tatsächlich committet werden
    • committete Transactions können tatsächlich abgebrochen werden
    • Transactions können in der Mitte zerrissen werden, sodass nur ein Teil ihrer Effekte erhalten bleibt
  • Bufstream geht davon aus, dass der Franz-go-Client für dieses Problem weniger anfällig ist, Jepsen hat Franz-go mit Techniken wie in dieser Arbeit jedoch nicht getestet
  • Andere Clients können anfällig sein oder auch nicht
  • Nutzer vor Bufstream 0.1.3 können auf folgende Probleme stoßen
    • producer.send() gibt fälschlich den Offset 0 statt des tatsächlichen Offsets zurück
    • metastabile Verfügbarkeitsprobleme, bei denen Clients hängen bleiben
  • Jepsen empfiehlt ein Upgrade auf 0.1.3
  • Die Gesamtarchitektur von Bufstream erscheint grundsätzlich solide
    • Der Ansatz, die Reihenfolge unveränderlicher Daten-Chunks über einen Coordination Service wie etcd festzulegen, ist relativ einfach und hat Vorbilder in OLTP- und Streaming-Systemen
  • Für den Betrieb werden zwei Verbesserungen empfohlen
    • Wenn beim Startup die Anforderung einer Shared File im Storage fehlschlägt, kann der Cluster abstürzen; Jepsen empfahl daher Retries hinzuzufügen, und Bufstream hat eine Retry-Schicht ergänzt
    • Wenn Abhängigkeiten nicht verfügbar sind, sollten Agents weiterlaufen, Backpressure und Systemstatus bereitstellen und sanfter recovern, statt sofort zu sterben
  • Stand 0.1.3 hat Bufstream zusätzliche Retry-Logik für etcd eingeführt, benötigt aber weiterhin konstante Überwachung, um online zu bleiben
  • Nutzer sollten prüfen, ob ein Process Supervisor vorhanden ist und das System auch bei längeren Ausfällen nicht aufgibt

Notwendige Anpassungen bei Kafka-Transaction-Dokumentation und Protokoll

  • Die offizielle Kafka-Dokumentation sagt über Transactions fast nichts, sodass Nutzer mehrere vage und teils widersprüchliche Quellen zusammenführen müssen
  • Jepsen empfiehlt dem Kafka-Team, ein zentrales Dokument mit klar beschriebenen Transaction-Semantiken zu erstellen, und verweist auf KAFKA-17671
  • Dieses Dokument sollte mindestens Folgendes festhalten
    • wann Consumer monoton steigende Offsets beobachten
    • wann Consumer bestätigte Records überspringen können
    • ob ein Rebalance mitten in einer Transaction Auswirkungen haben kann
    • wann Producer-Write-Offsets monoton steigen
    • wann G0, G1a, G1b, G1c, Fractured Read und das Lesen der Writes der eigenen Transaction zulässig sind
    • was Rückgabewerte und Offsets von poll() nach einer abgebrochenen Transaction bedeuten
    • wie Transaction-Errors, Errors beim Abort und Errors beim Rewind zu behandeln sind
  • Die Confluent-Dokumentation wiederholt, dass die Kafka-Standardwerte At-least-once-Delivery bieten, aber Jepsen weist darauf hin, dass das offenbar nicht stimmt
    • auto.offset.reset = latest kann nicht verarbeitete Records so erscheinen lassen, als wären sie „committed“
    • Auch die Confluent-Dokumentation zum Offset-Management spricht bei Standard-Auto-Commit vom Risiko eines Verlusts des Message-Fortschritts bei Abstürzen
    • Auch die Dokumentation, dass Consumer bei einem Transaction-Abbruch zurückgespult werden, stimmt nicht mit der Realität überein
  • Jepsen ist der Ansicht, dass das Kafka-Transaction-Protokoll grundlegend überarbeitet werden muss
    • Das Protokoll setzt implizit geordnete zuverlässige Zustellung voraus, obwohl Process-Pausen, unzuverlässige Netzwerke, nicht null Latenz und ungeordnete Zustellung über mehrere TCP-Sockets hinweg existieren
    • Das Kafka-Protokoll verteilt Messages über mehrere Nodes und TCP-Sockets, und Clients wiederholen Messages automatisch
    • Es fehlen Sequence Numbers zur Wiederherstellung derselben Client-Message-Reihenfolge sowie Transaction-Nummern zur Prüfung des Ziels einer Transaction
  • KIP-890 versucht, durch Erhöhen der Epoch bei jedem Transaction-Commit eine strengere Reihenfolge sicherzustellen
  • Auch Client-Libraries könnten helfen, indem sie den Producer neu initialisieren und so die Epoch erhöhen, wenn Messages nicht bestätigt werden
  • Der Java Kafka Client 3.8.0 ist für dieses Problem anfällig
  • Jepsen geht davon aus, dass Franz-go durch Re-Initialisierung bei Timeouts das Problem abschwächen oder verhindern kann, andere Client-Libraries wurden jedoch nicht untersucht

Zukünftige Arbeiten

  • Viele Nutzer verlassen sich eher auf die „exactly-once semantics“ der Kafka Streams API, als Transaktionen direkt zu handhaben; künftig könnte daher die Korrektheit von Streams-Anwendungen untersucht werden
  • Jepsen stieß bei der Untersuchung von KAFKA-17754 auch in Kafka auf unseen writes, konnte diese aus Zeitgründen jedoch nicht analysieren
    • unseen writes könnten ein Hinweis auf hanging transactions, stuck consumers oder Datenverlust sein
    • Offen bleibt auch die Frage, ob eine verzögerte Produce-Nachricht in eine zukünftige Transaktion geraten und damit die Transaktionsgarantien verletzen kann
    • Zudem besteht der Verdacht, dass der Kafka Java Client bei einem Request-Timeout eine Sequence Number wiederverwendet, sodass ein Write zwar bestätigt wird, aber stillschweigend verworfen werden könnte
  • Bei einem Rebalance-Ereignis kann sich die Consumer-Position vor- und zurückbewegen, doch die Regeln dafür sind unklar
  • Wenn Kafka das beabsichtigte Verhalten dokumentiert, möchte Jepsen es verifizieren
  • Jepsen erklärt, dass es sich um einen Random Process handelt, wodurch seltene Anomalien schwer zu finden sind
    • Probleme, die nur einmal auftreten, sind sehr schwer zu debuggen und zu reproduzieren
  • Bufstream nutzt auch Antithesis, das ein gesamtes verteiltes System auf einem deterministischen Hypervisor und in einem simulierten Netzwerk ausführt
    • Die Kombination aus Jepsens Workload-Generierung und History-Checking mit der deterministischen, wiederholbaren Umgebung von Antithesis könnte die Reproduzierbarkeit von Tests verbessern

1 Kommentare

 
GN⁺ 2024-11-14
Meinungen auf Hacker News
  • Wenn man bei der Untersuchung von Issues wie KAFKA-17754 auch unsichtbare Schreibvorgänge in Kafka gefunden hat, scheint es an der Zeit, dass Jepsen Kafka noch einmal gründlich unter die Lupe nimmt.
    Die letzte Untersuchung war 2013 (https://aphyr.com/posts/293-call-me-maybe-kafka, Kafka 0.8 Beta), und inzwischen wirkt es so, als stünde man gerade erst davor, in Kafka selbst mehrere Probleme aufzudecken.
    Aussagen wie „ein Schreibvorgang kann bestätigt und dann stillschweigend verworfen werden“ sind ziemlich beunruhigend.

    • Eine Kafka-Analyse würde ich sehr gern machen. :-)
  • Ich finde es sehr überraschend, dass ein Kafka-Consumer mit dem Default enable.auto.commit=true Offsets committen kann, unabhängig davon, ob die Anwendung sie tatsächlich verarbeitet hat.
    So habe ich Auto-Commit nie verstanden, und als Default ergibt das für mich keinen Sinn.
    Die Dokumentation ist nicht völlig eindeutig, aber insgesamt las es sich für mich so, als würden Offsets nur committet, wenn die Verarbeitung abgeschlossen ist.
    Die Anpassung des Auto-Commit-Intervalls habe ich so verstanden, dass sie — wie man es bei At-least-once-Verarbeitung erwarten würde — hilft, das Fenster für doppelte Verarbeitung zu verkleinern, nicht Nachrichtenverlust zu verursachen.

    • Das ist etwas überraschend, und ich stimme zu, dass die Dokumentation diesen Punkt nicht gut erklärt.
      Wenn man nicht explizit committet, hat Kafka keine Möglichkeit zu wissen, ob eine Nachricht verarbeitet wurde.
      Kafka nimmt an, dass eine übergebene Nachricht sofort verarbeitet wurde.
      Auto-Commit ist ungefähr so, als würde man jemandem eine Eistüte geben, sich sofort umdrehen und annehmen, dass die Person sie gegessen hat. Manche lassen sie direkt nach dem Entgegennehmen fallen und bekommen keinen einzigen Bissen ab.
    • Der Kernpunkt ist: Nur weil eine Nachricht erfolgreich an den Kafka-Client zugestellt wurde, heißt das nicht, dass die Anwendung sie verarbeitet hat.
      Wenn man diese Garantie möchte, muss man explizit acknowledgen.
      Wenn man zum Beispiel nur Nachrichten in eine Datenbank schreibt, gilt die Nachricht in dem Moment als bestätigt, in dem sie im Client-Handler-Callback ankommt.
      In der Praxis möchte man sie aber sehr wahrscheinlich erst dann bestätigen, wenn der DB-Insert erfolgreich war.
      Wenn die DB wegen Netzwerk, Kubernetes, Firewall-Konfiguration usw. nicht erreichbar ist und der Client währenddessen herunterfährt, weil ein Engineer einen Neustart versucht, entstehen leicht unverarbeitete Nachrichten.
    • Ich verstehe dieses Feature als etwas für High-Performance-Szenarien.
      Ein anderes System kann feststellen, ob etwas fehlgeschlagen ist, und mit diesem Feature lässt sich die Obergrenze verschieben, um Wiederverarbeitung zu reduzieren.
      Allerdings muss man, wenn das Timing passt und ein Fehler auftritt, davon ausgehen, dass man nach einem Neustart einige bereits verarbeitete Nachrichten erneut erhält.
      Das Problem entsteht, wenn es vor dem Auto-Commit keine solche Verarbeitung gibt.
      Beim Lesen wirkt es so, als sei beabsichtigt, erst eine ganze Weile nach der Verarbeitung zu committen; zugleich wirkt es widersprüchlich, dass ein Auto-Commit nur Einträge committen sollte, die ein paar Millisekunden vor dem Auto-Commit-Zeitpunkt liegen.
    • Die Existenz dieses Features lässt sich bis zu einem gewissen Grad rechtfertigen. Es wurde für synchrone Single-Thread-Consumer entworfen und stellt sich grob eine Schleife vor, die nach einem poll-Aufruf Nachrichten dauerhaft verarbeitet.
      Der verwirrende Punkt ist, dass die Auto-Commit-Prüfung nicht asynchron nach einem Timeout passiert, sondern beim nächsten poll-Aufruf.
      Daher sollte man Schreibvorgänge nur dann verlieren können, wenn man Nachrichten vor dem erneuten Aufruf von poll nicht dauerhaft verarbeitet, sondern lediglich speichert — etwa bei asynchroner Verarbeitung, Verzögerungen, Queues usw.
      Das entspricht dem dokumentierten Verhalten der Java-Client-Bibliothek (https://kafka.apache.org/32/javadoc/org/apache/kafka/clients...); ob die aktuelle Implementierung tatsächlich so funktioniert, ist eine andere Frage.
      Das Kafka-Protokoll sitzt zwischen High-Level und Low-Level und macht beides nicht besonders gut.
      Auto-Commit ist ein High-Level-Feature, das den Bau einfacher Anwendungen erleichtern soll, kann aber natürlich scheitern, wenn man es nicht in der erwarteten Weise nutzt.
      Meiner Meinung nach sollten Endnutzer heute eher High-Level-Implementierungen verwenden, die die Details korrekt handhaben, statt Kafka-Clients direkt zu nutzen: für Daten-Use-Cases etwa eine Stream-Processing-Engine, für Anwendungs-Use-Cases eine dauerhaft laufende Engine.
  • Wenn man sich die Produktseite (https://buf.build/product/bufstream) ansieht, frage ich mich, wie die Aussage „läuft nur in Ihrer AWS- oder GCP-VPC und kontaktiert nichts Externes“ mit der nutzungsbasierten Abrechnung von „$0.002 pro GiB vor Komprimierung“ zusammenpasst.
    Ich kann mir kaum vorstellen, dass das gesamte Geschäftsmodell auf einem Honor-System basiert.

    • In der Einführung steht: „Stand Oktober 2024 wurde Bufstream nur an ausgewählte Kunden ausgerollt“, daher halte ich ein Honor-System für möglich.
      Natürlich gibt es Missbrauchsrisiken, aber es könnte ein wertvoller Kompromiss sein, um bestimmte Kunden zu gewinnen.
    • Ein Programm ist entweder Open Source oder es ist es nicht.
      Wenn der Source nicht öffentlich ist, sollte man Behauptungen wie „kontaktiert nichts Externes“ niemals glauben.
  • „Das Kafka-Transaktionsprotokoll ist grundlegend kaputt und sollte überarbeitet werden“ klingt schmerzhaft.
    Aber wie immer sind die Untersuchung und der Artikel hervorragend.

  • Ich frage mich, ob Kyle sich NATS JetStream schon einmal angesehen hat. Mich würde interessieren, was er davon hält.

    • Noch nicht, aber du bist nicht die erste Person, die danach fragt.
      Einige haben angedeutet, dass das … wie soll ich sagen … interessant sein könnte. :-)
  • Ich kann das bufstream-GitHub-Projekt nicht finden; weiß jemand, wo es ist?

  • Nachdem ich die zugehörigen Blogposts und Dokumente gelesen habe, scheint Kafkas „Exactly-once Delivery“ als Eigenschaft eines Read-Process-Write-Jobs definiert zu sein, bei dem ein Worker aus Topic 1 liest und in Topic 2 schreibt, wobei beide Topics im selben logischen Kafka-System liegen.
    Falls das stimmt, wäre es meiner Meinung nach besser, das eine Transaktion zu nennen.

    • Kafka nennt das tatsächlich auch Transaktionen.
      Es gibt allerdings zwei Arten, „exactly once“ zu betrachten.
      Die eine bedeutet, ähnlich wie bei Datenbanktransaktionen, dass Effekte nicht dupliziert werden oder verschwinden dürfen.
      Die andere ist eher eine Eigenschaft eines Datenflussgraphen über Nachrichtenbeziehungen hinweg, die Topic-Partitionen überschreiten, und liegt etwas näher an der Konsistenz in ACID.
      Ähnlich wie ein serialisierbares Transaktionssystem bestimmte Invarianten auf Domänenebene garantiert, kann man Transaktionen verwenden, um diese Datenflusseigenschaft zu erreichen.
      Serialisierbarkeit garantiert zum Beispiel, dass Invarianten, die für jede einzelne Transaktion erhalten bleiben, auch in der nebenläufigen Ausführungshistorie erhalten bleiben.
      Man kann es so sehen, dass Kafka auf diese Weise zu „Exactly-once-Semantik“ gelangen will.
  • Nicht mit https://www.warpstream.com/ verwechseln.

    • Genau. WarpStream unterstützt nicht einmal Transaktionen.
  • Erratum: „Transactions may observe none, part, or all“ sollte meiner Meinung nach „Consumers may observe none, part, or all“ heißen.

    • Beides stimmt, aber aus Gründen der Klarheit habe ich Transaktionen geschrieben.
      Die Semantik von Consumern außerhalb von Transaktionen ist verschwommener.
      Alle Lesevorgänge in dieser Workload finden in einem Transaktionskontext statt und laufen über den Transaktions-Offset-Commit-Pfad.
  • Ich frage mich, wofür diese Software verwendet wird. Instrumentierung? Blackbox?

    • Jepsen ist ein Tool, das einen zum Weinen bringt, wenn man nicht wusste, dass es die Datenbank testet, an der man gerade arbeitet.
      Natürlich Freudentränen. Denn Aufmerksamkeit von Jepsen zu bekommen, ist an sich schon eine Leistung.
    • Es ist ein Kafka-Klon. Kafka ist im Wesentlichen eine dauerhafte Queue.