Monolith zerlegen: Erfahrungen und Stolperfallen

Keine Zeit? Hier die Kurzversion in 30 Sekunden.
- Der erste Schritt ist kein neuer Dienst, sondern eine Klärung der Modulgrenzen im bestehenden Monolithen, entlang fachlicher Schnitte (Bounded Contexts).
- Schrittweise Ablösung nach dem Strangler-Pattern ist weniger riskant als ein Neubau im Hintergrund.
- Das größte Hindernis ist fast immer die gemeinsame Datenbank, das zweitgrößte sind gemeinsame Bibliotheken, die heimlich koppeln.
- Verteilte Transaktionen vermeiden; lieber Prozesse so schneiden, dass sie mit Verzögerung und Wiederholung leben können.
- Monitoring, Tracing und automatisiertes Deployment gehören vor den ersten Dienst, nicht danach.
2014 habe ich über Microservices geschrieben, und der Ton war damals skeptisch. Ich hatte den Eindruck, dass für kleine und mittlere Projekte ein ordentlich gebauter Monolith die bessere Wahl ist und dass viele, die von kleinen Diensten schwärmen, die Kosten der Verteilung ausblenden. An dieser Grundhaltung hat sich nicht so viel geändert. Geändert hat sich, dass ich inzwischen in mehreren Projekten gesehen habe, wie ein Monolith tatsächlich auseinandergenommen wird, und dass ich differenzierter urteile. Manchmal ist es der richtige Schritt. Und fast immer ist der Weg dorthin unbequemer, als die Vorträge es nahelegen.
Hier ein paar Erfahrungen, ohne Anspruch auf Vollständigkeit.
Erst im Monolithen schneiden
Der Reflex, bei „zu groß“ sofort eine zweite Anwendung anzulegen, führt meistens in die Irre. Wenn ich ein System nicht innerhalb des Prozesses sauber in Teile gliedern kann, wird es mir über das Netzwerk erst recht nicht gelingen. Verteilung macht schlechte Grenzen nicht besser, sie macht sie teurer: Aus einem Methodenaufruf wird ein Netzwerkaufruf, aus einem Compilerfehler ein Laufzeitproblem.
Deshalb halte ich die Arbeit an den Modulgrenzen für den wichtigsten Schritt. Hilfreich ist dabei das Denken aus dem Domain-Driven Design: Ein System besteht aus mehreren fachlichen Kontexten (Bounded Contexts), in denen dieselben Wörter Verschiedenes bedeuten können. Ein „Kunde“ ist für den Vertrieb etwas anderes als für die Rechnungsstellung oder den Support. Wo solche Bedeutungen auseinanderlaufen, verläuft oft eine sinnvolle Grenze. Technische Schnitte (Oberfläche, Logik, Datenzugriff) taugen dafür dagegen selten, sie erzeugen Dienste, die für jede Änderung alle zugleich betreffen.
Praktisch heißt das: Pakete oder Namensräume ordnen, Abhängigkeiten zwischen den Teilen sichtbar machen, unerlaubte Zugriffe durch Tests oder Werkzeuge verbieten. Das lässt sich in Java genauso tun wie in C# oder PHP. Wenn das gelingt, hat man bereits einen Gewinn, selbst wenn nie ein einziger Dienst abgespalten wird.
Schrittweise statt Neubau
Wenn ein Teil tatsächlich herausgelöst werden soll, ist der Neubau im Hintergrund verlockend: Man schreibt alles neu und schaltet irgendwann um. Ich habe das selten gut ausgehen sehen. Das alte System entwickelt sich weiter, das neue hinkt hinterher, und der Moment der Umschaltung wird immer weiter geschoben.
Martin Fowler beschreibt dafür seit einigen Jahren das Bild der Würgefeige, das Strangler-Pattern. Die Pflanze wächst um den alten Baum, bis dieser ersetzt ist. Übertragen: Vor dem Monolithen sitzt eine Weiche, etwa ein Proxy oder eine Routing-Schicht. Eine Funktion nach der anderen wird neu gebaut, und die Weiche leitet die entsprechenden Anfragen an den neuen Teil um, alles andere geht weiter an den alten. Man kann jederzeit anhalten, und im Zweifel schaltet man einen Teil zurück. Das Risiko verteilt sich auf viele kleine Schritte statt auf einen großen.
Am besten beginnt man mit einem Stück, das klar umrissen ist und nicht zum Kern gehört. Ein Fehler dort tut weniger weh, und man lernt, was Betrieb, Überwachung und Auslieferung eigentlich verlangen.
Die Datenbank
Der Code lässt sich oft erstaunlich gut trennen. Dann kommt die Datenbank. Im typischen Monolithen gibt es ein gemeinsames Schema, in dem jeder Teil auf jede Tabelle zugreifen kann und es in der Praxis auch tut. Fremdschlüssel über Fachgrenzen hinweg, Joins quer durch alle Bereiche, Sichten, die irgendwer einmal für einen Bericht angelegt hat.
Solange zwei Dienste in dasselbe Schema schreiben, sind sie nicht unabhängig, egal wie getrennt die Prozesse laufen. Eine Änderung an einer Tabelle betrifft beide, nur merkt man es jetzt später. Ziel ist, dass jeder Teil seine Daten selbst besitzt und andere sie nur über eine Schnittstelle bekommen. Das ist der aufwendigste Teil der ganzen Übung. Joins müssen durch Aufrufe ersetzt werden, Berichte brauchen eine andere Quelle, und manche Daten werden zwangsläufig kopiert. Ich plane dafür deutlich mehr Zeit ein als für das Herauslösen des Codes, und ich schneide die Tabellen oft zuerst, noch innerhalb der einen Anwendung, bevor irgendwas verteilt wird.
Gemeinsame Bibliotheken und verteilte Transaktionen
Zwei weitere Stolperfallen sind weniger sichtbar. Die erste ist die gemeinsame Bibliothek. Die Versuchung ist groß, Modelle, Hilfsklassen und Zugriffscode in ein Archiv zu packen, das alle Dienste einbinden. Das spart Tipparbeit und erzeugt Kopplung durch die Hintertür: Ändert sich die Bibliothek, müssen alle Dienste neu gebaut und gemeinsam ausgeliefert werden, und die Unabhängigkeit ist dahin. Technische Hilfsmittel (Logging, Protokollbibliotheken) halte ich für unproblematisch. Fachliche Modelle zu teilen ist dagegen genau das, was man vermeiden wollte. Ein wenig Duplikat ist hier oft das kleinere Übel.
Die zweite sind Transaktionen. Im Monolithen reicht ein commit, und entweder passiert alles oder nichts. Über mehrere Dienste hinweg gibt es das nicht, jedenfalls nicht ohne hohen Preis. Verteilte Transaktionen über zwei-Phasen-Protokolle sind fehleranfällig und binden die Verfügbarkeit der Beteiligten aneinander. Besser finde ich es, den fachlichen Ablauf so zu schneiden, dass er mit Zwischenzuständen umgehen kann: erst reservieren, dann bestätigen, bei Misserfolg eine ausgleichende Gegenbuchung. Das ist mehr Arbeit im Modell, und die Fachseite muss mitdenken, denn „sofort konsistent“ ist dann eine Eigenschaft, die man bewusst aufgibt. Wo das fachlich nicht tragbar ist, ist das vermutlich ein Hinweis, dass die beiden Teile zusammengehören.
Erst die Werkzeuge, dann die Dienste
Ein Gedanke, der mir fast am wichtigsten ist: Die Infrastruktur muss vor dem ersten Dienst stehen. In einem Monolithen schaut man in ein Logfile und eine Datei mit einem Stacktrace. Sobald eine Anfrage durch mehrere Prozesse läuft, wird die Fehlersuche ohne Hilfsmittel zum Rätsel. Zentrale Logs, eine Kennung, die jede Anfrage durch alle Dienste begleitet, Metriken, und wenn möglich ein Werkzeug für verteiltes Tracing, wie Zipkin es vormacht, sollten bereitstehen, bevor man abspaltet.
Dasselbe gilt fürs Ausliefern. Wer für einen Monolithen einmal im Quartal von Hand deployt, wird das mit zehn Diensten nicht durchhalten. Automatisierter Build, automatisierte Tests, reproduzierbare Umgebungen und die Möglichkeit, einen Dienst unabhängig auszuliefern: Ohne diese Grundlagen verwandelt sich der Gewinn an Flexibilität in einen Verlust an Überblick.
Was ich daraus mitnehme
Ob sich der Aufwand lohnt, hängt weniger von der Größe des Codes ab als von dem, was einen schmerzt. Wenn verschiedene Teile sehr unterschiedlich skalieren müssen oder ihr Änderungstempo auseinandergeht, kann ein Schnitt viel bringen. Wenn dagegen ein überschaubares System ohne solche Spannungen läuft, gewinnt man oft nur zusätzlichen Betrieb. Meine Skepsis von 2014 gilt dafür weiter. Neu ist, dass ich das Thema nicht mehr als Glaubensfrage sehe, sondern als Abwägung, bei der zuerst die Ordnung im Inneren kommt und die Verteilung bestenfalls als Folge.
Christian Ackermann
Softwareentwickler (C++, C, Java, C#) aus der Oberpfalz, bloggt seit 2001 über Linux, Technik und Kochen.