Über unsMediaKontaktImpressum
Daniel Rosowski 22. Januar 2019

Vom Monolithen zu Self-Contained-Systems

Wer sich in den letzten Jahren auf diversen Konferenzen zu Software- und Architekturthemen getummelt hat, könnte den Eindruck gewinnen, Monolith ist kein Architekturstil mehr, sondern ein Schimpfwort. Das dem nicht so ist und wieso wir uns trotzdem für eine sanfte Migration zu einem anderen Architekturstil entschieden haben, darüber soll der folgende Artikel berichten.

Was verstehen wir unter einem Monolith?

Unter dem Begriff Monolith wird in der Softwarearchitektur ein Softwaresystem als zusammenhängende logische Einheit verstanden. Je nach Perspektive ist es sinnvoll, den Begriff noch weiter zu unterscheiden [1].

Modul-Monolith

Bei dieser Art des Monolithen wird der gesamte Quelltext des Softwaresystems innerhalb eines Moduls organisiert. Ein Modul kann bspw. eine JAR-Datei oder eine DLL sein. Das einzige Werkzeug zur Strukturierung und Organisation sind in diesem Fall programmiersprachliche Mittel wie z. B. Packages oder Namespaces.

Deployment-Monolith

Bei dem Deployment-Monolithen ist der Quelltext in unterschiedlichen Modulen organisiert. Allerdings müssen die Module beim Deployment immer als Ganzes ausgeliefert werden und können nicht unabhängig voneinander deployt werden. Ausnahmen wie OSGi bestätigen hier die Regel.

Laufzeit-Monolith

Bei dieser Art von System handelt es sich in der Regel um einen Deployment-Monolith, der weitere Funktionen über Remote Calls angebunden hat (s. Abb. 3).

Man spricht trotzdem von einem Monolithen, wenn die Remote-Systeme zwingend für den normalen Betrieb der Software zur Verfügung stehen müssen und nicht fehlertolerant (resilient) angebunden sind.

Monolithen sind böse! Immer?

In den letzten Jahren sind Monolithen immer mehr in Verruf geraten, obwohl dieser Architekturstil durchaus viele Vorteile bietet. Es gibt einige ernst zu nehmende Beispiele aus der Praxis, die zeigen, dass ein Monolith sehr wohl wartbar und zukunftsfähig sein kann, auch wenn Vertreter in Fachmedien und auf Konferenzen uns vom Gegenteil überzeugen wollen. Ein gern genommenes Beispiel ist Etsy. Bei Etsy arbeiten 150 Entwickler an einer monolithischen Anwendung, die 60 mal pro Tag deployt wird! Mit 1.49 Mrd. Page Views, 22+ Mio. Mitgliedern und knapp 95 Mio. Dollar Umsatz handelt es sich um den größten Markplatz für Handgemachtes weltweit.

In der Realität ist eine spezielle Form des Monolithen aber deutlich häufiger anzutreffen: Der Big Ball of Mud (BBoM). Der BBoM ist ein Softwaresystem mit einer hohen Entropie (Unordnung in einem System [2]), das auf ein uneingeschränktes Wachstum zurückzuführen ist. Abhängigkeiten werden da eingebaut, wo sie gerade gebraucht werden, was zu dem berühmten Spaghetti-Code führt. Zirkuläre Abhängigkeiten sind an der Tagesordnung und auch um andere Metriken wie Code-Duplikation ist es leider nicht sehr gut bestellt.

Ausgangslage

Diese besondere Art des Monolithen war es auch, die wir bei unserem Kunden vorfanden. Mit jeder Retrospektive wuchs die Unzufriedenheit der Entwickler, der Qualitätssicherung, des Betriebs und auch der Fachabteilung. Features konnten nicht mehr zeitnah umgesetzt werden, die Anzahl der Fehler auf Produktion nahm zu und auch die Stimmung innerhalb des Teams wurde zunehmend angespannter. Allen Beteiligten war klar, dass etwas passieren musste. Um den Ursachen der genannten Probleme auf den Grund zu gehen, haben wir uns entschieden, die letzten Retrospektiven einer Root-Cause-Analyse zu unterziehen. Je ein Vertreter aus jedem Bereich und ein Moderator haben an der Analyse teilgenommen. Im ersten Schritt wurden die Probleme in Gruppen zusammengefasst, also z. B. die Probleme, die Entwicklung, Testing oder Deployment betreffen. Als nächstes haben wir mit der 5-Why-Methode Ursachenforschung betrieben [3]. Letztlich konnte durch die Root-Cause-Analyse die Vielzahl der Probleme auf einige wenige  Ursachen zurückgeführt werden.

  1. Lange Releasezyklen
  2. Test-Eistüte
  3. Langer Buildprozess
  4. Teambildung eingeschränkt

