Container & Serverless - JAX https://jax.de/blog/container-serverless/ Java, Architecture & Software Innovation Fri, 18 Oct 2024 13:11:57 +0000 de-DE hourly 1 https://wordpress.org/?v=6.5.2 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.

]]>