Microservices sind nicht einheitlich definiert. Eine Definition bieten die Independent Systems Architecture (ISA) Principles. Sie bestehen aus neun Prinzipien, denen eine gute Microservices-Architektur genügen muss. Für diesen Artikel sind einige Prinzipien hilfreich: Das erste Prinzip besagt, dass Microservices Module sind. Microservices sind nur eine Möglichkeit, ein System zu modularisieren. Alternativen sind Packages, JAR-Dateien oder Maven-Projekte. Für Microservices gilt dasselbe wie für andere Arten von Modulen: So sollen Microservices beispielsweise lose gekoppelt sein.

Das zweite Prinzip besagt, dass die Module als Container umgesetzt werden. Das bietet mehrere Vorteile: So kann jeder Microservice unabhängig von den anderen neu deployt werden. Bei einem Absturz eines Microservice laufen die anderen Microservices weiter. Bei einem Deployment Monolithen hingegen würde ein Speicherleck die gesamte Anwendung zum Absturz bringen. So erhöhen Microservices die Entkopplung. Klassische Module entkoppeln nur die Entwicklung. Microservices entkoppeln andere Aspekte wie Deployment oder Ausfälle.

Ebenso sollen die Microservices Resilience bieten. Wenn ein Microservice ausfällt, müssen die anderen Microservices weiterhin laufen. Sonst wird die Entkopplung bezüglich Ausfällen nicht erreicht. Das System wäre außerdem nicht sonderlich stabil, da der Ausfall eines beliebigen Microservice zu einer Fehler-Kaskade führen kann und das gesamte System zum Ausfall bringen kann. Bei der hohen Anzahl Microservices ist das ein untragbares Risiko.

Die ISA-Prinzipien unterscheiden Mikro- und Makro-Architektur. Mikro-Architektur sind Entscheidungen, die auf Ebene jedes Microservice anders getroffen werden können. Die Makro-Architektur beeinflusst hingegen alle Microservices. Die Makro-Architektur muss langfristige stabil sein, denn Änderungen sind schwer umsetzbar. Schließlich beeinflussen sie alle Microservices. Außerdem soll die Makro-Architektur minimal sein, damit die unabhängige Entwicklung der Microservices möglichst wenig eingeschränkt wird.

Aus den ISA-Prinzipien lässt sich ableiten, dass Microservices lose gekoppelt sein sollen und Resilience unterstützen müssen. Das sind wichtige Faktoren bei der Auswahl passender Technologien.

Die Aufteilung in Mikro- und Makro-Architektur hat ebenfalls Auswirkungen auf die Technologie-Auswahl. Das Framework und die Programmiersprache, mit denen ein Microservice umgesetzt wird, kann Teil der Mikro-Architektur sein. Schließlich ist es ein wesentlicher Vorteil von Microservices, dass jeder Microservice mit einer anderen Technologie umgesetzt werden kann. Wenn die Makro-Architektur langfristig stabil sein soll, ist es kaum sinnvoll, eine Programmiersprache oder ein Framework festzuschreiben. Wer heute mit Java 9 und Spring Boot 2.0 entwickelt, kann sicher sein, dass der Stack in ein paar Jahren veraltet sein wird. Bei langlaufenden Projekten gilt daher: Wenn alle Microservices dieselben Technologien nutzen sollen, muss man entweder die Technologien veralten lassen oder man migriert alle Microservices gleichzeitig auf die aktuelle Technologie, was risikoreich und aufwändig ist. Nur wenn man unterschiedliche Technologien zulässt, dann kann man jeden Microservice einzeln modernisieren. Technologiefreiheit erlaubt Teams außerdem, das beste Werkzeug für jeweilige Herausforderung zu wählen. Dennoch müssen bestimmte Technologien auf Ebene der Makro-Architektur festgeschrieben werden z.B. Technologien für die Kommunikation. Sie beeinflussen alle Microservices und sind daher auch nicht leicht zu ändern.

Der Rest des Artikels beschäftigt mit den Integrationsmöglichkeiten, die bei Microservices eingesetzt werden können. Dabei ist ein wichtiger Aspekt, wie die Technologien lose Kopplung und Resilience unterstützen.

UI-Integration

Ein Microservice kann auch eine UI enthalten. Zum Beispiel kann der Microservice eine HTML-Seite anzeigen. Die Integration eines anderen Microservice kann einfach ein Link sein. Ein konkretes Beispiel zeigt die Crimson Assurance, die als Demo mit einer ausführlichen Anleitung zum Download bereitsteht. Das System ist ein Prototyp für eine Anwendung zur Unterstützung von Versicherungsmitarbeitern. Man kann Versicherte aufrufen und für ihre Autos Schäden erfassen. Das Erfassen des Schadens findet in einem anderen Microservice statt als die Auswahl und Anzeige des Kunden. Die Integration erfolgt über einen Link.

Eine solche Integration hat einige Vorteile: Die Kopplung ist sehr lose. Nur das Format der URL muss festgelegt sein. Der andere Service kann seine Web-Seite beliebig aufbauen.

Auch für Resilience ist gesorgt: Wenn der verlinkte Microservice ausfällt, kann der Link immer noch angezeigt werden.

Nachdem der Nutzer einen Schaden eingegeben hat, wird er zur Hauptanwendung zurückgeschickt. Das Formular zur Registrierung des Schadens liegt im Schaden-Microservice. Nach dem Abschicken des Formulars wird der Nutzer auf die Seite der Hauptanwendung mit Hilfe eines HTTP-Redirects zurückgeführt. Auch hier die die Kopplung sehr lose und Resilience recht gut umgesetzt.

Client-seitige Transklusion

Schließlich kann der Nutzer die Postbox aus dem Postbox-Microservice in die Web-Seite der Hauptanwendung einblenden. Man spricht von Transklusion. In der HTML-Seite ist ein Link mit einigen zusätzlichen Attributen enthalten. JavaScript-Code liest diese Attribute aus und ersetzt den Link durch einen Überblick aus dem Postbox-Microservice, wenn der Nutzer auf den Link klickt. Wenn der Postbox-Microservice nicht zur Verfügung steht, zeigt der Code eine Fehlermeldung an. Selbst wenn der JavaScript-Code gar nicht funktioniert, weil er nicht geladen werden konnte oder mit dem Browser des Benutzers inkompatibel ist, wird dennoch der Link angezeigt. Resilience ist also sichergestellt. Bei der Entkopplung ist es schwieriger: Damit die Postbox eingeblendet werden kann, muss das Layout zur Hauptanwendung passen. Ähnlich wie bei einer Schnittstellenabstimmung in einem klassischen System muss auch hier sichergestellt sein, dass die beiden System zueinander passen.

Ein Vorteil dieser Integrationen ist, dass sie technologisch einfach ist. Die Integration nutzt fundamentale Konzept von HTML und HTTP sowie ein wenig JavaScript-Code. Sie kann mit beliebigen Backend-Technologien genutzt werden. Crimson-Assurance besteht dementsprechend auch aus Spring-Boot- und Node.js-Microservices.

Server-seitige Transklusion

Die Transklusion kann auch auf dem Server stattfindet. So kann sichergestellt werden, dass keine Bestandteile der Seite nachgeladen werden müssen. Das kann beispielsweise für die Navigationsleiste sinnvoll sein. Für server-seitige Transklusion gibt es Standards wie SSI oder ESI. Ein Microservice liefert dann HTML aus, in dem SSI- bzw. ESI-Tags enthalten sind. Web Server interpretieren SSI-Tags während Web-Caches ESI implementieren. Der Web Server oder Web-Cache lädt entsprechend den Tags HTML-Schnipsel anderer Microservices nach.

Das Beispiel nutzt den Web-Cache Varnish (siehe Abb. 1) und sollte ebenfalls dank einer ausführlichen Dokumentation sehr einfach zu starten sein. Diesen Cache kann eine Website nutzen, um Zugriffe auf die Backend-Services zu vermeiden und aus dem Web-Cache zu bedienen. Mit ESI kann der Cache sogar Web-Seiten mit dynamischen Anteilen cachen. Die dynamischen Anteile kommen aus dem Backend und werden mit ESI in die gecachten statischen Seiten integriert. Das Beispiel nutzt diesen Ansatz, um eine Navigationsleiste zu integrieren. Alle Bestandteile werden 30 Sekunden im Cache gehalten.

Abb. 1: Transklusion mit ESI und Varnish
Abb. 1: Transklusion mit ESI und Varnish

Bezüglich der Kopplung gilt Ähnliches wie bei der client-seitigen Transklusion: Das Layout der Web-Inhalte muss so aufeinander abgestimmt sein, dass sie in einer Web-Seite kombiniert werden können. Bezüglich Resilience hilft der Cache ebenfalls: Wenn die Backends nicht verfügbar sind, werden die Daten 15 Minuten im Cache gehalten. So können Lese-Zugriffe weiterhin bearbeitet werden.

UI-Integration: Fazit

UI-Integration unterstützt lose Kopplung und Resilience. Technisch ist sie sehr einfach: Links, Redirects und ca. 60 Zeilen JavaScript können ausreichen.

Oft trifft man auf das Vorurteil, dass eine UI, die von mehreren Microservices gemeinsam dargestellt wird, kein einheitliches Look and Feel haben kann. Aber auch bei einem Deployment Monolithen kann eine Web-Seite völlig anders aussehen als alle anderen Web-Seiten. Der einzige Weg zu einem einheitliches Look and Feel ist eine Style Guide. Außerdem sind gemeinsame Assets hilfreich. Das setzt das Crimson-Assurance-Beispiel mit einem Asset Projekt um, während das ESI-Beispiel die Assets von einem Microservice ausliefern lässt.

Asynchrone Microservices

Eine weitere Möglichkeit zur Kopplung von Microservices ist asynchrone Kommunikation. Das bedeutet: Wenn ein Microservice gerade einen Request bearbeitet, darf er keinen anderen Microservice aufrufen und auf eine Antwort warten. Er darf also einen anderen Microservice nur dann aufrufen, wenn er nicht auf eine Antwort wartet. Beispielsweise kann ein Microservice gerade einen Request für eine Bestellung bearbeiten. Als Teil der Logik kann der Microservice einen anderen Microservice aufrufen, der eine Rechnung schreiben soll. Allerdings darf er nicht auf eine Antwort warten, sondern muss weiterarbeiten. Wenn der Empfänger gerade nicht verfügbar ist, wird die Nachricht später übermittelt. Die Rechnung würde also später geschrieben und Resilience wäre gewährleistet.

Der Microservice kann auch andere Microservices aufrufen und auf eine Antwort warten - aber nur, wenn der Microservice selber nicht gerade einen Request behandelt. So kann der Service beispielsweise regelmäßig neue Kundendaten abfragen, auf die Daten warten und sie replizieren. Bei einem Ausfall des Microservice findet keine Replikation statt. Also veralten die Daten, aber das System funktioniert weiterhin und Resilience ist gewährleistet.

Asynchrone Kommunikation mit Message-oriented Middleware Eine Message-oriented Middleware (MOM) kann eine Infrastruktur bereitstellen, um asynchrone Nachrichten zu verschicken. Ein MOM kann die Zustellung von Nachrichten mit einer hohen Sicherheit garantieren. Dazu muss es allerdings die Nachrichten dauerhaft speichern. Schließlich kann nur so sichergestellt werden, dass die Nachricht auch dann zugestellt wird, wenn der Empfänger gerade ausgefallen ist.

Message-oriented Middleware (MOM) und Kafka

In der Java-Welt ist JMS (Java Message Service) oft das Mittel der Wahl für asynchrone Kommunikation. Aber gerade im Microservices-Umfeld wird Kafka zunehmend wichtiger. Während andere MOMs meistens Nachrichten nur eine gewisse Zeit vorhalten, kann Kafka die Nachrichten beliebig lange speichern. Also kann ein Empfänger sich alle Nachrichten noch einmal zustellen lassen

Auch für die Nutzung von Kafka für Microservices-Systeme gibt es eine einfach Beispiel-Anwendung, bei der aus einer Bestellung eine Rechnung und eine Lieferung werden sollen.

Asynchrones REST

Natürlich wäre es denkbar, statt Kafka ein anderes MOM zu nutzen. Da alle Kommunikation zwischen den Microservices über das MOM gehen, muss es mit einer hohen Last zurechtkommen und hoch verfügbar sein. Das ist an sich kein Problem: Schließlich gibt es schon lange MOM-Installationen, die unternehmenskritisch sind. Dennoch kann es anspruchsvoll sein, das MOM entsprechend zu tunen.

Es wäre schön, wenn es eine Möglichkeit für asynchrone Kommunikation gäbe, die ohne MOM auskommt. Genau das ist mit REST möglich. Ein Microservice holt sich per HTTP GET von einem anderen Microservice die Events ab. Dieses Vorgehen scheint nicht besonders effizient zu sein, denn die Services kommunizieren recht häufig miteinander und in den meisten Fällen gibt es keine neuen Events. Das kann durch HTTP Caching gelöst werden: Der Client schickt beim HTTP Request den Zeitstempel der letzten ihm bereits bekannten Änderung mit. Wenn es keine neuen Events gibt, antwortet der Server mit einem HTTP Status 304 (not modified). Nur wenn es neue Nachrichten gibt, werden tatsächlich Daten mitgeschickt. Um keine überflüssigen Events zu übertragen, kann die Schnittstelle Optionen anbieten, um nur einige Events zu übertragen. So kann die Kommunikation sehr effizient gestaltet werden.

Wenn der Server sowieso die alten Events abgespeichert hat, dann kann er diese Events an der Schnittstelle anbieten, ohne dass dazu eine weitere Speicherung wie bei Kafka notwendig wäre. Das Beispiel nutzt Atom, um die Events dem Client zur Verfügung zu stellen. Dieses Daten-Format wird sonst genutzt, um Abonnenten Blogs oder Podcasts zur Verfügung zu stellen. Im Gegensatz zu Kafka und den meisten anderen MOM kann asynchrones REST Events nicht an nur einen Empfänger schicken. Jeder Empfänger bekommt alle neue Events und kann sie bearbeiten. So könnte eine Bestellung von mehreren Empfängern bearbeitet werden, so dass mehrere Rechnungen oder Lieferungen ausgelöst werden. Das Beispiel löst das Problem und schaut zunächst in der Datenbank nach, ob die Bestellung schon bearbeitet worden ist. Nur wenn das nicht der Fall ist, bearbeitet der Client die Bestellung. Sonst hat ein anderer Client die Nachricht schon bearbeitet. Die Clients synchronisieren sich also über die Datenbank. Bezüglich Skalierung hat dieses Vorgehen Nachteile: Nur ein Client bearbeitet einen Event, aber alle anderen überprüfen, ob der Event schon bearbeitet worden ist und führen dabei eine Datenbank-Operation aus.

Die Implementierung muss nicht nur für REST sondern auch für Kafka mit doppelt übertragenen Events zurechtkommen. Wenn ein Event vom Empfänger nicht quittiert wird, geht das MOM davon aus, dass der Event nicht erfolgreich bearbeitet worden ist und überträgt den Event erneut. Es kann aber sein, dass der Empfänger das Event erfolgreich bearbeitet hat und nur den Empfang nicht quittiert hat. Für diesen Fall muss der Client überprüfen, ob der Event bereits bearbeitet worden ist.

Synchrone Kommunikation

Viele Microservices-Projekten nutzen synchrone Kommunikation mit REST, obwohl das viele Nachteile hat. Bei synchroner Kommunikation kann ein Microservice gerade an einem Request arbeiten und währenddessen einen anderen Microservice aufrufen, um beispielsweise die Kundendaten auszulesen. Wenn der Kundendaten-Service gerade ausgefallen ist, muss der Aufrufer eine alternative Strategie umsetzten. Das kann eine fachliche Fragestellung sein: Nimmt man die Bestellung an, wenn man gerade die Zahlungsfähigkeit des Kunden nicht überprüfen kann? Es ist also viel schwieriger, Resilience sicherzustellen.

Bibliotheken wie Hystrix können nur einen Teil der Resilience-Herausforderungen lösen: So kann ein Timeout vermeiden, dass Microservices zu lange auf andere Microservices warten und dadurch ausfallen. Hystrix ist in Java geschrieben und schränkt daher die Technologie-Wahl ein. Eine Alternative ist der Istio-Proxy. Dieser Proxy sichert den Netzwerk-Verkehr ab und hängt nicht von einer Programmiersprache ab. Ein Microservice mit einem durch das Netz erreichbaren Service gegen Probleme zum Beispiel beim Netzwerk-Zugriff abzusichern erscheint absurd, aber der Proxy kann auf derselben Hardware laufen und durch das Loopback Device angesprochen werden.

Bei der Kopplung gilt dasselbe wie bei asynchroner Kommunikation: Für die Unabhängigkeit ist es wichtig, wer APIs und Datenstrukturen definiert und wie viele Microservices von Änderungen beeinflusst werden. Das ist unabhängig davon, ob die Kommunikation synchron oder asynchron ist.

Synchrone Microservices müssen folgende Herausforderungen lösen:

Die Service Discovery kommt eine entscheidende Rolle zu, denn sie kann die Basis für die Lösung der anderen Herausforderungen sein.

Consul

Consul bietet eine Lösung für Service Discovery. Im Beispiel benötigt die Registrierung eines Microservices benötigt nur die Spring-Cloud-Annotation @EnableDiscoveryClient, einige Einstellungen in der application.properties-Konfigurationsdatei und eine Abhängigkeit zur Bibliothek spring-cloud-starter-consul-discovery. Für das Load Balancing nutzt das Beispiel die Ribbon-Library von Netflix werden. Sie liest alle Instanzen eines Microservice aus Consul aus. Jeder Aufruf geht an eine andere Instanz. So findet das Load Balancing vollständig auf dem Client statt. Ein zentraler Load Balancer wird vermieden, der sonst ein Bottleneck und ein Single Point of Failure wäre. Für das Routing von Aufrufen von außen auf den richtigen Microservice nutzt das Beispiel einen Apache-Web-Server. Der Apache-Web-Server muss aber so konfiguriert werden, dass er alle Microservices-Instanzen kennt. Consul Template biete dafür eine Lösung: Es erstellt aus einem Template eine Konfigurationsdatei mit Einträgen aus der Consul-Service-Discovery. Der Apache-Web-Server wird so als Reverse Proxy und Load Balancer konfiguriert. Bei einer Änderung in Consul erstellt Consul Template eine neue Version der Konfigurationsdatei und startet den Apache-Web-Server neu. Der Web Server weiß nichts von Consul oder Service Discovery, sondern liest einfach nur Informationen aus der Konfigurationsdatei aus.

Dieser Aufbau führt aber zu Abhängigkeiten in den Spring-Boot-Projekten zu Consul, um die Registrierung in Consul umzusetzen und das Load Balancing zu implementieren. Das führt zwar nicht zu besonders großem Aufwand, aber die Abhängigkeiten machen es schwierig, Microservices mit einer anderen Technologie in das System einzuführen. Statt Ribbon und den Spring-Cloud-Funktionalitäten für die Registrierung in Consul müsste eine andere Bibliothek genutzt werden.

Für die Registrierung kann aber auch eine Lösung gewählt werden, die ohne Code oder Code-Abhängigkeiten auskommt: Registrator kann einen Docker Container in einer Service Discovery wie Consul registrieren, wenn der Container gestartet wird. So wird der Code unabhängig von Consul. Schließlich kann der Zugriff auf Consul über DNS (Domain Name System) stattfinden, das im Internet auch für die Auflösung von Hostnamen zu IP-Adressen genutzt wird. Die DNS-Anfragen unterstützen auch Load Balancing, so dass die Abhängigkeit zu Ribbon ebenfalls verschwindet. Das Beispiel wird so im Code vollständig unabhängig von Consul. Daher ist es auch kein Problem, einen Microservice in das System zu integrieren, der in einer anderen Programmiersprache oder mit anderen Frameworks implementiert ist.

Kubernetes

Kubernetes ist eine Plattform, die es erlaubt, Docker Container in einem Cluster ablaufen zu lassen. Kubernetes löst auch die typischen Herausforderungen für synchrone Microservices:

Das Beispiel setzt genau ein solches Vorgehen mit Kubernetes um.

Fazit

Microservices bieten Technologie-Freiheit bei der Implementierung der einzelnen Microservices. Daher teilen die ISA Prinzipien die Architektur-Entscheidungen in die globale Makro- und die nur einen einzelnen Microservice betreffende Mikro-Ebene auf. Die Entscheidung für eine Programmiersprache oder ein Microservice Framework kann ein Teil der Mikro-Architektur sein und ist sehr einfach zu revidieren: Man implementiert den nächsten Microservice mit einer anderen Programmiersprache und einem anderen Framework. Technologien zur Kommunikation hingegen sind auf der Makro-Ebene. Eine Entscheidung für eine solche Technologie ist schwieriger zu revidieren, weil sie alle Microservices beeinflussen kann. Für die Kommunikation gibt es zahlreiche Optionen (Abb. 2):

Die verschiedenen hier gezeigten Alternativen stellen Optionen für die Makro-Architektur dar. Jedes Projekt muss diese Entscheidungen selber treffen. Es gibt bei den hier präsentierten Ansätzen auch viele Variationsmöglichkeiten. Gerade diese Abwägung und Auswahl ist ein Kern der Architektur-Arbeit.

Abb. 2: Die Integrationsmöglichkeiten im Überblick
Abb. 2: Die Integrationsmöglichkeiten im Überblick

Neben den hier näher erläuterten Beispielen gibt es eine weitere Demo für typische Microservices-Technologien. Die hier gezeigten Ideen stehen auch im Mittelpunkt der kostenlosen Broschüre „Microservices Rezepte“ und des „Microservices Praxisbuchs“. Die kostenlose Broschüre „Microservices Überblick“ und das Microservices-Buch behandeln zwar auch Technologien, stellen aber die Architektur in den Mittelpunkt.