Caluga - Java blog

Mit Hilfe des WireProxy failover tests

Failover testen, ohne irgendwas zu killen — ein Wire-Proxy als Fehler-Simulator

Das Problem: Der schlimmste Ausfall ist der leise

Wer einen Datenbank-Treiber mit Replica-Set-Support baut, muss Failover testen. Klingt einfach: Primary wegnehmen, schauen ob der Treiber sich fängt. In der Praxis gibt es aber drei sehr verschiedene Arten, auf die ein Knoten „weg" sein kann:

  1. Sauberer Abgang — der Prozess beendet sich, das Betriebssystem schließt die Verbindung ordentlich (FIN). Der Treiber bekommt sofort einen Fehler.
  2. Harter Tod — kill -9, der Port ist zu, neue Verbindungen werden abgewiesen (RST). Auch das merkt der Treiber schnell.
  3. Stille — die Maschine friert ein, das Netz partitioniert, eine VM wird pausiert. Die TCP-Verbindung ist aus Sicht des Treibers völlig intakt. Es kommt nur… nichts mehr.

Fall 3 ist der Killer. Ein Treiber kann eine stumme Verbindung nicht von einem langsamen Server unterscheiden — ohne eigene Timeouts wartet er ewig. Und genau dieser Fall ist mit klassischen Mitteln am schwersten zu testen.

Unsere erste Version eines Failover-Tests war ehrlich gesagt Handarbeit: lokales Replica Set aufbauen, kill -9 hier, kill -STOP da, Logs anstarren. Der Test trug das Tag manual und lief folgerichtig nie in der CI. Regressions haben wir damit zwar gefunden — aber immer erst, nachdem sie jemand in echt erlebt hatte.

Die Idee: Fehler gehören auf die Leitung, nicht in den Prozess

Der Aha-Moment: Alle drei Ausfallarten sind aus Treibersicht reine Leitungsphänomene. Der Treiber sieht ja nicht den Prozess sterben — er sieht nur, was auf seinem Socket passiert (oder eben nicht passiert). Also braucht man gar keine Prozesse zu killen. Man braucht nur etwas, das zwischen Treiber und einem völlig gesunden Replica Set sitzt und die Leitung kontrolliert kaputt macht.

Ein TCP-Proxy pro Replica-Set-Knoten:

// Ein Proxy pro RS-Knoten, lauscht auf einem Zufallsport
WireProxy proxy1 = new WireProxy("mongo1.local", 27017);
proxy1.start();

// Der Treiber kennt NUR die Proxy-Adressen
driver.setHostSeed("localhost:" + proxy1.getListenPort(), ...);

// Failover auslösen: Primary "einfrieren" — wie kill -STOP,
// nur ohne dass irgendjemand kill -STOP macht
proxy1.setFaultMode(FaultMode.freeze);

Drei Fault-Modes, exakt die drei Ausfallarten von oben:

FaultModeVerhaltenSimuliert
closeVerbindung sauber schlieĂźen (FIN)geordneter Shutdown
resetVerbindung hart kappen (RST via SO_LINGER 0)Prozess-Tod, Port zu
freezeVerbindung annehmen, nie antworteneingefrorene VM, Netzwerk-Partition — der Fall, den kein Treiber „sehen" kann

freeze ist dabei bewusst gemein implementiert: Neue Verbindungen werden akzeptiert (das erledigt das OS aus dem Backlog, wie bei einem gestoppten Prozess auch) — und dann passiert einfach nichts mehr. Kein Fehler, kein Close, keine Daten. Genau wie in echt.

Der Trick: Dem Treiber die Topologie unterschieben

Ein Proxy allein reicht nicht. Moderne MongoDB-Treiber machen Server-Discovery: Sie fragen einen Knoten per hello, und der antwortet mit der kompletten Replica-Set-Topologie — hosts, primary, me. Alles echte Adressen. Der Treiber würde also nach dem ersten hello fröhlich an den Proxies vorbei direkt zu den echten Knoten verbinden, und die ganze Fehlersimulation läuft ins Leere.

