Clouds, Kubernetes & Serverless Blog - JAX-Konferenz https://jax.de/blog/clouds-kubernetes-serverless/ Java, Architecture & Software Innovation Fri, 18 Oct 2024 13:11:57 +0000 de-DE hourly 1 https://wordpress.org/?v=6.5.2 Enterprise Java in Zeiten von Cloud-Native and Friends https://jax.de/blog/enterprise-java-in-zeiten-von-cloud-native-and-friends/ Fri, 07 Oct 2022 07:46:40 +0000 https://jax.de/?p=87577 Seien wir doch einmal ehrlich. Der Java-Enterprise-Standard JEE ist in die Jahre gekommen. Der einstige Primus für die Entwicklung von großen, unternehmensweiten Anwendungen kommt für die Wunderwelt von Cloud-Native und Co. deutlich zu schwergewichtig und träge daher. Das Ende scheint – wieder einmal – nahe. Aber ist dem wirklich so? Oder gibt es doch noch einen Funken Hoffnung?

The post Enterprise Java in Zeiten von Cloud-Native and Friends appeared first on JAX.

]]>
Um sinnvoll beurteilen zu können, ob Jakarta EE (ehemals Java EE, ehemals J2EE – kurz: JEE) aktuell noch (s)eine Daseinsberechtigung hat, gilt es zu verstehen, wie heutzutage eine typische Unternehmensanwendung und deren Ablaufumgebung aufgebaut sind.

Die Welt von Cloud-Native and Friends

Die Anwendung selbst kommt häufig modular, z. B. in Form von Self-contained Systems oder Microservices daher. Die Kommunikation zwischen den einzelnen Teilen der Anwendung erfolgt in der Regel asynchron über Events. Ziel dieses Architekturansatzes ist es, eine möglichst große Unabhängigkeit der einzelnen Services untereinander zu erreichen. Das gilt sowohl für die Entwicklung als auch für die Tests, das Deployment und den Betrieb.

Die Ablaufumgebung ist dann meist einer der etablierten Cloud-Provider oder alternativ eine Managementplattform, die ein Cloud-ähnliches Erlebnis im eigenen Rechenzentrum ermöglicht (z. B. Red Hat OpenShift).

Die wesentlichen Key-Player, die es in einem solchen Set-up zu unterstützen gilt, sind die De-facto-Standards Docker-Container und die mittlerweile bei der Cloud Native Computing Foundation (kurz: CNCF) beheimatete Containermanagementplattform Kubernetes, die zur automatisierten Bereitstellung, Skalierung und Verwaltung der containerisierten Anwendung genutzt wird.

So weit, so gut. Aber was genau bedeutet das nun für den Aufbau einer Anwendung? Und wie hängt das mit unserer Ausgangsfrage „Ist JEE noch zeitgemäß?“ zusammen?

In einer Welt von stark verteilten Anwendungen, deren einzelne Bestandteile in der Cloud in Form von Managed Containern hochgradig automatisiert bereitgestellt und verwaltet werden, gelten andere Regeln als noch vor Jahren im Umfeld monolithischer Anwendungen mit einem zentralen Application Server als Ablaufumgebung.

Die einzelnen Bestandteile der verteilten Anwendung sollten möglichst klein sein, um schlanke Container-Images zu ermöglichen. Die Start-up-Zeiten der Container sollten möglichst gering sein, um so bei Bedarf eine Skalierung in Echtzeit zu erlauben. Die Resident Set Size (kurz: RSS), also der vom Laufzeitprozess benötigte Speicher, sollte möglichst niedrig sein, um so bei gleichen Ressourcen mehr Container zu gewährleisten. Denn in der Cloud gilt nun einmal die goldene Regel „Je weniger Ressourcen, desto weniger Kosten“.

Zusammengefasst lässt sich also sagen, dass der Bedarf einer Cloud-Native-Anwendung bzw. ihrer Bestandteile wie folgt charakterisiert werden kann:

  • klein aka niedriger Speicherbedarf

  • schnell aka geringe Start-up Time

  • flexibel aka Modularisierung

Abbildung 1 zeigt eine typische Ablaufumgebung auf Basis eines Kubernetes-Clusters.

Abb. 1: Kubernetes-Cluster

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Die Welt von Jakarta EE

Klein, schnell und flexibel. Das sind nicht unbedingt die ersten Attribute, die einem einfallen, wenn man an den Java-Enterprise-Standard denkt. Das Gegenteil ist der Fall.

Anwendungen auf Basis von JEE sind eher groß. Das gilt insbesondere dann, wenn man den Application Server in die Betrachtung mit einbezieht. Die Start-up-Zeit einer JEE-Anwendung, also der Zeitraum vom Deployment innerhalb eines App-Servers bis zur Beantwortung des ersten Requests, ist in der Regel recht hoch. Und mit besonderer Flexibilität trumpft eine auf JEE basierende Anwendung auch nicht gerade auf. Zwar kann – Stand heute – zwischen zwei unterschiedlichen Varianten namens Full Profile („bitte einmal alles“) und Web Profile („bitte einmal fast alles“) gewählt werden, das erscheint aber in Hinblick auf die angestrebte Modularisierung eher wie eine Wahl zwischen Not und Elend.

Die Ausgangsbasis, um in der neuen Welt von Cloud-Native und Co. mitspielen zu dürfen, könnte also kaum ungünstiger sein. Warum also an JEE festhalten? Zumal mittlerweile etliche Java-basierte Frameworks – wie z. B. Spark, Micronaut, Helidon SE oder Meecrowave – existieren, die speziell für das oben beschriebene Microservices- und Cloud-Szenario entworfen wurden.

JEE wurde von Anfang an als Lösung für Enterprise Applications konzipiert. Entsprechend stabil sind die zugehörigen Laufzeitumgebungen. Die APIs sind nach mehr als zwanzig Jahren Evolution auf jeden Fall ausgereift. Auch wenn sie aufgrund von Abwärtskompatibilität noch so einige Altlasten mit sich herumschleppen. JEE ist ein sehr weit verbreiteter Standard. Entsprechend viel Wissen existiert in der Community. Apropos Standard: Durch die Vielzahl an JEE-Providern besteht keine Abhängigkeit zu einem speziellen Hersteller.

Bevor wir also all diese Vorteile über Bord werfen und uns nach proprietären Lösungen umschauen, kann es durchaus Sinn ergeben, zu überlegen, ob und wie wir JEE evtl. doch für stark verteilte Anwendungen in Cloud-Umgebungen nutzen können.

Schaut man sich einmal die JEE APIs im Detail an, so wird schnell deutlich, dass die reine Implementierung von Microservices dank JAX-RS, JSON-B, JSON-P und CDI denkbar einfach ist. Das Problem liegt also weniger in den Fähigkeiten der APIs als vielmehr in der Größe des resultierenden Artefakts und der damit zusammenhängenden Start-up-Zeit sowie der zugehörigen Laufzeitumgebung namens Application Server.

Der Herausforderung der Größe des Deployment-Artefakts versucht man durch das Konstrukt der Profile Herr zu werden. Die Idee hinter den Profilen ist, dass ein Subset an APIs genommen und zu einem Profile kombiniert werden kann. Dabei ist es durchaus erlaubt, das Profile um eigene APIs zu ergänzen, die nicht zwangsläufig auch in das Full Profile einfließen müssen. So soll verhindert werden, dass sich die JEE-Spezifikation unnötig aufbläht.

Für ein Profile wird in einer eigenen Spezifikation das Zusammenspiel der eingebundenen APIs definiert und so garantiert, dass diese optimal aufeinander abgestimmt sind. Stand heute – also Jakarta EE 9 – gibt es, wie bereits beschrieben, genau zwei Profiles, die leider beide recht groß und somit für unser Zielszenario „Microservices in der Cloud“ eher ungeeignet sind. Mit der kurz vor dem Release stehenden, neuen Spezifikation Jakarta EE 10 wird allerdings ein weiteres, deutlich minimalistischeres Profile namens Core Profile eingeführt werden, das es erlaubt, sehr kleine Artefakte zu erzeugen. Zu den APIs des Core Profiles gehören u. a. CDI 4.0 light, JSON-B 3.0, JSON-P 2.1, JAX-RS 3.1 – eine optimale Basis für die Umsetzung von Microservices!

Bleibt also noch die Herausforderung des Application Servers als zentrale Ablaufumgebung. Die JEE-Spezifikation ist ursprünglich für eine Welt angedacht, innerhalb derer in relativ großen Abständen monolithische Anwendungen in einer stabilen Laufzeitumgebung deployt werden. Oder anders formuliert: JEE ist aufgrund des Single-Runtime-Ansatzes weder für stark verteilte Anwendungen noch für feingranulare und hochfrequente Deployments konzipiert. Aber wo ein Wille ist, ist auch ein Weg.

JEE als Distributed Runtime(s)

JEE und das Konzept des zentralen Application Servers werden quasi synonym verwendet. Das dem nicht zwingend so sein muss, zeigt Arjan Tijms – aktives Mitglied der Jakarta Specification Group – in seinem Blogpost „You don’t need an application server to run Jakarta EE applications“ [1].

Denn wirft man einmal einen genaueren Blick in die JEE-Spezifikation, so steht dort nirgends geschrieben, dass es eine losgelöste Instanz eines Application Servers inkl. aller Implementierungen der spezifizierten APIs geben muss. Das Gegenteil ist der Fall. In Abschnitt 2.9 „Flexibility of Product Requirements“ der aktuellen Jakarta-EE-9-Spezifikation heißt es: „As long as the requirements in this specification are met, Jakarta EE Product Providers can partition the functionality however they see fit. A Jakarta EE product must be able to deploy application components that execute with the semantics described by this specification.“

Und in Abschnitt 2.12.2 heißt es weiterhin zur Rolle des Jakarta EE Product Providers: „A Jakarta EE Product Provider is the implementor and supplier of a Jakarta EE product that includes the component containers, Jakarta EE platform APIs, and other features defined in this specification. […] A Jakarta EE Product Provider must make available the Jakarta EE APIs to the application components through containers.“

In Konsequenz bedeutet das, dass es durchaus legitim ist, nur die Bestandteile des Servers zu nutzen, die für die innerhalb der eigenen Anwendung verwendeten JEE APIs notwendig sind. Weiterhin können diese Bestandteile des Servers durchaus auch im Rahmen des Build-Prozesses mit der Anwendung selbst zu einem Artefakt aka ueber.jar (oder hollow.jar plus app.war) gebündelt werden. Ein Trick, den sich etliche Jakarte EE Product Provider zunutze machen und neben der klassischen Application-Server-Variante auch Alternativen anbieten (Abb. 2). Details und Beispiele dazu finden sich in dem oben referenzierten Blogpost.

Abb. 2: Application Server vs. Runnable JAR

NEUES AUS DER JAVA-ENTERPRISE-WELT

Serverside Java-Track entdecken

 

Was fehlt zum Glück?

Wie es scheint, haben wir soeben die beiden großen Herausforderungen, nämlich zu große Artefakte und die Notwendigkeit eines Application Servers, mit Hilfe des Core Profile und der Erzeugung eines ueber.jar im Build-Prozess elegant aus dem Weg geräumt. Haben wir es damit geschafft? Ist so eine verteilte Anwendungslandschaft auf Basis von Self-contained Services und/oder Microservices mit JEE realisierbar? Die Antwort ist jein.

Die einzelnen Services lassen sich problemlos implementieren und können auch getrennt voneinander gestartet, aktualisiert und skaliert werden. Das eigentliche Problem liegt aber weniger in der Implementierung und dem Betrieb der Services (Micro Architecture), sondern vielmehr in deren Zusammenspiel (Macro Architecture). Denn genau hier fehlt die zentrale Instanz des Application Servers. In einer verteilten Anwendungswelt treten Herausforderungen auf, die so in der JEE-Spezifikation nicht vorgesehen sind (Abb. 3).

Abb. 3: Distributed Runtimes

Funktionalitäten, die sonst zentral innerhalb der Application-Server-Instanz abgehandelt wurden, müssen nun anders realisiert werden. Verteilt anfallende Informationen müssen zusammengesammelt und verdichtet werden. Kontexte, wie zum Beispiel der Security Context, müssen von einem Service zu einem anderen Service propagiert werden. Fehlersituationen und Ausfälle einzelner Services müssen erkannt und kompensiert werden.

Genau diese Herausforderungen hat bereits 2016 die Initiative MicroProfile.io [2] erkannt und einen entsprechenden API-Stack entworfen, der, ergänzt um einige wenige APIs aus JEE, das Implementieren und Managen verteilter Services auf Basis von Java ermöglicht (Abb. 4) [2].

Abb. 4: MicroProfile APIs

Es fällt auf, dass die genutzten JEE APIs aus der MicroProfile-Spezifikation nahezu identisch mit denen des neu angedachten Core Profile sind. Das kommt nicht von ungefähr. Es ist vorgesehen, zukünftige Versionen der MicroProfile-Spezifikation dahingehend abzuändern, dass die jeweils aktuelle Version des Core Profile als Basis dient.

Ein subjektives Zwischenfazit

Dank Core Profiles freier Interpretation des Begriffs Application Server (Stichwort ueber.jar bzw. hollow.jar) und der bewusst auf die Herausforderungen verteilter Systeme zugeschnittenen APIs des Jakarta MicroProfile, besitzen wir eine sehr gute Grundlage für die Realisierung von Self-contained Services bzw. Microservices für die Zielplattform Cloud.

Die auf dieser Basis entstehenden Services liegen in der Regel bei einer Größe von unter 100 MB und sind nach ihrem Start in einigen Sekunden ansprechbar. Klein genug und schnell genug, um regelmäßige und losgelöste Deployments via Container in der Cloud durchzuführen.

Problematisch bleibt es allerdings nach wie vor immer dann, wenn die zu verarbeitende Workload sehr ungleichmäßig anfällt und daher neue Instanzen eines Service on the fly via Kubernetes Autoscaling bereitgestellt werden sollen. Denn für eine Nahezu-Echtzeitskalierung ist die Größe des zu deployenden Artefakts und damit auch die Start-up-Zeit nach wie vor ein wenig zu hoch.

Insbesondere der Extremfall der Serverless Functions, bei denen ein Service für die Beantwortung eines einzelnen Calls gestartet und danach wieder direkt beendet wird, schließt sich in diesem Szenario per Definition aus. Denn dort werden Start-up-Zeiten im Bereich von einigen wenige Millisekunden benötigt.

Ein Blick hinter die Kulissen

Auch wenn wir schon viel erreicht haben, scheinen wir nun also mit unseren JEE-Bordmitteln in Kombination mit Jakarta MicroProfile an unsere Grenzen zu stoßen. Dazu passt sehr gut ein Zitat von Filipe Spolti (Red Hat): „I started thinking about my application’s performance – in this case, the bootstrap time – and asked myself whether I was happy with the actual time my application took to start up. The answer was no. And, nowadays, this is one of the most important metrics to be considered when working with microservices, mainly on a serverless architecture.“

Aber warum schaffen wir es nicht, auch die Bootstrap Time noch weiter zu optimieren? Um das zu verstehen, hilft ein Blick auf das, was während der Boot-Phase einer JEE-Anwendung hinter den Kulissen passiert. Während des Starts einer JEE-Anwendung wird ein relativ komplexer Prozess zur Verarbeitung und Auflösung der Metadaten durchlaufen (Abb. 5).

Abb. 5: Metadata Processing zum Start-up

Annotationen werden gescannt, Abhängigkeiten aufgelöst und auf Eindeutigkeit geprüft, Proxies erzeugt und vieles, vieles mehr. Das kostet nicht nur eine Menge kostbarer Zeit, sondern bedarf auch zusätzlicher Ressourcen, die zum größten Teil nur während der Bootstrap-Phase benötigt werden. Möchte man also kleiner und schneller werden, ist genau hier der Hebel, an dem es anzusetzen gilt.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Voodoo kommt ins Spiel

So schön es ist, dass das Processing der Metadaten und somit die Auflösung der Abhängigkeiten bereits zum Zeitpunkt des Deployments durchgeführt und somit verhindert wird, dass fehlerhafte Anwendungen überhaupt erst starten, so lästig ist es für das von uns angestrebte Wunschszenario. Den Prozess weiter nach hinten zu schieben, also auf einen Zeitpunkt, an dem die Anwendung bereits deployt ist und Requests entgegennehmen kann, ist wenig sinnvoll, da es so zu Fehlern in Produktion kommen kann. Was aber wäre, wenn man die ganzen Aufwände weiter nach vorne verlagert?

Ein sehr großer Anteil des Metadata Processing kann problemlos bereits zur Build Time erfolgen. Pseudodynamische Konstrukte können so durch statischen Code ersetzt werden. Das bringt nicht nur einen deutlichen Boost für die Start-up-Zeit, sondern verkleinert zusätzlich das resultierende Artefakt um einen erheblichen Faktor und erlaubt uns somit deutlich kleinere Container. Genau diesen Ansatz zur Build-Time-Optimierung verwendet das Quarkus-Framework [3]: „A Kubernetes Native Java stack tailored for OpenJDK HotSpot and GraalVM; crafted from the best of breed libraries and standards“.

