Microservices: Zweiter Frühling in der Cloud
Viele IT-Teams stehen derzeit vor der Aufgabe, ihre Systeme für die digitale Transformation fit zu machen. Die Integration von Altsystemen in digitale Workflows mit direkter Kundeninteraktion, stellt neue Herausforderungen bereit. Nicht immer ist es möglich oder sinnvoll, diese Altsysteme in moderne, skalierbare Architekturen zu überführen. Meist ergibt sich hier eine Lücke in der Belastbarkeit dieser Systeme gegenüber neueren Entwicklungen, welche von Anfang an auf Skalierbarkeit ausgelegt sind.
Oft wird hierbei eine Migration auf eine Microservice-Architektur gewünscht. Diese bietet gegenüber klassischen Architekturen oft deutlich bessere Möglichkeiten, zielgerichtet zu skalieren – allerdings ist die Dekomposition eines gewachsenen Stückes Software oftmals nicht so einfach, wie ein Projekt auf der grünen Wiese zu beginnen.
Das Projekt
Wie eine solche Migration funktionieren kann, soll daher im Rahmen dieses Artikels prototypisch dargestellt werden. Im Rahmen eines Projektes sollte eine mittlerweile etwas in Jahre gekommene Applikation eines größeren Kunden überarbeitet werden. Aufgrund des hohen Alters und der Komplexität des Systems, stiegen die Wartungs- und Erweiterungskosten immer weiter an. Verschärft wurde die Situation dadurch, dass nur wenige automatische Tests vorhanden waren. Dies erzeugt bei einem Refactoring und einem eventuellen Aufteilen des Monolithen auf mehrere Services ein sehr hohes Risiko.
Mittelfristig war geplant, die Anwendung durch eine System aus Microservices zu ersetzen. Daher wurde für die neuen Anforderungen, welche im Rahmen des hier beschriebenen Projektes umgesetzt werden sollten, beschlossen, diese bereits in einen eigenen Service auszulagern. Die dabei entstehende Infrastruktur sollte dann als Blaupause für die weitere Auslagerung von Funktionalität aus dem Monolithen dienen. Da die neue Funktionalität keine Abhängigkeiten zu bestehenden Geschäftsprozessen hatte, war dieses Vorgehen ohne großes Risiko möglich.
Ein weiteres Ziel des Projektes war, die Applikation so zu überarbeiten, dass eine bessere Skalierbarkeit und Entwicklungsgeschwindigkeit in Zukunft erreicht werden kann.
Technik
Beim Umbau einer monolithischen Applikation in eine Microservice-Architektur ergeben sich neue Herausforderungen bei der Interaktion der Services untereinander. Um hier nicht komplett auf der grünen Wiese zu beginnen, sollte lieber auf bestehende Frameworks aufgebaut werden. Im Java-Bereich ist dies beispielsweise mit dem Technologie-Stack von Netflix möglich. Dieser stellt mehrere Module bereit, die als Basiskomponenten für ein solches Microservice-System dienen können. Im Folgenden werde ich auf die Module eingehen, welche im Rahmen des Projektes benutzt wurden.
Ein wichtiges Thema in einer Microservice-Landschaft, ist die Möglichkeit, herauszufinden wie andere Services anzusprechen sind. Dies kann im einfachsten Fall über eine Konfigurationsoption gelöst werden, ist jedoch in sich dynamisch ändernden Cloud-Umgebungen nicht oder nur mit großem Aufwand möglich. Eine andere Möglichkeit wäre, vor alle Services immer einen Load-Balancer zu schalten und die Services über diesen zu adressieren. Dadurch kann eine Änderung an der Software dahinter dadurch geschehen, in dem die verwalteten Instanzen ausgetauscht werden, ohne dass sich die Zugriffsadresse von außen ändert. Allerdings führt ein Load-Balancer eine neue Komponente in die Architektur ein, die natürlich wieder ausfallen kann.
Im Netflix-Stack wird dieses Problem daher über eine Service Registry gelöst, welche im Projekt Eureka [1] entwickelt wurde. Diese bietet eine Schnittstelle, unter der sich die laufenden Services mit Hilfe eines eindeutigen Namens registrieren. Diese Registrierung muss regelmäßig erneuert werden, ansonsten entfernt der Eureka-Server die Instanzen wieder aus seinem globalen Verzeichnis. Die Clients laden sich zudem in regelmäßigen Abständen das komplette Verzeichnis aus der Registry. Beim Zugriff auf einen anderen Dienst, wird nun aus der lokal vorhandenen Kopie, eine Instanz dieses Namens zufällig ausgewählt und als Ziel von Anfragen genutzt. Eine schematische Darstellung des Prozesses kann der Abb.1 entnommen werden.
Durch diese Umsetzung ergibt sich ein Verfahren, welches sehr robust gegen Ausfälle ist. Sollte die zentrale Infrastruktur für die Service Registry nicht mehr erreichbar sein, können die Microservices noch andere Services auffinden. Zudem ist in Eureka noch ein Zonen-Konzept eingebaut. Jede Instanz gibt bei der Registrierung einen Ort mit, an dem sie sich befindet. Somit kann bei der Suche nach einem geeigneten Dienst auch die Lokalität mit einbezogen werden. Damit kann sichergestellt werden, dass für Aufrufe anderer Services zunächst Instanzen im selben Rechenzentrum gefunden werden und der Zugriff somit innerhalb desselben erfolgt.
Neben dem Zugriff auf Dienste innerhalb des Systems, ist auch der Zugriff für externe Benutzer relevant. Dieser sollte normalerweise über eine gesammelte Schnittstelle erfolgen, um keine externen Abhängigkeiten auf die interne Architektur des Systems entstehen zu lassen. Hierfür steht im Netflix-Stack der Service Zuul [2] bereit. Dieser implementiert eine Routing Engine, mit der HTTP-Anfragen von externen Clients in das System geleitet werden. Beim Zugriff auf die Dienste, welche innerhalb der Systemgrenzen laufen, wird dabei wieder auf Eureka für die Lokalisierung von ansprechbaren Instanzen zurückgegriffen.
Zuul bietet allerdings nicht nur ein Routing von außerhalb der Systemgrenzen an, sondern es kann durch Filter zusätzlich noch eine Transformation der HTTP-Anfragen erfolgen. Dies ermöglicht es, an der Schnittstelle nach außen Querschnittsthemen wie zum Beispiel Logging, Authentifikation, Abrechnung und weitere zu implementieren, ohne dass diese Funktionen in jedem Microservice noch einmal erfolgen müssen.
Ein weiteres Problem beim Zugriff in einem verteilten System ist die Fehlerbehandlung. Da das gesamte System nicht innerhalb desselben Prozesses läuft, kann hier nicht zu den "traditionellen" Mechanismen wie zum Beispiel Exceptions gegriffen werden. Oft kann ein Fehler beim Aufruf eines anderen Services nur durch Ausbleiben einer Antwort ermittelt werden.
Da dieses Szenario oft durch eine Überlastsituation beim aufgerufenen Service entsteht, hat Netflix hierfür eine Implementierung des Circuit Breaker Patterns namens Hystrix [3] bereitgestellt. Dieses Projekt bietet eine Sicherung an, welche bei einer zu hohen Anzahl von Fehlern in kurzer Zeit weitere Anfragen auf Client-Seite abblockt. Dadurch entsteht eine Karenzzeit, in der das aufgerufene System sich erholen kann. Während dieser Zeit können als Fallback Standardwerte oder Daten aus dem Cache zurückgegeben werden. Nach einer konfigurierbaren Wartezeit wird dann mit einem einzelnen Request geprüft, ob wieder Anfragen möglich sind. Sollte dem so sein, wird die Belastung dann wieder langsam hochgefahren. Somit kann ein Szenario vermieden werden, bei dem sich durch fehlerhafte und langsame Antwortzeiten eine Fehler-Kaskade zu einem Ausfall hochschaukelt.
Während diese Werkzeuge eine sehr gute Ausgangsbasis für die Implementierung der genannten Features sind, ist ihre Benutzung doch mit einem manuellen Einbau in vorhandene Software-Projekte verbunden. Für Benutzer des Spring-Projekts steht daher im Projekt Spring Cloud [4] eine Anbindung an das populäre Framework bereit. In Verbindung mit Spring Boot [5] ist daher eine vereinfachte Konfiguration und Einbindung dieser Komponenten in eigene Projekte möglich. Dies ist insbesondere für Teams sinnvoll, in denen bereits Spring-Know-how vorhanden ist.
Gemäß dem Prinzip Konvention-vor-Konfiguration [6] werden vom Spring-Cloud-Team die Module mit sinnvollen Default-Werten bereitgestellt und erweitert. So wird zum Beispiel für jeden Service der per Konfiguration in Zuul konfiguriert ist, bereits ein Hystrix Circuit Breaker erstellt. Zudem beinhaltet das Projekt integrierte Benutzeroberflächen für eine einfache Abfrage des aktuellen Status'.
Umbau der Applikation
Um den Umbau der Architektur für das System einzuleiten, wurde zunächst der Ist-Zustand untersucht. Es handelte sich beim vorhandenen System um eine klassische Architektur, wie sie in der Abb.2 dargestellt ist.
Die Applikation beinhaltet sowohl die HTML-basierte Benutzeroberfläche, wie auch die fachliche Logik in einem Artefakt. Von dort aus erfolgt eine Speicherung der Daten in einer SQL-Datenbank. Zudem erfolgt eine Interaktion mit mehreren anderen Systemen über eine Webservice-Schnittstelle.
In einem ersten Schritt wurden die Umbauten an der Architektur vorgenommen, welche ein besseres Abfangen von Überlastszenarien ermöglichen. Hierzu wurde zunächst ein Zuul-Proxy zwischen das System und den Benutzer geschaltet. Dieser beinhaltet bereits einen Hystrix-Circuit-Breaker und ermöglicht bei zu vielen Anfragen eine Karenzzeit für das Erreichen eines stabilen Systemzustands, wie Abb.3 entnommen werden kann.
Zudem wurden in die Applikation selber auch noch einmal Circuit-Breaker integriert, um nachgelagerte Systeme ebenfalls abzusichern.
Nachdem das System auf diese Art gesichert worden war, konnte mit weiteren Arbeiten begonnen werden. Durch den Zuul-Proxy war es möglich, den Applikationskontext zu verschieben, sodass diese nun nicht mehr auf oberster Ebene anzusprechen war. Sie ist nun unterhalb der URL /app/* des Gesamtsystems. Danach können dann wie in Abb.4 gezeigt, weitere Services in die URL-Struktur eingehängt werden.
Im vorliegenden Fall wurde ein weiterer Service erstellt, in Abb.4 als "Service A" bezeichnet, und unterhalb der URL /api/a/* verfügbar gemacht. Dieser kapselt die neue Funktionalität und bietet diese mittels einer REST-API zur Nutzung an. Anschließend muss diese noch in die Benutzeroberfläche integriert werden.
Benutzeroberfläche
Bei serverseitig gerenderten HTML ist es naturgemäß schwierig, neue Funktionalitäten zu integrieren ohne diese zu ändern. Daher wurde die neue Funktionalität mit Javascript innerhalb des neu implementierten Services bereitgestellt. Diese kann vom Benutzer über einen Button in einem zentralen Navigationsbereich gestartet werden.
Für das Laden dieser Funktionalität wurde innerhalb der Legacy-Applikation eine leere Javascript-Datei hinterlegt und in die Webseiten eingebunden. Wenn sie innerhalb einer Entwicklungsumgebung gestartet wird, ergibt sich dadurch keine Änderung zum bisherigen Verhalten. Beim Aufruf über den Zuul-Proxy wird die Anfrage zu dieser Datei allerdings zum neuen Service A umgeleitet. Bei diesem erfolgt über das Laden der Datei dann die Integration des Buttons in die Oberfläche.
Wenn der Button vom Benutzer gedrückt wird, erscheint die neue Funktionalität mit Hilfe eines modalen Dialogs in der bisherigen Webseite. Somit kann eine moderne Javascript-Applikation in die vorhanden Benutzerschnittstelle integriert werden, ohne diese in großem Maße anpassen zu müssen.
Fazit
Im vorliegen Fall wurde gezeigt, wie auch eine schwer veränderbare Altanwendung in eine Microservice-Landschaft überführt werden kann. Ausgehend von den Erfahrungen, konnte das Team im Anschluss noch weitere Funktionalitäten auf neue Services verteilen und das Gesamtsystem somit deutlich robuster und erweiterbarer gestalten.