Die Lösung: Der Proxy parst die Antworten vom Server (Richtung Backend→Client) und schreibt in jeder hello-Response die echten Adressen auf die Proxy-Adressen um. Der Treiber lebt damit in einer konsistenten kleinen Scheinwelt, in der das Replica Set aus drei Proxies besteht — und jede Verbindung, auch die, die die Discovery selbst aufmacht, läuft durch die Fehlerschleuse. Ein eigener Test pinnt genau das: Nach vollem Verbindungsaufbau darf der Treiber ausschließlich mit Proxy-Adressen verbunden sein.

Nettes Detail am Rande: Nur die Server-Antworten werden geparst. Alles, was der Client Richtung Server schickt, wird als rohe, längen-präfixte Frames durchgereicht — je weniger der Proxy „versteht", desto weniger kann er versehentlich verfälschen.

Für die Fälle, in denen man das echte Replica Set steuern will (ein echter replSetStepDown löst nun mal die echteste aller Elections aus), gibt es einen separaten Kontrollkanal direkt zu den echten Knoten — sauber getrennt vom Datenpfad, den der Treiber sieht.

Was dabei rauskam: echte Bugs, mit Zahlen

Das Schöne an so einem Harness: Er findet nicht theoretische, sondern beobachtbare Bugs. Eine Auswahl dessen, was die Suite beim ersten ernsthaften Einsatz aus unserem Connection-Pool gespült hat:

  • Die unerreichbare Deadline. borrowConnection() hatte ein Zeitlimit — das aber nur griff, wenn der Pool leer war. Nach einem Freeze war der Pool voll mit toten Verbindungen, das Limit wurde nie geprĂĽft, Reads hingen fest. Klassischer Fall von „der Timeout existiert, aber der Codepfad erreicht ihn nie".
  • Reads, die sich nicht erholen, obwohl Writes es tun. Die Read-Preference-Logik (NEAREST / PRIMARY_PREFERRED / SECONDARY_PREFERRED) fiel nach einem Failover nie auf den gesunden neuen Primary zurĂĽck. Live beobachtet: Ein einziges countAll() verbrachte 26 Sekunden damit, den toten Ex-Primary zu hämmern — während der frisch gewählte Primary direkt daneben idle war. Writes: längst wieder ok. Reads: eingefroren.
  • Der „schnellste Host", der gar nicht mehr existiert. Der NEAREST-Cache zeigte nach dem Failover noch ~11 Sekunden auf den eingefrorenen Ex-Primary — und jeder einzelne Read zahlte erst mal ein volles Server-Selection-Timeout, bevor er woanders hin durfte.
  • Warten statt wechseln beim Stepdown. Sagt der alte Primary beim Stepdown selbst, wer der neue ist, wartete der Treiber trotzdem stur auf den nächsten Discovery-Zyklus, statt die Information zu nutzen.

Jeder dieser Fixes hat jetzt einen deterministischen Unit-Test — aber gefunden wurden sie alle vom Proxy-Harness, unter realistischen Bedingungen, in der CI.

Bonus: Ein Harness, zwei Server

Weil der Proxy nur Wire-Protocol spricht, ist ihm egal, was dahinter steht. Dieselbe Test-Suite läuft bei uns gegen ein echtes MongoDB-Replica-Set und gegen PoppyDB, unseren MongoDB-kompatiblen In-Memory-Server mit eigener Leader Election. Der Treiber wird also gegen zwei komplett unabhängige Server-Implementierungen auf identisches Failover-Verhalten geprüft — und nebenbei härtet die Suite PoppyDBs eigene Election gleich mit.

Fazit

  • Failover-Bugs sind fast immer Leitungs-Bugs — also simuliert man sie am besten auch auf der Leitung.
  • Ein TCP-Proxy mit drei Fault-Modes (close, reset, freeze) deckt die drei realen Ausfallarten ab; freeze ist der, der wehtut.
  • Ohne hello-Rewriting bringt der schönste Proxy nichts — die Discovery verrät sonst die echten Adressen.
  • Kein kill -9, kein sudo, keine handgebaute Infrastruktur: Das Ganze läuft als normaler Test gegen jedes erreichbare Replica Set — und damit endlich in der CI statt im Kopf des einen Kollegen, der weiĂź, wie man den manuellen Test bedient.

Der Code (WireProxy, AddressRewriter, die komplette Failover-Suite) liegt im Morphium-Repository unter morphium-core/src/test/java/de/caluga/test/morphium/testutil/proxy/ und landet mit Morphium 6.3.0 im Release.