Das Resultat kann sich sehen lassen. Allein durch die Verwendung der Build-Time-Optimierung sind die resultierenden Artefakte, also die Self-contained Services bzw. Microservices, nur etwa halb so groß wie ohne deren Verwendung. Noch interessanter ist aber, dass sich durch das Ersetzen von dynamischen durch statische Komponenten die Bootstrap-Zeit ca. um den Faktor fünf verbessert.

Das sind schon einmal sehr positive Aussichten. Aber Quarkus geht noch einen Schritt weiter. Als zusätzliche Optimierung kann optional aus dem via Build Time Optimization erzeugten plattformunabhängigenJava-Artefakt ein plattformabhängiges Runnable erzeugt werden. Ahead-of-Time-Compilation heißt hier das Zauberwort (Abb. 6).

Abb. 6: Ahead-of-Time-Compilation

Dank AoT-Compilation schrumpft unser Artefakt ca. um Faktor zehn, und Start-up-Zeiten im Bereich von wenigen Millisekunden werden möglich. Unser Ziel scheint erreicht! Aber irgendwie klingt das zu schön, um wahr zu sein. Wo also bitte ist der Haken?

Zum einen bringt die Verwendung der beiden eben angesprochenen Optimierungen (Build Time Optimization und Ahead-of-Time Compilation) einige Restriktionen für die eigene Anwendung mit sich. Das betrifft insbesondere die direkte oder indirekte Verwendung von Reflection [4], aber zum Beispiel auch die leicht eingeschränkte Funktionalität durch Quarkus’ eigene CDI-Variante namens ArC [5]. Alles nichts, was man nicht in den Griff bekommen kann, aber was am Ende evtl. ein wenig manuellen Zutuns bedarf, um seine Services inkl. der eingebundenen 3rd-Party unter Quarkus zum Laufen zu bekommen.

Zum anderen ist unser Runnable nun nicht mehr plattformunabhängig! Dabei ist WORA (write once, run anywhere) doch das große Versprechen von Java! Aber ist das wirklich ein Problem? Unsere Services sollen in einem Container laufen. Das gilt sowohl auf lokalen Rechnern als auch in der Cloud. D. h. nicht das zugrunde liegende OS des Rechners ist entscheidend, sondern vielmehr das des Containers. Und genau das ist auf allen Systemen gleich und vorhersehbar. „Write once, run predictable“ aka WORP ist das neue WORA.

 

Fazit

JEE war jahrelang der Primus, wenn es um die Umsetzung von großen, unternehmensweiten Anwendungen ging. Grund dafür waren vor allem ein über zwei Jahrzehnte ausgereiftes API sowie eine sehr stabile Runtime. Entsprechend groß waren/sind die Community und die Anzahl der existierenden Anwendungen.

Typische JEE-Anwendungen sind in der Regel monolithischer Natur und laufen in einer zentralen Ablaufumgebung, dem JEE Application Server. In Zeiten von Cloud und Co. ändern sich die Anforderungen (Abb. 7). Aus einem Monolithen wird eine Vielzahl von Services. Aus der zentralen Ablaufumgebung eine Cloud-Native-Umgebung mit den Key-Playern Docker-Container und Kubernetes.

Abb. 7: Jakarta EE in Zeiten von Cloud-Native

Die Realisierung einzelner Services ist dank APIs wie JAX-RS, JSON-P, JSON-B und CDI mit JEE kein Problem. Erzeugt man im Build-Prozess ein entsprechendes schlankes Paket (ueber.jar oder besser hollow.jar plus app.war), sind auch die resultierenden Containergrößen und Start-up-Zeiten akzeptabel.

Durch die Verteiltheit des Systems und den Wegfall des Application Servers als der zentralen Laufzeitumgebung ergeben sich neue Herausforderungen. Hier hilft das Jakarta MicroProfile mit seinen speziell auf verteilte Systeme zugeschnittenen APIs.

Mit der Kombination von JEE und MicroProfile haben wir somit einen guten Stack zur Implementierung von Microservices-basierten Anwendungen an der Hand. Das gilt zumindest dann, wenn der Anspruch an die eigene Anwendungswelt nicht darin besteht, alle paar Minuten neue Deployments vorzunehmen oder bestehende Services in Nahezu-Echtzeit rauf- bzw. runterzuskalieren.

Ist das notwendig, so kann auf Quarkus und seine Build Time Optimization zurückgegriffen werden. Metadata-Processing, das normalerweise zur Bootstrap-Zeit ausgeführt werden würde, kann in der Build-Phase durch statischen Code ersetzt werden, und so lassen sich das resultierende Artefakt in seiner Größe sowie die zu dessen Start notwendige Zeit deutlich reduzieren.

Muss es noch kleiner und schneller sein, z. B. in Szenarien mit stark schwankender Workload und entsprechender Anforderung an dynamische Skalierung, oder aber bei der Implementierung von Serverless Functions, kann mit Hilfe der Ahead-of-Time Compilation von Quarkus ein natives Excecutable erzeugt werden. Das ist dann zwar nicht mehr plattformunabhängig, was aber bei einer vorhersehbaren Ablaufumgebung – dem Container – kein wirkliches Problem darstellt.

 

Links & Literatur

[1] https://blogs.oracle.com/javamagazine/post/you-dont-always-need-an-application-server-to-run-jakarta-ee-applications

[2] https://MicroProfile.io

[3] https://quarkus.io

[4] https://quarkus.io/guides/writing-native-applications-tips

[5] https://quarkus.io/guides/cdi-reference#limitations

The post Enterprise Java in Zeiten von Cloud-Native and Friends appeared first on JAX.

]]>
Schneller als der Schall https://jax.de/blog/schneller-als-der-schall/ Mon, 05 Sep 2022 11:01:55 +0000 https://jax.de/?p=87425 Das Aufkommen von Microservices, Serverless, Cloud und Co. schien das Ende der Ära Java als Universallösung für Anwendungen im Umfeld des Enterprise Computing eingeläutet zu haben. Zu unterschiedlich sind die Anforderungen an Flexibilität in der schönen neuen Welt im Vergleich zu den Möglichkeiten schwergewichtiger Application Server Runtimes. Doch dann kam Quarkus und machte Java wieder salonfähig. Ein Blick hinter die Kulissen.

The post Schneller als der Schall appeared first on JAX.

]]>
Seien wir ehrlich: Auch wenn uns Java in den vergangenen 20 Jahren sowohl im Frontend als auch im Backend gute Dienste geleistet hat, hätte wohl kaum jemand sein Erspartes darauf gewettet, dass Java uns auch in die Wunderwelt der Cloud begleiten könnte. Zu groß die augenscheinliche Diskrepanz zwischen den Anforderungen der Cloud und den Möglichkeiten von Java.

Ein Zitat von Filippe Costa Spolti, Senior Software Engineer bei Red Hat, bringt dies sehr schön auf den Punkt: „I started thinking about my application’s performance – in this case, the bootstrap time – and asked myself whether I was happy with the actual time my application took to start up. The answer was no. And, nowadays, this is one of the most important metrics to be considered when working with microservices, mainly on a serverless architecture.“ [1]

Während in der Cloud kleine, schnelle und flexible Anwendungen gefragt sind, kommt Java eher schwerfällig daher – so der allgemeine Konsens.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Mit Quarkus (aktuell in der Version 2.75) ist 2019 allerdings ein für Container optimierter Lösungsansatz gelungen, der dieses Vorurteil zunichtemachen will. „Supersonic/Subatomic/Java“, so der provokante Disclaimer auf der Homepage von Quarkus.io [2]. Quarkus wurde von Beginn an mit der Absicht konzipiert, sich als „Kubernetes-native Full-Stack Java Framework für JVMs und native Kompilierung“ optimal in die Welt von Microservices, Cloud und Co. einzubetten.

Aber immer der Reihe nach. Bevor wir uns mit der Lösung beschäftigen, gilt es zunächst einmal, das Problem zu verstehen. Was ist in der Cloud eigentlich so anders als im gewohnten Enterprise-Computing-Umfeld? Und warum bringt dies erhebliche Probleme für unser gutes altes Enterprise Java mit sich?

Bereit für die Cloud?

Eine Anwendung in die Cloud zu verlagern, kann – wenn man es richtig angeht – etliche Vorteile mit sich bringen. Schnelle Provisionierung neuer Ressourcen, bedarfsgerechte und automatische Skalierung von Services sowie erhöhte Kosteneffizienz dank Pay-per-Use-Modell, um nur einige zu nennen.

Im Gegenzug verlangen Cloudumgebungen deutlich mehr Flexibilität von den in ihnen laufenden Anwendungen als wir es aus dem klassischen Enterprise Computing gewohnt sind. Natürlich ließe sich eine monolithische Anwendung eins zu eins in die Cloud verfrachten (aka lift & shift). Das würde allerdings fast keinen Mehrwert bringen. Um die Vorteile der Cloud voll auszuspielen, bedarf es einer an die Cloudumgebung adaptierten Anwendungsarchitektur, welche die Software als Service versteht und entsprechend klein, schnell und flexibel ist. Klein im Sinne von niedrigem Speicherbedarf, schnell im Sinne von schnellen Start-up-Zeiten und flexibel im Sinne von modular aufgebaut (für einen tieferen Einblick in die Anforderungen an eine Cloud-native Anwendung verweise ich auf die „Twelve-Factor-App“-Methode [3]). Nur wenn diese Voraussetzungen gegeben sind, lässt sich eine Anwendung bzw. lassen sich einzelne Module/Services der Anwendung bei Bedarf schnell und effizient hoch und runter skalieren. Ein wichtiger Aspekt, der in der Cloud bares Geld wert ist – und das nicht nur im übertragenen Sinne!

Als Laufzeitumgebung für Anwendungen in der Cloud hat sich in den letzten Jahren Kubernetes in Kombination mit Docker-Containern als eine Art De-facto-Standard etabliert. Abbildung 1 zeigt eine typische automatisch skalierbare Cloudumgebung auf Basis eines Kubernetes Clusters. Das ist die Welt moderner Softwareanwendungen in den Jahren 2020plus.

Abb. 1: Kubernetes Cluster

NEUES AUS DER JAVA-ENTERPRISE-WELT

Serverside Java-Track entdecken

 

Enterprise Java und die Cloud

Schaut man sich die Anforderungen an eine für die Cloud optimierte Anwendung an, scheint eine auf Enterprise Java basierende Anwendung genau das Gegenteil davon zu sein. Warum ist das so?

Der Enterprise Java Standard Java EE (aka Jakarta EE) wurde ursprünglich konzipiert, um unternehmenskritische Anwendungen dauerhaft in einer stabilen Umgebung – dem Java EE Application Server – ablaufen zu lassen. Dauerhaft bedeutet hier, dass eine einmal deployte Anwendung über Wochen oder Monate hinweg ohne jegliche Änderungen läuft.

Dieses Konzept der stabilen und sehr langlebigen Laufzeitumgebung hat seinen Preis. Basiert eine Anwendung auf dem Enterprise-Java-Standard – egal ob Full Profile oder das etwas schlankere Web Profile – sind zur Laufzeit schnell etliche 100 MB Memory Footprint erreicht. Den Großteil davon macht dabei die Server Runtime aus. Aber auch, wenn nur diejenigen Bestandteile des Servers verwendet werden, die tatsächlich für die Anwendung notwendig sind, und daraus zum Beispiel eine Self-contained Application in Form eines Runnable JAR gebaut wird, ist eine Anwendungsgröße deutlich unter 100 MB nicht wirklich realistisch. Gleiches gilt im Übrigen auch für die alternative Verwendung des Spring Frameworks. Ein solches Konstrukt ist deutlich zu groß für eine automatische Skalierung im Sekundenbereich – egal, ob dabei Kubernetes und Container zum Einsatz kommen oder nicht.

Aber der Speicherbedarf stellt nicht einmal das größte Problem von Enterprise-Java-Anwendungen in der Cloud dar. Fast noch stärker ins Gewicht fällt die lange Start-up-Zeit. Sie macht es nahezu unmöglich, kontinuierlich, zeitnah und schnell neue Instanzen einer Anwendung bzw. einzelner Services zu deployen und zu starten, um so zum Beispiel on the fly erhöhte Last abzufangen. Serverless-Szenarien, bei denen Start-up-Zeiten im Bereich von Millisekunden erwartet werden, verbieten sich per Definition.

Der Grund für die hohen Start-up-Zeiten ist leicht erklärt. Enterprise Java hat in den letzten zehn Jahren den Fokus verstärkt auf Aspekte wie „Ease of Development“ und „Convention over Code“ gelegt. Diese beinhalten u. a. die intensive Verwendung von Annotationen und anderen Metadaten, die erst während des Deployments bzw. beim Start-up der Anwendung gescannt und aufgelöst werden. Über diesen Mechanismus wird zum Beispiel zum Start einer Enterprise Application sichergestellt, dass es für jeden CDI Injection Point genau eine Bean mit passendem Typ, Qualifier und Scope gibt, die zur Laufzeit eingebunden werden kann. Gibt es dagegen keine passende Bean bzw. mehr als eine, kommt es beim Starten der Anwendung zu einer entsprechenden Exception.

Natürlich werden in diesem Moment nicht wirklich alle abhängigen Klassen erzeugt und initialisiert. Das würde tatsächlich noch einmal deutlich länger dauern und entsprechend mehr Speicherplatz benötigen. Stattdessen werden zunächst nur Proxies als Stellvertreter generiert.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Dieser Ablauf des Metadata Processings während des Deployments bringt zwei große Nachteile mit sich. Zum einen kosten das Scannen der Metadaten und das Auflösen der Abhängigkeiten Zeit. Zum anderen werden für diesen Prozess zusätzliche Klassen zum Aufbau und zur Verwaltung der Abhängigkeiten benötigt, die rein gar nicht nichts mit der eigentlichen Anwendung zu tun haben. Abbildung 2 zeigt das Metadata Processing während des Deployments einer Jakarta-EE-Anwendung.

Abb. 2: Metadata Processing

Zusammenfassend kann man sagen, dass Enterprise Java im Hinblick auf die Cloud zwei große Probleme aufweist. Zum einen sind die auf Jakarta EE basierenden Anwendungen bzw. Services in der Regel einfach zu groß. Zum anderen starten sie zu langsam. Da helfen auch gut gemeinte Ansätze zur Minimierung des Server-Overheads, wie zum Beispiel das Jakarta MicroProfile, nur bedingt weiter. Denn das wesentliche Problem, das Auflösen der Abhängigkeiten zum Zeitpunkt des Deployments, bleibt auch hier bestehen.

Quarkus bringt nun einige Optimierungen mit, die ihren Hebel zielgerichtet an den beschriebenen Problemstellen ansetzen.

Build-Time-Optimierung

Die erste Optimierung basiert auf der (berechtigten) Annahme, dass ein Großteil der durch Java EE ermöglichten Dynamik zur Laufzeit in typischen Service-basierten Szenarien nicht wirklich benötigt wird und sich die damit verbundene Auflösung der Abhängigkeiten daher problemlos von der Deployment- bzw. Start-up-Phase in die Compile- und Build-Phase verlagern lässt. Denn wurde eine Anwendung erst einmal als Container Image zur Verfügung gestellt, ändert sich dieses Image in der Regel nicht mehr.

Durch einen entsprechenden zusätzlichen Schritt in der Build Pipeline (Abb. 3), in dessen Verlauf etliche dynamische Konstrukte aufgelöst und durch statische Pendants ersetzt werden, schafft es Quarkus, die Größe der Anwendung – je nach verwendeten Libraries – etwa um die Hälfte zu minimieren. Durch den Wegfall des Metadata Processings zum Start der Anwendung verkürzt sich auch die Start-up-Zeit deutlich. Verstärkt wird dieser Effekt noch einmal durch die Verwendung des in Version 1.5 eingeführte Fast-Jar Classloaders [4]. Dieser persistiert zur Build-Zeit die Lokationen aller Klassen und Ressourcen, sodass sie zum Start-up nur noch eingelesen werden müssen.

Abb. 3: Build-time-Optimierung

Möchte man wissen, welche Optimierungen von Quarkus in diesem Schritt durchgeführt wurden, kann dazu im Development Mode (dazu später mehr) ein eigens dafür generierter Debug Endpoint aufgerufen werden: http://localhost:8080/q/arc/[beans|observers|removed-beans]

Supersonic via Ahead-of-time Compilation

Auch wenn uns diese Optimierung schon eine deutliche Verminderung der Anwendungsgröße und einen entsprechenden Boost der Start-up-Zeit beschert, kann von „Supersonic“ noch keineswegs die Rede sein. Szenarien, in denen eine Anwendung bzw. ihre Services regelmäßig neu deployt oder bei Bedarf automatisch skaliert werden sollen, sind zwar durchaus denkbar. Serverless-Szenarien, in denen Start-up-Zeiten im Bereich einiger weniger Millisekunden benötigt werden, dagegen nach wie vor eher nicht.

