(Try to) Kill your Monolith

Von der Entstehung eines Monolithen und dem Versuch, ihn zu zerlegen

Marek Iwaszkiewicz, Slava Heinrich

©Shutterstock / kmls

Bei der Entwicklung von Softwaresystemen und -architekturen gibt es verschiedene Ansätze und Paradigmen, die jeweils kontextabhängige Vor- und Nachteile haben. Zu gerne folgen wir bei der Beurteilung von Architekturen entweder unseren persönlichen Vorlieben oder wir verurteilen Lösungen, die nicht mehr State of the Art oder zu komplex sind, um sie mit überschaubarem Aufwand zu überblicken. Eine objektive Beurteilung einer gewachsenen Architektur kann aber nur auf einer strukturierten und sachlichen Analyse basieren.

Im Rahmen dieses Artikels geht es uns darum, den Leser auf eine wertungsfreie, zum Teil fiktive historische Reise mitzunehmen. In deren Rahmen beschreiben wir die Entstehung und das Wachstum eines Monolithen sowie die Herausforderungen beim Versuch, ihn zu modernisieren und zu zerlegen.

State of the Art sind derzeit Microservices-basierte Architekturen. Monolithische Architekturen sind verpönt, obwohl sie durchaus auch Vorteile haben können. Die Beurteilung einer Architektur bzw. eines größeren, historisch gewachsenen Systems ist insofern schwierig, als bei der Beurteilung nicht nur technische, sondern auch wirtschaftliche, organisatorische und fachliche Aspekte sowie die historischen Rahmenbedingungen berücksichtigt werden müssen.

Die Historie spielt bei der Bewertung eine wichtige Rolle. Designentscheidungen folgen nicht selten aktuellen Trends und basieren auf den zum jeweils aktuellen Zeitpunkt verfügbaren technischen Mitteln. Technologien, die heute angesagt sind, sind es ein paar Jahre später häufig nicht mehr – auch wenn sie ihren Zweck weiterhin gleichwertig erfüllen. Eine heute richtig wirkende Lösung könnte sich in Zukunft aufgrund eines sich im Kern veränderten Produkts als nicht mehr richtig erweisen. In diesem Fall muss bei der Bewertung differenziert werden: Liegt der Fehler im ursprünglichen Design oder beim Versäumnis einer Anpassung des Systems an die evolutionären Produktänderungen? Bei einem Versäumnis könnten die Ursachen organisatorischer Natur sein.

Als Beispiele dienen hier das Fehlen von Regeln und Maßnahmen zur Erfassung und Beseitigung technischer Schulden oder ein zu hoher erzwungener Durchsatz bei der Umsetzung neuer Anforderungen und das damit verbundene ständige Stricken mit der heißen Nadel. Oder aber ein Design wirkt nur falsch, weil eine hohe fachliche Komplexität auf das technische Design ausstrahlt und es schwerer greifbar macht. Hierbei verhält es sich wie in der Physik mit dem Energieerhaltungsprinzip: Komplexität lässt sich nicht einfach durch andere bzw. neuere Technologien beseitigen. Änderungen der Gesamtarchitektur ohne das nötige Verständnis bergen die Gefahr, die Konsequenzen nicht zu verstehen. Sie können den Gesamtwert des Produkts maßgeblich beeinträchtigen, ohne dass es rechtzeitig bemerkt wird.

Gerade die Historie ist es, die zu häufig ignoriert wird, obwohl sie spannend und aufschlussreich zugleich sein kann. In diesem Sinne beginnen wir nun also unsere Reise.

Es war einmal …

Begeben wir uns eine Dekade zurück und werfen einen Blick auf zusammengetragene Erfahrungen zur Entstehung eines Monolithen. Wir schreiben das Jahr 2010 – wir gehen also gerade mal zehn Jahre zurück. Dabei werden wir einige Architekturprinzipien und Patterns betrachten.

