Über unsMediaKontaktImpressum
Christoph Iserlohn & Till Schulte-Coerne 28. November 2017

Warum es nicht immer Microservices sein müssen

oder: Ein Loblied auf den Monolithen

Der Microservice-Zug rollt. Und wie immer in der IT-Wirtschaft muss jetzt, zumindest gefühlt, jede neue Architektur eine Microservice-Architektur oder eine ihrer vielen Spielarten sein. Warum ist das so?

Viele (IT-)Organisationen leiden unter schleppender Feature-Entwicklung, langen Release-Zyklen und schlechter Wartbarkeit ihrer Systeme. Und der Schuldige dafür ist schnell ausgemacht: der monolithische Aufbau der Systeme. Microservices versprechen hier Abhilfe. Aber auch Microservices bergen teilweise erhebliche Risiken, die wir in diesem Artikel ansprechen werden.

Außerdem beheben Microservices nicht einfach von Zauberhand die den Monolithen zugrundeliegenden Probleme. Schleppende Feature-Entwicklung wird meistens durch schlechte Organisation und mangelnde Teamkoordination verursacht. Lange Release-Zyklen entstehen durch fehlende Automatisierung und geringe Testabdeckung. Schlechte Wartbarkeit folgt auf mangelnde Modularisierung. Ganz unabhängig davon, ob es sich um Monolithen oder Microservices handelt.

Die Autoren haben beide geraume Zeit als Entwickler und Architekten in diversen Microservice-Architekturen unterschiedlicher Couleur gearbeitet und auch manchmal dabei gelitten. Dieser Artikel soll die vermeintlich gelösten Probleme der Microservice-Ansätze beschreiben und eventuelle Alternativen in klassischeren – eher monolithischen – Architekturen vorstellen, die gegebenenfalls leichtgewichtiger und einfacher umzusetzen sind.

Denn: in der IT ist eigentlich kaum etwas komplizierter als ein verteiltes System. Sollten wir also nicht immer sehr gute Gründe haben und erst alle anderen Mittel und Wege ausschöpfen, bevor wir das verteilte Fass öffnen?

Wir wollen dabei keine einfachen Rezepte verkaufen und nicht verschweigen, dass ordentliche Monolithen nicht mühelos zu haben sind. Sorgfältige Planung ist ein Muss. Ohne eine gewisse Fingerfertigkeit und permanente Anstrengung, die Architektur nicht aus dem Ruder laufen zu lassen, geht es nicht. Aber dies gilt, soweit wir wissen, für jede Architektur. Zeitdruck, unzureichende Fähigkeiten, Disziplinlosigkeit und mangelndes Qualitätsbewusstsein haben noch jede Architektur in die Knie gezwungen.

Ordentliche Monolithen sind aber auch nicht nur ein theoretisches Hirngespinst. Basecamp [1], der Linux-Kernel und Facebook [2] zeigen, dass Monolithen durchaus ein Erfolgsrezept sein können.

Entkoppelung

Von der Makroebene aus betrachtet, ist eines der wichtigsten Ziele von Microservices [3] die scharfe Trennung, klare Zuweisung und Entkoppelung von Verantwortlichkeiten – in der Regel an verschiedene Teams. Das Ziel ist, die Zeit zu verkürzen, die es braucht, neue Features zu entwickeln und Änderungen vorzunehmen ("Time To Market").

Wenn wir uns dann fragen, warum eigentlich in eher monolithischen Architekturen die Entwicklungszyklen so lang sind, so wird die Ursache meist in permanenten Querzugriffen auf Klassen/Module/Methoden/Funktionen außerhalb der Zuständigkeit des jeweiligen Teams, der berühmte "Big Ball of Mud", gesucht. Also in der Unfähigkeit, Änderungen und Erweiterungen vorzunehmen, ohne andere Teams dadurch zu beeinflussen.

Was tun? Wir weisen jetzt einem Team einen Microservice zu, also ein eigenes System, das relativ klein und damit übersichtlich und einfach ist. Das Team ist dadurch im Optimalfall in der Lage, das System komplett autonom weiter zu entwickeln und unabhängig von anderen Teams zu deployen.

Komplexität wird externalisiert