Aber auch hier hat Quarkus eine Lösung im Gepäck: Ahead-of-time Compilation. Die Idee ist, den Build-Prozess um einen weiteren Optimierungsschritt zu erweitern, im Zuge dessen unter Zuhilfenahme von GraalVM ein native Executable erzeugt wird (Abb. 4). Der kompilierte Source Code wird also zur Laufzeit nicht mehr interpretiert, sondern kann direkt als nativer Maschinencode ausgeführt werden. Dies ist selbst gegenüber stark optimiertem Bytecode unter Verwendung von Just-in-time Compilern ein Quantensprung. Die Größe der Anwendung verringert sich, je nach Anwendung, um einen Faktor bis zu zehn gegenüber der ursprünglichen Variante. Die Start-up-Zeit sinkt in den Bereich von Millisekunden. Das ist Supersonic!

Abb. 4: Ahead-of-time-native-image-Generierung

Das Erzeugen eines native Executable für das eigene OS ist dank Maven-Plug-in denkbar einfach [5].

./mvnw package -Dnative

Aber Achtung: Zum einen dauert der zugehörige Build-Prozess relativ lang und ist somit nicht für ein regelmäßiges Build in der eigenen Entwicklungsumgebung geeignet. Zum anderen – und das ist deutlich schwerwiegender – möchte man am Ende ja nicht unbedingt ein Executable, das auf der eigenen Maschine läuft, sondern vielmehr eines für die produktive Umgebung. Und dies ist, wie wir ganz am Anfang des Artikels gelernt haben, ein von Kubernetes gemanagter Docker-Container auf 64-Bit-Linux-Basis.

 

Möchte man ein Runnable für Linux erzeugen, und zwar ohne dafür extra GraalVM auf dem eigenen Rechner oder der CI/CD-Infrastruktur zu installieren, kann auf folgendes Maven Command zurückgegriffen werden:

./mvnw package -Dnative -Dquarkus.native.container-build=true

Mit Hilfe der Direktive quarkus.native.container-build=true findet der Build-Prozess innerhalb eines temporär erzeugten Containers statt.

Noch einen Schritt weiter geht folgendes Maven Command. Mit seiner Hilfe wird nicht nur ein Executable für eine zukünftige Containerumgebung erstellt, sondern der Container gleich mit:

./mvnw package -Pnative -Dquarkus.native.container-build=true 
                        -Dquarkus.container-image.build=true

WORA vs. WORP

Mit Hilfe von Build-time Optimization und Ahead-of-time Compilation gelingt es Quarkus, Enterprise Java auch in Zeiten von Microservices, Serverless, Cloud und Co. konkurrenzfähig zu machen. Dank native Executables werden sowohl der Ressourcenverbrauch als auch die Start-up-Zeiten deutlich minimiert.

Aber … nutzen wir nicht gerade deshalb Java, um unabhängig von dem zugrunde liegenden Betriebssystem zu sein? Gilt plötzlich der 1995 von Sun ins Leben gerufene Slogan „Write once, run anywhere“ nicht mehr? In der Tat, in Zeiten von Containern als Ablaufumgebung ist das ehemalige Highlight der Plattformunabhängigkeit nahezu obsolet geworden – zumindest, wenn es um Enterprise Java geht. An die Stelle von „anywhere“ rückt „predictable“ im Sinne einer vorhersehbaren Ablaufumgebung, da wir auf Basis des Containers überall die gleiche Zielumgebung schaffen können. WORP ist das neue WORA!

Und das funktioniert wirklich?

Ja und nein. Es ist leicht vorstellbar, dass die gezeigten Optimierungen auch einige Limitierungen mit sich bringen. Diese ergeben sich zum einen durch die Limitierungen der GraalVM bzw. SubstrateVM [6], zu denen u. a. Dynamic Class Loading, Native VM Interfaces, Reflection und Dynamic Proxies gehören. Zum anderen kommen weitere Limitierungen durch die Verwendung der Quarkus-eigenen Dependency-Injection-Lösung (ArC DI) hinzu, die sich zwar anfühlt wie CDI 2.x, am Ende aber nur ein Subset der Features darstellt [7]. So werden z. B. @ConversationScoped und @Interceptors nicht unterstützt. Gleiches gilt für CDI Portable Extensions.

Die genannten Limitierungen spielen in der Praxis allerdings kaum eine Rolle bzw. können in der Regel relativ einfach umgangen werden. Die Grundidee von Quarkus ist, dass 80 Prozent aller Anwendungsfälle aus dem Enterprise-Computing-Umfeld out of the box funktionieren sollten. Für die restlichen 20 Prozent ist ein wenig Handarbeit vonnöten.

So bringt Quarkus von Haus aus Unterstützung für die wichtigsten (De-facto-)Enterprise-Standards und Libraries mit. Hierzu gehören u. a. MicroProfile, Netty, Vert.x, Apache Carmel, Elastic Serach, Flyway, Neo4j, Kafka, ActiveMQ, Kubernetes und AWS Lambda.

Getreu dem Motto „was nicht passt, wird passend gemacht“ bietet Quarkus zusätzlich ein sehr mächtiges Extension Framework [8], mit dessen Hilfe auch diejenigen Libraries eingebunden werden können, die aufgrund der aufgezeigten Limitierungen out of the box nicht mit Quarkus funktionieren würden. Abbildung 5 zeigt noch einmal den gesamten Quarkus-Stack im Überblick.

Abb. 5: Der Quarkus-Stack

 

Fazit

Quarkus ist mit dem Ziel angetreten, sich als „Kubernetes-native Full-Stack Java Framework für JVMs und native Kompilierung“ optimal in die Welt von Microservices, Serverless, Cloud und Co. einzubetten und hebt Enterprise Java dabei auf die nächste Stufe.

Schmale Build-Artefakte führen zu sehr schlanken Container Images. Schnelle Bootzeiten erlauben ein sofortiges Scale-up. Und dank geringem RSS-Speicher (Resident Set Size) können mehr Container bei gleichem RAM instanziiert werden.

Erreicht wird dies durch Build Time Optimization und Ahead-of-time Compilation. Etwaige Limitierungen, die diese beiden Ansätze mit sich bringen, können durch einen eigenen Extension-Mechanismus mit ein klein wenig Handarbeit aus dem Weg geräumt werden.

Damit die zusätzlichen Schritte im Build-Prozess sich nicht negativ auf die Turnaround-Zeiten innerhalb der Entwicklung auswirken, bietet Quarkus einen eigenen Development Mode mit einer Art Hot Deployment. Kurz und gut: Quarkus ist ein Framework, das wirklich Spaß macht!

 

Links & Literatur

[1] https://developers.redhat.com/blog/2019/04/12/migrating-java-applications-to-quarkus-lessons-learned#

[2] https://quarkus.io

[3] https://12factor.net

[4] https://developers.redhat.com/blog/2021/04/08/build-even-faster-quarkus-applications-with-fast-jar#

[5] https://quarkus.io/guides/building-native-image

[6] https://github.com/oracle/graal/blob/master/substratevm/Limitations.md

[7] https://quarkus.io/guides/cdi-reference

[8] https://quarkus.io/guides/writing-extensions

The post Schneller als der Schall appeared first on JAX.

]]>
Keynote: Revolutionizing Java-Based Cloud Deployments with GraalVM https://jax.de/blog/keynote-revolutionizing-java-based-cloud-deployments-with-graalvm/ Mon, 16 May 2022 13:39:53 +0000 https://jax.de/?p=86676 In this JAX 2022 keynote, Thomas Wuerthinger, Senior Research Director at Oracle Labs and the GraalVM founder and project lead, introduces you to GraalVM.

The post Keynote: Revolutionizing Java-Based Cloud Deployments with GraalVM appeared first on JAX.

]]>
GraalVM offers native compilations of Java-based applications to make them leaner and cheaper in the cloud: They start instantly and use less memory. Additionally, there is improved performance predictability, simplified packaging, and better scalability. This talk will cover how to take advantage of this new revolutionary way to run Java-based applications including trade-offs and limitations.

 

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

The post Keynote: Revolutionizing Java-Based Cloud Deployments with GraalVM appeared first on JAX.

]]>
Knative für Java-Entwickler: „Der größte Vorteil ist das vereinfachte Deployment-Modell“ https://jax.de/blog/knative-fuer-java-entwickler-interview/ Mon, 28 Oct 2019 11:00:32 +0000 http://new.jax.de/?p=73287 Zu den neueren Top-Themen im Kubernetes-Universum zählt ohne Frage Knative. Doch was verbirgt sich hinter Knative? Und wie kann Java-Entwicklern der Einstieg am besten gelingen? Diesen Fragen widmet sich Roland Huß, Principal Software Engineer bei Red Hat, in seinem Talk auf der W-JAX 2019.

The post Knative für Java-Entwickler: „Der größte Vorteil ist das vereinfachte Deployment-Modell“ appeared first on JAX.

]]>
Wer Roland Huß einmal live erleben möchte, der hat bei der diesjährigen W-JAX 2019 in München wieder Gelegenheit dazu. Roland wird dort eine Session zum Thema, „Knative für Java Devs“ halten und einen Überblick über Knative und die drei Komponenten Build, Serving und Eventing geben. Welche Aspekte von Knative aus Perspektive von Java-Entwicklern besonders wichtig sind (einschließlich Live-Demos), vermittelt der Vortrag ebenfalls.

 
Redaktion: Hallo Roland, in Deiner Session auf der W-JAX 2019 sprichst Du über Knative. Knative verbindet Serverless mit Containern – Worum geht es dabei im Kern bzw. was steckt dahinter?

Roland Huß: Knative ist eine Kubernetes basierte Plattform, um zustandslose Applikationen einfach zu deployen und zu verwalten. Dazu unterstützt Knative Anfrage-basiertes “Scale-To-Zero” und Autoscaling sowie deklaratives Loadbalancing, um verschiedene Rollout- bzw. Rollback-Strategien umzusetzen. Des weiteren bietet Knative eine umfassende Anbindung an externe Quellen, zu der sich Knative-Anwendungen, zur Verarbeitung Event-gesteuerte Anfragen, flexibel verbinden können.

 
Redaktion: Nach Docker kam Kubernetes, jetzt heißt das neue Top-Thema Knative. Ist der Hype um Knative gerechtfertigt?

Roland Huß: Mit der Mächtigkeit von Kubernetes ist eine gewisse Komplexität in der Verwendung verbunden, was jede bestätigen kann, die schon mal mit Kubernetes Resourcedeskriptoren gearbeitet hat. Durch einen weiteren Abstraktionschritt schränkt Knative zwar die Möglichkeiten von Kubernetes etwas ein, vereinfacht aber dadurch auch das Deployment von zustandslosen Anwendungen enorm. Mit der gerade entstehenden Knative CLI “kn” ist es sogar möglich, komplett ohne YAML Deskriptoren auszukommen. Das Einzige, was man dann braucht, ist ein Container Image, dass die Anwendung enthält.

Mit Knative entgeht man dem gefürchteten Vendor-Lockin.

Dadurch das Knative auf dem de-facto Standard ‘Kubernetes’ basiert, ist es in der Public Cloud portierbar aber auch im heimischen Datenzentrum zu verwenden. Sprich, man entgeht dem gefürchteten Vendor-Lockin. Zudem gibt es inzwischen mit Google Cloud Run oder Red Hat OpenShift Serverless Angebote, die direkt Knative unterstützen.

Mein persönliches Highlight aber ist “Scale-to-Zero”, da damit Anwendungen tatsächlich nur dann laufen, wenn sie auch benötigt werden und somit Projekte auch risikolos, ohne große Kosten ausprobiert werden können, da nur nach Bedarf abgerechnet wird.

 
Redaktion: Ist Knative für die Arbeit mit jeder Container-Technologie als auch Serverless-Architektur geeignet? Worauf muss man achten?

Roland Huß: Die Deployment-Einheit ist bei Knative wie bei Kubernetes ein Container, sodass jede Applikation, die in ein Container Image gepackt werden kann, mit Knative betrieben werden kann. Es gibt jedoch auch einige Einschränkungen gegenüber klassischen Kubernetes-Deployments. So muss die Anwendung über HTTP oder gRPC kommunizieren, darf nur auf einen Port lauschen und kann keine persistent Volumes in Kubernets verwenden (sondern muss ihr Zustandsmanagement in externe Dienste auslagern). Es zeigt sich, dass der Großteil, der in Kubernetes betriebenen Anwendungen, diesen Anforderungen genügt und sich das Deployment-Modell durch die Beschränkungsehr vereinfachen lässt und die flexible, lastbasierte Autoskalierung erst möglich macht.

 
Redaktion: Wie gelingt Java-Entwicklern der Einstieg in die Arbeit mit Knative am besten?

Roland Huß: Am besten lassen sich einfache, per-se zustandslose, REST-basierte Java-Microservices mit Knative deployen, die man bereits in Container Image gepackt hat. Zum Ausprobieren kann man die Beispiele auf der Knative Webseite knative.de durchspielen, dort sind auch Java-Beispiele enthalten. Die Installation von Knative selbst ist zumeist problemos und Anleitungen für verschiedene Kubernetes-Plattformen (inklusive Minikube) findet man ebenfalls auf der Knative-Webseite. Alternativ kann man auch die gehostete Knative-Plattform auf Google Cloud Run nutzen. Allerdings ist diese noch in der Beta-Phase und auch nicht alle Knative-Funktionen stehen dort bislang zur Verfügung.

In Kombination mit einer modernen Java-Microservice-Plattform wie Quarkus macht das dynamische und häufige Skalieren der Anwendung erst so richtig Spaß.

 
Redaktion: Was ist der größte Vorteil von Knative hinsichtlich der Java-Entwicklung?

Roland Huß: Der größte Vorteil ist das vereinfachte Deployment-Modell, dass es einem Java-Entwickler sehr erleichtert, einen Applikations-Container auf Kubernetes zu starten. Es genügt hier ein kn service create --image myrepo/myapp und es werden alle nötigen Kubernetes-Ressourcen (Deployment, Service, Ingress, ..) automatisch angelegt. So hat man sofort eine Anwendung, die ohne Last innerhalb von Minuten auf 0 Pods skaliert.

In Kombination mit einer modernen Java-Microservice-Plattform wie Quarkus, die auf schnelle Startup-Zeiten und geringem Speicherverbrauch getrimmt ist, macht das dynamische und häufige Skalieren der Anwendung aber erst so richtig Spaß.

 
Redaktion: Vielen Dank für das Interview!

 

Quarkus-Spickzettel


Quarkus – das Supersonic Subatomic Java Framework. Wollen Sie zeitgemäße Anwendungen mit Quarkus entwickeln? In unserem brandaktuellen Quarkus-Spickzettel finden Sie alles, was Sie zum Loslegen brauchen.

 

Jetzt herunterladen!

The post Knative für Java-Entwickler: „Der größte Vorteil ist das vereinfachte Deployment-Modell“ appeared first on JAX.

]]>
Cloud-native Java mit Micronaut https://jax.de/blog/cloud-native-java-mit-micronaut/ Tue, 18 Jun 2019 13:48:36 +0000 https://jax.de/?p=68562 Ja, richtig gelesen, es gibt Alternativen. Obwohl sich der Platzhirsch Spring bei Java-Anwendungen großer Beliebtheit erfreut, sollte man nicht vergessen, dass es daneben auch noch andere Frameworks gibt, die einen Blick wert sind. Hier geht es um Micronaut, ein noch vergleichsweise junges Framework, das jedoch einige interessante Eigenschaften aufweist, die es besonders im Cloudumfeld zu einem echten Rivalen gegenüber Spring machen. In diesem Artikel wird eine Anwendung einmal mit Spring Boot und einmal mit Micronaut implementiert. Danach werden die beiden Ansätze verglichen und geschaut, wo welches Framework überlegen ist.

The post Cloud-native Java mit Micronaut appeared first on JAX.

]]>
Entwickelt wird das Micronaut Framework von OCI, genauer gesagt unter der Federführung von Graeme Rocher, der schon das Grails Framework ins Leben gerufen hat. Sowohl die Erfahrungen mit Spring als auch mit Grails sind in Micronaut eingeflossen. Daher kommen die Paradigmen und das Programmiermodell erfahrenen Spring-Entwicklern schon von Beginn an sehr vertraut vor. Das Framework wird beschrieben als „modernes, JVM-basiertes Full Stack Framework, um modulare, einfach zu testende Microservices- und Serverless-Anwendungen zu bauen“. In dieser Beschreibung liegt der wesentliche Unterschied zum Spring Framework: Micronaut legt den Fokus auf Microservices und Serverless-Anwendungen, womit sich JVM Frameworks aktuell noch eher schwertun.

Der Nachteil von Spring

Java-Anwendungen kommen von Haus aus mit einigem Overhead daher. Die JVM allein benötigt nach offiziellen Angaben bereits etwa 128 MB RAM und 124 MB Festplattenspeicher. Für traditionelle Anwendungen ist das voll und ganz vertretbar, bei Docker-Containern in einem Cluster oder gar als FaaS-Instanz sind solche Zahlen aber nicht mehr zeitgemäß. Zum Vergleich: Nichttriviale Anwendungen in der Programmiersprache Go sind nach der Kompiliation oftmals nur 20 bis 30 MB groß. Eine andere wichtige Metrik ist die Startzeit einer Anwendung. Durch den Laufzeit-Reflection-Ansatz von Spring sind Startzeiten jenseits der zwanzig Sekunden keine Seltenheit. Auch das ist besonders für Serverless-Anwendungen nicht hinnehmbar.