Generell gilt, dass man eine Architektur nur auf Basis von Patterns und Prinzipien beurteilen kann, und das Wissen um diese sollte daher vorhanden sein. Zur Orientierung dienen kann SOLID [1] oder eine der wichtigsten Eigenschaften nachhaltiger Architekturen „Lose Kopplung und hohe innere Kohärenz“. Das Nichteinhalten bewährter Patterns und Paradigmen bei der Umsetzung kann Ursachen wie fehlendes Wissen oder durch Zeitdruck begründete mangelnde Kapazitäten haben. Jeder von uns wird sich mit diesen Punkten sicherlich schon konfrontiert gesehen haben – bewusst oder unbewusst. Durch fehlendes Wissen und der aus der Psychologie bekannten Verfügbarkeitsheuristik [2] läuft jeder einzelne von uns Gefahr, voreilige Schlüsse zu ziehen.

Wir beginnen die Entwicklung einer Applikation mit der Anlage eines Repositories im Versionsverwaltungssystem. Das Team umfasst initial einige wenige Entwickler. Die Frage nach dem korrekten fachlichen und technischen Schnitt lässt sich zu diesem Zeitpunkt nicht eindeutig beantworten, wodurch der Monolith-First-Ansatz durchaus richtig sein kann. So geht es also mit der Anlage des Repositories für die Applikation mit dem Namen A los. Es wird sauber gearbeitet und so erfolgt auch die Anlage der Jobs zur Continuous Integration (CI) in Hudson (Jenkins kam später). Die Software wird automatisch gebaut, getestet und paketiert und es werden Softwaremetriken erfasst. Maven in Version 2 wird als Build-System genutzt und ein Release erfolgt mit dem Maven-Release-Plug-in. Bei der Selektion der verwendeten Technologien und Frameworks setzt man weitestgehend auf das Neueste und nutzt u. a. Spring 2.5, JSF 1 und Java 6. Als Repository-Manager wird Nexus verwendet. Im Jahr 2010 wurde in der Fachwelt teilweise noch der Nutzen von Nexus diskutiert [3] und der Einsatz wurde erst allmählich selbstverständlich. Man darf sich also durchaus selbst auf die Schulter klopfen.

Abbildung 1 zeigt die zum derzeitigen Zeitpunkt noch sehr einfache Welt. Die Applikation A wird in einem zentralen Repository entwickelt. Das Ergebnis des Hudson-Build-Jobs ist ein WAR-Artefakt, das ins Nexus Repository publiziert wird. Das Deployment des WAR-Artefakts von der Applikation auf dem Servlet-Container erfolgt manuell.

Abb. 1: Back in the old days

Abb. 1: Back in the old days

Die Version bekommt der zuständige Administrator von den Entwicklern per E-Mail zugeschickt. Weitere manuelle Schritte fallen im Bereich der Datenbank bei der Anlage und bei den Updates des Schemas an.

Auf zum Monolithen

Das Business blüht und gedeiht und die Beliebtheit der Applikation führt dazu, dass die Kunden neue Funktionalitäten bestellen. Die bestehende Applikation wird sukzessive erweitert und wächst. An dieser Stelle muss bei den Erweiterungen differenziert werden. Zum einen wird die bestehende Funktionalität vertikal erweitert, zum anderen werden horizontale Erweiterungen in Form neuer Features eingebaut, die bei gründlicher Betrachtung nur peripher mit der Kernapplikation zu tun haben und als separate Applikation für eine andere Kundengruppe hätten umgesetzt werden können. Zur Trennung verwendet man Packages und Maven-Submodule. Der trügerische Gedankengang hier ist: Immerhin haben die Anforderungen mit demselben Produkt zu tun und können folglich innerhalb einer Applikation implementiert werden. Das ist natürlich zu einfach. Da man unter Zeitdruck steht, einigt man sich darauf, dass das Pragmatische gerade das Beste ist. Unterstützend wirkt hier die Hoffnung, dass nach der Fertigstellung des neuen Features die Zeit da sein wird, sich um einen sauberen Schnitt zu kümmern und die Applikation zu zerlegen. Wem kommt das nicht bekannt vor?