Allerdings kaufen wir uns dies teuer ein. Wir verstecken durch die Trennung nämlich eine nicht unerhebliche Komplexität in der Kommunikation zwischen den Systemen und machen diese quasi unsichtbar. So ist innerhalb von Microservice-Umgebungen die Frage nach den Zuständen unserer Geschäftsprozesse bei weitem nicht so einfach zu beantworten, wie in einem einzigen gut gebauten Monolithen, da sich diese Zustände klassischerweise zwischen den Systemen verteilen und teilweise sogar komplett dorthin verlagern.

Gewohnte Muster fallen weg, wie z. B. die Navigation über den gesamten Code des Systems mit Hilfe einer guten IDE. Ebenso nehmen wir uns die Möglichkeit, eigene Annahmen gegenüber anderen Teilen des Systems automatisch durch den Compiler zu testen, da wir jetzt zum Aufruf anderer Teile unseres Systems Aufrufe über das Netzwerk benötigen und nicht mehr einfache Methodenaufrufe nutzen können. Und über den Performance-Nachteil von Netzwerk-Aufrufen gegenüber direkten Methodenaufrufen schweigen wir hier lieber.

Tests

Wie teuer eine Aufteilung in Microservices sein kann, sieht man auch am typischen Umgang mit Integrationstests. Diese sind in Microservice-Landschaften nicht innerhalb der Continuous Delivery-/Deployment-Pipeline möglich, da dazu ein stabiler Stand des Gesamtsystems notwendig wäre, gegen das getestet werden könnte. Dies ist aber nur gegeben, wenn man die Deployments serialisiert, was unweigerlich zu einen "Deployment-Stau" führt und somit das Ziel von unabhängigen Deployments konterkariert. Daher werden echte Integrationstests oft einfach weggelassen und, wenn es gut läuft, durch nur unzureichenden Ersatz wie "Consumer Driven Contracts" etc. ersetzt [4].

Häufig sieht man aber nur einige Tests pro Microservice, die sich "Integrationstests" nennen, aber keine sind, da sie nur einen Microservice testen und nicht das Gesamt-System.

Weitere Alternativen, wie die Synchronisation aller Teams und Releases, um das "Gesamt-Release" vorher testen zu können, kompromittieren letztlich das Ziel von Microservices: autonome Teams mit unabhängigen Deployments. Die Frage, ob es sich bei einem solchen Konstrukt überhaupt noch um Microservices handelt und nicht um einen (Deployment-)Monolithen mit allen Nachteilen der Microservices, muss der Leser oder die Leserin an dieser Stelle selbst beantworten. Provokant kann man vermutlich formulieren, dass ein Gesamtsystem von Microservices einfach nicht vernünftig testbar ist.

Ganz anders verhält sich dies bei Monolithen. Eines unserer Hauptargumente für den Monolith ist die einfache Frage, ob unser Gesamtsystem funktioniert oder nicht. Diese ist bei einem einzigen System natürlich viel einfacher zu beantworten, als bei vielen kleinen, unabhängigen Systemen.

Das User-Interface

Hat man jetzt mit einiger Kraft die Backend-Systeme in Microservices zerschnitten, so ist typischerweise noch immer ein einheitliches UI zur Nutzung unserer Systeme notwendig. Ein gängiges Pattern dabei sind Single Page-Monolithen, die leider genauso schnell zum "Big Ball of Mud" degenerieren, wie es im Backend vorher auch der Fall war. Teilweise enthalten sie so viel Applikationslogik, dass man sich fragt, was genau die Backend-Services eigentlich noch so tun. Da Client-Logik in einem Browser grundsätzlich nicht zu trauen ist, muss daher jede geschäftskritische Berechnung mindestens im Backend ablaufen. Will man diese Logik im Sinne des Single Pages-Ansatzes zusätzlich auch im Client ausführen, wird jeder Menge dupliziertem Code Tür und Tor geöffnet wird, was die Situation noch verschlimmert.

Denkt man Microservices konsequent und sorgt auch für eine Aufteilung des Clients in viele unabhängige UI-Systeme, so ergeben sich neue Probleme. So müssen UI-Übergänge in der Regel mehr oder weniger instantan erfolgen. Klickt ein User beispielsweise auf einen Link, der in ein anderes UI-System zeigt, so erwartet der User, dass die neue Ansicht sich schnell aufbaut. Das Problem dabei ist aber, dass dazu meist auch etwas Status aus dem vorherigen UI-System benötigt wird.