Was Micronaut von Spring unterscheidet

Micronaut geht einen anderen Weg als Spring und kann damit einige der Performanceeinbußen wettmachen. Besonders die Startzeit wird ungeheuer verringert, was Java-Entwicklern den Einstieg in die Serverless-Welt eröffnet. Aber auch der RAM-Verbrauch sinkt.

Wie erreicht Micronaut diese Verbesserungen? Die Antwort liegt in der Kompilation. Spring durchsucht zur Laufzeit per Reflection den Classpath nach Beans, initialisiert diese und lädt sie dann dynamisch in den Application Context. Dann werden die Beans dort injectet, wo sie benötigt werden. Auch wenn das ein sehr einfacher und erprobter Ansatz ist, verlängert er jedoch die Startzeit durch diesen Overhead. Die Startzeit leidet dabei umso mehr, je mehr Klassen die Anwendung enthält. Micronaut hingegen verwendet Annotation Processors, die die nötigen Informationen zur Compile-Zeit sammeln und ahead of time (AOT) die nötigen Transformationen für Dependency Injection (DI) und Aspect-oriented Programming (AOP) erledigen. Das verkürzt die Startzeit der Anwendung, erhöht jedoch die Compile-Zeit. Zudem fallen durch dieses Vorgehen etwaige Fehler wie eine nicht zu erfüllende Abhängigkeit schon zur Compile-Zeit auf. Außerdem ist die Startzeit nicht abhängig von der Größe der Anwendung; einmal kompiliert, ist die Startzeit dadurch relativ konstant. Die Implikation dieses Compile-Zeit-Ansatzes ist natürlich, dass die Libraries, die zusätzlich zum Framework in die Anwendung einfließen, ebenfalls auf das Nachladen von Beans per Reflection verzichten müssen. Das AOP Framework AspectJ ist beispielsweise ungeeignet für Micronaut, weshalb Micronaut selbst eine AOP-Lösung bereitstellt. Wie stark die durch das Framework erzielten Verbesserungen sind, wird in den folgenden Beispielen gezeigt.

Die Spring-Anwendungt

Als Beispiel lässt sich eine einfache Anwendung für einen Einkaufswagen verwenden. Der komplette Code ist auf GitHub verfügbar. Per HTTP lassen sich Produkte in den Einkaufswagen legen, abfragen oder wieder löschen. Zunächst kommt die Spring-Boot-Anwendung. Dazu besucht man die Seite https://start.spring.io/ und stellt eine Java-8-Anwendung mit Gradle, Spring Boot 2.1.2 und dem Webpaket zusammen. Das Archiv kann irgendwo auf dem Rechner entpackt werden.

Es folgt der Java-Code der Anwendung. Wer mit Spring Boot vertraut ist, sollte damit keine Probleme haben. Zunächst wird ein Controller mit dem Namen ShoppingCartController.java benötigt (Listing 1).

Listing 1

@RestController("/shoppingCart")
public class ShoppingCartController {

  private final ShoppingCartService shoppingCartService;

  public ShoppingCartController(ShoppingCartService shoppingCartService) {
    this.shoppingCartService = shoppingCartService;
  }

  @GetMapping
  public List<Product> getAllProducts() {
    return shoppingCartService.getAllProducts();
  }

  @PostMapping
  public void addProduct(@RequestBody Product product) {
    shoppingCartService.addProduct(product);
  }

  @DeleteMapping
  public Optional<Product> deleteProduct(@RequestBody Product product) {
    return shoppingCartService.deleteProduct(product);
  }
}

Als Nächstes folgt ein Service unter ShoppingCartService.java (Listing 2).

Listing 2

@Service
public class ShoppingCartService {

  private final ArrayList<Product> products = new ArrayList<>();

  public List<Product> getAllProducts() {
    return products;
  }

  public void addProduct(Product product) {
    products.add(product);
  }

  public Optional<Product> deleteProduct(Product product) {
    Optional<Product> result = products.stream()
      .filter(p -> p.getId().equals(product.getId()))
      .findFirst();
    result.ifPresent(products::remove);
    return result;
  }
}

Der Service hält der Einfachheit halber alle Produkte in einer lokalen Liste. Fehlt noch ein POJO für das Produkt in Product.java (Listing 3).

Listing 3

public class Product {
  private final Long id;
  private final String description;
  ... Konstruktor, Getter, Setter ...
}

Wenn die Anwendung mit ./gradle bootRun ausgeführt wurde, kann man mit einem Tool wie cURL die Endpunkte ansprechen, um die Funktion zu testen.

Ressourcenverbrauch: Einige interessante Metriken der Anwendung sind in Abbildung 1 zu sehen. Als Compile-Zeit wird die Zeit für den Gradle-Task bootJar nach einem vorherigen ./gradlew clean genommen. Die Startzeit beträgt laut Spring-Ausgabe 3,72 Sekunden. Die tatsächliche Startzeit enthält zusätzlich noch die Startzeit der JVM, womit sie in Summe etwa 5 Sekunden beträgt.


 

Die Micronaut-Anwendung

Die vorangegangene Anwendung dient als Vergleichspunkt für die nachfolgende Micronaut-Anwendung. Der komplette Code ist ebenfalls auf GitHub verfügbar. Anders als bei Spring Boot kommt Micronaut mit einem Kommandozeilentool daher, das die Erstellung von Projekten übernimmt. Für die Installation sei auf die offizielle Micronaut-Seite verwiesen.

Mit dem Tool mn lässt sich die Anwendung nun mittels $ mn erstellen. Der Befehl startet eine Shell, wo einige Micronaut-spezifische Befehle zur Verfügung stehen. Eine neue Anwendung lässt sich im aktuellen Verzeichnis mit create-app erstellen. Wenn man dahinter noch –features= eingibt und einmal auf TAB drückt, bekommt man eine Übersicht über die zusätzlichen Features, die Micronaut mitliefert. Darunter finden sich die JVM-Sprachen Groovy und Kotlin sowie mehrere Projekte aus dem Netflix-Stack für Microservices.

Zunächst reichen die Standardeinstellungen bis auf eine Kleinigkeit: GraalVM Native Image. Worum es sich dabei handelt, darauf wird später noch eingegangen. Der vollständige Befehl lautet:

mn> create-app --features=graal-native-image 
com.example.myshop.shoppingcart.shopping-cart-micronaut

Mit exit wird die Shell beendet.

Der Code

Zuerst kommt wieder der Controller, der sich über die Micronaut-Shell mit folgendem Befehl erstellen lässt:

mn> create-controller ShoppingCart

Dieser Befehl erstellt sowohl den Controller als auch einen dazugehörigen Test und erspart dem Programmierer etwas Zeit. Die Service Bean kann folgendermaßen erstellt werden:

mn> create-bean ShoppingCartService

Ein Vorteil für Spring-Entwickler: Der Code der Spring-Anwendung lässt sich fast eins zu eins kopieren; Micronaut will den Entwicklern kein neues Programmiermodell aufzwingen. Das Framework ändert jedoch einige Namen der Annotationen. Aus @RestController wird @Controller, aus @GetMapping wird @Get usw. Beim ShoppingCartService wird aus @Service @Singleton. Das Produkt-POJO benötigt im Konstruktor noch die @JsonProperty-Annotationen der Jackson Library (der Rest des Codes bleibt identisch):

...
  public Product(@JsonProperty("id") Long id,
    @JsonProperty("description") String description) {
  …

Ressourcenverbrauch: Die Zahlen der beiden Beispielanwendungen im Vergleich sind in Abbildung 2 zu sehen. Dies zeigt die Verbesserungen von Micronaut gegenüber Spring. Während die Compile-Zeit nun signifikant länger ist, kann das Framework bei anderen Metriken punkten. Dabei ist zu beachten, dass die Startzeit je nach Größe der Anwendung bei Spring immer länger werden wird, während die Startzeit der Micronaut-Anwendung relativ konstant bleibt.


 

GraalVM

Der Befehl zur Erstellung der Micronaut-Anwendung enthielt das Feature graal-native-image. Bei GraalVM handelt es sich um eine virtuelle Maschine mit Unterstützung für verschiedene Sprachen, die von Oracle entwickelt wird. Sie ermöglicht es Entwicklern, Code aus verschiedenen Sprachen innerhalb der gleichen Runtime laufen zu lassen. Aber das ist nur der Anfang: GraalVM bietet zudem die Möglichkeit, Java-Anwendungen in native Binaries kompilieren zu lassen. Diese können dann ohne JVM oder GraalVM ausgeführt werden. Dieser Schritt wird nur möglich, wenn die Anwendung wenig bis gar kein reflexives Nachladen von Klassen benutzt. Micronaut eignet sich daher sehr gut für diesen Anwendungsfall.

Micronaut-Anwendung binär kompilieren

Dieses Tool lässt sich an der zuvor erstellen Micronaut-Anwendung demonstrieren. Dazu benötigt man eine GraalVM-Installation nach der offiziellen Dokumentation. Nachdem man GraalVM installiert hat, erhält man einen „JDK-Ersatz“. Alle Programme wie java und javac sind enthalten und verhalten sich genau wie ihr ursprüngliches Gegenstück. Jedoch liefert GraalVM zusätzlich zu den normalen JDK-Programmen ein Programm native-image, das die Kompilierung zu einer nativen Binary vornehmen kann.

Das Micronaut CLI hat bereits das Bash Script build-native-image.sh im Projektverzeichnis generiert. Es enthält im Wesentlichen einen Gradle-Aufruf zur Generierung der JAR und den Aufruf von native-image. Der Nachteil an diesem Verfahren: Es benötigt eine Menge RAM. Wer nicht genug RAM bereitstellt, für den wird der Prozess mit dem ominösen Fehler 137 enden; 16 GB RAM sollten mindestens vorhanden sein. Die dadurch erzeugte Binary erscheint im Hauptverzeichnis und lässt sich bequem ohne eine JVM starten:

$ ./shopping-cart-micronaut
14:53:31.707 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 16ms. Server Running: http://localhost:8080

Eine Startzeit von 16 ms stellt eine erhebliche Verbesserung dar. Die restlichen Metriken sind in Abbildung 3 zu sehen.


 

Die Compile-Zeit ist verständlicherweise miserabel. Nicht nur, dass Micronaut die Beans zur Compile-Zeit auflöst. Darüber hinaus wird der resultierende Java Bytecode in nativen Code übersetzt. Vorteil für Entwickler: Der Schritt muss lokal eigentlich nie ausgeführt werden. Während man lokal auch die Java-Version zum Testen nutzen kann, führt lediglich der Build-Server den zeitfressenden Kompilierungsschritt aus. Auch der Größenunterschied ist nicht wirklich problematisch. Die JAR an sich ist zwar nur 11,3 MiB groß, jedoch benötigt man hierfür noch eine JRE, die noch einmal Platz verbraucht. Die Binary kommt auch ohne eine JRE aus und kann einzeln oder innerhalb eines minimalen Docker Image ausgeliefert werden. Besonders der geringe RAM-Verbrauch zeigt, wie wertvoll der Ansatz für die Serverless-Welt sein kann, in der jedes Megabyte RAM bares Geld kostet.

Fazit

Das noch junge Framework Micronaut bietet Java-Entwicklern die Möglichkeit, schlanke und schnelle Anwendungen für die Cloud zu schreiben, ohne dabei auf das vertraute Programmiermodell von Spring verzichten zu müssen. Sollten Java-Entwickler also Spring abschreiben und Micronaut verwenden? Meiner Meinung nach ist es noch nicht so weit. Bei der Entscheidung, welches Framework man für eine größere Anwendung verwenden will, kommt es nicht nur auf die Performance an. Auch die Community und Lehrmaterialien müssen stimmig sein und an dieser Stelle hängt Micronaut (noch) hinterher. Bei den meisten Projekten auf GitHub handelt es sich um kleinere Beispielanwendungen. Wie sich das Framework bei einer realen Anwendung verhält, ist also noch ungewiss.

Dennoch ist Micronaut für kleine Anwendungen einen Blick wert, gerade im schon so oft erwähnten Serverless-Umfeld. Und nicht zuletzt ist Wettbewerb gut für den Markt. Vielleicht halten ja einige Ideen der Micronaut-Entwickler Einzug ins Spring Framework.

 

Java-Dossier für Software-Architekten 2019


Mit diesem Dossier sind Sie auf alle Neuerungen in der Java-Community vorbereitet. Die Artikel liefern Ihnen Wissenswertes zu Java Microservices, Req4Arcs, Geschichten des DevOps, Angular-Abenteuer und die neuen Valuetypen in Java 12.

Java-Wissen sichern!

The post Cloud-native Java mit Micronaut appeared first on JAX.

]]>
Was bedeutet Cloud Native Java für Entwickler wirklich? https://jax.de/blog/cloud-native-java-fuer-entwickler/ Mon, 12 Nov 2018 16:09:21 +0000 https://jax.de/?p=65590 Panel-Diskussion auf der W-JAX mit Elisabeth Engel, Uwe Friedrichsen, Dr.Roland Huß, Eberhard Wolff und Sebastian Meyen über die Bedeutung von Cloud Native für Java-Entwickler

The post Was bedeutet Cloud Native Java für Entwickler wirklich? appeared first on JAX.

]]>
Auf der W-JAX 2018 waren Diskussionen in jedem Raum und an jedem Tisch zu Gange. In der Podiumsdiskussion am Donnerstag bereiteten unsere Experten die Besonderheiten von Cloud-Native, was Java-Entwickler lernen müssen und warum wir der Entwicklung von Cloud-Native mehr Aufmerksamkeit schenken sollten, für unsere Besucher auf.

Alle, die nicht dabei sein konnten, können sich die Diskussion hier im Video nachträglich anschauen:

 

 

Wir bedanken uns recht herzlich bei unseren Experten, dem ganzen Software & Support Media Team sowie unseren zahlreichen Besuchern!

The post Was bedeutet Cloud Native Java für Entwickler wirklich? appeared first on JAX.

]]>
Einmal als Container verpacken? – Java im Zeitalter von Kubernetes https://jax.de/blog/container-serverless/java-im-zeitalter-von-kubernetes/ Thu, 27 Sep 2018 12:33:27 +0000 https://jax.de/?p=65275 Dr. Roland Huß leitet Sie in unseren Blogartikel ein über zwei Zusammenfassungen zu Kubernets und Microservices. Er geht anschließend ins Detail wie Sie Container mit Maven packen und die optimale Konfiguration aufsetzen.

The post Einmal als Container verpacken? – Java im Zeitalter von Kubernetes appeared first on JAX.

]]>
von Dr. Roland Huß
Die Java Virtual Machine (JVM) ist über die Jahre zu einer einzigartigen Laufzeitumgebung gereift, die insbesondere mit Serveranwendungen mit einer überragenden Performanz besticht. Dabei ist die JVM hochgradig optimiert für langlaufende und exklusive Serveranwendungen, die lange das vorrangige Betriebsmodell für Java-basierte Backend-Anwendungen war. Diese Optimierungen wurden vorrangig auf Kosten der Start-up-Zeit und des Hauptspeicherverbrauchs realisiert. Nachdem Microservices- und Container-basierte Betriebsmodelle in den letzten Jahren in den Vordergrund rücken, sind es heute vor allem viele potenziell kurzlebige Prozesse, die auf Plattformen wie Kubernetes elastisch skaliert werden.

Lesen Sie für weitere Erläuterungen zum Thema die beiden Textkästen „ Kurz & Knapp – Microservices mit Docker“ sowie „ Kurz & Knapp – Kubernetes“.

 

Kurz & Knapp – Microservices mit Docker

Vor nicht allzu langer Zeit führten viele von uns den Kampf gegen diese großen, monströsen Monolithen, die das Übel der gesamten IT-Welt in sich zu vereinen schienen. Riesige Entwicklerteams, lange Releasezyklen, irrsinniger Synchronisationsaufwand vor jedem Update, Monsterrollbacks bei fehlgeschlagenen Rollouts, all diese Merkmale klassischer Monolithen haben zu einiger Frustration bei uns Entwicklern geführt.
Das war die Geburtsstunde der Microservices, bei denen Geschäftslogik in kleine Portionen in dedizierte Services gekapselt wird und sich so von separaten, überschaubaren Teams entwickeln lässt. Die Vorteile dieses Ansatzes liegen auf der Hand: Überschaubare Codebasis, keine Kompromisse bei der Auswahl des Technologiestacks, individuelle und unabhängige Releasezyklen und kleinere, fokussierte Teams.
Der Wechsel auf eine verteilte Anwendung im Microservices-Stil hat jedoch auch ihren Preis: APIs müssen abgestimmt werden, Services können jederzeit wegbrechen, Backpressure-Effekte bei vielen abhängigen Services und natürlich die erhöhte Komplexität bei der Verwaltung vieler Services sind wohl die wichtigsten Herausforderungen einer Microservices-basierten Architektur.
Dennoch scheint es, dass die gesamte IT-Welt auf den Microservices-Zug aufgesprungen ist. Natürlich wird, wie immer, das Pendel bald zurückschlagen und wir werden auf die harte Tour lernen müssen, dass Microservices nicht alle Probleme dieser Welt lösen. Neben der Problematik, dass wir uns nun um verteilte Systeme kümmern müssen, ist es, wie schon erwähnt, vor allem die schiere Menge der Services, die den Betrieb erschwert. Die Komplexität eines Monolithen löst sich nicht einfach durch einen Architekturwechsel in Nichts auf.
Zufällig oder nicht, parallel zur dieser Architekturrevolution hat sich auch ein Betriebsmodell herauskristallisiert, das wie Deckel auf Topf zu Microservices passt. Gemeint ist der Betrieb von Applikationen in uniformen Containern, der dank Dockers überragender User Experience nun auch für Normalsterbliche auf einfache Weise benutzbar geworden ist.
Nun können Entwickler (Dev) einfach einen Microservice in einen Container stecken, egal mit welcher Technologie er entwickelt wurde. Wie immer sind bei der Bestückung mit Java-Anwendungen jedoch ein paar Besonderheiten zu beachten, auf die wir im Folgenden detailliert eingehen werden.
Die Administratoren (Ops) können dann wiederum solche Container betreiben, ohne sich im Detail damit zu beschäftigen, welche Technologie genau darin enthalten ist. Diese Eigenschaft, die Linux-Container tatsächlich mit realen Frachtcontainern aus der Schiffahrt teilen, liefert eine ausgezeichnete technische Schnittstelle für die Kommunikation zwischen Dev und Ops, sodass die Containerisierung ein integraler Bestandteil der DevOps-Bewegung geworden ist.

 

Kurz & Knapp – Kubernetes

Wer eine ganze Flotte an Containern betreiben muss, braucht die Unterstützung einer Orchestrierungsplattform für Container. Genau das ist die Aufgabe von Kubernetes, das sich mittlerweile als De-facto-Standard auf dem Markt etabliert hat.
Das Open-Source-Projekt Kubernetes wurde 2014 von Google gestartet und hat als Ziel, der Allgemeinheit die Erfahrungen zur Verfügung zu stellen, die Google mit der eigenen internen Containerplattform Borg gewonnen hat.
Einige Kerneigenschaften von Kubernetes sind:

• Die optimierte Verteilung von Containern auf einen Cluster von Linux-Maschinen (Nodes)
• Horizontale Skalierung, auch automatisch
• Selbstheilung
• Service Discovery, damit sich Microservices gegenseitig leicht finden können
• Support für verschieden Updatestrategien
• Verteilte Volumes zum Persistieren von Daten
• Router und Loadbalancer zum Zugriff auf Dienste von außerhalb des Clusters

Insbesondere die Eigenschaft der sogenannten Selbstheilung ist es wert, etwas näher betrachtet zu werden. Dazu muss man wissen, dass Kubernetes ein deklaratives Framework ist, bei dem man beschreibt, wie ein Zielzustand aussehen soll. Das steht im Gegensatz zu einem imperativen Framework, bei dem die einzelnen Schritte beschrieben werden, die dann letztendlich zu dem gewünschten Zustand führen. Die Beschreibung des Zielzustands hat zum Vorteil, dass Kubernetes den aktuellen Zustand periodisch mit dem Zielzustand abgleichen kann. Weicht der aktuelle vom gewünschten Zustand ab, führt Kubernetes Aktionen durch, um die Vorgabe wieder zu erreichen. Diesen Prozess nennt man Reconciliation – eine der wichtigsten Eigenschaften von Kubernetes.
Wie aber wird dieser Zielzustand beschrieben? Das geschieht mithilfe sogenannter Resource-Objekte. Diese Objekte, die sich im JSON- oder YAML-Format beschreiben lassen, werden über ein generisches REST API am Kubernetes-API-Server verwaltet. Dieses API kennt die klassischen CRUD-Operationen (Create, Retrieve, Update, Delete). Alternativ besteht auch die Möglichkeit, sich via WebSockets für Resource-Events zu registrieren.
Die grundlegende Resource ist hierbei der Pod, der eine Verallgemeinerung eines Containers darstellt. Ein Pod kann entweder einen oder auch mehrere Container enthalten und ist das Kubernetes-Atom. Container, die in einem Pod zusammengefasst sind, haben den gleichen Lebenszyklus, d. h., sie leben und sterben gemeinsam. Container innerhalb eines Pods können sich außerdem gegenseitig sehen und teilen sich den Netzwerkraum, sodass die Prozesse in den Containern einander über localhost erreichen können. Zusätzlich können Pod-Container sich auch Volumes teilen, sodass auch darüber Dateien ausgetauscht werden können.
Jeder Pod hat eine eigene, flüchtige IP-Adresse innerhalb des Kubernetes-privaten Netzwerks. Auf diese IP-Adresse kann von außerhalb nicht zugegriffen werden, was auch tatsächlich keinen Sinn ergeben würde, da ein Pod bei einem Neustart eine neue IP-Adresse zugewiesen bekommt.
Services ermöglichen es, mit einer stabilen Adresse auf die Container eines Pods über das Netzwerk zuzugreifen. Dabei kann man sich einen Service wie einen Reverse Proxy oder Load Balancer vorstellen, der vor den Pods steht. Ein Service ist zugleich ein virtuelles Konstrukt ohne Lebenszyklus und wird beispielsweise über eine reine Netzwerkkonfiguration realisiert. Ein Microservice spricht mit einem anderen Microservice nur über Services. Jeder Service hat dabei einen Namen, über den er über einen internen DNS-Server gefunden werden kann.
Da Services zwar eine permanente, aber auch nur intern zugängliche IP-Adresse besitzen, bedarf es einer weiteren Ressource: Mit Ingressobjekten kann der Zugriff von außerhalb des Kubernetes-Clusters auf Services realisiert werden. Dabei kann man sich ein Ingressobjekt als eine Art Konfiguration eines dynamisch konfigurierbaren Load Balancer vorstellen.
Wie eingangs erwähnt, verfügt Kubernetes über selbstheilende Eigenschaften. Das bedeutet, dass ein Pod, der sich ungewollt verabschiedet, automatisch wieder neu gestartet wird. Wie aber funktioniert das genau? Pods haben ein sogenanntes ReplicaSet als Babysitter. Typischerweise wird ein Pod nämlich nicht direkt vom Benutzer über das Kubernetes API erzeugt, sondern mithilfe eines ReplicaSet. Dieses ReplicaSet ist wiederum eine Ressource, die eine Pod-Beschreibung enthält und spezifiziert, wie viele Kopien dieses Pod erzeugt werden sollen. Im Hintergrund werkelt nun ein sogenannter Controller, der periodisch überprüft, ob die konfigurierte Anzahl an Pods tatsächlich läuft. Falls das nicht der Fall ist, werden entweder Pods beendet oder aber neue aus der mitgelieferten Pod-Beschreibung erzeugt.
Neben diesen gerade vorgestellten vier Ressourcentypen gibt es noch eine Vielzahl weiterer Arten, die verschiedene Konzepte oder Patterns repräsentieren. Die Grundidee bleibt aber stets die gleiche: Eine Ressource beschreibt ein Konzept und kann über das REST API verwaltet werden. Die Menge der Ressourcen nennt man auch die Data Plane. Mit der sogenannten Control Plane, die aus einer Vielzahl von Controllern besteht, werden diese Ressourcen ausgewertet und überwacht. Es ist Aufgabe der Control Plane, einen gewünschten Zielzustand herzustellen, wenn der aktuelle Zustand von diesem abweicht.

 

Aufgrund dieser Vorgeschichte ist Java aktuell als Cloud-Laufzeitumgebung klar im Nachteil gegenüber Sprachen wie Golang oder auch interpretierten Sprachen wie etwa Python, die deutlich zügiger starten und bei vergleichbarer Funktionalität bis zu zehnmal weniger Arbeitsspeicher benötigen.

Zum Glück gibt es Initiativen wie GraalVM [1] von Oracle, die hier aufzuholen versuchen. In unserem Zusammenhang wollen wir uns aber insbesondere the Substrate VM näher ansehen. Diese spezielle VM verwendet das so genannte Verfahren „Ahead-of-Time Compilation“ (AOT), um vorab Java-Bytecode in native Programme zu kompilieren. Obwohl es insbesondere Limitierungen in Bezug auf dynamische Eigenschaften wie Reflection oder dynamisches Classloading gibt, klappt das in vielen Fällen schon erfreulich gut. Frameworks wie Spring arbeiten eifrig daran, kompatibel zu Substrate VM zu werden  [2].

Bis sich diese neuartigen JVMs in Produktion einsetzen lassen, wird es noch einige Zeit dauern. Die produktiv am häufigsten eingesetzte Java VM sind immer noch die auf OpenJDK 8 basierenden Distributionen. Um diese erfolgreich in Containern betreiben zu können, müssen einige Details beachtet werden, insbesondere was die Speicher- und Threadkonfiguration betrifft.

Java 8 verwendet bei der Berechnung von Defaultwerten für den Heap-Speicher oder die Anzahl der internen Threads als Basis den insgesamt vorhandenden Hauptspeicher bzw. die Anzahl der zur Verfügung stehenden CPU Cores. Kubernetes und Docker können die den Containern zur Verfügung stehenden Ressourcen begrenzen. Das ist eine wichtige Eigenschaft, die es Orchestrierungsplattformen wie Kubernetes ermöglicht, die Resourcen optimal zu verteilen.

Nun ist es aber leider so, dass ein Java-Prozess, der innerhalb eines Containers gestartet wird, dennoch immer den gesamten Hauptspeicher und die gesamte Anzahl der Cores eines Hosts sieht, ganz unabhängig von den gesetzten Containergrenzen. Das führt dazu, dass beispielsweise der Defaultwert für den maximal verwendbaren Hauptspeicher viel zu groß gewählt wird, sodass die von außen eingestellte, harte Begrenzung erreicht wird, ohne das die JVM z. B. mit einer Garbage Collection wieder Speicher freigeben kann. Das Ergebnis sind Out-of-Memory-(OOM-)Fehler, die dazu führen, dass der Container hart beendet wird. Das gleiche gilt ebenfalls für die Anzahl der zur Verfügung stehenden Cores: Wenn beispielsweise auf einer Machine mit 64 Cores ein Java-Container gestartet wird, der auf zwei Cores begrenzt ist (z. B. mit der Docker Option cpus), dann wird die Java VM dennoch 64 Garbage-Collector-Threads starten, für jeden Core einen. Jeder dieser Threads benötigt wiederum standardmäßig 1 MB Speicher, sodass hier 62 MB extra verbraten werden, was zu dem kuriosen Effekt führen kann, dass eine Applikation auf dem Desktop funktioniert, nicht jedoch, wenn es in einem Cluster mit gut ausgestatteten Knoten wie GKE läuft. Auch andere Applikationen wie Java EE Server benutzen die Anzahl der Cores, die von Runtime.getRuntime().getAvailableProcessors() fälschlicherweise zurückgeliefert wird. So startet z. B. Tomcat für jeden sichtbaren Core einen eigenen Listener Thread, um HTTP-Anfragen zu beantworten.

Die von Java getroffene Annahme bei der Berechnung der Defaultwerte, sämtliche Ressourcen eines Hosts alleine zur Verfügung zu haben, ist generell infrage zu stellen. In einer containerisierten Laufzeitumgebung ist sie tatsächlich fatal.

Zum Glück gibt es seit JDK 8u131+ (und JDK 9) die Option XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap, die die JVM dazu veranlasst, tatsächlich die via cgroups gesetzten Speichergrenzen zu honorieren. Mit Java 10 werden die cgroups-Limits dann automatisch übernommen, was auch die für die CPU-Anzahl gilt.

Unabhängig von diesen Verbesserungen ist es dennoch zu empfehlen, die Werte für den maximalen Heap-Speicher und Threadkonfigurationen, die sich auf die Anzahl der Cores stützen, explizit zu setzen. Dabei hilft z. B. ein Java-Start-up-Skript wie run-java.sh [3], das unabhängig von der verwendeten Java-Version sinnvolle Defaultwerte setzen kann. Die fabric8 Java Basisimages [4] enthalten bereits dieses Skript und können direkt als Grundlage für containerisierte Java-Anwendungen verwendet werden.

Nach den eher technischen Besonderheiten, die beim Betrieb von Java in Containern im Allgemeinen zu beachten sind, stellt sich natürlich die Frage, wie man Java am besten in Container packen kann. Ein Java-Container-Image unterscheidet sich zunächst einmal nicht besonders von den Images anderer Applikationen: Es ist ein Image, das eine JVM enthält und das einen bytecompilierten Java-Code ausführt.

Es gibt jedoch viele verschiedene Wege, wie diese Java-Images gebaut werden können. Neben dem klassischen Ansatz mit docker build und einem Dockerfile gibt es weitere Variationen, die zum Teil auch gleich dabei helfen, Kubernetes-Resource-Deskriptoren zu erzeugen.

 

Container packen mit Maven

Für die vorherrschenden Java-Buildsysteme Maven und Gradle existieren verschiedene Plug-ins, die das Bauen von Java-Container-Images in den Buildprozess integrieren können. Mit dem docker-maven-plugin [5] lassen sich die Images auf verschiedene Weise konfigurieren. Neben der empfohlenen Konfiguration mit einem Dockerfile existiert auch eine eigene XML-Syntax, die mit einer Maven Assembly zur Definition der Containerdaten arbeitet. Maven-Artefakte können direkt im Dockerfile referenziert werden, und für die einfachen Fälle bedarf es neben dem Dockerfile auch keiner weiteren Konfiguration. Zusätzlich zum Bauen von Docker-Images erlaubt das docker-maven-plugin auch das Starten von Containern, was beispielsweise für integrierte Integrationtests recht nützlich ist.

Das fabric8-maven-plugin [6], das auf dem docker-maven-plugin basiert, geht indes noch einen Schritt weiter. Die Idee dieses Plug-ins besteht darin, dass zwar lokal gebaut, jedoch die Anwendung direkt in einem Kuberntes-Cluster auch während der Entwicklung deployt wird. Durch Introspektion ist es diesem Plug-in möglich, gänzlich ohne Konfiguration auszukommen, sofern ein gängiger Technologiestack wie Spring Boot, Vert.x, Thorntail, Java EE WAR, generelle Java Fat Jars oder Karaf verwendet wird. Anhand der vorhandenen Konfiguration kann das fabric8-maven-plugin eigenständig Docker-Images mit vorausgewählten Basisimages erzeugen.

Darüber hinaus hilft dieses Plug-in bei der Erstellung der Kubernetes-Deployment-Deskriptoren. Im Zero-Konfigurationsmodus werden Annahmen über die Applikation getroffen. Standardmäßig werden ein Deployment und ein Service-Objekt erzeugt, dass das gebaute Docker-Image referenziert. Die Konfiguration kann aber auch über sogenannte Fragmente weitläufig angepasst werden. Spezielle Maven Goals wie fabric8:debug und fabric8:watch helfen beim Debugging und automatischen Redeployment der zu entwickelnden Anwendung im Cluster.

Im Artikel „Von Null auf Kubernetes“ (in der aktuellen Ausgabe des Java Magazins) wird dieses Plug-in anhand eines Beispiels ausführlich vorgestellt.

Eine weitere interessante Build-Integration mit Maven und Gradle bietet Jib [7]. Ziel von Jib ist es ebenfalls, das Bauen von Docker Images in den regulären Buildprozess zu integrieren. Dabei stehen neben dem eigentlich Bauen des Images zwei Eigenschaften im Vordergrund: Geschwindigkeit und Reproduzierbarkeit.

Geschwindigkeit wird durch das Aufteilen der Applikation in verschiedene Schichten realisiert – normalerweise muss bei Codeänderungen nur eine davon gebaut werden. Das klappt allerdings nur für bestimmte Klassen von Anwendungen.

Falls sich der Inhalt einer dieser Schichten nicht ändert, wird sie nicht neu gebaut. Somit können die Images reproduzierbar wieder erzeugt werden, da Jib Zeitstempel und andere buildspezifische Daten herausfiltert.

Jib arbeitet mit einem sogenannten Daemonless Build, bei dem es keines Docker Daemon bedarf. Dabei werden alle Imageschichten und Metadaten lokal im Docker- oder OCI-Format erzeugt. Das Ganze passiert direkt aus dem Java-Code des Plug-ins heraus, ohne eine externes Tool zu verwenden.

Eine Bedingung für die effektive Nutzung von Jib ist, dass das Projekt eine spezielle Struktur für die Paketierung als sogenannte Flat Classpath App mit einer Main-Klasse, Abhängigkeiten in Form von JAR-Dateien und Resource-Dateien wie Properties, die aus dem Classpath gelesen werden. Das Gegenstück dazu sind Fat Jars, die all diese Artefakte in einem einzigen JAR vereinigen. Spring Boot hat das Fat-Jar-Format als Paketierung übrigens populär gemacht. Jib dagegen stellt sich auf den Standpunkt, dass das Docker Image selbst die eigentlich Paketierung repräsentiert, sodass am Ende wiederum nur ein einzelnes Artefakt (in diesem Fall das Image) gemanagt werden muss.