Am Gesamtbild ändert sich zunächst wenig, nur dass das Team um weitere Entwickler aufgestockt wird. Wider Erwarten gab es die erhoffte Zeit zum Aufräumen nicht und was einmal gut funktioniert hat, wird gerne wiederholt. Die Applikation besteht nach einiger Zeit aus mehreren vertikalen Unterapplikationen oder Businessaspekten, die wir hier mit Feature F1 bis Fn bezeichnen, wobei ein Feature auch geteilte Funktionalitäten bündeln kann. Innerhalb des Teams hat sich eine vertikale, Feature-basierte Unterteilung gebildet – der Code verweilt aber weiterhin in einem einzigen Repository. Hier haben wir ihn nun: den klassischen Monolithen (Abb. 2).

Abb. 2: Der klassische Monolith

Abb. 2: Der klassische Monolith

Dieses Verfahren setzt man jedoch nicht beliebig fort, sodass neue Applikationen in einem neuen, dedizierten Repository entwickelt und separat deployt werden. So entsteht die in Abbildung 3 dargestellte Applikation B. Man stellt fest, dass beide Applikationen Schnittmengen haben, da sie auf Daten zugreifen müssen, die von Feature F3 implementiert sind. Man beschließt, das Feature F3 auszuschalen und diesem ein eigenes Repository zu schenken. Das Feature F3 wird als geteilte Business-Library mit eigener Persistenz zwischen beiden Applikationen geteilt. Dem Shared-Database-Antipattern [4] entgeht man hier nur scheinbar.

Abb. 3: Applikation B

Abb. 3: Applikation B

Die geteilte Business-Library birgt mehrere Nachteile. Einer davon tangiert die Kaskadierung bei Änderungen in F3. Betrachten wir den Fall, in dem eine neue Anforderung in Applikation A Änderungen im Feature F3 erfordert. Man muss stets im Auge behalten, welche Auswirkungen die Änderungen auf alle anderen Applikationen haben, die F3 als Library nutzen. Das ist nicht unbedingt einfach, da, wenn eine Library erst einmal im Classpath ist, ihre Klassen zur Nutzung zur Verfügung stehen (das Java-9-Modulsystem Jigsaw kam später). Shared Libraries bergen weiterhin die Gefahr, dass die sie nutzenden Applikationen gemeinsam deployt werden müssen.

Bei Änderungen in F3 und dem Ausrollen nur einer Applikation würde es zu einem inkonsistenten Zustand kommen – viel Spaß bei der Fehlersuche. Ist das der Fall, spricht man von einem Allokationsmonolithen [5]. In Abbildung 4 ist eine vereinfachte Darstellung des nun entstandenen verteilten Allokationsmonolithen abgebildet. Das Deployen der veröffentlichten Komponenten wird sowohl immer aufwendiger als auch immer fehleranfälliger – ein weiterer Automatisierungsgrad muss her.

Abb. 4: Vereinfachte Darstellung des entstandenen verteilten Allokationsmonolithen

Abb. 4: Vereinfachte Darstellung des entstandenen verteilten Allokationsmonolithen

Mittlerweile gibt es mit Jenkins eine mächtigere Alternative zu Hudson und so erfolgt der Umstieg auf Jenkins. Es werden Jenkins-Jobs geschrieben, mit denen alle verteilten Komponenten gebaut und in den richtigen Versionen zusammen verdrahtet werden. Zudem erfolgt das Deployment in die unterschiedlichen Stages automatisiert per Knopfdruck. Damit ist Continuous Delivery eingeführt. Die Implementierung von Continuous Delivery führt zu einer wesentlich höheren Effizienz. Diese Effizienz bedingt wiederum ein schnelleres Wachstum des Systems in seiner vorhandenen monolithischen Ausprägung.

Verjüngungskur

Bei all dem Fortschritt und der erfolgreich eingeführten Automatisierung gab es kaum bis gar keine Zeit für die Modernisierung des Stacks. Das Engineering-Team geht den schon länger gehegten Wunsch des Upgrades an und sieht sich mit Hindernissen konfrontiert. Geplant ist unter anderem ein Upgrade auf eine neue Spring- und die aktuelle Java-Version. Um die Risiken gering zu halten, nimmt man sich vor, zuerst exemplarisch für eine Applikation das Upgrade durchzuführen und alle weiteren Applikationen iterativ nachzuziehen. Jedoch muss man schnell feststellen, dass einem die monolithische Beschaffenheit des Systems hier einen Strich durch die Rechnung macht. Ursache ist die Transitivität von Dependencies in Maven.

In Abbildung 5 sehen wir eine etwas andere Sicht auf das System und erkennen hier, dass das Spring-Upgrade von Version 2.5 auf 4 sich nicht nur auf die Applikation A beschränken würde. Die Hoffnung, dass Spring hinsichtlich solcher Upgrades beliebig kompatibel ist, wird nicht erfüllt. Dasselbe gilt für andere angebundene Bibliotheken. Wird die Applikation A mit der aktuellen Spring-Version ausgeführt, greift diese Version auch auf die Library zum Feature F3 zu, die jedoch auf der älteren Version basiert. Die Funktionsfähigkeit von F3 ist nicht ohne weiteres gewährleistet. Beschließt man nun, die Shared Library ebenfalls auf die neue Spring-Version zu aktualisieren, schlägt das weiter durch, und dieselben Fragen bezüglich der Funktionsfähigkeit und Kompatibilität müssen für die Applikationen B, C und jede weitere in dieser Form beteiligte Komponente und Applikation beantwortet werden.

Eine aufwendige Prüfung ergibt, dass maximal ein Upgrade auf eine weniger aktuelle Version möglich ist, und so wird es dann auch gemacht. Mit den Kompatibilitäten muss generell sorgsam umgegangen werden. So könnte ein Upgrade des Servlet-Containers auf eine neue Version zudem den Einsatz von Java 8 erfordern. Spring ist jedoch erst ab Version 4.0.3 im Hinblick auf Java 8 production-ready [6]. Das ist ärgerlich, da wir doch soeben festgestellt haben, dass die Verwendung eben dieser Spring-Version nicht möglich ist.

Abb. 5: Eine etwas andere Sicht auf das System

Abb. 5: Eine etwas andere Sicht auf das System

Divide and Conquer

Ein Problem von Monolithen ist, dass das Upgrade von Technologien zu einem nicht ungefährlichen Kraftakt werden kann. Big-Bang-Modernisierungen sind ab einer gewissen Größe kaum noch zu rechtfertigen. Das Modernisieren ganz sein zu lassen, ist ebenfalls keine Option. Ein Ausweg bestünde in regelmäßigen kleineren Upgrades und einer guten Testautomatisierung. Hiermit ließen sich größere Versionssprünge umgehen. Ist das nicht möglich, eignen sich die folgenden Strategien:

  1. vertikale Dekomposition

  2. Ausschalung von Features in Form von Services

  3. technologieagnostische Integration über HTTP

Im richtigen Leben ist der Umfang der Komponentendiagramme bereits nach kurzer Zeit wesentlich größer als in den bisher dargestellten Diagrammen, die auf die für diese Geschichte relevante Essenz reduziert sind. Diagramme von gewachsenen Architekturen passen in der Regel nicht auf eine Tafel. Wichtige Eigenschaften nachhaltiger Softwarearchitekturen sind lose Kopplung und hohe innere Kohärenz. In einer größeren Spaghettiarchitektur würde gerade gegen dieses Prinzip verstoßen werden. In diesem Fall müsste zuerst eine vertikale Dekomposition erfolgen, durch die in einem vom Monolithen entkoppelten Modul größere Spielräume für Umbaumaßnahmen geschaffen würden. Die monolithischen Eigenschaften bleiben dabei jedoch erhalten. In unserem fiktiven Beispiel ist das nicht der Fall, sodass man sich den Strategien 2 und 3 widmen kann.

Die Shared Library F3 aus Abbildung 4 ist in diesem Sinne bereits ausgeschalt, jedoch ist sie im Hinblick auf die Nutzung nicht Framework-agnostisch. Um technologische Koppelung sowie die Problematik mit Versionskonflikten zu lösen, erfolgt die Portierung von F3 auf einen Service, der über HTTP und REST seine Funktionalitäten zur Verfügung stellt. Das Ergebnis ist in Abbildung 6 dargestellt.