Lange Releasezyklen

Eines der agilen Mantren lautet "release early, release often". Da sich die Entwicklungsabteilung des Kunden selbst für "agil" hielt, war das natürlich auch der Anspruch. Der Fachbereich sollte innerhalb kurzer Zeit den Fortschritt am Produkt sehen können und Feedback geben können. Leider gestaltet sich das bei einem Modul-Monolithen nicht ganz so einfach. Durch die große Codebase gibt es nur ein "Alles-oder-Nichts-Deployment". Entweder das gesamte Produkt wird deployt, oder aber es wird gar nichts deployt. Dem versucht man durch Feature-Branches Herr zu werden, was allerdings wieder zu neuen Problemen führt (Stichwort CI-Theater [4]). Außerdem ist durch die lückenhafte Testabdeckung die Qualitätssicherung am Ende einer Iteration der Flaschenhals, da viele Dinge manuell oder teilweise manuell getestet werden müssen.

Test-Eistüte

Mike Cohn hat vor etlichen Jahren sein Idealbild einer Testumgebung für ein Softwaresystem vorgestellt. Dieses Bild hat er Testpyramide genannt, da es mit einer breiten Basis startet und nach oben hin spitz zuläuft [5]. An der breiten Basis sind die Unittests angesiedelt. Davon soll es reichlich geben, da ein Unittest schnell zu entwickeln ist (günstig) und auch sehr schnell in der Ausführung sein soll. Zumindest dann, wenn es sich um einen echten, isolierten Unittest handelt. Es gibt mittlerweile verschiedene Varianten der Testpyramide und je nachdem welche man nimmt, findet man in der Spitze der Pyramide UI- oder Akzeptanztests. Davon sollte es nach Möglichkeit wenig geben, da diese Art von Tests sehr aufwändig zu entwickeln (teuer) und i. d. R. auch sehr langsam in der Ausführung sind. Die Konsequenz davon sind lange Buildprozesse und lange Releasezyklen.

Langer Buildprozess

Der lange Buildprozess kommt durch die große Codebase im Modul-Monolithen und die erwähnte Test-Eistüte zustande. Von einigen Kollegen wurde der Buildprozess als der "Puls der Entwicklung" bezeichnet. D. h. ein roter Build gleicht einem Herzstillstand und muss so schnell wie möglich behoben werden. Ein roter Build sollte auch als Stoppschild gelten, so dass keine Commits (Pushs) mehr erlaubt sind, solange der Build rot ist. Wird hingegen bei einem roten Build weiter fröhlich vor sich hin commitet, gleicht die Suche nach dem kaputten Commit hinterher der berühmten Nadel im Heuhaufen. Ein langer Buildprozess betrifft aber auch die lokale Entwicklung. Welche Entwicklerin möchte schon 45 Minuten warten, bis die Tests lokal durchgelaufen sind, um dann erst zu committen? "Das wird schon der CI-Server machen" heißt es dann und dann kommt es häufig zu den besagten Mustern.

Softwareentwicklung ist eine hochkommunikative Teamleistung – zumindest für nicht-triviale Systeme.

Teambildung eingeschränkt