Ein Beispiel: Es gibt ein System zur Erstellung von Verträgen und eines zur Verwaltung von Kunden. Will man einen neuen Vertrag anlegen, so wird man zuerst nach einem Kunden gefragt. Ist dieser nicht vorhanden, muss man offensichtlich ins Kunden-System weitergeleitet werden, um ihn dort neu anzulegen. Ist dies erledigt und man kommt in das Vertragssystem zurück (beispielsweise per Redirect), so erwartet man dort jetzt natürlich den neu angelegten Kunden vorzufinden. Dazu hätte das Vertrags-System aber instantan über die Neuanlage des Kunden informiert werden müssen, was die Systeme wieder deutlich enger koppelt, als in der Regel gewünscht ist.

Andere UI-Integrationstechniken wie Transklusion [5], die dem Benutzer vortäuschen, das UI sei nur ein einziges System, haben ihre eigenen Probleme. Bettet man auf einer Ansicht Fremdinhalte aus einem anderen System ein, so wird früher oder später eine Anforderung kommen, die man mit technischen Argumenten wegdiskutieren muss, weil man sie auf Grund der inhärenten Limitierungen von Transklusion nicht erfüllen kann.

Kehren wir diesbezüglich zu unserem Beispiel von oben zurück: Man könnte in einer Liste der Verträge (von denen es hier so viele gäbe, dass sie nicht alle auf einmal in den Client passen) die Kundeninformationen per client-seitiger Transklusion jeweils in die einzelnen Listeneinträge einbetten. Dies geht gerade mit HTTP/2 sehr leicht und performant. Wie aber erklären Sie Ihrem Kunden, dass er die Vertragsliste nun zwar nach Vertragsnummer, nicht aber nach der Straße des Kunden, die er ja in der Vertragsansicht sieht, filtern kann?

Einen ähnlichen Effekt gibt es auch beim Reporting. Dass Sie eine Liste von Entitäten des jeweiligen Systems haben, zu der nur noch die eine oder andere Information aus einem anderen System dazu soll, ist in Microservice-Architekturen eher die Regel als die Ausnahme.

Wie baut man einen "guten" Monolithen?

Was genau hindert uns daran, innerhalb eines Systems die Verantwortungen unterschiedlicher Teams sauber auf Module herunter zu brechen und den Zugriff nur über definierte Schnittstellen zu erlauben, also den gefürchteten Querzugriff auf Interna anderer Module zu unterbinden?

Eigentlich sollte es doch in jeder Programmiersprache entsprechende Konzepte dazu geben. Leider ist dem oft nicht so. So sind in OO-Sprachen wie Java vorhandene Konzepte zum Information-Hiding (wie Sichtbarkeiten von Methoden und Klassen) meist nur zur modulinternen Benutzung gedacht. Für modulübergreifende Dinge gibt es Interfaces, deren ausschließliche Nutzung man aber leider oft nicht technisch erzwingen kann.

Im Hinblick auf Java muss dies allerdings nicht so sein. Dieser technische Zwang war schon vor geraumer Zeit ein Haupttreiber hinter Entwicklungen wie JEE oder OSGI und wird in Zukunft mit dem Jigsaw-Modulsystem in Java 9 neuen Drive erleben.

Aber muss es denn eigentlich immer technischer Zwang sein? Wäre nicht teilweise auch eine organisatorische Lösung denkbar und vielleicht sogar billiger? Mit Reviews, beispielsweise unter Benutzung von Codeanalyse-Tools wie jQAssistant, können auch heute schon Querzugriffe kostengünstig vermieden werden, ohne gleich ein verteiltes System zu bauen. Oder man könnte versuchen, für gewisse Skills und Architekturverständnis der Entwickler zu sorgen – was natürlich auch beim Einsatz von Microservice-Architekturen nicht schaden würde.

Interne Abhängigkeiten

Egal wie man die Verhinderung von Querzugriffen auf Interna von Modulen löst, Querzugriffe auf die exponierten Interfaces sind gewollt. Aber auch hier ergeben sich natürliche Probleme, die der Unabhängigkeit von Teams entgegen stehen, und zwar unabhängig davon, ob wir eine Microservice-Architektur bauen oder einen Monolithen.

So muss es möglich sein, aus zwei oder mehr Modulen ein anderes (gemeinsames) Modul in unterschiedlichen Versionen zu verwenden. Die Lösung für dieses Problem ist wie bei allen Interface-Versions-Themen immer die selbe: Man muss versuchen, möglichst lange das alte Interface beizubehalten und es eher zu erweitern, als es nicht abwärtskompatibel zu verändern. Oder etwas allgemeiner formuliert: Man achte eher auf Kompatibilität als auf Versionierung, auch wenn dies manchmal schwierig ist.

Externe Abhängigkeiten

Befürworter von Microservices werfen häufig das Argument auf, dass unterschiedliche Aufgaben in jedem Microservice jeweils mit unterschiedlichen, genau auf die Aufgabe abgestimmten Technologien zu lösen sind. Dies ist aber aus unserer Sicht in der Praxis ein eher selten gesehenes Feature, da man sich meist keine unterschiedlichen Technologien für die Applikationslogik wünscht. Gründe dafür gibt es einige: fehlende Verfügbarkeit der passenden Skills, leichtere Wartbarkeit bei einheitlicher Technologie, Support-Kosten für die verschiedenen Technologien. Zudem gäbe es in der Regel auf den meisten Ausführungsumgebungen (JVM, CLR) und Compiler-Suiten noch immer die Möglichkeit, unterschiedliche Sprachen in einer Anwendung zu nutzen.

Meist geht es bei dem Argument aber zurecht um die Verwendung von gemeinsamen externen Abhängigkeiten wie technischen Bibliotheken, Frameworks, Ausführungsumgebungen oder Infrastrukturen. Hier ist ein grundsätzlicher Vorteil von Microservices gegenüber Monolithen keineswegs wegzudiskutieren. So ist es natürlich viel einfacher, innerhalb einer Microservice-Landschaft unterschiedliche Abhängigkeiten jeweils in unterschiedlichen Versionen zu nutzen, als dies in einem Monolithen zu tun (wenn dies dort überhaupt möglich ist). Im Monolithen sind wir meist gezwungen, Abhängigkeiten für das gesamte System zu aktualisieren. Dies stellt je nach Größe des Monolithen natürlich eine große organisatorische Hürde dar, da dies in der Regel viele Teams betrifft, die dann wiederum synchronisiert werden müssen.

In Monolithen sollte man also unbedingt einen konservativen Ansatz für Abhängigkeiten wählen. So sollte man stabile und auf Langfristigkeit ausgelegte Abhängigkeiten nutzen und genau definieren, wann man diese – beispielsweise aus Security-Gründen – aktualisiert und wann nicht. Bei "echt" stabilen Abhängigkeiten sollte die Aktualisierung – außer aus Gründen der Security – größtenteils ohnehin nicht ständig zwingend erforderlich sein.

Dass aber irgendwann doch ein Update einer Abhängigkeit, beispielsweise eines Frameworks oder einer Bibliothek, notwendig sein wird, steht außer Frage. So ist in monolithischen Architekturen immer streng darauf zu achten, dass solche Updates schrittweise (also in unserem Fall vermutlich modulweise) möglich sind, und nicht auf einmal ausgerollt werden müssen. Wie dies im Einzelfall aussehen kann, hängt natürlich von den Abhängigkeiten selbst ab und ist nicht pauschal und auch sicher nicht einfach zu beantworten. Auf dieses Thema müssen wir also in monolithischen Architekturen besonders viel Wert legen und uns des Problems permanent bewusst sein, gerade weil es in der Regel erst später auftritt.

Eine Alternative ist, auch in Monolithen Updates grundsätzlich nicht lange liegen zu lassen. Beispielsweise kann man Policies über die Mindestaktualität aller Abhängigkeiten einführen, die beim Build automatisch überprüft werden und diesen dann ggf. scheitern lassen.

Grundsätzlich haben Monolithen beim Thema Update von gemeinsamen Abhängigkeiten auch Vorteile: So kann prinzipiell jedes Team, das ein Update durchführen will, dieses prinzipiell auch in anderen Teilen des Repositories durchführen. Außerdem besteht bei Security-Themen das eigentliche Problem eher darin, zu wissen, dass man ein Security-Problem hat, und nicht nur darin, ein solches Problem zu beheben. Diese Übersicht über potentielle Security-Probleme ist bei Monolithen auf Grund der deutlich geringeren Anzahl von Technologien, Abhängigkeiten und eingesetzter Versionen natürlich einfacher.

Was man an dieser Stelle nicht verschweigen sollte, ist, dass es durchaus unterschiedliche Ansätze gibt, um das Problem der gemeinsam verwendeten externen Abhängigkeiten innerhalb eines monolithischen System zu adressieren. So war es beispielsweise schon in J2EE möglich, unterschiedliche Module (EAR - Enterprise Application Archive) mit isolierten Classloadern zu laden, um unterschiedliche Versionen der gleichen Bibliothek in den Modulen zu verwenden.

Pseudo-Services

Eine weitere Alternative ist, den Monolithen in mehrere Anwendungen zu zerteilen, die zwar eigenständig laufen, aber dabei alle die gleichen Module für den Datenzugriff und die selbe Datenbank benutzen. Daher kann hierbei natürlich nicht von Microservices die Rede sein. Die Vorteile der Microservices, insbesondere bezüglich der Möglichkeit, technische Abhängigkeiten in unterschiedlichen Versionen zu benutzen, sind hier aber deutlich besser gegeben.

"Naives" Datenbank-Sharing ist aus unserer Sicht zurecht verpönt, da es beinahe immer in Form eines Moduls daher kommt, welches alle anderen Module in genau einer Version zu verwenden haben. Für wartbare Monolithen ist dies aber keinesfalls akzeptabel und wir müssen einige Mühe aufwenden, um dies in einem Monolithen konsequent zu vermeiden. Grundsätzlich wichtig ist hierbei, die gemeinsam genutzten Entitäten auf ein Minimum zu beschränken. Dazu kommt analog zu der üblichen Versionierungs- vs. Kompatibilitäts-Diskussion eine Diskussion darüber hinzu, dass man Neues eher daneben baut, anstatt Bestehendes zu verändern. Auf einer rein technischen Ebene helfen kann die Verwendung von Datenbank-Views.

Datenkonsistenz

Sind die Probleme der Datenbank-Integration konsequent adressiert und gelöst, so bietet sie uns dann in der Regel die Vorteile der Transaktionalität und der Datenkonsistenz, die in einer Microservice-Landschaft typischerweise nicht gegeben sind. Dort existieren Transaktionen über mehrere Microservices und übergreifende Konsistenz naturgemäß nicht. Statt dessen diktiert uns das CAP-Theorem das schwächere "Eventual Consistency"-Modell [6]. Neben der erzwungenen Beschäftigung mit dem CAP-Theorem in verteilten Systemen – welches zudem häufig falsch verstanden wird – verlangen unserer Erfahrung nach viele Anwendungsfälle nach harten Konsistenzgarantien.

Bei "Eventual Consistency" müssen diese Konsistenzgarantien von der Applikationslogik sichergestellt werden. Dies ist ein nicht zu unterschätzender Komplexitätstreiber, der konsequent berücksichtigt werden sollte, wenn die unterliegende Datenbank an ihre Skalierungsgrenzen kommt oder man aus anderen Gründen die Aufteilung der Datenbasis in Erwägung zieht. Apropos Skalierung...

Was ist mit Skalierbarkeit?

Der Ansatz der "Pseudo-Services", also einer Architektur mit vielen Anwendungen, die aber trotzdem als monolithisch zu betrachten sind, hat einen weiteren Vorteil mit den Microservices gemeinsam: Er ermöglicht, Teile des Gesamtsystems leichtgewichtig und unabhängig voneinander zu skalieren, wenn es für diese Teile höhere Skalierungsanforderungen gibt. Ein einfaches Beispiel wäre ein Administrationsbereich eines Forums, das sicherlich für deutlich weniger Zugriffe skalieren muss, als der öffentliche Forenbereich.

Viele Probleme der Entwicklung liegen gar nicht in der Technik, sondern in den Organisationen.