Abb. 6: Der Service F3 wird von den Applikationen A, B und C aufgerufen

Abb. 6: Der Service F3 wird von den Applikationen A, B und C aufgerufen

Alle Applikationen greifen nun per HTTP auf den Service F3 zu. Das hat den großen Vorteil, dass der Service in Bezug auf die eingesetzten Technologien und Frameworks vom Rest isoliert ist und daher unabhängig modernisiert werden kann. Dieses Vorgehen wendet man schließlich konsequent bei allen anderen Shared Libraries an. Bei all der Freude erkennt das Team, dass man zwar einige wesentliche Probleme beseitigt, sich aber gleichzeitig zusätzliche Komplexität ins Boot geholt hat. Diese Komplexität ist vielfältiger Natur. Zum Beispiel gilt es jetzt, eine wesentlich größere Verteilung zu beherrschen. Die Systemlandschaft besteht jetzt aus vielen Applikationen und noch wesentlich mehr HTTP-Services. Jeder Request durchläuft nun mehrere Systeme. Aspekte wie Logging, Security, Error Handling, Performance und Resilience sind wesentlich aufwendiger zu handhaben als zuvor. Zudem stellt sich die Frage, wie die Anbindung eines Service an den jeweiligen Consumer zu erfolgen hat.

In Abbildung 6 wird der Service F3 von den Applikationen A, B und C aufgerufen. Die Frage lautet hier: Sollen die Applikationen den Client für den Zugriff auf F3 jeweils selbst implementieren oder gibt es einen vorprogrammierten Client? Implementiert der Consumer den Client selbst, muss man stets auch an alle weiteren Aspekte wie Performance-Logging, Pooling, Error Handling und das Weiterleiten von Request IDs und Securitytokens denken. Vorprogrammierte Clients haben den Vorteil, dass sie die wiederkehrenden Aspekte einmal zentral implementieren. Diese Clients können anschließend als Library in den Consumern eingebunden und einfach genutzt werden. Hier ist erneut Vorsicht geboten, denn in diesem Fall sind vorprogrammierte Clients nie Framework-agnostisch und man läuft Gefahr, es wieder mit Konflikten im Bereich der transitiv mitgebrachten Dependencies zu tun zu bekommen. So könnte eine Komponente zwei verschiedene Clients einbinden, die jeweils mit dem Apache HttpClient daherkommen – nur in unterschiedlichen Versionen, die im Worst Case nicht miteinander kompatibel sind. Einen Tod muss man bekanntlich sterben und so entscheidet sich das Engineering-Team für die Variante mit den vorprogrammierten Clients. Das läuft eine Zeitlang gut, da man bei technologischer Homogenität die eben erwähnten Konflikte nicht bekommt. Nun kommt es aber so, dass nach einer gewissen Zeit eben diese Homogenität nicht mehr gegeben ist und zu jedem Service mehrere Clients in den sich im Einsatz befindenden Technologien implementiert werden. Es fühlt sich so an, als würde man irgendwie vorwärts stolpern.

Einen Ausweg würde der Contract-First-Ansatz bilden. In Zeiten von SOAP wäre das einfach gewesen. Man hätte mit einer WSDL den Contract vom Service definiert. Aus der WSDL würden der Service-Provider und die Consumer die Stubs zum jeweils verwendeten Framework generieren. Da die Kommunikation jedoch auf REST basiert, ist man diesbezüglich blockiert. WADLs [8] adressieren diesen Aspekt, haben sich jedoch nie richtig durchgesetzt. Erst als sich neue Möglichkeiten mit OpenAPI und den Swagger-Tools auftun, scheint ein passabler Weg gefunden zu sein. Daher erfolgt die Umstellung der Services auf die OpenAPI-Spezifikation. Der Contract wird in einer YAML-Datei definiert und die Stubs für den Service und die Clients werden mit dem OpenAPI-Maven-Plug-in generiert. Die Fragen nach den Cross-cutting Concerns wie Error Handling und Performancemessung werden damit jedoch nicht beantwortet.

Wo stehen wir jetzt eigentlich? Wir haben eine Systemlandschaft, in der eine Menge an Applikationen und Services miteinander über HTTP kommunizieren. Das besonders Positive ist die technologische Entkopplung der einzelnen Komponenten, durch die ein Technologie-Upgrade isoliert für jede Komponente erfolgen kann. Das Deployment der Komponenten erfolgt aber weiterhin im Verbund, sodass wir weiterhin von einem verteilten Allokationsmonolithen sprechen müssen. Das Erreichte ist zwar gut, es reicht jedoch bei Weitem nicht aus. Dennoch sind die Weichen für die nächsten Schritte gestellt, sodass eine Microservices-basierte Architektur anvisiert werden kann – aber das ist eine andere Geschichte.

Wir konnten hoffentlich zeigen, dass ein Softwaresystem historisch wächst und sich dabei stets in einem Rahmen befindet, der Jahre später oft nicht mehr präsent ist. Zudem haben wir gezeigt, dass ein größerer Monolith nicht durch ein paar wenige Handgriffe zerlegt werden kann und dessen Zerlegung zwar Probleme beseitigt, jedoch gleichzeitig auch neue schafft. Die Zerlegung eines Monolithen in Microservices, um dann einen verteilten Monolithen statt Microservices zu erhalten, sollte nicht das Ziel sein. Letztendlich würden wir eine erhöhte Komplexität im Hinblick auf die Infrastruktur erlangen, ohne die tatsächlichen Vorteile von Microservices zu nutzen. Von einer Microservices-basierten Architektur kann noch lange keine Rede sein. Die geschilderten Schritte zur Zerlegung waren allesamt richtig, sie reichen jedoch nicht aus und die Reise muss konsequent weitergehen.

Quo Vadis?

Wie es in unserem fiktiven Beispiel weitergeht? Es folgen eine Portierung auf Kubernetes in der Cloud und ein Umbau von CI/CD auf ein komponentenweises Deployment. Für Anforderungen, die mehrere Services tangieren, werden Feature-Toggles eingeführt. Die APIs werden so gebaut, dass sie beliebig abwärtskompatibel sind. Nichts ist einfacher als das, oder? Zudem haben wir schon Jahre zuvor gelernt: Die Zeit dafür wird in ausreichendem Maße vorhanden sein.

Nun, diese Umsetzungen wären der konsequente Schritt, wohlwissend, wie schwierig das neben dem üblichen Tagesgeschäft sein wird. Viele halten eine Microservices-basierte Architektur für ein Allheilmittel, weil sie das Architekturparadigma auf einzelne, greifbare Aspekte reduzieren und dadurch die Komplexität verkennen. Mag der Weg dorthin zwar heute richtig wirken, einfach ist er keinesfalls.

Um abschließend dem von uns adressierten historischen Kontext leicht provokant Geltung zu verschaffen, möchten wir uns mit einem Zitat von Googles Kubernetes-Koryphäe Kelsey Hightower verabschieden: „Monolithen sind die Zukunft, denn das Problem, das die Menschen mit Microservices lösen wollen, stimmt nicht wirklich mit der Realität überein. Nur um ehrlich zu sein – und das habe ich bereits getan, bin von Microservices zu Monolithen gegangen und wieder zurück. In beide Richtungen.“ [7].

Wer weiß, in welchem Licht unsere heutigen Bemühungen und Entscheidungen in zehn Jahren stehen werden …

 

Geschrieben von
Marek Iwaszkiewicz
Marek Iwaszkiewicz
Marek Iwaszkiewicz ist für die directline AG als Senior Software Engineer tätig. Er beschäftigt sich seit Jahren mit Java EE Architekturen und Technologien. Seine Schwerpunkte liegen unter anderem im Bereich des Buildmanagements. Kontakt: @Marek_Iwaszkiewicz
Slava Heinrich
Slava Heinrich
Slava Heinrich arbeitet bei der Verti Versicherung AG als Softwarearchitekt. Seinen Schwerpunkt bilden Java-Architekturen, das Spring Framework sowie die Zerlegung und Modernisierung eines Monolithen.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: