Anwendungen in Container packen und auf Kubernetes betreiben

Java im Zeitalter von Kubernetes

Roland Huß

©Shutterstock / vectorpouch

Java ist die dominierende Programmiersprache für Geschäftsanwendungen. Wie ein Fels in der Brandung trotzte es den Stürmen der letzten zwanzig Jahre. Die beiden letzten großen Wellen – Microservices und Containerisierung – sind jedoch gerade dabei, die Art und Weise, wie wir unsere Java-Anwendungen betreiben, radikal zu verändern.

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 Startup-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 „Microservices mit Docker – a perfect Match!“ sowie „Kubernetes kurz und knapp“. 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.

Microservices mit Docker – a perfect Match!
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 kleinen 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.

W-JAX 2019 Java-Dossier für Software-Architekten

Kostenlos: Java-Dossier für Software-Architekten 2019

Auf über 30 Seiten vermitteln Experten praktisches Know-how zu den neuen Valuetypes in Java 12, dem Einsatz von Service Meshes in Microservices-Projekten, der erfolgreichen Einführung von DevOps-Praktiken im Unternehmen und der nachhaltigen JavaScript-Entwicklung mit Angular und dem WebComponents-Standard.

 

Kubernetes kurz und knapp

Wer eine Legion von Containern betreiben will, 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 verschiede 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 den 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 Ressource 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 Netzwerkkonguration 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 Balancers 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 ReplicaSets. Dieses ReplicaSet ist wiederum eine Ressource, die eine Pod-Beschreibung enthält und spezifiziert, wie viele Kopien dieses Pods 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 dieselbe: 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.

Zum Glück gibt es Initiativen wie GraalVM von Oracle, die hier aufzuholen versuchen. In unserem Zusammenhang wollen wir uns aber insbesondere die 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.

Bis sich diese neuartigen JVMs in Produktion einsetzen lassen, wird es noch einige Zeit dauern. Die produktiv am häufigsten eingesetzten Java VMs 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 Ressourcen 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 Dock er 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 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 die 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, das unabhängig von der verwendeten Java-Version sinnvolle Defaultwerte setzen kann. Die fabric8-Java-Basis-Images 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 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 Integrationstests recht nützlich ist.

Das fabric8-maven-plugin, das auf dem dockermaven-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 Basis-Images 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, das 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“ (Java Magazin 11.2018) wird dieses Plug-in anhand eines Beispiels ausführlich vorgestellt.

Eine weitere interessante Build-Integration mit Maven und Gradle bietet Jib. 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 Image 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 Daemons bedarf. Dabei werden alle Image-Schichten 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 hat, 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 eigentliche 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, sodass 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 eine alternative Paketierung, ebenso wie die Hollow Jars 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 XMLKonfiguration 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 Build-Integrationen 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 (Designing Distributed Systems, Kubernetes Patterns) 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 erfolgen, was in der Praxis aber etwas hakelig ist; einfacher ist die Verwendung einer dezidierten Library wie spotify/dns-java wie in 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 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“ 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 Konfigurations-Images für die einzelnen Umgebungen (Immutable Configuration) dar. Diese Konfigurations-Images können direkt mithilfe von Docker-Build-Parametern parametrisiert werden, sodass sich auch der Wartungsaufwand in Grenzen hält. Auch hier spielt ein Init-Container eine wichtige Rolle. Dieser verwendet das Konfigurations-Image 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 Konfigurations-Images 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 Image-Versionierung 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).

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 ganze 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 Heap-Speicher 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 Änderungen 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 gerne in einem Service Mesh eingesetzt. Ein Service Mesh die Gesamtheit aller Dienste, die eine Anwendung ausmachen. Das bekannteste Tool zur Kontrolle eines solchen Service Mesh für Kubernetes ist Istio, das Envoy 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 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 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 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 Requests 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 sich nun 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.

Geschrieben von
Roland Huß
Roland Huß
Roland Huß ist ein Software Engineer, der für Red Hat an fabric8, einer Microservices-Plattform für Kubernetes und OpenShift arbeitet. Er entwickelt seit fast 20 Jahren, hat aber niemals seine Wurzeln als Systemadministrator vergessen. Roland arbeitete aktiv in Open-Source-Projekten mit und betreut sowohl Jolokia, die JMX-HTTP Bridge als auch das populäre Docker-Maven-Plug-in fabric8io. Und er liebt Chilis.
Kommentare

Hinterlasse einen Kommentar

1 Kommentar auf "Java im Zeitalter von Kubernetes"

avatar
4000
  Subscribe  
Benachrichtige mich zu:
Kai Gülzau
Gast

„Spring-Boot-, Thorntail- und Java-EE-Anwender schauen also erst einmal in die Röhre, …“
Warum? Es gibt doch sogar extra ein Beispiel, wie man In mit Spring Boot benutz:
https://github.com/GoogleContainerTools/jib/tree/master/examples/spring-boot