Zusätzliche Komplexität durch Microservices
Wie in der Einführung zu dieser Artikelserie erwähnt, haben Microservices nicht nur Vorteile und man kann schnell in eine Falle tappen, aus der man nur schwer hinauskommt und man sich den Monolithen wieder zurückwünscht.
Typischerweise sind es nicht die rein technischen Herausforderungen, die zum Problem werden. Die Wahl des Microservice-Frameworks, der PaaS-Technologie oder der Infrastrukturkomponenten ist nur selten der Grund für ein Scheitern einer Microserices-Architektur. Vielmehr sind es die organisatorischen und architektonischen Aspekte, deren Bedeutung man am Anfang oft unterschätzt und die einem dann umso stärker auf den Kopf fallen.
Ein Beispiel: Die technische Herausforderung, Services über das Netzwerk miteinander kommunizieren zu lassen, ist trivial. Unabhängig davon, ob man synchrones REST, asynchrone Events oder andere Protokolle dafür verwendet. Was aber viel mehr Kopfzerbrechen bereitet ist, wenn Upstream-Services sich weiterentwickeln, man die Schnittstellen (nicht abwärtskompatibel!) anpassen muss und was das für Auswirkungen auf die Downstream-Services hat. Welche technischen und organisatorischen Möglichkeiten hat man geschaffen, um mit dieser Situation umzugehen?
- Wie bekommen die Teams, die die Downstream-Services entwickeln, diese Änderung mit?
- Müssen die ihren Code sofort anpassen oder wird eine neue Version vom Upstream-Service, parallel zur Aktuellen, deployt und es gibt eine gewisse Übergangsfrist?
- Wie werden die Änderungen an der Schnittstelle beschrieben, sodass man die Konsequenzen davon verstehen kann? Die syntaktische Komponente lässt sich zwar gut, z.B. mit OpenAPI, beschreiben, die Semantik der Änderung ist aber oft um Einiges gefinkelter.
- Können die Downstream-Teams die Änderungen aus guten Gründen beeinspruchen oder müssen sie diese hinnehmen?
- Wie geht man mit Regressionen um, die passiert sind, obwohl eigentlich Abwärtskompatibilität garantiert wurde? Bekommt das Upstream-Team diese direkt mit und muss die beheben oder ist das Downstream-Team in der Verantwortung diese zu erkennen und zu melden?
- Apropos Regressionen: Wie beweist man, dass eine Änderung zu Regressionen geführt hat? Kennt das Upstream-Team die Art und Weise, wie ihr Service aufgerufen wird, sodass es die Auswirkungen einer Änderung abschätzen kann?
Wenn man sich solcher Probleme nicht bewusst ist, dann kann es den Spaß an Microservices, die innerhalb eines Produktteams entwickelt werden, schnell vermiesen. Wenn sich die Probleme aber über mehrere organisatorische Einheiten im Unternehmen erstrecken, dann bekommt das eine ganz besondere Dynamik und sorgt für böses Blut und unangenehme Meetings.
Wann machen Microservices Sinn?
Was sind überhaupt gute Gründe für Microservices? Anders gefragt: Unter welchen Bedingungen bringen einem Microservices insgesamt mehr Vorteile als Nachteile?
Meiner Meinung nach gibt es zwei Szenarien, in denen es definitiv Sinn macht:
- Man muss einzelne Komponenten eines Systems zur Laufzeit deployen und aktualisieren können.
- Das System ist so groß, dass es von mehreren Teams entwickelt wird und man will diesen maximale Autonomie geben.
Beim ersten Punkt steht die Evolution des Gesamtsystems im Mittelpunkt, welches aus fachlichen Komponenten aufgebaut ist, deren Zusammenspiel die Funktionalität des Systems ausmacht. Hier helfen Microservices zur Laufzeit, indem man das System selektiv aktualisieren und erweitern kann.
Bei der Aufteilung kann auch Resilienz eine Rolle spielen, da z.B. der Wirkungsradius eines Speicherlecks geringer ist, wenn es nur eine separat deployte Komponente betrifft.
Der zweite Punkt hingegen bezieht sich auf den Prozess der Softwareentwicklung. Selbst mit sauber definierten Schnittstellen zwischen Komponenten, ist die Entwicklung von großen Systemen eine Herausforderung, vor allem wenn die beteiligten Teams aus unterschiedlichen organisatorischen Einheiten stammen. Coding-Richtlinien, Softwaredesign, Deploymentprozesse und Teststrategien müssen dann zu einem großen Teil abgestimmt werden, was oft sehr herausfordernd ist. Man hat das Gefühl, dass man sich mehr im Weg steht, als dass die Zusammenarbeit auf diese Art irgendeinen Mehrwert stiftet. Auch hier können Microservices helfen, indem die Teams voneinander entkoppelt werden.
Es gibt noch weitere Gründe, z.B. dynamische Skalierbarkeit zur Laufzeit. Nämlich dass eine Microservices-Architektur das Potential hat, flexibler auf steigende Lasten zu reagieren. Bei diesem Argument bin ich aber sehr vorsichtig, auch wenn ich es schon oft gehört habe, um die Einführung von Microservices zu argumentieren. Ich bin der Meinung, dass eine Anwendung schon sehr hohe Lasten ertragen muss, damit der Punkt erreicht ist, ab dem der Monolith zu schwach skaliert. Dass Netflix und Amazon zur Laufzeit auf Microservices setzen müssen, das ist klar. Das Backend einer Banken-Applikation, die vielleicht ein paar Tausend parallele Sessions bedienen muss, würde auch als Monolith funktionieren.
Das Argument der besseren Modularisierung und der geringeren Komplexität der einzelnen Services ist an sich auch nicht überzeugend, da man genauso innerhalb eines Monolithen eine saubere Modulstruktur pflegen kann. Im Gegenteil, wenn man es nicht schafft einen Monolithen sauber zu strukturieren, dann wird es einem mit Microservices auch nicht gelingen.
Deswegen ist meine These: Wenn ich handfeste Vorteile darin sehe, dass ich Komponenten zur Laufzeit voneinander unabhängig deployen und/oder diese zur Entwicklungszeit von unterschiedlichen Teams bauen lassen kann, dann machen Microservices Sinn. Wenn dies nicht gegeben ist, dann ist die Wahrscheinlichkeit, dass sich Microservices "auszahlen werden", gering.
Aber gehen wir mal davon aus, dass es für unseren Anwendungsfall Sinn macht. Wie geht man das nun an?
Wie beginnt man eine Microservices-Architektur zu bauen?
Gegenfrage: Muss man ein System wirklich bereits von Beginn an als Microservices umsetzen? Ich behaupte: Nein!
Vor allem zu Beginn der Entwicklung ist die Struktur des Systems noch relativ "flexibel". Man analysiert (hoffentlich) die Domäne, leitet sich eine Modularisierung daraus ab, baut Modelle und und füllt damit Datenbanken. Und was so gut wie immer passiert, ist dass man vergangene Erkenntnisse revidiert oder zumindest adaptiert und dann oft draufkommt, dass die Realität doch ein wenig anders aussieht. Man beginnt dann zu refactoren, um die Systemstruktur an das neue Bild der Realität anzupassen. Vielleicht lag man auch anfangs richtig, es haben sich aber die Anforderungen geändert und wieder muss man alles anpassen. Es bleibt dynamisch.
Was sich aber sehr wohl nach einer gewissen Zeit (einigermaßen) stabilisiert, ist die Makrostruktur. Die großen Module, die die Domänen abbilden, kristallisieren sich heraus und bleiben dann. Es ist aber extrem schwierig, diese Modulstruktur am Anfang der Entwicklung vorherzusehen.
Wenn man bereits am Anfang die noch volatile Architektur in Microservices umsetzt, dann sind diese Refactorings sehr teuer. Ein Grund hierfür ist, dass Microservices sich niemals eine Datenbank teilen dürfen. Wenn man dann vor dem Problem steht, dass man die Makrostruktur adaptieren und Microservices und deren Datenmodelle trennen bzw. zusammenlegen muss, dann ist das zur Entwicklungszeit besonders aufwendig, vor allem wenn die Daten in der Produktion on-the-fly migriert werden müssen.
Nicht nur deswegen empfehlen viele, Systeme am Anfang gar nicht als Microservices zu bauen, sondern mit einem Monolithen zu beginnen ("Monolith-first" [^1]). Wenn sich die Architektur abgekühlt hat und man zuversichtlich ist, dass die Modularisierung sinnvoll und zukunftsträchtig ist, kann man beginnen die Module inkrementell als Microservices herauszulösen.
Basics der Modularisierung
Es gibt unterschiedliche Ansätze, wie man zu einer sinnvollen Modularisierung kommt. Diese jetzt gegenüberzustellen, ist nicht im Scope von diesem Artikel. Ein paar generelle Beobachtungen und Empfehlungen will ich aber teilen.
Die wohl wichtigste Regel, die ich mitgeben will, ist, dass Module anhand fachlicher Kriterien geschnitten werden sollten, nicht (wie schon leider oft gesehen) anhand technischer. Das beginnt bei der Package-Struktur innerhalb einer Komponente und geht bis zur Makrostruktur, die dann in Microservices resultieren kann. Nur dann kann man ein sinnvolles Datenmodell entwerfen und dem Modul für diese Daten auch die fachliche "Ownership" gewähren.
Im Allgemeinen empfiehlt es sich, sich mit Domain-driven Design auseinanderzusetzen, im Besonderen mit den Ansätzen für Strategic Design. Module nach den Grenzen vom Bounded Context zu schneiden, führt zu starker Kohärenz und macht es unwahrscheinlich, dass man in Zukunft die Modulstruktur von Grund auf refactoren muss.
Wo wir gerade beim Thema Bounded Contexts sind. Das Identifizieren dieser ist nicht trivial, bedarf einiger Erfahrung und vor allem sind hier tiefgreifende Domänekenntnisse erforderlich. Hier ist die klare Empfehlung, dass die Kommunikation zwischen den Entwicklern und den Domänenexperten (a.k.a. Fachbereich) nicht zu kurz kommt. Künstliche Kommunikationsblocker ("Entwickler reden nur mit dem Business-Analysten, dieser redet dann mit dem Fachbereich") richten viel Schaden an. Technische Exzellenz bringt wenig, wenn Entwickler die Domäne, das Business und die Probleme die sie lösen sollen, nicht ausreichend verstehen. Und das rächt sich dann auch mit einer falschen Modularisierung. Methoden, wie Domain Storytelling und Event Storming, bieten eine prima Basis für das gemeinsame Erarbeiten eines Verständnisses der Domäne.
Wenn man Module in einzelne Microservices herauszieht, dann kommt noch eine weitere Regel hinzu (die bei einer Modularisierung innerhalb eines Monolithen nicht in dieser Strenge gilt): Die Services müssen auch isoliert funktionieren. Laufzeitabhängigkeiten zwischen Services, die bei Nichterfüllung zu Fehlern führen, sind zu vermeiden. Z.B indem man die Services gar nicht erst in Einzelne trennt, wenn sie eine logische Einheit darstellen, die nur im Tandem funktioniert. Aufgrund der Dynamik eines verteilten Systems, ist es unmöglich die Verfügbarkeit eines Services zu garantieren. Deswegen dürfen Ausfälle zwar in einer reduzierten fachlichen Funktionalität resultieren (Ratingservice ist down, User sieht keine Produktbewertungen), aber keine Fehlerkaskaden in den anderen Services verursachen (Shoppingservice ist trotzdem verfügbar und nimmt Bestellungen an).
Module sind identifiziert, was nun?
Wir kennen die Domäne also nun gut genug, um eine sinnvolle Modularisierung zu definieren und sind nach intensiven Überlegungen zu der Meinung gelangt, dass wir den Split zu Microservices brauchen. Wie gehen wir das nun an?
Auch hier gilt es am Boden zu bleiben und mit kleinen Schritten anzufangen. Man muss nicht vom Tag 1 an, eine full-blown Microservice-Architektur, die Netflix ebenbürtig ist, auf die Beine stellen. Stattdessen muss man sich auf die überlebenswichtigen Features konzentrieren und Schritt für Schritt eine stabile Plattform bauen. Und klarerweise nicht auf die organisatorischen Aspekte vergessen!
Wie so ein Pfad aussehen kann, das werden wir uns im nächsten Beitrag ansehen.
Abschließende Worte
Beim Schreiben dieses Artikels habe ich an mehrere Projekte aus den letzten fünf Jahren zurückdenken müssen. Die Warnung, nicht zu schnell eine Microservices-Architektur zu bauen, sondern sich zuerst auf die Lösung des eigentlichen Problems zu konzentrieren, basiert auf zahlreichen Beobachtungen von Projekten, wo es umgekehrt angegangen wurde und man dann einen hohen Preis dafür gezahlt hat. Man hat viele Bürden auf sich genommen, die unnötig waren. Man hat die auch kaum hinterfragt, da man überzeugt war, mit Microservices auf das richtige Pferd gesetzt zu haben. Die Frage, wieso man sich für diesen Weg entschieden hat, hat man aber nur selten überzeugend beantworten können.
Deswegen ist es mir wichtig, Pragmatismus zu predigen und wiederholt die Frage nach dem "warum?" zu stellen. Microservices machen durchaus Sinn, aber sie sind kein Silver-Bullet. Das Produkt muss schon ein wenig reifen und oft auch seine Daseinsberechtigung am Markt erkämpft haben, damit der Schritt zu Microservices gerechtfertigt ist.
Um Randy Shoup aus seinem Talk "Monoliths, Migrations, and Microservices" [^2] zu zitieren:
No one starts with microservices. (...) Past a certain scale, everyone ends up with microservices.
Und noch viel treffender:
There may have been an ebay or Amazon competitor in 1995, that instead of building a business built a distributed system. But there's a reason why we've never heard of that company.
[^1] Martin Fowler - Monolith-first: https://martinfowler.com/bliki/MonolithFirst.html
[^2] Randy Shoup – Monoliths, Migrations, and Microservices: youtu.be/gOZFmFNl1uk