Eine Maßnahme aus den Retrospektiven hieß "fachliche Teams". Die Idee war gut. Man bildet fachliche Teams, die für eine bestimmte Funktion des Produkts zuständig ist (z. B. Kundenschnittstelle oder Reporting). Die fachlichen Teams setzen sich dann aus Entwicklern, Qualitätssichlern, Betrieblern und auch einem Vertreter des Fachbereichs zusammen. Leider war es durch die monolithische Architektur ein schwieriges Unterfangen, da sich die Fachlichkeit u. U. quer durch die Codebase gezogen hat. Der Monolith behindert bei der Teambildung. Tatsächlich handelt es sich um eine Wechselwirkung zwischen Organisationsstruktur und der Architektur eines Softwaresystems. Die Referenz zu Conways Gesetz ist mittlerweile ja schon fast zu einem Allgemeinplatz geworden [6]. Trotzdem darf sie an dieser Stelle nicht fehlen. Softwareentwicklung ist nun mal eine hochkommunikative Teamleistung – zumindest für nicht-triviale Systeme.

Teilsysteme bilden

Die Entscheidung, den Monolithen in kleinere Teilsysteme zu zerlegen, ist gefallen. Das sollte aber nicht bedeuten, dass der Monolith "aufgegeben" wird. Ganz im Gegenteil. Je strukturierter und aufgeräumter der Monolith ist, desto einfacher wird es in Zukunft sein, die Teilsysteme heraus zu lösen. Aus diesem Grund sollte die weitere Erosion des Monolithen verhindert werden.

Bei der Frage, an welchem Architekturstil wir uns bei der Bildung der Teilsysteme orientieren sollen, schieden sich die Geister. Reflexartig antwortete das eine Lager "Microservices", was denn sonst [7]? Die etwas erfahreneren Entwickler kamen allerdings mit einer gesunden Skepsis daher. Um über den Sinn und Unsinn von Microservices zu philosophieren, sollten wir den Begriff erst einmal definieren.

Microservices

Microservices sind

  • nüchtern gesagt, erst mal nur ein Modularisierungskonzept unter vielen,
  • klein – wobei das jeder unterschiedlich für sich auslegt,
  • separate Deployments,
  • programmiersprachen-unabhängig und
  • nicht eindeutig definiert.

Microservices sind also eine feine Sache, wenn man denn den Bedarf hat, verschiedene Programmiersprachen einzusetzen und viele kleine Teams orchestrieren möchte. Wenn die Entwicklungsabteilung allerdings bis jetzt nur den Monolithen gekannt hat, kann es schnell ziemlich unübersichtlich werden. Das Schneiden eines Microservice ist nicht trivial und auch der Betrieb einer microservice-basierten Architektur ist schwieriger als man auf den ersten Blick vermuten würde. Stichwörter wie Fault Tolerance und Eventual Consistency sollte jeder kennen, der ernsthaft in Erwägung zieht, Microservices zu entwickeln. Denn die Komplexität aus dem Monolith verschwindet nicht auf magische Weise, sie verlagert sich in erster Linie auf den Betrieb.

Self-Contained Systems

Ein anderer Architekturstil, der in letzter Zeit mehr Fahrt aufgenommen hat und der eventuell besser zu einer Enterprise-IT-Landschaft passt, sind die sogenannten Self-Contained Systems (SCS) [8]. Auch wenn das Konzept nicht wirklich neu, geschweige denn revolutionär, ist, hat es geholfen, dem Kind einen Namen zu geben. Wir sprechen von einem SCS, wenn

  • das System größer als ein Microservice ist,
  • es in sich geschlossen ist, d. h. eine Kommunikation mit der Außenwelt nicht zwingend für den normalen Betrieb des Systems erforderlich ist,
  • es sich i. d. R. um eine Webanwendung mit UI handelt und
  • es mit anderen Systemen über die UI integriert wird.

Durch die Größe einerseits und die beschränkte Kommunikation mit anderen Systemen auf der anderen Seite, passt es deutlich besser in die Enterprise-IT als die Microservices. Oder anders ausgedrückt: bedingt eine Microservice-Architektur den Einsatz von entsprechenden Tools, die das Deployment und die Orchestrierung der verschiedenen Microservices vereinfacht oder überhaupt erst ermöglicht. Man denke in dieser Hinsicht z. B. an Docker und Kubernetes. Da wir es bei einer SCS-Architektur im Vergleich mit weniger und dafür größeren Systemen zu tun haben, ist der Betrieb nicht gezwungen, einen Zoo an neuen Tools zu beherrschen, bevor die neue Architektur in Betrieb genommen werden kann. Sicherlich sind Docker und Co. auch für diesen Anwendungsfall hilfreich, aber es geht eben noch mit den bekannten Tools, ohne dass man den Verstand verliert.

Neben den bereits genannten Gründen haben wir uns auch wegen der Homogenität der Programmiersprache für eine SCS-Architektur entschieden. Das Produkt ist eine klassische JEE-Anwendung und da alle Entwickler einen Java-Hintergrund haben, lag es nahe, sich auf diese Programmiersprache zu beschränken. Außerdem sollten unsere Teilsysteme über eine eigene UI verfügen, die hinterher in einem gemeinsamen Portal integriert werden, so wie die Anwender es bisher von dem Monolithen gewohnt waren.

Der richtige Schnitt

Der Monolith war bisher nur in technische Module aufgeteilt, das Backend, die Weboberfläche und die Batches. Eines der Ziele war es, fachliche Teams zu bilden, d. h. wir mussten auch die Module fachlich schneiden. Aber mit welcher Fachlichkeit fängt man an? Dartpfeile werfen oder würfeln schien uns nicht angemessen, also haben wir nach anderen Metriken als Entscheidungsgrundlage gesucht. Als erstes haben wir untersucht, welche Teile des Systems in der Vergangenheit besonders häufig geändert wurden. Dazu haben wir zum einen das Ticketsystem befragt und uns eine Auswertung der Epics geben lassen, die in den Sprints der letzten Monate eine Rolle gespielt haben. Zusätzlich haben wir die Commits untersucht und ein Reporting der geänderten Pfade der letzten Monate generiert.

Neben dem Blick in die Vergangenheit sollte aber auch die zukünftige Entwicklung nicht außer Acht gelassen werden. Wir haben einen Blick in die Glaskugel riskiert und untersucht, was sich in der Zukunft wohl am häufigsten ändern wird. Dazu haben wir das Backlog analysiert und Gespräche mit den Fachbereichen und anderen Stakeholdern geführt. Besonders hilfreich bei der Entscheidungsfindung war die Analyse innerhalb der AAG. Jeder erfahrene Entwickler hatte einen "Favoriten", was seiner oder ihrer Meinung nach als erstes in ein eigenes Teilsystem ausgelagert werden sollte.

Kundenschnittstelle

Es herrschte ziemlich schnell Konsens darüber, dass die Kundenschnittstellen als erstes ausgelagert werden müssen. Die Neuanlage einer neuen Kundenschnittstelle konnte bisher nur im Monolithen stattfinden. Somit musste für die Aufschaltung eines neuen Kunden erst eine neue Version des kompletten Produkts deployt werden. Dadurch hat sich, zur Unzufriedenheit des Kunden, der Prozess entsprechend in die Länge gezogen. Neben den kaufmännischen Problemen waren die Kundenschnittstellen aber auch durch technische Probleme gekennzeichnet, z. B. eine hohe Codeduplikation, schlechte Testbarkeit oder eine hohe Kopplung. Der perfekte Kandidat also. Die Ausgangslage sah wie folgt aus (s. Abb. 1).

Der Kunde legt den zu verarbeitenden Datensatz auf dem FTP-Server ab. Die Eingangsschnittstelle pollt den FTP-Server, findet den neuen Datensatz und legt diesen in der Datenbank ab. Daraufhin startet die Verarbeitung des Datensatzes und legt das Ergebnis wiederum in die Datenbank. Die Ausgangsschnittstelle bringt das Ergebnis in das vom Kunden gewünschte Format und legt das Ergebnis dann auf dem FTP-Server ab. Sämtliche Prozesse, die eben beschrieben wurden, sind als Batchjobs implementiert und werden über ein Schedulingverfahren regelmäßig gestartet.

Das neue System

Das neue System sollte Ein- und Ausgangsschnittstellen aller Kunden enthalten, so dass der Monolith keine kundenindividuellen Anpassungen mehr enthielt. Im Rahmen des Projekts wurden folgende Teilschritte identifiziert, um das neue System erfolgreich mit dem Monolithen zu integrieren.

  1. Integration der Serviceschicht
  2. Integration der Datenschicht
  3. Integration der Benutzerschnittstelle

Integration der Serviceschicht

Unter der Integration der Serviceschicht verstehen wir den Informationsfluss vom FTP-Server zur Verarbeitung bis hin zur Ergebnisausgabe (s. Abb. 5). Die Kommunikation mit dem FTP-Server findet nun ausschließlich mit dem neuen System statt. Wo vorher noch über die Datenbank integriert wurde, haben wir uns für eine Message Queue entschieden. Wir erinnern uns, dass ein SCS auch ohne die Anbindung an Fremdsysteme funktionieren soll. REST oder ähnliche synchrone und fehleranfällige Mechanismen entfallen. Weitere Argumente für eine Message Queue sind Fault Tolerance, Retry und weitere Schmankerl, die wir gleich mit dazu bekommen.

Der Prozess ist weitestgehend gleich geblieben, nur dass die zu verarbeitenden Datensätze und die Ergebnisse jetzt mittels Message Queue zwischen den Systemen ausgetauscht werden. Allerdings wurden die zeitgesteuerten Batchjobs durch ein eigenes System ersetzt, was das Deployment und den Betrieb deutlich vereinfacht.

Integration der Datenschicht

Auch bei der Integration der Datenschicht gab es einige Aspekte, die berücksichtigt werden mussten (s. Abb. 6). So wäre es bspw. problematisch, wenn die Stammdaten der Kunden weiterhin über die Datenbank des Monolithen abgefragt werden würden. Dadurch wäre man an den Releasezyklus des Monolithen gebunden und hätte so den Vorteil des separaten Deployments verloren. Sinnvoller ist es hier, über eine REST API die Kundenstammdaten anzufragen, oder sogar einen Teil der Daten zu replizieren. Wir haben uns für die REST API entschieden, da diese in Zukunft auch noch an anderen Stellen gebraucht wird. Außerdem kann man die REST Calls recht einfach cachen.

Dass bei der Zerlegung eines Systems die Methoden aus dem Domain-Driven-Design hilfreich sein können, ist keine neue Erkenntnis. Im Fall der Kundenschnittstellen liefert uns der Bounded Context eine Abgrenzung zu den Datenstrukturen des Monolithen. Alle Daten, die ausschließlich für die Ein- und Ausgangsschnittstellen der Kunden benötigt werden, wandern in separate Datenbanktabellen.

Im nächsten Evolutionsschritt werden die neuen Datenbanktabellen in ein separates Datenbankschema ausgelagert. Die Anfrage der Stammdaten findet weiterhin über die REST-Schnittstelle statt (s. Abb. 7).

Integration des User Interface

Bei der Integration des User Interface wird generell zwischen der serverseitigen und der clientseitigen Integration unterschieden. Die serverseitige Integration (s. Abb. 8) wird zumeist über Edge-Side-Includes (ESI) oder Server-Side-Includes (SSI) gelöst. ESI sind besonders bei CDNs und Proxy-Servern eine beliebte Technik. Vor allem beim Caching hat diese Technik klare Vorteile gegenüber den clientseitigen Techniken. SSI geht auf das Apache-Modul mod_ssi zurück. Ähnlich wie bei ESI wird die Seite hier bereits auf dem Server zusammengefügt. Allerdings arbeiten beide serverseitigen Ansätze synchron. Somit kann es bei einem langsamen Drittsystem durchaus zu längeren Wartezeiten für den Endbenutzer kommen.

Die clientseitigen Techniken (s. Abb. 9) arbeiten zumeist asynchron über AJAX. Dies hat den Vorteil, dass die Seite bereits geladen werden kann, auch wenn ein Drittsystem noch nicht bereit ist.

Da bei der UI des Monolithen Java Server Faces zum Einsatz kommt, bietet sich der clientseitige Ansatz an. Es gibt bereits eine Vielzahl Javascript-Bibliotheken, die sich sehr gut in JSF integrieren lassen. Aber auch JSF selber kann von Haus aus per AJAX Facelets dynamisch laden. Die Integration der Kundenschnittstellen in die UI des Monolithen ist allerdings noch in einem frühen Stadium und derzeit noch Work-in-Progress (s. Abb. 10).

Rollout

Da der Kunde einen beträchtlichen Teil seines Umsatzes mit dem Produkt erzielt, sollte das Risiko so gering wie möglich gehalten werden. Zu diesem Zweck, wurden zu Anfang zwei Kunden ausgewählt, die über das neue System geleitet werden sollte. Alle anderen wurden nach wie vor über das Altsystem abgewickelt und sollten nach und nach migriert werden. Das Ganze wurde über eine Art Feature Toggle am Kunden gelöst. Ist das "Feature" aktiviert, ignoriert das Altsystem den Datensatz des Kunden und das neue System kümmert sich darum (s. Abb. 11). Diese Art des Rollouts wird auch Canary Release genannt [9].

Lessons Learned

Jeder Entwickler oder Architekt mit einigen Jahren Erfahrung weiß, dass es für diese Art von Unterfangen kein Erfolgsrezept gibt. Dafür sind die Systeme, mit denen wir es zu tun haben, zu komplex und zu unterschiedlich. Das, was in unserem Fall funktioniert hat, kann bei einem anderen Monolithen fehlschlagen, z. B. weil der fachliche Schnitt nicht ohne weiteres möglich ist. Eventuell müssen die Module erst einmal im Monolithen sauber herausgebildet werden, bevor man sich an eine Zerlegung wagt. Trotzdem haben wir (und andere) einige Beobachtungen gemacht, die in jedem Fall sinnvoll sind.

Architektur sollte eine Teamentscheidung sein und nicht aus dem Elfenbeinturm kommen. Die Entwickler wissen i. d. R. selber, welche Stellen die größten Probleme verursachen und haben häufig auch gute Ideen zu den Maßnahmen, die angegangen werden sollten. Wer selber vor der Frage steht, wie man anfangen soll einen Monolithen zu zerlegen, der sollte mit einer kleinen, unabhängigen Funktionalität starten. Erstens entwickelt man dabei ein Gefühl für das Sizing oder den Schnitt. Und zweitens kann der Betrieb erste Erfahrungen mit einem Multi-Deployment-Setup sammeln. Wichtig an dieser Stelle ist aber, dass man es nicht bei den kleinen, unabhängigen Funktionalitäten belässt. Sonst läuft man Gefahr aus seinem Modul-Monolithen lediglich einen Laufzeit-Monolithen zu machen.

Ein häufiger Fehler bei einem solchen Vorhaben ist, dass entweder die UI oder die Datenschicht außer Acht gelassen wird. Wenn man ein Teilsystem aus dem Monolithen heraus trennen möchte, gehören alle Schichten dazu, sonst ist man weiterhin an den Releasezyklus des Monolithen gekoppelt.

Bei dem Abschnitt "Rollout" haben wir bereits gesehen, dass zu Anfang noch ein Hybridmodell zwischen alten und neuen Kundenschnittstellen gefahren wurde. Tatsächlich haben wir zu diesem Zeitpunkt die Architektur unseres Systems insgesamt deutlich verschlechtert. Bei dieser Art von Vorgehen ist es wichtig, dass man den entsprechenden Legacy-Code im Altsystem zeitnah entfernt. In unserem Fall hieß das, die übrigen Kunden so schnell wie möglich auf das neue System zu leiten. Wenn man die Architektur mit einer Fitness-Funktion beschreiben würde (analog zur Evolution), so würde diese sich im ersten Schritt verschlechtern. Erst nach den Aufräumarbeiten hätten wir die Komplexität reduziert und die Gesamtarchitektur des Systems verbessert (s. Abb. 12).

Autor

Daniel Rosowski

Daniel Rosowski ist langjähriger Softwareentwickler und -architekt mit dem Schwerpunkt auf Java. Anfangs als Softwareentwickler und Consultant bei einem Bielefelder Beratungshaus, hat er 2011 die Smartsquare GmbH mitgegründet.
>> Weiterlesen
Das könnte Sie auch interessieren
botMessage_toctoc_comments_9210