Wie können wir mit dieser Situation in einem echten Monolithen mit nur einer einzigen Anwendung umgehen? Zum einen kann man dem entgegen halten, dass die Skalierbarkeitsanforderungen an Monolithen ohnehin niedriger sind, da nicht permanent auf irgendwelche Aufrufe über das Netzwerk gewartet werden muss. Zum anderen ist eine mögliche Lösung, falls eben doch horizontal skaliert werden muss, auch eigentlich sehr einfach: Wir skalieren einfach alles.

Wenn wir in dem obigen Beispiel den Admin-Bereich einfach mitskalieren, indem wir einfach die monolithische Anwendung, die Admin-Bereich und Forum enthält, mehrfach starten, so ist abgesehen von etwas mehr verbrauchtem Speicher dabei kein anderer negativer Effekt zu erwarten.

In der realen Welt müssen wir dabei aber noch einige grundsätzliche Dinge beachten: So müssen wir natürlich darauf achten, dass alle Teile unserer Anwendung dann auch skalierbar sind. Benötigt unser Admin-Backend von oben zum Beispiel eine Session (ein Evergreen, um nachhaltig Skalierbarkeit zu verhindern), so ist "skalier' einfach alles" eben sehr leicht gesagt, aber nicht so leicht getan.

Außerdem sollte man bei diesem Ansatz auch darauf achten, dass das Mitskalieren der weniger benötigten Teile der Anwendung wenigstens so ressourcenschonend wie möglich vonstatten geht. Konkret bedeutet das, dass beispielsweise Thread-Pools für unterschiedliche Bereiche der Anwendung optimal getrennt und damit unterschiedlich zu dimensionieren sind. Grundsätzlich gesprochen: Teile der Anwendung sollten niemals pro-aktiv (eager) Ressourcen blockieren, die sie überhaupt nicht benötigen. Außerdem sollten sie, wenn sie doch Ressourcen blockiert haben, diese später auch wieder frei geben.

Der richtige Schnitt

In welche Microservices muss ich eigentlich mein System schneiden, um nicht bei einem verteilten "Big Ball of Mud" zu landen? Auf Microservices zu setzen, sorgt nämlich nicht emergent für eine wohlgeordnete Struktur des Gesamtsystems. Die einfache Antwort lautet: pro fachlichem Prozess ein Microservice. In der Praxis ist diese Aussage leider oft nicht sehr hilfreich. Viele Prozesse, die uns in der realen Welt begegnen, sind übergreifend und nicht so trivial, dass sie auf einem Microservice abgebildet werden können.

Adieu autonomes Team, hallo Teamsynchronisation.

Um trotzdem einen guten Schnitt zu bekommen und die Abhängigkeiten zwischen den Microservices klein zu halten, wird oft empfohlen, Domain-Driven Design (DDD) einzusetzen. DDD bietet mit dem Konzept des bounded context und der Beschreibung der unterschiedlichen Arten von Beziehungen zwischen verschiedenen bounded contexts (customer/supplier, conformist, shared kernel etc.) ein gutes Werkzeug, um die Verantwortlichkeiten und Beziehungen der einzelnen Microservices zu modellieren. Dieses Werkzeug ist aber natürlich nicht auf Microservices beschränkt, sondern kann uns genauso gut auch beim Modulschnitt in einem Monolithen helfen. Aber auch hierfür gibt es eine wichtige Erkenntnis: Die Analyse und Modellierung mittels DDD erzeugt ein statisches Modell. Prozesse können und werden sich aber im Laufe der Zeit ändern. Folglich wird sich auch der Schnitt meiner Microservices oder meiner monolithischen Module ändern müssen. In beiden Fällen muss unbedingt für diesen Fall vorgesorgt sein.

Wartbarkeit

Dies bringt uns zum Thema Wartbarkeit. Während der einzelne Microservice dank der geringen Größe und damit einhergehenden Übersichtlichkeit leicht wartbar ist, fangen die Probleme an, wenn Zuständigkeiten zwischen Microservices verschoben werden müssen. Im Normalfall können dann nicht einfach Methoden und Objekte mittels der Refactoring-Funktionen der IDE hin und her verschoben werden. Der Code muss von Hand per Copy & Paste in ein anderes Projekt eingefügt werden. Vorausgesetzt, die Microservices sind in der selben Programmiersprache geschrieben. Sonst bleibt nichts anderes übrig, als die entsprechenden Teile neu zu schreiben. Für Schnittstellen, die von einem Microservice in einen anderen wandern, müssen alle aufrufenden Microservices geändert werden – und zwar koordiniert. Adieu autonomes Team, hallo Teamsynchronisation. Hohe Wartungsaufwände und ein unflexibles System sind die Folge.

Das monolithische System hingegen ist hierbei deutlich leichter anpassbar. Die gesamte Bandbreite an Refactoring-Werkzeugen der IDE steht zur Verfügung. Schnittstellenverschiebungen müssen nicht über alle Teams hinweg synchronisiert werden. Meist genügt ein Klick. Auf längere Sicht kann ein guter Monolith sogar adaptiver für eine dynamische Organisation mit sich wandelnden Prozessen sein, als eine Microservice-Landschaft.

Betrieb

Neben der reinen Pflege des Codes erzeugt auch die zum Betrieb von Microservices nötige Infrastruktur oft nicht unerhebliche Aufwände. So erfordert der Betrieb einer Microservice-Landschaft eine ganze Reihe von Infrastrukturkomponenten, um zentrales Logging, Service-Discovery, die Verwaltung von Geheimnissen, Distributed Tracing, eventuell eine PKI und vieles mehr zu ermöglichen. Manches davon braucht man auch für den Betrieb eines monolithischen Systems – beispielsweise zentrales Logging. Anderes dagegen ist überflüssig: Distributed Tracing braucht man zum Beispiel nur, weil schon ein Aufruf im Frontend zig Aufrufe von Microservices über Netz nach sich ziehen kann.

Diese Komponenten erhöhen zum einen die Komplexität des Gesamtsystems, zum anderen müssen sie betrieben werden – ein oft nicht triviales Unterfangen. Alternativ bieten sich verschiedene IaaS- und PaaS-Angebote an, die einem den Betrieb der benötigten Komponenten abnehmen, verbunden mit den entsprechenden Kosten. Wenn man hierbei dann nicht sehr gut aufpasst, bekommt man noch einen Vendor-Lock-in gratis dazu.

Debugging

Ist ein System erst einmal im Betrieb, wird es immer wieder mal zu Fehlern kommen, egal wie hoch die Testabdeckung ist. Dann fängt das Debugging an. Wie beim Refactoring ist man in einem monolithischen Systeme mit IDE und einem guten Debugger hier gut aufgestellt.

Idealerweise kann ein Fehler, nachdem ein Überblick mittels Log-Ausgaben geschaffen wurde, Schritt für Schritt mit dem Debugger nachvollzogen und gefixt werden – im Optimalfall auf dem lokal gestarteten System. In der Microservice-Landschaft müssen zunächst die Log-Ausgaben der verschiedenen Microservices korreliert und die Aufrufkaskade der einzelnen Microservices über das "distributed tracing" nachvollzogen werden, um einen Überblick zu bekommen. Nachvollziehbarkeit, Schritt für Schritt, ist so gut wie unmöglich, es sei denn man hantiert mit einer ganzen Armada von Debuggern gleichzeitig. Vorausgesetzt, das System bzw. die betroffenen Microservices sind überhaupt lokal lauffähig.

Security

In der ganzen Diskussion um Microservice-Architekturen spielt der Security-Aspekt oft nur einer untergeordnete Rolle. Eigentlich unverständlich in einer Zeit, in der fast täglich neue Vorfälle bekannt werden, bei denen Millionen von teils sehr sensiblen Datensätzen entwendet werden und Sicherheitslücken eigene Webseiten bekommen.

Und wenn, dreht es sich meistens nur um Themen wie OAuth und OpenID Connect, sprich Authentifizierung und Autorisierung in verteilten Systemen. Dies ist zwar wichtig, deckt aber nur ein kleinen Teil der relevanten Security-Probleme in einer Microservice-Landschaft ab.

Um nur einige zu nennen:

  • Die Angriffsfläche vergrößert sich, weil die meisten Schnittstellen nun über das Netzwerk exponiert sind.
  • Der Einsatz einer Vielzahl von Bibliotheken, Frameworks, Programmiersprachen und Ausführungsumgebungen erhöht die Wahrscheinlichkeit von Sicherheitslücken und Konfigurationsfehlern.
  • Statische Analysen von Aufrufhierarchien, um Logikfehler zu entdecken, sind nicht mehr möglich.
  • Eine große Anzahl von Geheimnissen (Zugangsdaten für Datenbanken, API-Keys, Zertifikate usw.) muss verwaltet werden.

Alles Dinge, die man durch technische und organisatorische Maßnahmen in den Griff bekommt. Gleichwohl zu Kosten, die bei einem monolithischen System zumindest niedriger ausfallen.

Fazit

Die meisten Vorteile von Microservice-Architekturen, wie zum Beispiel Entkoppelung durch Modularisierung oder Skalierbarkeit, lassen sich (zugegebener Maßen mit einigem Aufwand) auch in einer monolithischen Architektur realisieren, ohne dabei Probleme mit UI-Integration oder der Datenkonsistenz zu bekommen. Leichteres Debugging und einfacherer Betrieb sind weitere Pluspunkte.

Viele Probleme der (Weiter-)Entwicklung von IT-Systemen liegen jedoch gar nicht in der Technik, sondern in den Organisationen. Versuche, diese mit technischen Mitteln zu lösen, wozu wir explizit die jeweils gewählte Software- und System-Architektur zählen und damit auch den Microservice-Ansatz, sind dabei nicht zwingend zielführend. Dass cross-funktionale Teams mit Features, die sie eigenverantwortlich von der Konzeption über das Deployment bis hin zum Betrieb entwickeln können, schneller am Markt sind, liegt auf der Hand – unabhängig davon, ob es sich um ein monolithisches System oder ein aus Microservices zusammengesetztes handelt. Passt umgekehrt die Organisationsstruktur nicht zu der gewählten Software- und System-Architektur – beispielsweise getrennte Teams und Zuständigkeiten für UI, Entwicklung, DB usw. – wird ein Scheitern vorprogrammiert sein.

Die Organisationsstruktur zu ändern, ist in der Regel deutlich schwerer, als die Software- und Systemarchitektur. Deshalb ist die Versuchung groß, organisatorische Probleme mit technischen Mitteln zu lösen. Dies wiederum betrachten wir als einen der Hauptgründe dafür, warum Microservice-Ansätze scheitern. Zumal die Entscheidung über Organisationsänderungen häufig nicht bei denen liegt, die über Software- und Systemarchitektur entscheiden – auf der anderen Seite werden diese jedoch für die langsame Feature-Entwicklung verantwortlich gemacht.

Die Entscheidung über die richtige Software- und Systemarchitektur sollte sich deshalb immer zuerst an den Problemen orientieren und nicht an den Lösungsansätzen. Einige Leitfragen können bei der Orientierung helfen:

  • Welche Probleme sollen konkret gelöst werden?
  • Was ist der größte Schmerz bei dem aktuell verwendeten Ansatz?
  • Passt die angestrebte Lösung zur Organisationsstruktur?
  • Wenn nein: kann die Organisationsstruktur an die angestrebte Lösung angepasst werden?
  • Ist eine neue Architektur wirklich nötig oder reicht es, die tatsächliche Umsetzung der aktuellen Architektur zu verbessern?

Wird nach Beantwortung der Fragen eine Microservice-Architektur favorisiert, dann müssen zusätzlich folgenden Fragen positiv beantwortet werden können:

  • Kann die nötige Infrastruktur betrieben werden?
  • Ist "Eventual Consistency" ein ausreichendes Konsistenzmodell für die Problemlösung?
  • Können die eingeschränkte Testbarkeit und Debugging-Möglichkeiten durch andere Maßnahmen kompensiert werden?

Wenn ja, können Microservices ein valider Ansatz sein. Die Entscheidung dürfte dennoch unserer Meinung nach in vielen Fällen lauten: wir nutzen ein monolithisches System. Allerdings dann bitte mustergültig umgesetzt!

Autoren

Till Schulte-Coerne

Till Schulte-Coerne, Senior Consultant bei innoQ, realisiert seit mehreren Jahren Webanwendungen mit diversen Technologien und Frameworks.
>> Weiterlesen

Christoph Iserlohn

Christoph Iserlohn hat langjährige Erfahrung mit der Entwicklung und Architektur von verteilten Systemen. Er ist Senior Consultant bei der InnoQ.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben