Kolumne: EnterpriseTales

Wie hält „Cloud-native“ Einzug in Enterprise Java?

Wie hält „Cloud-native“ Einzug in Enterprise Java?

Kolumne: EnterpriseTales

Wie hält „Cloud-native“ Einzug in Enterprise Java?


Sowohl Spring als auch Quarkus proklamieren für sich, Cloud-native zu sein [1], [2]. Aber was heißt das eigentlich und wie sieht’s mit dem Rest des (Enterprise-)Java-Ökosystems aus? Diese Ausgabe unserer Kolumne erläutert, was eigentlich im Allgemeinen hinter dem vielgehörten Begriff Cloud-native steckt und welche Auswirkungen das auf die Softwareentwicklung in Enterprise Java im Speziellen hat.

Als Amazon 2002 die AWS-Plattform startete [3], war wohl noch niemandem klar, dass damit eine Zeitenwende im Enterprise Computing eingeläutet wurde – die Cloud war geboren. Seither hat sich viel bewegt, initial war die Begeisterung groß, weil man sich per Knopfdruck einen Root-Server in der Cloud erstellen konnte. Mittlerweile ist aber vielen klar geworden, dass der eigentliche Mehrwert der Cloud gerade darin liegt, keinen eigenen Root-Server mehr betreiben zu müssen, sondern sich auf die Implementierung der fachlichen Anwendungen fokussieren zu können. Die können dann auf den verschiedenen Clouds proprietär betrieben werden, ohne dass man sich um die Administration der Server kümmern muss.

Lange Zeit war es allerdings so, dass man dabei Cloud-Anbieter-spezifische APIs verwenden und sich somit schon früh auf einen Anbieter festlegen musste. Mittlerweile gibt es aber mit Kubernetes einen De-facto-Standard, um die eigene Applikation anbieterunabhängig in der Cloud zu betreiben. Aber was ist in diesem Zusammenhang eigentlich eine Applikation, und was bedeutet, dass sie „Cloud-native“ ist?

Die Cloud Native Computing Foundation [4] definiert Cloud-native folgendermaßen: „Cloud-native Technologien ermöglichen es Unternehmen, skalierbare Anwendungen in modernen, dynamischen Umgebungen zu implementieren und zu betreiben. [...] Best Practices wie Container, Service Meshes, Microservices, immutable Infrastruktur und deklarative APIs, unterstützen diesen Ansatz.“

In dieser Definition fallen ein paar Schlüsselworte, die ich im Folgenden erklären möchte.

Container, Service Meshes und Microservices

Die ersten drei (nämlich Container, Service Meshes und Microservices) dürften dem geneigten Java-Magazin-Leser bereits bekannt sein, selbst wenn er sie noch nicht aktiv im Unternehmen einsetzt. Dennoch lohnt es sich, auch hier einen Blick darauf zu werfen, warum die genannten Technologien denn geeignet sind, „skalierbare Anwendungen in modernen, dynamischen Umgebungen zu implementieren“. Um das zu verstehen, werfe ich einen Blick darauf, wie ein klassischer Deployment-Prozess ohne Cloud-native-Gedanken eigentlich aussieht.

Klassischerweise ist es so, dass das Artefakt, das das Entwicklungsteam ausliefert, ein Web Application Archive (WAR) oder ein Enterprise Application Archive (EAR) ist. Dieses wird dem Operations-Team quasi über den Zaun geworfen, das es zunächst auf den Testsystemen und später (nach umfangreichen manuellen und selten automatisierten Tests) auf dem Produktionssystem auf einem Application Server installiert, von dem das Entwicklungsteam nicht so genau wusste, wie er konfiguriert ist. Häufig unterschied sich zudem die Konfiguration von Test- und Produktivsystem. Konfigurationen wurden häufig manuell auf die Systeme übertragen. Das war ein fehleranfälliger Prozess.

Um eine Anwendung der alten Art zu skalieren, musste zunächst vom Admin ein neuer Application Server gestartet und dann exakt so konfiguriert werden wie die bestehenden Server (inklusive der Applikation). Dann musste der Load Balancer so konfiguriert werden, dass er den neuen Server mit aufnahm und mit Last versorgte. Auch hier waren viele manuelle Befehle des Operations-Teams notwendig. Kommerzielle Application-Server-Hersteller bieten immerhin Schnittstellen an, um Aktionen für alle Server des Clusters gleichzeitig zu bedienen. Nichtsdestotrotz bleiben ein paar Probleme.

Deklarative APIs

Wie bereits geschrieben, führt ein Admin mehrere Befehle aus, um einen Server so zu konfigurieren, dass er an der Arbeit des Clusters teilnimmt. Das Ausführen von Befehlen ist ein imperatives Vorgehen. Um bei einem weiteren Server denselben Zustand zu erzielen, muss dieselbe Abfolge von Befehlen erneut ausgeführt werden. Das geschieht in der Regel über ein Skript. Ein Problem entsteht, wenn an irgendeiner Stelle des Skripts eine Änderung vorgenommen werden muss. Niemand kann dann so genau sagen, ob das Skript im weiteren Verlauf immer noch das tut, wofür es ursprünglich geschrieben wurde, oder ob die Änderung unerwünschte Auswirkungen auf die nachfolgenden Befehle hatte. Zudem muss bei bestehenden Servern geschaut werden, wie dort derselbe Zustand erreicht werden kann. Hier sind alle Befehle noch im alten Zustand und in alter Reihenfolge ausgeführt worden.

Im Gegensatz zum Ausführen von Befehlen geht es bei den im Zitat erwähnten deklarativen APIs darum, den Zielzustand zu beschreiben. Das ausführende System kümmert sich dann automatisch darum, dass dieser Zielzustand erreicht wird.

Ein Beispiel dafür sind die Konfigurationen von Kubernetes. In ihnen trägt man nicht den Befehl ein, bitte eine weitere Instanz eines Pods (in dem der Java-Server läuft) zu starten. Dieser Befehl würde auch nur in Kombination mit dem aktuellen Zustand das gewünschte Ergebnis erzielen. Also wenn aktuell zwei Instanzen des Servers laufen und man drei Instanzen haben möchte, führt der Befehl, eine Instanz zu starten, zum gewünschten Ergebnis. Wenn aber bereits drei Instanzen laufen, läuft nach dem Befehl eine Instanz mehr als gewünscht. Beim deklarativen API von Kubernetes ist das anders. Hier wird einfach der Zielzustand (drei laufende Instanzen) angegeben. Kubernetes stellt dann selbstständig fest, ob dieser bereits erreicht ist. Falls nicht, würde automatisch eine weitere Instanz gestartet. Würde der aktuelle Zustand z. B. vier Instanzen umfassen, würde entsprechend automatisch eine Instanz gestoppt. Falls die laufenden Instanzen einen anderen Zustand haben als gewünscht (z. B. eine alte Version), fährt Kubernetes nach und nach alle diese Instanzen herunter und startet parallel Instanzen mit dem gewünschten Zustand (z. B. der neuen Version).

Immutable Infrastructure

Nicht nur innerhalb von Kubernetes, sondern auch beim Aufsetzen und Aktualisieren hat sich das Paradigma der deklarativen APIs durchgesetzt. Nun ist allerdings z. B. das Update eines Kubernetes-Clusters auf eine neue Version nichts, was komplett automatisiert durchgeführt werden kann. Ein klassisches Update ist allerdings imperativ. Um das Cluster dennoch deklarativ aktualisieren zu können, hat sich ein weiteres Pattern etabliert. Es nennt sich Immutable Infrastructure. Infrastruktur, wie z. B. der Kubernetes-Cluster, wird dabei nicht mehr aktualisiert, sondern bleibt für immer unverändert und wird lediglich ersetzt. Dank des deklarativen API ist es dann möglich, den neuen Cluster automatisch in den gleichen Zustand zu bringen wie den alten. Die Beschreibungen des Clusters erfolgen mit Tools wie Terraform, Puppet oder Ansible. Alle bieten ein deklaratives API zum Beschreiben von Infrastruktur. Diese Beschreibung wird als Infrastructure as Code bezeichnet. Man kann sie wie Source Code einer Versionsverwaltung übergeben und so Änderungen nachverfolgen und nichtfunktionierende Änderungen zurückrollen. Der Unterschied zur klassischen Administration ist, dass nicht die Änderung auf einem Server zurückgerollt wird. Stattdessen wird eine neue Serverinstanz mit der zurückgerollten Änderung erzeugt und die alte verworfen. Die Serverinstanzen an sich sind immutable.

Auswirkungen auf die Softwareentwicklung

Die bisher beschriebenen Punkte haben sehr viel mit dem Betrieb von Software zu tun, und es sollte deutlich werden, dass sich das Aufgabenfeld eines Administrators beim Wechsel auf Cloud-native komplett ändert.

Das obige Zitat aus [4] geht aber noch einen Schritt weiter: „Die zugrunde liegenden Techniken ermöglichen die Umsetzung von entkoppelten Systemen, die belastbar, handhabbar und beobachtbar sind. Kombiniert mit einer robusten Automatisierung können Softwareentwickler mit geringem Aufwand flexibel und schnell auf Änderungen reagieren.“

An dieser Stelle wird es auch für den klassischen Enterprise-Java-Entwickler interessant. Denn wer möchte nicht mit geringem Aufwand flexibel und schnell auf Änderungen reagieren? Was also genau müssen wir tun, um dorthin zu kommen? Die Stichworte hier sind: entkoppelt, belastbar, handhabbar und beobachtbar sowie „robuste Automatisierung“.

Lose Kopplung, Resilience, Managability und Observability

Um Software schnell entwickeln und in Produktion bringen zu können, hat sich gezeigt, dass es sehr hilfreich ist, wenn man sie in kleinere fachliche Teile schneidet (Microservices). Dadurch verschwindet die Komplexität natürlich nicht, aber sie wird aus dem Code entfernt und auf die Ebene der Kommunikation der Services untereinander verschoben.

Um die dadurch neu entstehende Komplexität auf der Makroebene zu beherrschen, ist es wichtig, dass die entstehenden Services erstens nicht zu viel miteinander kommunizieren (lose gekoppelt sind) und dass zweitens die Kommunikation so gestaltet ist, dass der aufrufende Service jederzeit damit umgehen kann, dass der aufzurufende Service auch mal nicht verfügbar ist oder fehlerhafte Ergebnisse liefert. Diese Eigenschaft des aufrufenden Service nennt man Resilience. Das muss nicht komplett selbst implementiert werden. Auch ein sinnvoll konfiguriertes Service Mesh kann Resilience zu einem großen Teil übernehmen.

Um die vielen Services weiterhin managen zu können, ist es wichtig, dass Probleme schnell sichtbar gemacht werden, um sie zu beheben. Hier kommt das Thema „Observability“ ins Spiel. Observability muss auf verschiedenen Ebenen geschaffen werden. Einerseits auf der technischen Ebene: Kubernetes muss wissen, wie der Zustand einer Service-Instanz ist, um sie ggf. neu zu starten bzw. zu ersetzen; andererseits auf der Ebene der Entwickler: Wenn ein Fehler auftritt, muss dieser über die verschiedenen Service Calls hinweg nachvollzogen werden können (verteiltes Logging und Tracing). Außerdem benötigt man jederzeit eine technische und auch fachliche Sicht auf den aktuellen Zustand des Systems (Metrics) und ggf. auch historische Informationen über Systemzustand und -verhalten (Journaling).

Kompexität beherrschen durch Automatisierung

Alle genannten Eigenschaften lassen sich nur erreichen und damit die Komplexität von vielen Services beherrschen, wenn möglichst viel automatisiert wird. Das fängt beim Erstellen der Deployment-Artefakte an, die in einem Cloud-native-Umfeld OCI-Container (also z. B. Docker-Container [5]) inklusive der zugehörigen Deployment-Skripte (z. B. Helm Charts [6]) sind, geht über das Deployment (also CI/CD) bis zu Betrieb und Monitoring. Dabei melden die Instanzen (Pods) kontinuierlich ihren aktuellen Zustand an Kubernetes. Ist eine Instanz zeitweise überlastet, wird sie automatisch vorübergehend nicht mehr mit Traffic versorgt. Ist sie dauerhaft kaputt, wird sie automatisch entfernt und durch eine neue Instanz ersetzt. Sind die Services – wie oben beschrieben – resilient implementiert, können Teile des Systems sogar damit umgehen, wenn ein gesamter Teilbereich vorübergehend nicht verfügbar ist.

Auswirkungen auf Enterprise Java

Was hat nun all das für Auswirkungen auf Enterprise-Anwendungen, die in Java geschrieben sind? Im Code muss eigentlich nicht viel getan werden. Lediglich auf die genannte Observability und die Resilience muss geachtet werden. Dafür gibt es mittlerweile eine Reihe von Frameworks, die einen unterstützen. Und auf diese Frameworks für Logging, Tracing, Health-Checks und Resilience beziehen sich Quarkus und Spring, wenn die Rede davon ist, dass sie Cloud-native sind.

Im Bereich des Java-Standards bietet Microprofile mittlerweile die genannten Frameworks in standardisierter Form. Möchte man in Java also Cloud-native entwickeln, ist man nicht auf Spring oder Quarkus beschränkt und kann einen Blick auf die weiteren Implementierungen von Microprofile werfen [7].

Eine weitere Eigenschaft, die im Cloud-native-Umfeld von Nutzen sein kann und die z. B. klassische Application Server nicht mitbringen, ist das Verpacken in einen Container und dessen geringe Start-up-Zeit. Da es in einem Kubernetes-Cluster durchaus vorkommen kann, dass eine nicht mehr korrekt funktionierende Instanz ersetzt wird, ist es von Vorteil, wenn dieser Prozess nicht allzu lange dauert. Insbesondere Quarkus hat sich darauf spezialisiert, indem der Java-Code mit Hilfe von GraalVM zu einem nativen Container-Image kompiliert wird.

Fazit

Monolithische Applikationen sind schwer weiterzuentwickeln, weil die Komplexität des Codes mit wachsender Codebasis in der Regel überproportional zunimmt. Die Idee von Cloud-native ist es, diese Komplexität aus dem Code herauszunehmen und auf die Ebene der Inter-Service-Kommunikation zu verlegen. Aus den einzelnen Codestücken ergeben sich dann kleine Deployment-Einheiten, die unabhängig weiterentwickelt, deployt und skaliert werden können. Dadurch (und durch einen geeigneten Service-Schnitt) gelingt es, aus einer Mischung aus fachlicher und technischer Komplexität eine rein technische zu machen, die aus dem Management vieler Instanzen und deren Kommunikation besteht. Das lässt sich dann automatisieren und somit beherrschen.

Dadurch ist es in Cloud-native-Anwendungen sehr leicht und schnell möglich, neue fachliche Anforderungen in Produktion zu bringen. Dabei muss es nicht unbedingt eine Public Cloud sein, auch eine Private Cloud ist möglich. Wichtig ist nur, dass die genannten Cloud-native-Paradigmen verinnerlicht werden. Umgesetzt werden können sie dann Stück für Stück.

limburg_arne_sw.tif_fmt1.jpgArne Limburg ist Softwarearchitekt bei der OPEN KNOWLEDGE GmbH in Oldenburg. Er verfügt über langjährige Erfahrung als Entwickler, Architekt und Consultant im Java-Umfeld und ist auch seit der ersten Stunde im Android-Umfeld aktiv.

Arne Limburg

Arne Limburg ist Lead Architect bei der open knowledge GmbH in Oldenburg. Er verfügt über langjährige Erfahrung als Entwickler, Architekt und Trainer im Enterprise- und Microserivces-Umfeld. Zu diesen Bereichen spricht er regelmäßig auf Konferenzen und führt Workshops durch. Darüber hinaus ist er im Open-Source-Bereich tätig, unter anderem als PMC Member von Apache OpenWebBeans und Apache DeltaSpike und als Urheber und Projektleiter von JPA Security.