Flat Classpath Apps haben so einige Nachteile, aber immerhin den einen großen Vorteil, dass sie erlauben, die verschiedenen Artefakte in verschiedene Schichten des Docker-Images zu organisieren. Dabei werden die am wenigsten veränderlichen (wie z. B. die Abhängigkeiten) in eine tiefere Schicht des Layerstacks gelegt, so dass dieser nicht neu gebaut werden muss, sofern sich an den Abhängigkeiten nichts ändert. Und genau das macht Jib: Es steckt alle Jars, von denen die Anwendung abhängt, in eine Schicht, alle Resource-Dateien in eine andere, und die eigentlichen Applikationsklassen in eine dritte. Die drei Schichten werden lokal gecacht. Somit ist ein erneutes Bauen der Images, bei denen sich nur der Applikationscode ändert, viel schneller möglich, als wenn jedes Mal ein einzelnes Fat Jar gebaut werden müsste, das natürlich auch nach jedem Bauen anders aussehen würde.

Damit ist Jib sehr schnell für inkrementelle Builds von Flat Classpath Apps, da Artefakte mit unterschiedlicher Volatilität in unterschiedlichen Schichten gepuffert werden. Dass zudem kein Docker Daemon erforderlich ist, reduziert die Anforderungen an das Buildsystem, erhöht aber andererseits auch die Sicherheit, da das Bauen des Images keine Root-Berechtigungen mehr benötigt.

Der Nachteil besteht aber darin, dass Jib tatsächlich nur für die angesprochenen Flat Classpath Apps Sinn ergibt. Spring-Boot-, Thorntail- und Java-EE-Anwender schauen also erst einmal in die Röhre, da diese auf Fat Jars oder WAR als Paketierungsformat setzen. Für Spring Boot gibt es mit dem Spring Boot Thin Launcher [8] eine alternative Paketierung, ebenso wie die Hollow Jars [9] für Thorntail. Diese Technologien wären prinzipiell auch für einen Einsatz mit Jib geeignet, bislang fehlt jedoch eine entsprechende Unterstützung. Außerdem ist der Start-up der Anwendung hart kodiert mit einem einfachen Aufruf von Java. Es gibt keine Möglichkeit, ein optimiertes Start-up-Skript wie das angesprochene run-java-sh einzubinden. Bei der Verwendung von Jib ist auch die Verwendung von XML-Konfiguration Pflicht, da keine Dockerfiles unterstützt werden.

Wenn also das Projekt passt (flat classpath), dann sollte man sich Jib unbedingt anschauen, ansonsten sind sicher die anderen Buildintegrationen besser geeignet.

 

Cloud-native Java-Patterns

Um die Vorzüge von Kubernetes voll auszuschöpfen, bedarf es allerdings mehr als einfach nur Java-Anwendungen in Container zu packen. Dazu müssen die Möglichkeiten, die die Infrastruktur bereitstellt, bei der Programmierung direkt miteinbezogen werden.

Es haben sich eine ganze Reihe von Mustern herausgebildet, die Best Practices für die Programmierung und den Betrieb von Anwendungen auf Kubernetes einfangen. Dabei ist auch zu betonen, dass Kubernetes selbst eine Manifestierung langjähriger Erfahrungen und Muster des orchestrierten Containerbetriebs darstellt.

In den folgenden Abschnitten werden wir einige dieser Muster vorstellen. Darüber hinaus seien die weiterführenden Publikationen [10], [11] nahegelegt, die die folgenden und auch noch weitere Patterns im Detail beleuchten.

 

Service, wo bist du?

Wie am Anfang kurz skizziert, eignen sich insbesondere Microservices für den Betrieb mit Kubernetes. Es liegt in der Natur von Microservices, dass sie zwar für sich genommen klein sind, dafür aber bei nichttrivialen Anwendungsfällen in großer Anzahl vorliegen, die mehrheitlich voneinander abhängen. Daher müssen Microservices in der Lage sein, sich auf einfache Weise gegenseitig zu finden. Der Mechanismus dazu nennt sich Service Discovery und kann auf viele verschiedene Weisen realisiert werden. In Kubernetes wird die Service Discovery über einen internen DNS-Server bereitgestellt. Jeder Service trägt einen Namen und hat eine fixe interne IP-Adresse. Die Zuordnung dieses Namens zu der IP-Adresse kann über eine DNS-Anfrage aufgelöst werden. D. h., dass ein Microservice order-service, der auf einen Microservice inventory-service zugreifen möchte, einfach inventory-service direkt im Zugriffs-URL wie z. B. http://inventory-service/items verwendet. Der DNS-Eintrag der Services ist ein SRV-Eintrag, der auch die Portnummer enthält. Wenn also diese Portnummer nicht über eine Konvention festgelegt ist (z. B. immer Port 80 für alle Services), dann kann man diesen auch über eine DNS-Anfrage erhalten. Das kann direkt mit einer JNDI-Anfrage [12] erfolgen, was in der Praxis aber etwas hakelig ist; einfacher ist die Verwendung einer dezidierten Library wie spotify/dns-java [13] wier in Listing 1.

Listing 1

import com.spotify.dns.*;

DnsSrvResolver resolver = DnsSrvResolvers.newBuilder().build();
List<LookupResult> nodes = resolver.resolve("redis");
for (LookupResult node : nodes) {
  System.out.println(node.host() + ":" + node.port());
}

 

Konfiguration von Kubernetes leicht gemacht

Jede Anwendung muss auch konfiguriert werden. Typischerweise sind es einfache Dateien, die dazu während der Laufzeit eingelesen und ausgewertet werden. Dieses Prinzip gilt auch für Kubernetes, bei dem typischerwese ConfigMap-Objekte für die Konfiguration verwendet werden.

Diese ConfigMaps lassen sich auf zwei Arten verwenden: Einerseits können diese Key-Value-Paare als Umgebungsvariablen beim Starten eines Pods gesetzt werden. Andererseits können sie auch als Volumes gemountet werden, wobei der Key zum Dateinamen wird und der Value zum Inhalt des Files. Falls sich ConfigMaps im Nachhinein ändern, wird die Änderung direkt in den gemounteten Dateien reflektiert. Wenn die Anwendung einen Hot-Reloading-Mechanismus für ihre Konfigurationsdateien besitzt, können die Änderungen ohne Neustart direkt übernommen werden. Das gilt natürlich nicht für Umgebunsgvariablen, die nur beim Start eines Prozesses gesetzt werden können.

Einen bedeutenden Nachteil haben ConfigMaps jedoch: Für große Konfigurationsdateien sind sie nicht gut geeignet, da der Wert eines ConfigMap-Eintrags maximal 1 MB groß sein darf. Außerdem ist die Verwaltung solcher ConfigMaps für große Konfigurationsdateien recht aufwendig. Das gilt umso mehr, wenn Konfigurationen für verschiedene Umgebungen (z. B. Entwicklung, Staging, Produktion) verwaltet werden sollen. Diese umgebungsspezifischen Konfigurationen unterscheiden sich jedoch nur geringfügig voneinander, wie z. B. bei den Verbindungsparametern einer Datenbank. Für diesen Fall eignen sich die Configuration-Template- und Immutable-Configuration-Muster, wie sie in dem Buch “Kubernetes Patterns” [11] beschrieben sind.

Bei dem Configuration-Template-Pattern wird ein Kubernetes-Init-Container benutzt, der vor den eigentlich Pods startet. Dieser Init-Container enthält ein Template der eigentlichen Konfigurationsdatei, die entsprechende Platzhalter für die unterschiedlichen, umgebungsspezifischen Parameter enthält. Die Werte dieser Parameter werden in einer ConfigMap gespeichert, die wesentlich kleiner als die eigentliche Konfigurationsdatei ist. Der Init-Container verwendet das Konfigurationstemplate, setzt beim Starten die Parameter aus der ConfigMap ein und erzeugt die finale Konfigurationsdatei, die wiederum vom Applikationscontainer verwendet wird. Während der Init-Container für alle Umgebungen gleich ist, enthalten die ConfigMaps umgebungsspezifische Werte. Bei einer Änderung der Konfiguration, die für alle Umgebungen gleich ist, muss somit nur einmal der Init-Container mit dem aktualisierten Template ausgetauscht werden. Das hat gegenüber einer reinen ConfigMap-basierten Konfiguration deutliche Vorteile bezüglich der Wartbarkeit.

Eine weitere Alternative stellt die Konfiguration direkt mit dedizierten Konfigurationsimages für die einzelnen Umgebungen (“mmutable Configuration) dar. Diese Konfigurationsimages können direkt mithilfe von Docker-Buildparametern parameterisiert werden, sodass sich auch der Wartungsaufwand in Grenzen hält. Auch hier spielt ein Init-Container eine wichtige Rolle. Dieser verwendet das Konfigurationsimage und kopiert beim Starten die volle Konfigurationsdatei in ein Volume, sodass die Anwendung diese direkt auswerten kann.

Der große Vorteil dieses Patterns liegt darin, dass die Konfigurationsimages versioniert sind und sich über eine Docker Registry auch verteilen lassen. Jede Änderung der Konfiguration bedarf eines neues Image, sodass sich die Historie der Konfigurationänderungen über die Imageversionierung lückenlos verfolgen lässt.

 

 

Bedürfnisse erklären

Damit Kubernetes bestimmen kann, wie Container optimal im Cluster verteilt werden können, muss Kubernetes wissen, welche Anforderungen die Applikation hat. Da sind zum einen Laufzeitabhängigkeiten, ohne die die Applikation nicht starten kann. Das kann beispielsweise der Bedarf nach einem permanenten Speicher sein, der durch Persistent Volumes (PV) bereitgestellt wird. Mithilfe eines Persistent Volume Claim (PVC) kann die Applikation die Größe des angeforderten Plattenspeichers spezifizieren. Es muss also ein Volume in ausreichender Größe zur Verfügung stehen, ansonsten kann der Pod nicht starten.

Ähnliches gilt für die Abhängigkeiten zu anderen Kubernetes-Resource-Objekten wie ConfigMaps oder Secrets. Auch hier kann der Pod nur starten, wenn die referenzierten Ressourcen zur Verfügung stehen.

Die Definition dieser Containerabhängigkeiten ist recht einfach und Teil der Applikationsarchitektur. Etwas mehr Aufwand bedarf die Bestimmung der Ressourcenanforderungen wie Hauptspeicher, CPU oder Netzwerkbandbreite. Kubernetes unterscheidet hier zwischen komprimierbaren (CPU, Netzwerk) und nicht komprimierbaren Ressourcen (Speicher), da komprimierbare Ressourcen bei Bedarf gedrosselt werden können, während das für nicht komprimierbare ausgeschlossen ist. Bei Überschreiten z. B von Speichergrenzen muss der Pod gestoppt werden, da Kubernetes keine generische Möglichkeit hat, den Verbrauch von außen her zu reduzieren.

Die Ressourcenanforderungen eines Pods können über die beiden Parameter request und limit für die enthaltenen Container spezifiziert werden (Listing 2).

 

Listing 2

 

apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - image: nginx
    name: nginx
    resources:
      limits:
        cpu: 300m
        memory: 200Mi
      requests:
        cpu: 200m
        memory: 100Mi

Dabei gibt request an, wieviele Ressourcen mindestens zur Verfügung stehen müssen, um die Container eines Pods zu starten. Findet Kubernetes keinen Clusterknoten, der diese Mindestanforderung von spezifiziertem Speicher oder CPU aller Container eines Pods zur Verfügung stellen kann, dann startet der Pod nicht. Der andere Parameter limit dagegen stellt die obere Grenze dar, bis zu der die Ressourcen maximal wachsen können. Wird dieser Wert von einem Container überschritten, wird entweder gedrosselt oder der ganz Pod gestoppt. Kubernetes wird typischerweise bei der Verteilung der Pods nur die request-Werte in Betracht ziehen, d. h., es wird ein Overcommitment der Ressourcen zugunsten einer effektiven Auslastung in Kauf genommen. Damit kann Kubernetes in die Situation geraten, dass es Pods abschießen muss. Um zu bestimmen, welche Pods den Knoten verlassen müssen, wendet Kubernetes bestimmte Quality-of-Service-(QoS-)Regeln an:

 

  • Best-Effort: Wenn keine request– oder limit-Werte für alle Container eines Pods gesetzt sind, sind das die ersten Pods, die gestoppt werden.
  • Burstable: request und limit sind spezifiziert, aber unterschiedlich. Diese Pods haben kleinere Ressourcengarantien, und damit bessere Chancen, einen Platz im Cluster zu finden. Sie können jedoch bis zum limit wachsen. Pods dieser QoS-Klasse sind die nächsten, die heruntergefahren werden.
  • Guaranteed: request und limit sind beide spezifiziert und gleich groß. Damit wird garantiert, dass die Pods in ihrem Ressourcenverbrauch nicht weiter wachsen werden. D. h., sie können zwar nur schwer initial deployt werden (wenn z. B. request recht hoch ist), werden aber nur dann gestoppt, wenn keine Pods der QoS Klassen Burstable oder Best-Effort mehr auf dem Knoten sind.

 

Warum ist das für uns Java-Entwickler so wichtig? Aufgrund der QoS-Klassen, aber auch aus Gründen des Kapazitätsmanagements, ist es wichtig, alle Container mit Ressourcenlimits zu konfigurieren, um eine möglichst reibungslose Verteilung der Anwendungscontainer zu ermöglichen. Oft muss etwas mit den konkreten Grenzwerten experimentiert werden, da nicht von vornherein klar ist, wie der Ressourcenverbrauch sein wird. Auf jeden Fall aber sollten der JVM analoge Begrenzungen für den initialen und maximalen Heapspeicher mit -Xms und Xmx mitgegeben werden, die zu den Ressourcenlimits passen. Dabei ist zu beachten, dass der Heap-Speicher nur einen Bruchteil des gesamten Speichers einer JVM ausmacht. Erfahrungen haben gezeigt, dass der sogenannte Non-Heap Speicher 60 Prozent oder mehr des gesamten Speicherbereichs sein kann. Auch hier ist wieder Experimentieren angesagt. Das bereits erwähnte Start-up-Skript run-java.sh hilft hier bei einem initialen Set-up mit sinnvollen Defaultwerten.

 

Service Mesh

Eines der wichtigste Kubernetes-Designmuster ist das Sidecar-Pattern mit den Spezialisierungen Ambassador (oder Proxy) und Adapter. Bei einem Sidecar gibt es in einem Pod einen Hauptcontainer, der um ein oder mehrere Sidecar-Container erweitert wird. Diese Sidecars fügen der eigentlichen Applikation neue Funktionalität hinzu. Ein einfaches Beispiel wäre ein HTTP-Server wie Nginx als Hauptanwendung, der HTML-Seiten von einem Volume aus liefert. Ein Sidecar-Container könnte dann diese Seiten periodisch mit einem Github Repository abgleichen, sodass Änderung auf GitHub automatisch von dem laufenden HTTP-Server übernommen werden. Damit kann dem HTTP Service eine neue Funktionalität hinzugefügt werden, ohne dabei den Applikationscontainer anzupassen.

Insbesondere für Querschnittsfunktionalitäten wie Circuit Breaking, Security, Load Balancing oder Tracing ist dieses Muster sehr interessant. Der Sidecar-Container kapselt dabei die Zugriffe auf bzw. von der Außenwelt und kann sich transparent in die Netzwerkkommunikation einschalten. Diese Technik wird insbesondere gerne in einem Service Mesh eingesetzt. Dabei ist ein Service Mesh die Gesamtheit aller Dienste, die eine Anwendung ausmachen. Das bekannteste Tool zur Kontrolle eines solchen Service Mesh für Kubernetes ist sicherlich Istio [14], das Envoy [15] als Proxy-Komponente nutzt und folgende orthogonale Infrastrukturaspekte transparent bereitstellt:

 

  • Load Balancing zwischen Services
  • Circuit Breaking
  • Feingranulare API Policies für Zugriffskontrolle, Rate-Limits und Quotas
  • Service-Monitoring und Tracing
  • Erweitertes Routing zwischen den Services

 

Das Schöne an einer Service-Mesh-Unterstützung durch die Plattform für Entwickler von verteilten Microservices ist, dass Infrastrukturaspekte klar von der Geschäftslogik getrennt werden. Im Gegensatz zum direkten Einsatz der Tools aus dem Netflix-OSS-Stack wie Hystrix oder Ribbon, wird diese Funktionalität hier transparent von Istio bereitgestellt, sodass wir uns als Anwendungsentwickler nicht mehr mit Circuit Breaking oder clientseitiger Lastverteilung beschäftigen müssen.

 

Was bringt die Zukunft?

Tatsächlich sind Kubernetes-Aufsätze wie Istio Bestandteile des nächsten Evolutionschritts im Kubernetes-Kosmos. Das kürzlich vorgestellte Knative [16] baut auf Istio auf und erweitert Kubernetes unter anderem um einen Source-zu-Container-Entwicklungsworkflow und, vielleicht noch wichtiger, einen Baukasten für Serverless-Plattformen auf Kubernetes.

Knative build  [17] umfasst Komponenten zum Bauen von Images innerhalb eines Kubernetes-Clusters. Dieses Subsystem ist an Googles Cloud Build (früher: Google Container Builder) angelehnt und verfolgt ein vergleichbares Konfigurationskonzept. Der eigentliche Java Build ähnelt dem von OpenShifts S2I-(Source-to-Image-)Mechanismus, bei dem der Java-Sourcecode innerhalb des Clusters mit einem Build-Tool wie Maven kompiliert wird. Der Unterschied zu S2I besteht darin, das Knative build Docker multi-stage Builds benutzt, während S2I einen eigenen Lifecycle nutzt. Beide Ansätze haben aber jeweils das Problem, dass für Java alle Abhängigkeiten bei jedem Build erneut von einem Maven Repository geladen werden müssen. Es gibt Möglichkeiten, lokale oder nahegelegene Caches zu nutzen, aber das bedarf eines nicht zu unterschätzenden Extraufwands.

Knative serving [18] dagegen ist ein Projekt, das die Implementierung eines Severless Frameworks wesentlich vereinfachen wird. Dazu bietet es eine Möglichkeit, eine Anwendung komplett auf 0 Pods automatisch herunterzuskalieren und bei Eintreffen eines Request wieder aufzuwecken. Das wird durch einen Load Balancer realisiert, der permanent läuft und Anfragen entgegennehmen kann. Viele Softwarehersteller wie Pivotal oder Red Hat waren neben Google bei der Entstehung von Knative beteiligt und sind bereits dabei, Knative in ihre Produkte zu integrieren.

Nachdem wir zunächst die Containerrevolution mit Docker hatten und nun sich alle auf Kubernetes als den gemeinsamen Nenner für die Containerorchestrierung geeinigt haben, ist für die nahe Zukunft zu erwarten, dass Knative- oder Service-Mesh-Support im Allgemeinen die nächste Stufe auf unserem Weg zu Cloud-nativen Java-Anwendungen sein wird.

 

Cheat-Sheet: Die neuen JEPs im JDK 12


Unser Cheat-Sheet definiert für Sie, wie die neuen Features in Java 12 funktionieren. Von JEP 189 „Shenandoah“ bis JEP 346 „Promptly Return Unused Committed Memory from G1“ fassen wir für Sie zusammen, was sich genau ändern wird!

Cheat-Sheet sichern!

 

Links & Literatur

[1]: https://www.graalvm.org/

[2]: https://jira.spring.io/browse/SPR-16991

[3]: https://github.com/fabric8io-images/run-java-sh

[4]: https://github.com/fabric8io-images/java

[5]: https://github.com/fabric8io/docker-maven-plugin

[6]: https://github.com/fabric8io/fabric8-maven-plugin

[7]: https://github.com/GoogleContainerTools/jib

[8]: https://github.com/dsyer/spring-boot-thin-launcher

[9]: http://docs.wildfly-swarm.io/2.0.0.Final/#hollow-jar

[10]: https://azure.microsoft.com/en-us/resources/designing-distributed-systems/en-us/

[11]: https://leanpub.com/k8spatterns

[12]: https://stackoverflow.com/questions/6473320/get-dns-srv-record-using-jndi

[13]: https://github.com/spotify/dns-java

[14]: https://istio.io/

[15]: https://www.envoyproxy.io/

[16]: https://cloud.google.com/knative/

[17]: https://github.com/knative/build

[18]: https://github.com/knative/serving

The post Einmal als Container verpacken? – Java im Zeitalter von Kubernetes appeared first on JAX.

]]>
Mein Leben ohne Server – Serverless Tipps und Tricks https://jax.de/blog/container-serverless/mein-leben-ohne-server-serverless-tipps-und-tricks/ Mon, 19 Feb 2018 10:07:37 +0000 https://jax.de/?p=62500 Den Begriff Serverless hat zwischenzeitlich wohl jeder schon gehört. Niko Köbler leitet Sie ein in die Welt von Serverless inklusive Tipps und Tricks zu Anwendungsszenarien, Programmiermodellen, Debugging, Latenzen und Sicherheit.

The post Mein Leben ohne Server – Serverless Tipps und Tricks appeared first on JAX.

]]>
 

Amazon Web Services hat den Begriff Serverless [1] vor ziemlich genau drei Jahren, im November 2014, auf der Hausmesse re:invent, mit dem Dienst AWS Lambda eingeführt. Mittlerweile kann ich auf rund zwei Jahre Erfahrung mit dem Serverless-Ökosystem in der AWS Cloud zurückblicken. In dieser Zeit konnte ich diese noch sehr junge Technologie sehr erfolgreich einsetzen, habe aber auch die eine oder andere negative Erfahrung gemacht, die mich hat lernen lassen.

 

Serverless Computing Manifesto

  • Functions are the unit of deployment and scaling.
  • No machines, VMs, or containers visible in the programming model.
  • Permanent storage lives elsewhere.
  • Scales per request. Users cannot over- or under-provision capacity.
  • Never pay for idle (no cold servers/containers or their costs).
  • Implicitly fault-tolerant because functions can run anywhere.
  • BYOC – Bring Your Own Code.
  • Metrics and logging are a universal right.

 

Übrigens: das Serverless-Ökosystem besteht für mich nicht nur aus AWS Lamda. Lambda ist der typische Vertreter, wenn es um Function-as-a-Service geht, was nur ein Subset der Serverless-Welt darstellt. Auch Komponenten wie das API-Gateway, Datastorage wie z. B. S3 und DynamoDB, Messaging Services wie SQS und SNS, Streams wie Amazon Kinesis und viele weitere mehr gehören für mich zum Komplettpaket dazu (Abb. 1). Natürlich gibt es außer AWS noch andere Anbieter von Serverless-Diensten, in diesem Artikel beschränke ich mich jedoch ausschließlich auf meine Erfahrungen mit AWS und hier hauptsächlich auf den Dienst AWS Lambda. Die eine oder andere Erfahrung lässt sich aber durchaus ebenso auf andere Umgebungen übertragen.

 

Abb. 1: Serverless-Bausteine in AWS

 

Anwendungsszenarien

Sehr oft höre ich die Frage, in welchen Szenarien oder wofür man Serverless am besten einsetzen kann. Diese Frage ist gleichermaßen einfach wie auch schwer zu beantworten. Da Serverless keine neue Technologie an sich ist, sondern nur eine weitere Abstraktions-, Virtualisierungs- oder Isolationsebene darstellt, kann damit im Grunde alles gemacht werden, was bislang auf anderen Plattformen ausgeführt wurde.

Ein komplette Webanwendung auf Serverless-Komponenten aufzubauen ist natürlich möglich (Abb. 2). In diesem Kontext wird man statische Ressourcen vielleicht in Amazon S3 ablegen, dynamische Inhalte über AWS Lambda mit DynamoDB verwalten, über das API-Gateway den Lambdafunktionen ein API geben, CloudFront für das CDN (Content Delivery Network) verwenden und die unterschiedlichen AWS-Backend-Domains mit Route 53 einheitlich über einen eigenen Domainnamen publizieren. Das ist kein Problem, wird aber sehr schnell sehr komplex, da viele unterschiedliche Services beteiligt sind und viele einzelne, kleine Ressourcen verwaltet werden müssen. Eine gute und aktuelle(!) Dokumentation der beteiligten Komponenten ist unerlässlich. In der AWS-Welt können die AWS Simple Icons [2] dabei helfen, aussagekräftige Architekturdiagramme zu zeichnen.

 

Abb. 2: Serverless-Webarchitektur

 

Die eigentlichen Stärken von Serverless liegen in der ereignisgetriebenen Verarbeitung von Daten. Ereignisse, die von anderen AWS-Diensten ausgesendet werden (Amazon S3: ein neues Objekt wird abgelegt, verändert oder gelöscht, DynamoDB: Datensätze werden eingefügt, geändert oder gelöscht etc.) oder Daten in Form von Ereignissen, die über AWS-Dienste gesendet werden, wie Nachrichten über SQS Queues oder SNS Topics oder (Echtzeit-)Datenströme (Streams) über Amazon Kinesis (Abb. 3). Die Dienste sind fast allesamt selbst Serverless-Ressourcen und kommunizieren untereinander asynchron über Events, ein typisches Merkmal für verteilte (Micro-)Services. Wenn ein Dienst ein empfangenes Ereignis nicht direkt selbst verarbeiten kann, kann der Entwickler eine Lambdafunktion dazwischenschalten, die die Aufgabe der Datenverarbeitung oder -transformation übernimmt. Auf diese Art ist es sehr einfach, große Datenmengen, die z. B. über Kinesis empfangen werden, in kleinen Batches von beispielsweise 100 Events jeweils von Lambdafunktionen zu verarbeiten und dann in einem anderen Dienst zu persistieren. Auch die MapReduce- Verarbeitung von bereits bestehenden (Massen-)Daten, etwas aus Amazon S3, ist für Serverless ein geeigneter Use Case (Abb. 4).

 

 


Abb. 3: Serverless-Processing mit Serverless-Komponenten

 

Wenn Serverless nichts anderes ist als eine weitere Abstraktionsschicht auf vorhandenen Ressourcen oder Plattformen, kann man schnell zu der Aussage kommen, dass Serverless nichts anderes als Platform as a Service (PaaS) ist. Das ist grundsätzlich nicht falsch. Serverless basiert letztendlich auf einer vom Cloud-Anbieter verwalteten und als Dienst angebotenen Plattform, ist als logische Evolution von Paas zu sehen und damit eine spezielle Form dieser Umgebung. Zwischen einer klassischen PaaS und Serverless gibt es vor allem zwei Unterschiede: Einerseits sind in der Serverless-Welt die Artefakte und Ressourcen, die in Betrieb gebracht werden, deutlich kleiner. Es werden beispielsweise keine ganzen Anwendungen oder Container mehr deployt, sondern nur noch einzelne Ressourcen in der Größe einer Funktion. Oder es wird kein komplettes Messaging-System betrieben, sondern es werden nur einzelne Queues genutzt, die bereits als Dienst vorhanden sind. Andererseits muss sich der Entwickler bei einer PaaS um die Grenzen der Verfügbarkeit und Skalierung noch selbst kümmern. Er muss sie einstellen, überwachen und gegebenenfalls anpassen. Bei Serverless passiert dies implizit mit dem anfallenden Request-Aufkommen. Eine Über- und Unterprovisionierung der Umgebung ist somit keine Gefahr mehr, da nur die Ressourcen allokiert werden, die wirklich benötigt werden. Gleichzeitig basiert hierauf auch das Abrechnungsmodell: Es wird nur das bezahlt, was wirklich verwendet wurde. Leerlaufzeiten eines Systems, einer ganzen Plattform werden nicht abgerechnet.

Natürlich muss sich irgendjemand um diese Umgebung, um diese Plattform kümmern. Das können (und sollten) wir bei Serverless aber denjenigen überlassen, die das können und sich ausschließlich um die kosteneffiziente Bereitstellung solcher Services kümmern: den Serverless-Cloud-Anbietern. Der Aufwand, eine auch nur annähernd ähnliche Umgebung On-Premise, also bei sich selbst im eigenen Rechenzentrum, aufzubauen, sollte nicht unterschätzt werden. Nur weil man einen Kubernetes-Cluster [3] auf ein paar (begrenzten) Maschinen betreibt, hat man noch keine private Cloud im eigenen Rechenzentrum. Da gehört mehr dazu.


Abb. 4: Serverless-MapReduce-Architektur

Programmiermodell

Die Ablaufumgebung – ein Container – einer Lambdafunktion ist flüchtig. Nachdem eine Funktion ausgeführt wurde, ist deren Umgebung nicht mehr vorhanden. Das stimmt natürlich nur zur Hälfte, da AWS den Container für eine eventuelle erneute Ausführung der Funktion im Cache behält. Dieses Caching ist allerdings nicht definiert und es kann sein, dass der Container innerhalb weniger Minuten komplett gelöscht wird oder aber über eine längere Zeit „lebt“. Diese Steuerung des Cache ist einerseits davon abhängig, wie oft die Funktion generell aufgerufen wird (wenn AWS feststellt, dass die Funktion häufig verwendet wird, wird sie potenziell länger gecacht). Andererseits spielt die Auslastung der Availability Zone (AZ, entspricht bei AWS einem autarken Rechenzentrum), in der der Container existiert, eine Rolle. Einer Lambdafunktion kann man nämlich nur die Region (bei AWS den physischen Standort der AZs) angeben, in der sie ausgeführt werden soll. AWS entscheidet dann selbstständig, in welcher AZ die Funktion letztendlich gestartet wird. Somit kann AWS eine gleichmäßigere Lastverteilung über alle in einer Region verfügbaren AZs erreichen.

Mit diesem Caching kann der Entwickler im Programming Model also arbeiten, er darf sich nur nicht darauf verlassen. So werden beispielsweise alle erzeugten und instanziierten Objekte in einem Funktionsartefakt ebenfalls gecacht, wenn der gesamte Container zwischengespeichert wird. Einzig die direkt durch Lambda aufgerufene Handler-Funktion (in Java-Umgebungen die aufgerufene Methode) wird verworfen und bei einer erneuten Ausführung der Funktion neu durchlaufen. Objekte sollten für eine Wiederverwendung also immer außerhalb der eigentlichen Handler-Funktion gespeichert werden (z. B. als Instanzvariablen) und nie ohne Überprüfung, ob schon eine Instanz des Objekts existiert, erzeugt werden. Möglicherweise existiert das Objekt bereits und lässt sich wiederverwenden. Das spart eine nicht unerhebliche Menge an Zeit bei der Ausführung des Funktionscodes.

In AWS Lambda ist es möglich, eigene Prozesse in der Ablaufumgebung zu starten. Man sollte aber darauf achten, dass diese Prozesse beendet werden, bevor die Handler-Methode beendet ist. Werden die Prozesse asynchron aufgerufen, ist das eine nicht ganz triviale Sache. Der Hintergrund ist der, dass durch die Funktion erzeugte Prozesse, die bei der Beendigung der Funktion selbst noch nicht beendet sind, ebenfalls gestoppt werden. Wird der Container nun gecacht und kommt es zu einer Wiederverwendung dieses Containers, läuft der vormals gestartete Prozess an genau dem Punkt weiter, an dem er pausiert wurde, bevor der Container in den Ruhezustand ging. Das kann zu unvorhergesehenen und unerwünschten Nebenwirkungen und Seiteneffekten führen. Hier also besser aufpassen, falls jemand mit selbst gestarteten Prozessen hantiert.

In Node.js- und JavaScript-Funktionen werden die für die asynchrone Ausführung auf den Callstack gelegten Callback-Funktionen jedoch noch alle ausgeführt, bevor der Container gestoppt wird, auch wenn die eigentliche Handler-Funktion bereits abgearbeitet ist. Dieses Verhalten lässt sich im Context-Objekt der Funktion mit der Property callbackWaitsForEmptyEventLoop beeinflussen. Per Default steht das Property auf true, kann aber auf Wunsch auf false gesetzt werden. Dann verhält sich die Callback-Loop so, dass die asynchronen Funktionen, die zum Zeitpunkt der Beendigung der Handler-Funktion noch nicht ausgeführt wurden, erst beim nächsten Aufruf des Containers zur Ausführung kommen. Auch hier sind unerwünschte Seiteneffekte möglich!
 

Der Clouds, Container & Serverless Track auf der W-JAX 2018

 

Fehlerbehandlung und Debugging

Wie oben geschrieben, liegt die eigentliche Stärke von Serverless in der Bearbeitung von Daten(-strömen). Funktionen, die in solchen Pipelines verwendet werden, werden fast ausschließlich asynchron aufgerufen. AWS Lambda hat die Eigenschaft, dass asynchrone Funktionen im Fehlerfall, egal welche Ursache, falls nicht anders konfiguriert, bis zu zwei Mal mit zeitlichem Abstand wiederholt werden. Kann die Funktion auch nach den Wiederholungen nicht erfolgreich ausgeführt werden, wird sie verworfen, mitsamt dem aufrufenden Ereignis, das die zu verarbeitenden Daten enthält. Damit existiert erst einmal keine Nachvollziehbarkeit des Fehlers und die Daten sind gegebenenfalls unwiederbringlich verloren. Abhilfe schafft hier die Verwendung von Dead Letter Queues (DLQ). Jeder Lambdafunktion kann eine DLQ für den Fehlerfall zugewiesen werden. Die DLQ kann entweder eine SQS Queue oder ein SNS Topic sein. Ereignisse (und die in den Events enthaltenen Daten), die zu einer fehlerhaften Ausführung der Funktion geführt haben, werden nach allen erfolglosen Versuchen inklusive der Fehlerinformation von Lambda in die angegebene DLQ gesendet. Auf diese Nachrichten kann dann wieder reagiert werden. Entweder verarbeitet eine andere Lambdafunktion die Fehler-Events automatisch (wenn möglich), oder die Events werden automatisch per E-Mail verschickt. Hier sind viele Varianten möglich, auch dass ein Admin sich die Fehler-Events manuell abruft und entscheidet, was damit geschehen soll. Hauptsache, die Ereignisse gehen nicht verloren und können für eine nachgelagerte Verarbeitung genutzt werden.

Erfolgreiche Unterstützung bei der Fehlersuche und beim Debugging von AWS-Komponenten bietet der Dienst AWS X-Ray. Er zeichnet alle Requests auf, die durch entsprechend aktivierte Ressourcen aufgerufen werden bzw. diese durchlaufen. Ein Request oder Event lässt sich dabei von Anfang bis Ende durch alle beteiligten Ressourcen hindurch verfolgen. Die genaue Fehlerlokation wird ebenfalls entsprechend grafisch und farbig dargestellt. Wichtig zu wissen ist, dass X-Ray primär für die Echtzeitdarstellung der Request Flows gedacht ist und aktuell nur Daten bis sechs Stunden in die Vergangenheit speichert und darstellen kann. Wer ältere Fehler analysieren möchte, muss sich eine andere Lösung einfallen lassen.

 

Latenzen

Latenzen, speziell Start-up-Latenzen von Lambdafunktionen, sind immer wieder Gegenstand vieler Diskussionen. Es wird dabei gerne behauptet, dass sich Serverless-Funktionen ja nicht produktiv einsetzen ließen, da die hierbei anfallenden Latenzen kontraproduktiv seien. Dem ist aber nicht so. Werfen wir einen Blick auf den bereits mehrfach angesprochenen Einsatzzweck für eine asynchrone Datenverarbeitung, so spielen geringe Latenzen nur eine untergeordnete Rolle, da kein Anwender auf das Ergebnis direkt wartet. Die Berechnungen werden in einem anderen Dienst abgelegt und dort zur Ausgabe oder Anzeige aufbereitet. Ob dies nun ein paar Sekunden früher oder später geschieht, ist unerheblich.

Ist eine Lambdafunktion bereits einmal ausgeführt worden, wird der Container für eine Wiederverwendung gecacht. Kommt es nun zu einer erneuten Verwendung des bereits bestehenden, nur pausierten Containers, steht dieser deutlich schneller für die Funktionsausführung zur Verfügung, da er ja nicht mehr erzeugt werden muss und eine möglicherweise benötigte JVM-Instanz bereits gestartet ist. Ebenso müssen die darin befindlichen Objekte, wenn richtig implementiert, nicht neu instanziiert werden und lassen sich wiederverwenden.

Wird eine Lambdafunktion also häufig verwendet, sind die Start-up-Latenzen, die lediglich beim initialen Ausführen und Erzeugen von Container- und Programmobjekten auftreten, im Verhältnis zu den übrigen Ausführungen deutlich in der Unterzahl und damit im Gesamtkontext zu vernachlässigen. Es gibt eine Stellschraube für Lambdafunktionen, mit der Latenzen und andere zeitkritische Parameter beeinflusst werden können. Dies ist der für die Funktion zur Verfügung stehende Arbeitsspeicher. Mit der Angabe des Speichers verändert sich nämlich nicht nur dieser, sondern auch die zur Verfügung stehende CPU-Rechenleistung und die Netzwerkbandbreite. Mehr Speicher heißt auch mehr von den anderen Ressourcen. Damit kann eine Funktion, die mehr Speicher und damit mehr CPU-Leistung zugewiesen bekommt, ihren Programmcode schneller initiieren und steht früher für die eigentliche Verarbeitung des Events zur Verfügung. Die Start-up-Latenz verringert sich. Auch wenn die Funktion selbst eigentlich nicht viel Arbeitsspeicher benötigt, kann es sinnvoll sein, diesen nach oben anzupassen, um eben von einer geringeren Latenz zu profitieren. Das wirkt sich direkt auf die anfallenden Kosten aus. Die Rechenzeit wird in GB-Sekunden abgerechnet. Weist man einer Funktion einen Arbeitsspeicher von 512 MB zu und diese wird 1 Sekunde lang ausgeführt, so hat man 0,5 GB-Sekunden Rechenzeit verbraucht.

Ein Rechenbeispiel: eine Funktion hat 256 MB Arbeitsspeicher zugewiesen und benötigt regelmäßig etwa vier Sekunden Ausführungszeit. Dies ergibt eine Rechenzeit von 1 GB-Sekunde. Kann nun durch eine Veränderung des Arbeitsspeichers auf 512 MB die regelmäßige Ausführungszeit auf eine Sekunde reduziert werden, so werden nur noch 0,5 GB-Sekunden Rechenzeit benötigt und damit nur diese abgerechnet. Obwohl also mehr Arbeitsspeicher zugewiesen wurde und die Kosten des Lambdaservice sich vordergründig am Arbeitsspeicher orientieren, ist die in Rechnung gestellte Rechenzeit letztendlich geringer. Damit ist es letztendlich günstiger, als weniger Arbeitsspeicher zuzuweisen. Ein Parameter, an dem es sich lohnt, länger zu experimentieren, verschiedene Einstellungen gegenüberzustellen und zu vergleichen. Bei entsprechend vielen Funktionsaufrufen und summierter Rechenzeit kann sich das im Monat schon mal lohnen. Hilfe bietet hier ein spezieller Serverless-Rechner [4].

Eine offizielle Empfehlung von AWS ist übrigens, dass Lambdacontainer mit zeitgesteuerten Events (Cron-Events) aus dem CloudWatch-Dienst warmgehalten werden können, wenn diese regelmäßig, beispielsweise alle fünf Minuten, ein Keep Alive Event gesendet bekommen. Funktionen, die nur selten aufgerufen werden, aber eine hohe Start-up-Latenz haben, können damit im Cache gehalten und die Latenz kann verringert werden. Gerade bei synchron aufgerufenen Funktionen, etwa über das API-Gateway, macht sich das positiv bemerkbar. Auch mit öfter aufgerufenen, dadurch aber insgesamt weniger Rechenzeit verbrauchenden Funktionen (weil weniger initiale Start-up-Zeiten) und gegebenenfalls weniger Speicher benötigenden Funktionen können letztendlich Kosten gespart werden. Auch wenn es auf den ersten Blick nicht so und zudem merkwürdig erscheint.

Nicht nur Java-basierte Funktionen haben eine bemerkenswerte Start-up-Latenz, auch Funktionen, die auf Node.js (JavaScript) basieren, können beim ersten Start durchaus ein bis drei Sekunden brauchen, bis sie an die eigentliche Funktionsausführung kommen, je nach Implementierung und Umfang der mit deployten Bibliotheken. Generell ist eine skriptbasierte Sprache schneller in der ersten, initialen Ausführung als kompilierter Java-Code, der eine JVM-Umgebung benötigt. Python ist ebenfalls eine interpretierte Skriptsprache und gewinnt gerade in der Datenverbeitung von IoT oder Machine-Learning-(ML-)Anwendungen wieder zunehmend an Popularität. Die Sprache Go [5] stellt eine interessante Alternative für Funktionen dar, ist sie doch statisch getypt und liegt als kompilierter Bytecode vor, benötigt aber so gut wie keine Start-up-Zeit durch eine VM o. ä. Go steht derzeit (Mitte November 2017) aber noch nicht direkt als Ablaufumgebung für AWS-Lambda-Funktionen zur Verfügung.

 

Grenzen

Auch wenn der Begriff Serverless impliziert, dass alles ohne Server läuft und damit vielleicht grenzenlos erscheint, gibt es durchaus wichtige Grenzen im Serverless-Universum, die Entwickler kennen sollten. Je nach Dienst können es sehr viele, granulare Grenzen sein, die einem erfolgreichen Einsatz von Serverless Computing zunächst im Wege stehen können. So ist z. B. bei Lambda der Default-Timeout für eine Funktion auf drei Sekunden eingestellt. Passt der Entwickler ihn nicht an, kann es durchaus passieren, dass eine Funktion gar nicht zur eigentlichen Ausführung kommt. Der Timeout schlägt vor der Codeausführung zu und die Funktion weist eine hohe Start-up-Latenz auf. Gleichzeitig kann eine Lambdafunktion aber maximal fünf Minuten lang laufen, danach wird sie von AWS hart terminiert. Lambda eignet sich also nicht für langlaufende Batch-Verarbeitungen, hierfür gibt es den Dienst AWS Batch (der nicht zu den Serverless-Diensten zählt).

Ganze Applikationskontexte, wie eine Java-EE- oder Spring-Anwendung, sind nicht wirklich sinnvoll mit AWS Lambda zu betreiben (wenngleich möglich [6], [7]). Die (auch bei hoher Speicherallokation) benötigte Start-up-Zeit steht nicht im Verhältnis dazu, dass der Container nach fünf Minuten wieder abgebrochen wird. Es gibt durchaus Unternehmen, bei denen schlägt die Grenze von 1 000 gleichzeitig ausführbaren Lambdafunktionen zu. Wenn man eine große Serverless-Landschaft betreibt und gleichzeitig in dem jeweils selben AWS-Account Produktions-, Test- wie auch Entwicklungsumgebung hat, kann das schon mal vorkommen. Diese Grenze ist allerdings nur eine Vorgabegrenze und lässt sich über den AWS-Support erweitern. Funktionen, die direkt mit einem Ereignis aufgerufen werden und durch die maximale Anzahl an gleichzeitigen Lambdafunktionen nicht ausgeführt werden können, werden abgebrochen und als fehlerhafter Aufruf gezählt. Das initiierende Ereignis wird nicht für eine spätere Ausführung aufbewahrt. Hierfür muss der Entwickler selbst sorgen. Das kann beispielsweise dadurch geschehen, dass Lambdafunktionen nicht direkt von Ereignissen anderer Dienste aufgerufen werden, sondern alle Events zuvor ein eine SQS-Queue geschrieben und damit dann Lambdafunktionen aufgerufen werden. Der Lambdadienst startet dann maximal 1 000 Funktionen gleichzeitig, und die nicht gleichzeitig zu verarbeitenden Ereignisse verbleiben in der Queue bis zu ihrer Verarbeitung. Auf diese Weise gehen keine Daten verloren.
 

Free: Mehr als 40 Seiten Java-Wissen


Lesen Sie 12 Artikel zu Java Enterprise, Software-Architektur und Docker und lernen Sie von W-JAX-Speakern wie Uwe Friedrichsen, Manfred Steyer und Roland Huß.

Dossier herunterladen!

 

Sicherheit

Alle Lambdafunktionen laufen im Standard in einer eigenen VPC (Virtual Private Cloud [8]), so etwas wie ein eigenes Netzwerk. In dieser VPC sind Verbindungen zu öffentlichen Adressen im Internet über HTTP offen, es gibt keine Beschränkungen für einzelne Hosts. Von außen kann nicht auf diese VPCs zugegriffen werden, hierfür ist kein Port verfügbar. Zudem ist ein Angriff von außen schon deswegen schwierig, da die Funktion und der Container selbst ja nur dann aktiv sind, wenn gerade ein Ereignis verarbeitet wird, also kein dauerhafter Zugriff möglich ist. Weiterhin ist der dedizierte Host, auf dem der Container betrieben wird, nach außen nicht bekannt und kann von Ausführung zu Ausführung unterschiedlich sein.

Soll die Lambdafunktion auf Ressourcen innerhalb eigener VPCs zugreifen können, so kann der Entwickler die entsprechende VPC der Funktion zuweisen. Allerdings muss er dann bedenken, dass ein Zugriff auf andere Funktionen als in der dann verwendeten VPC nicht mehr möglich ist. Lässt die eigene VPC beispielsweise keinen Zugriff auf öffentliche Adressbereiche zu, wird auch die Lambdafunktion nicht darauf zugreifen können. Das kann gerade in Bezug auf die Nutzung von anderen (öffentlichen) AWS APIs (z. B. für DynamoDB, SNS, SQS etc.) problematisch werden. Diese Dienste haben ja öffentliche API-Endpunkte. Abhilfe kann hier ein NAT-Routing in der verwendeten VPC schaffen, sodass eine Lambdafunktion darüber auf öffentliche Endpunkte zugreifen kann.

Wird ein API-Gateway verwendet, ist natürlich hier auf die entsprechend sichere Konfiguration zu achten. So lassen sich etwa die Gesamtzugriffe pro Sekunde begrenzen, um die Auswirkungen von potenziellen Denial-of-Service-Attacken (DoS) zu minimieren. Falls eine Authentifizierung und Autorisierung notwendig sind, müssen diese mit geeigneten Mitteln konfiguriert oder implementiert werden. Im Amazon API Gateway steht hierfür eine Authentifizierung gegenüber dem AWS-internen IAM (Identity & Access Management) und selbst zu definierenden Userpools mit Amazon Cognito [8] zur Verfügung. Wem das nicht ausreicht, der kann eine Lambdafunktion als Custom Authorizer in das API-Gateway einhängen. Wie ein Custom Authorizer für die Überprüfung eines JSON Web Tokens aussieht, zeigt der jwtAuthorizr [9].

Ebenso kann die Steuerung von Zugriffen auf das API über API-Keys und -Kontingente vor unberechtigten oder überzähligen Aufrufen auf das öffentliche API schützen. Dabei ist es möglich, unterschiedliche Kontingente auf verschiedene API-Keys zu verteilen, sodass z. B. jeder Kunde bzw. Nutzer des APIs nur die Aufrufe durchführen darf, für die er bezahlt hat. Kontingente sind einstellbar auf Zugriffe pro Sekunde (für eine Begrenzung der gleichzeitigen Aufrufe) und eine Gesamtanzahl an Aufrufen pro Tag, Woche oder Monat.

 

Testen

Funktionen sind sehr einfach zu testende Einheiten, in Form von Unit-Tests also unproblematisch zu handhaben. Externe Abhängigkeiten und APIs werden entsprechend gemockt, und der Code kann auf korrekte Funktionalität getestet werden. Sobald man aber an Integrationstests kommt und in seiner Funktion auf Cloud-Ressourcen und -Dienste zugreift, muss man diese Ressourcen direkt ansprechen. Das kann zu Latenzen durch Netzwerklaufzeiten führen und zudem die Kosten für die Cloud-Ressourcen beeinflussen. Schließlich werden diese Ressourcen ja genutzt. Nur weil es sich hierbei um Tests handelt, rechnet AWS nicht nichts ab. Man kann aber auch eine Lösung finden, die die Cloud-APIs in der eigenen, lokalen Umgebung offline zur Verfügung stellt. Diese kann beispielsweise LocalStack [10] sein, das fast das komplette AWS-API in einer Docker-Umgebung für eine lokale Offlinenutzung mockt. AWS hat mit AWS SAM Local [11] eine ähnliche, jedoch etwas eingeschränktere Möglichkeit im Portfolio.

 

Fazit

Serverless Computing bringt zwar keine neue, eigenständige Technologie mit, verlangt jedoch ein eigenes Verständnis für den Umgang mit Cloud-Ressourcen. Wer einfach so weitermacht wie bisher und seine Anwendung einfach nur woanders hin deployen möchte, um Geld in der Cloud zu sparen, wird schnell enttäuscht werden und wahrscheinlich letztendlich sogar auf höheren Kosten sitzen bleiben. Serverless Computing stellt eine sehr evolutionäre Form des Cloud-Computings dar. Der Entwickler kann damit sehr effizient und gleichermaßen effektiv hochskalierbare, elastische und flexible Anwendungen der unterschiedlichsten Art realisieren. Er muss sich nur darauf einlassen. Auch auf den potenziellen Lock-in des Cloud-Anbieters. Wo gibt es heute noch eine wirkliche Anbieterunabhängigkeit? Selbst ein Kubernetes-Cluster bringt den Lock-in auf Kubernetes. Man ist zwar unabhängig von einem Cloud-Anbieter, aber auf Kubernetes angewiesen. Möglicherweise hat man Angst vor steigenden und dadurch möglicherweise explodierenden Kosten, da der Cloud-Anbieter durch diese Abhängigkeit seine Preise erhöhen könnte. Fakt ist aber, dass die großen Cloud-Anbieter ihre Kosten in den letzten Jahren ausschließlich gesenkt haben, nicht erhöht.

Serverless ist die logische Evolution von Virtualisierung und Containern und wird in Zukunft eine große Rolle spielen. Dennoch wird Serverless nicht, wie auch alle anderen Technologien und Paradigmen zuvor, die Silver Bullet für alle unsere Probleme werden. „Use the right tool for the right job“ wird es auch hier heißen. Serverless ist ein weiteres Werkzeug im Portfolio des heutigen Entwicklers. Aber je mehr Werkzeuge wir haben, desto besser können wir arbeiten. Die Zeit, in der wir nur den Hammer verwenden mussten, ist längst vorbei.

 

Links & Literatur

[1] Serverless: https://aws.amazon.com/serverless/
[2] AWS Simple Icons: https://aws.amazon.com/architecture/icons/
[3] Kubernetes: https://kubernetes.io/
[4] Serverless-Rechner: http://serverlesscalc.com
[5] Spring-Anwendungen mit AWS Lambda: https://github.com/serverlessbuch/lambda-spring
[6] Java-EE-Anwendungen mit AWS Lambda: https://github.com/serverlessbuch/lambda-jaxrs-cdi
[7] Virtual Private Cloud: https://aws.amazon.com/vpc/
[8] Amazon Cognito: https://aws.amazon.com/cognito/
[9] jwtAuthorizr: https://github.com/serverlessbuch/jwtAuthorizr
[10] Localstack: https://localstack.cloud/
[11] AWS SAM Local: https://github.com/awslabs/aws-sam-local

 

Erfahren Sie mehr über Serverless auf der W-JAX 2018:


● Container vs. Serverless – the Good, the Bad and the Ugly
● Kubernetes Patterns

The post Mein Leben ohne Server – Serverless Tipps und Tricks appeared first on JAX.

]]>