Kolumne: EnterpriseTales - Die Wahl der richtigen Architektur

Müssen es immer Microservices sein?

Arne Limburg

© S&S_Media

Microservices waren in den letzten Jahren ein echtes Hypethema. Doch vielerorts setzt nach der ersten Euphorie nun langsam Ernüchterung ein. Zeit für mich, einmal einen unvoreingenommenen Blick auf die Wahl der richtigen Architektur zu werfen.

Architekturtrends scheinen sich wie so vieles in Wellenbewegungen zu entwickeln. Um die Jahrtausendwende war SOA (Service-oriented Architecture) der Architekturtrend schlechthin. Bereits ein paar Jahre später merkte man, dass der gewählte Ansatz große Nachteile hatte: Systeme, die zur Entwicklung entkoppelt waren, mussten zur Laufzeit dennoch zusammenspielen. Schnittstellenänderungen mussten immer an zwei Systemen (Aufrufer und Aufgerufener) implementiert werden. Der Versuch, das Problem mit einem Enterprise Service Bus zu entschärfen, scheiterte, weil weiterhin zwei Systeme angefasst werden mussten (diesmal Aufgerufener und ESB).

Da war es doch deutlich leichter, gleich alles in eine Deployment-Einheit zu packen – es entstanden monolithische Applikationen. Irgendwann wurden diese allerdings so groß, dass sie für die Teams nicht mehr beherrschbar waren. Die Folge war, dass scheinbar kleine Änderungen unverhältnismäßig lange brauchten, bis sie umgesetzt waren. Getrieben durch Netflix, Amazon und Co. kam der Trend der Microservices auf, der kleine Änderungszyklen versprach.

Schwergewichtige Monolithen

Aber was genau hindert die Entwickler eigentlich daran, auch in den alten Monolithen neue Features schnell in Produktion zu bringen? Meistens sind es die so viel zitierten „historisch gewachsenen“ Strukturen. Diese manifestieren sich in unleserlichem Spaghetticode mit nicht nachvollziehbaren Aufrufketten. Dazu kommt der Mangel an automatisierten Tests.

Bei Monolithen, die im ersten Jahrzehnt dieses Jahrtausends entstanden sind, wurde häufig ein großer Fokus auf die Schichtenarchitektur gelegt. Die Einhaltung dieser Architektur war in der Regel wichtiger, als auf den fachlichen Schnitt zu achten.

Wenn es überhaupt fachliche Module gab, bezogen sich diese häufig auf Domänenobjekte und nicht auf Use Cases. Das hat(te) zur Folge, dass an einzelnen Use Cases (häufig sogar an einzelnen Requests) mehrere Module beteiligt waren (nämlich alle Module der beteiligten Domänenobjekte). Das Ergebnis war ein unübersichtlicher Codefluss durch die Module und hohe Abhängigkeiten zwischen den Modulen.

Ein Entwickler konnte dann nicht mehr genau abschätzen, was seine Codeänderung tatsächlich für Auswirkungen auf die beteiligten Module hat. Fehlende automatisierte Tests taten ihr Übriges. Nach jeder noch so kleinen Änderung musste eigentlich die gesamte Anwendung auf unerkannte Seiteneffekte überprüft werden. Mehrwöchige Testphasen vor einem Release waren die Folge, was im Ergebnis zu maximal zwei bis vier Releases pro Jahr führte. Viele der so aufgebauten Monolithen existieren heute noch. Allein die Tatsache der seltenen Releases führt dazu, dass ein Feature verhältnismäßig lange braucht, um in Produktion zu gelangen. Dazu kommen der höhere Entwicklungs- und Testaufwand.

Darüber hinaus erfordert der ungünstige Modulschnitt bei jeder Änderung einen hohen Abstimmungsaufwand zwischen verschiedenen Teams. Das erhöht natürlich die Entwicklungszeit eines Features weiter.

Kleinere Änderungszyklen

Aber was ist an Microservices anders? Warum ist es mit ihnen schneller möglich, Features in Produktion zu bringen?

Da ist zunächst einmal der Schnitt. Microservices sind grundsätzlich vertikal nach Use Cases geschnitten. Die Realisierung eines „normalen“ Features betrifft dann in der Regel nur einen Microservice. Allein dieser Unterschied zu den Monolithen sorgt schon dafür, dass die meisten der oben genannten Probleme der Monolithen nicht mehr vorhanden sind: Da ein Microservice verhältnismäßig klein ist, ist der Code überschaubar und die Seiteneffekte eines Features sind abschätzbar. Zudem ist es bei einer so kleinen Codebasis einfacher, die Testabdeckung hochzuhalten. Abstimmung zwischen verschiedenen Teams fällt häufig ganz weg.

Sollten sich zwei Microservices doch einmal gegenseitig aufrufen müssen, so ist diese Kommunikation expliziter. Eine Schnittstellenänderung muss aktiv kommuniziert und so umgesetzt werden, dass die aufrufenden Microservices nicht kaputtgehen. Ein Entwickler kommt nicht „mal eben“ auf die Idee, eine Schnittstelle zu ändern und die aufrufende Seite gleich mit zu ändern, so wie es vielleicht im Monolithen der Fall wäre. Das hat zur Folge, dass derjenige, der den Code ändert, sich im betroffenen Code auch gut auskennt. Im Monolithen gilt das ggf. nicht für beide Seiten der Schnittstelle.

Dadurch, dass die Änderungen überschaubar sind, ist natürlich auch der Testaufwand überschaubar, der betrieben werden muss, bis der Code produktiv gehen kann. Im Idealfall läuft der komplette Prozess bis zum Deployment in die Produktionsumgebung automatisch ab. Dadurch sind viel häufigere Releases möglich, als es beim Monolithen der Fall war. Allein deshalb ist ein Feature deutlich schneller in Produktion als früher.

Ein weiterer Punkt von Microservices, der die Durchlaufzeit von Features verringert, ist die Unabhängigkeit der einzelnen Teams. Selbst wenn zwei Microservices voneinander abhängen, sieht es der Architekturansatz vor, dass sie unabhängig entwickelt, deployt und betrieben werden können.

Der Preis der Unabhängigkeit

Diese Unabhängigkeit bekommt man natürlich nicht geschenkt. Wenn es Beziehungen zwischen Microservices gibt, muss natürlich ein besonderes Augenmerk auf das Schnittstellendesign gelegt werden. Clients müssen als Tolerant Reader implementiert und serverseitig müssen Schnittstellenänderungen abwärtskompatibel realisiert werden, um die Entwicklungs- und Laufzeitunabhängigkeit zu bewahren. Das alles ist selbstverständlich mit einem höheren Aufwand verbunden. Zudem werden Themen wie Logging und Tracing in einer Microservices-Landschaft deutlich komplexer als beim Monolithen. Es werden Architekturvorgaben benötigt, die über die Codestruktur innerhalb der einzelnen Services hinausgehen. Statt sich auf den inneren Aufbau der einzelnen Services zu beziehen, regeln diese Vorgaben die Kommunikation der Services untereinander.

Es gibt nun mal gewisse Entscheidungen, die für alle Services gemeinsam getroffen werden müssen. Diese können sowohl technologischer als auch architektonischer Natur sein. Zu behandeln sind Themen wie Service Discovery, Authentication, Tracing, ggf. ein gemeinsames Logformat, um das Logging zu zentralisieren, Health-Checks, Testing-Strategien (z. B. über Consumer-driven Contract Testing) oder auch eine gemeinsame Dokumentation des API (z. B. über OpenAPI). Zu guter Letzt ist es selbstverständlich auch deutlich aufwendiger, einen Zoo an Microservices zu betreiben als einen einzelnen Monolithen.

Der Vorteil der Unabhängigkeit einzelner Microservices muss also gegen den Aufwand aufgewogen werden, der entsteht, wenn eine Microservices-Architektur betrieben und weiterentwickelt wird. Nicht immer ist es dabei für die Gesamtapplikation sinnvoll, sie auf Microservices umzustellen.

Modulare Monolithen

Der große, bereits erwähnte Vorteil von Microservices ist, dass sie unabhängig voneinander entwickelt, deployt und betrieben werden können. Das Hauptproblem, das ich oben bei den „alten“ Monolithen beschrieben habe, besteht aber häufig gar nicht aus den drei Aspekten Entwicklung, Deployment und Betrieb. Der größte Schmerzpunkt liegt häufig im Aspekt Entwicklung. Deployment und Betrieb an sich stellen meistens kein Problem dar. Wenn in solchen Monolithen also die Phasen Entwicklung und ggf. noch Abnahmetest beschleunigt werden könnten, würde nichts dagegensprechen, die Applikation als Monolith zu deployen und zu betreiben.

Auch Monolithen lassen sich nämlich gut strukturieren: Zunächst einmal ist die Aufteilung in fachliche Module wichtig. Dabei sollte der Fokus nicht daraufgelegt werden, welche Domänenobjekte ein Modul enthält, sondern vielmehr, welche Use Cases es abbilden soll. Dabei kann es durchaus vorkommen, dass ein Domänenobjekt in unterschiedlichen Ausprägungen in unterschiedlichen Modulen vorkommt. Durch den Fokus auf Use Cases schafft man es im Idealfall, dass die Module komplett unabhängig voneinander sind. Sollten sie doch Abhängigkeiten aufweisen, dann sollten diese lose gekoppelt werden. Fachliche Events, die in Java z. B. als CDI Events oder Spring Application Events realisiert werden können, sollten zur Kommunikation zwischen Modulen verwendet werden. Die Menge der gemeinsam genutzten fachlichen Klassen sollte möglichst gering sein, den fachlichen Kern abbilden und einer möglichst geringen Änderungsfrequenz unterliegen. Eric Evans beschreibt ein solches Modul in seinem Buch „Domain Driven Design“ als „Abstract Core“.

Unabhängige Teams

Wenn es mehrere Teams gibt, die an einem Monolithen entwickeln, ist die Abstimmung der Teams untereinander eine sehr große Herausforderung, die zeitaufwendig sein und kurze Releasezyklen verhindern kann. Dann muss sichergestellt werden, dass jedes Team an einem eigenen Modul arbeitet und dass die Module wie beschrieben entkoppelt sind, der Code also komplett getrennt ist (im Idealfall in unterschiedlichen Source Code Repositories liegt) und es separate Build-Artefakte gibt. Damit die Teams tatsächlich unabhängig arbeiten können, muss die Trennung aber noch weiter gehen: Es darf keine gemeinsam genutzten Datenbanktabellen geben und natürlich auch keine direkten Aufrufe der Module untereinander.

Wenn das alles gewährleistet ist, steht der Entwicklung eines Monolithen (auch mit mehreren Teams) nichts im Weg.

Weitere Gründe für Microservices

Wenn es also möglich ist, modulare Monolithen zu bauen und dadurch an die Entwicklungsgeschwindigkeit von Microservices heranzukommen, ohne sich die Komplexität des Betriebs von Microservices einzuhandeln – welchen Grund gibt es dann noch, Microservices zu bauen?

Natürlich gibt es ein paar weitere Gründe, die ich an dieser Stelle nicht alle aufzählen möchte. Ich greife nur einen exemplarisch heraus: unabhängige Skalierbarkeit. Bereits bevor der Architekturtrend der Microservices aufkam, hatten wir als open knowledge GmbH Kunden, die für spezielle Aufgaben, die besonders viel Rechenpower benötigten, wie z. B. die Verarbeitung von Bildern, spezielle Server aufsetzten, die dann unabhängig von der eigentlichen Applikation skaliert werden konnten. Mit Microservices ist ein solches Szenario noch flexibler zu handhaben. Jeder Microservice kann genau so viel CPU, Arbeitsspeicher und Storage erhalten, wie er benötigt.

Gute Monolithen fallen nicht vom Himmel

Meine Erfahrung zeigt mir, dass die meisten Monolithen (insbesondere die historisch gewachsenen) nicht den Anforderungen entsprechen, die ich oben beschrieben habe. Andere Projekte, die wir in den vergangenen Jahren durchgeführt haben, zeigen aber auch, dass es möglich ist, solche Monolithen zu entwickeln. Was soll man also tun, wenn der eigene Monolith eher zu der Fraktion „historisch gewachsen“ gehört?

Wenn andere Gründe nicht gegen die Entwicklung der Applikation als Monolith sprechen, spricht allein die Tatsache, dass er schlecht strukturiert ist, auch nicht grundsätzlich gegen die monolithische Architektur. Die Frage ist nur: Wie überführe ich den schlecht strukturierten, historisch gewachsenen Monolithen in einen sauber modularisierten Monolithen? Je nachdem, wie der Zustand des Codes ist, ist das natürlich ein mehr oder weniger umfangreiches Unterfangen, das vordergründig der Applikation erst einmal keinen Mehrwert bietet. Wenn man zu seinem Vorgesetzten (oder dem Product Owner) geht und sagt: „Wir brauchen jetzt ein Jahr, um unseren Code neu zu strukturieren. Danach können wir auch wieder schneller Features umsetzen, in der Zwischenzeit gibt es aber keine neuen Features“ – dann wird das selten auf Zustimmung stoßen. Da ist es doch deutlich attraktiver, sagen zu können: Es gibt da dieses neue Architekturparadigma „Microservices“, mit dem kann man besser und schneller entwickeln. Darauf müssen wir umstellen, um wieder effizienter arbeiten zu können.

Natürlich ist es schade, dass vielerorts Budget für eine Umstellung auf Microservices vorhanden ist, nicht aber Budget für ein Refactoring eines bestehenden Monolithen. Man muss diese Gegebenheiten aber annehmen und ggf. z. B. in eine Migrationsstrategie einfließen lassen. Übrigens sollte man dabei auch eine Einschätzung des Budgets einfließen lassen, das für eine Umstellung auf Microservices bereitsteht, auf halbem Wege ausgehen oder gekürzt werden kann.

Es gibt verschiedene Strategien, von einem Monolithen zu Microservices zu migrieren. Ein paar Aspekte gilt es dabei aber unabhängig von der gewählten Strategie zu beachten:

  1. Das grundsätzliche Ziel ist immer das Herauslösen unabhängiger Module …
  2. … und jeder Schritt dorthin sollte immer in sich abgeschlossen und atomar sein, d. h. sollte das Projekt nach einem Schritt abgebrochen werden, sollte das Gesamtsystem auf jeden Fall besser (weil modularisierter) dastehen als vor dem Schritt.

Beachtet man diese beiden Punkte, kann man eine Migration ruhigen Gewissens angehen. Dann ist es auch fast egal, ob am Ende eine Menge an Microservices oder doch „nur“ ein modularer Monolith herauskommt.

Fazit

Damit Softwaresysteme langfristig wartbar sind, sollten sie in (möglichst) unabhängige Module unterteilt werden. Beim Architekturansatz der Microservices ist diese Trennung so strikt, dass sie sich automatisch von der Datenbank über die Codebasis bis zu den Deployment-Einheiten und Laufzeitumgebungen erstreckt. Die strikte Einhaltung der Modulgrenzen ist so auf jeden Fall sichergestellt. Damit einher geht aber eine höhere Komplexität in Laufzeit und Betrieb der Services. Daher sollte man überprüfen, ob in der eigenen Situation nicht andere, weniger strikte Aufteilungen in Module möglich sind. Wenn man seinen Monolithen in kleine, möglichst unabhängige Module unterteilt, ist der Code auch gut wartbar, und kleine Features innerhalb eines Moduls sind schnell zu realisieren.

Arbeitet man mit mehreren Entwicklungsteams an einer Applikation, sollte auf jeden Fall sichergestellt werden, dass diese Teams unabhängig voneinander arbeiten können, um den Abstimmungsaufwand gering zu halten. Auch hier bieten Microservices eine sehr strikte Variante. Mit Microservices sind Teams in der Lage, unabhängig zu entwickeln und auch zu deployen. Eine unabhängige Deploybarkeit bietet häufig gar keinen Mehrwert.

Ziel sollte es immer sein, die Anwendung modular aufzubauen. Und das ist ja auch eines der Ziele von „Microservices“-Migrationen. Aber unabhängig davon, ob das Schlagwort „Microservices“ im Budgetantrag auftaucht oder nicht: Wichtig ist, die Migration so zu planen, dass jeder Schritt auch dann einen Mehrwert bietet, wenn die Migration nach ihm abgebrochen wird.

Wenn man am Ende einen modularen Monolithen statt einer Microservices-Architektur hat, kann das häufig die beste Lösung sein. Um das zu erreichen, muss allerdings die richtige Migrationsstrategie gewählt werden. Aber das ist dann eine eigene Kolumne wert. In diesem Sinne – stay tuned.

Geschrieben von
Arne Limburg
Arne Limburg
Arne Limburg ist Softwarearchitekt bei der open knowledge GmbH in Oldenburg. Er verfügt über langjährige Erfahrung als Entwickler, Architekt und Consultant im Java-Umfeld und ist auch seit der ersten Stunde im Android-Umfeld aktiv.
Kommentare

1
Hinterlasse einen Kommentar

avatar
4000
1 Kommentar Themen
0 Themen Antworten
0 Follower
 
Kommentar, auf das am meisten reagiert wurde
Beliebtestes Kommentar Thema
1 Kommentatoren
Marc Teufel Letzte Kommentartoren
  Subscribe  
Benachrichtige mich zu:
Marc Teufel
Gast
Marc Teufel

Nein, es müssen nicht immer Micro Services sein! Definitv nicht! Ich möchte hier auch noch auf den Leitartikel des aktuellen Java Magazins verweisen. „KISS of Death by Complexity“ von Uwe Friedrichsen. Diesen zu lesen lohnt sich – auch bei der Frage: Machen Micro Services im eigenen Dunstkreis überhaupt Sinn? —> https://kiosk.entwickler.de/java-magazin/java-magazin-10-2020/kiss-of-death-by-complexity-2/