#JAVAPRO #Container #CloudNative

Cloud-Größen wie Google, Twitter und Netflix haben die Kern-Bausteine ihrer Infrastruktur quelloffen verfügbar gemacht. Das Resultat aus vielen Jahren Cloud-Erfahrung ist nun frei zugänglich, jeder kann selbst Cloud-Native-Anwendungen entwickeln – Anwendungen, die in der Cloud zuverlässig laufen und fast beliebig skalieren. Die Bausteine wachsen zu einem großen Ganzen zusammen: dem Cloud-Native-Stack. Dieser Artikel stellt wichtige Konzepte und Schlüssel-Technologien vor, und beschreibt wie eine Cloud-Native-Anwendung schrittweise auf diesem Stack zur Ausführung gebracht wird.

Einführung und Motivation

Moderne Geschäftsmodelle verlassen sich heutzutage immer mehr auf leistungsstarke und zuverlässige IT-Systeme oder würden ohne diese erst gar nicht mehr funktionieren. Mit immer neuen, innovativen Ideen und Dienstleitungen wird versucht Kunden an sich zu binden. Das Resultat sind eine stetig steigende Anzahl an Usern, Endgeräten, Verbindungen, Traffic und Daten die von heutigen Systemen verarbeitet werden wollen. Man spricht von Hyperscale. Damit diese Geschäftsmodelle nicht von Fehlern und Systemabstürzen gefährdet werden, ist die Antifragilität solcher Systeme essentiell. Und auch die Konkurrenz schläft nicht. So müssen die neuen Ideen und Features in immer kürzeren Zyklen entwickelt und in Betrieb gebracht werden. Continuous-Delivery und DevOps spielen in diesem Zusammenhang eine wichtige Rolle.

Klassische Betriebsmodelle und Applikationen scheinen wenig geeignet zu sein diesen hohen Anforderungen gerecht zu werden. Der Trend geht klar in Richtung Cloud-nativer Applikationen, also Anwendungen, die in der Cloud zuverlässig laufen und fast beliebig skalieren. Doch wie werden solche Systeme
gebaut und mit was werden sie betrieben? Diesen Fragen geht dieser Artikel im Folgenden nach.

Die sieben Design Prinzipien

Cloud-nativer Anwendungen Cloud-Native-Anwendungen müssen zwingend anders entworfen und gebaut werden als bisherige Web-Applikationen. Denn „Everything fails, all the time“ (Werner Vogels, Amazon CTO). Entwickler und Architekten sollten diesen Zustand in der Cloud als Normalfall akzeptieren und nicht als Ausnahmefall behandelt. Auch die „Acht Irrtümer der verteilten Datenverarbeitung“ sind plötzlich aktueller denn je. Die folgenden sieben Design-Prinzipien sollten dringend von Beginn an berücksichtigt werden, damit die eigene Cloud-native Anwendung am Ende auch das hält was sie verspricht.

  • Design for Distribution: Die Anwendung ist API-getrieben mit Microservices entwickelt und kann in einem Container betrieben werden.
  • Design for Performance: Die Anwendung ist responsive, arbeitet in weiten Teilen asynchron und geht schonend mit den Ressourcen um.
  • Design for Resiliency: Die Anwendung ist tolerant gegenüber Fehlern und selbstheilend.
  • Design for Elasticity: Die Anwendung ist zustandslos und skaliert dynamisch in beiden Richtungen.
  • Design for Automation: Typische Dev- und Ops-Aufgaben lassen sich über geeignete Schnittstellen automatisieren.
  • Design for Delivery: Kurze Roundtrips während der Entwicklung und die automatisierte Provisionierung der Anwendung sind Pflicht.
  • Design for Diagnosability: Cluster-weite Logs, Metriken und Traces können zu Diagnosezwecken gesammelt und ausgewertet werden.

Die Anatomie des Cloud Native Stack

Cloud-Native-Anwendungen bringen also zusätzliche Komplexität beim Entwurf, der Umsetzung und beim Betrieb mit sich. Es braucht geeignete Abstraktionen um diese Komplexität leichter beherrschbar zu machen: den Cloud-Native-Stack. Der schematische Aufbau dieses Stacks ist in (Abb. 1) dargestellt. Auf der untersten Ebene befindet sich die Cluster-Virtualization. Diese Ebene entkoppelt von der physischen Hardware. Die Technologien auf dieser Ebene befassen sich mit der Bereitstellung virtueller Ressourcen, wie etwa Memory, Storage oder Netzwerk. Darüber sitzt der Cluster-Scheduler. Dieser kennt und verwaltet die virtuellen Ressourcen im Cluster. Der Scheduler führt einzelne Container aus, weist diesen die benötigten Ressourcen zu und kümmert sich dabei um deren faire und gleichmäßige Verteilung innerhalb des Clusters. Der Cluster-Orchestrator führt ganze Applikationen auf dem Cluster aus. Er arbeitet dabei eng mit dem Scheduler zusammen. Der Orchestrator ist für den kompletten Lebenszyklus einer Cloud-nativen Anwendung verantwortlich. Er überwacht den Zustand und stellt sicher, dass der gewünschte Systemzustand jederzeit gegeben ist. Die Application-Platform stellt die Ablaufumgebung, Infrastrukturbausteine sowie APIs
bereit gegen die eine Cloud-Native-Application programmiert wird.

Cloud Native Stack mit Docker, Kubernetes und Spring Boot. (Abb. 1)

Cloud Native Stack mit Docker, Kubernetes und Spring Boot. (Abb. 1)

Nun gibt es nicht nur diesen einen Cloud-Native-Stack. Der in (Abb. 1) dargestellte Stack ist lediglich einer von vielen möglichen Ausprägungen. Bei der konkreten Technologieauswahl helfen die Cloud-Native-Landkarten2 der Cloud Native Computing Foundation (CNCF) oder der QAware GmbH. Vorkonfektionierte Stacks wie OpenShift oder Cloud Foundry erfreuen sich zunehmender Beliebtheit, speziell im Enterprise Umfeld.

In vier Schritten zur cloud-nativen Anwendung

Die Entwicklung von Anwendungen auf dem Cloud-Native-Stack folgt einem einfachen Muster. Es müssen die folgenden vier Schritte durchlaufen werden (vgl. Abb. 2):

  1. Microservices: Die funktionalen Teile der Applikation werden als Microservices entworfen und entwickelt.
  2. Containerisierung: Die einzelnen Microservices werden in Containern verpackt und verteilt.
  3. Komposition: Die Anwendungsbausteine werden durch zusätzliche Infrastrukturbausteine verbunden und erweitert.
  4. Orchestrierung: Die Anwendungs- und Infrastrukturbausteine werden auf einem Cluster-Betriebssystem gesamthaft zur Ausführung gebracht.
4-steps (Abb. 2)

4-steps (Abb. 2)

Die folgenden Abschnitte gehen nun auf jeden dieser Schritte detailliert ein.

Von Entwicklung-Komponenten zu Betriebs-Komponenten

Microservices und Self-Contained-Systems (SCS) sind essentielle Architekturmuster mit denen Cloud-native Anwendungen und Systeme umgesetzt werden. Nüchtern betrachtet sind diese Muster aber nur alter Wein in neuen Schläuchen, denn
die Konzepte von Softwarekomponenten und Schnittstellen sind nicht neu. Microservices sind lediglich das Resultat einer konsequenten Anwendung der komponentenbasierten Softwareentwicklung vom Design bis hin zum Betrieb.

Bereits in der Entwurfsphase eines Systems denken wir in Komponenten. Diese sind fachlich anhand von Use-Cases geschnitten. Sie bilden kohäsive Funktionseinheiten, sorgen selbst für ihre Datenintegrität und sind lose miteinander gekoppelt. Während der Entwicklung werden die Komponenten dann zu Einheiten der Planung, Arbeitsteilung, Umsetzung und Integration. Wirklich neu ist nun die Abbildung der Entwicklungs- auf Betriebskomponenten. Eine Komponente wird hier zur Einheit für Release, Deployment und Skalierung. Bei diesem Übergang gibt es Freiheitsgrade, die bewusst und mit Augenmaß eingegangen werden sollten (Abb. 3).

Werden alle Komponenten eines gesamten Systems auf genau eine einzelne Betriebskomponente abgebildet, entsteht ein Betriebsmonolith. Dieser hat die bekannten Nachteile aber auch gewisse Vorteile. Fehlersuche und Debugging sind einfach, die Infrastrukturkomplexität ist niedrig und es gibt keine Latenz bei
Aufrufen zwischen den Komponenten.

Von Entwicklungs-Komponenten zu Betriebs-Komponenten. (Abb. 3)

Von Entwicklungs-Komponenten zu Betriebs-Komponenten. (Abb. 3)

Microservices entstehen, wenn einzelne Komponenten auf eigene Betriebskomponenten abgebildet werden. Diese Dekomposition bringt Vorteile mit sich. So sind nun unabhängige Releases und Deployments der funktionalen Teile eines Systems einfach möglich. Diese können wesentlich flexibler skaliert werden, die Ressourcen werden effizienter genutzt. Durch die höhere Laufzeitisolation der einzelnen Teile sind nun auch nicht mehr die Stabilität und die Verfügbarkeit des Gesamtsystems gefährdet. Doch die Dekomposition bringt auch Nachteile und Pflichten mit sich. Durch die Verteilung müssen plötzlich Dinge wie Latenz, Bandbreite und Verfügbarkeit beachtet werden. Ein gutes Schnittstellendesign ist nun essentiell für ein effizientes Kommunikationsverhalten der Microservices. Die Suche nach Fehlern gestaltet sich schwieriger und die höhere Infrastrukturkomplexität erfordert zwingend eine automatische Bereitstellung und Konfiguration aller Teile.

Docker, Docker, Docker

Docker ist sicherlich eine der Schlüsseltechnologien durch die Cloud-Native-Anwendungen wie wir sie heute kennen erst möglich geworden sind. Docker ist eine leichtgewichtige Form der Virtualisierung die erst ab der Ebene des Betriebssystems aufsetzt. Verglichen mit der klassischen Virtualisierung sind hier die Image-Größen deutlich kleiner und es gibt auch keinen Overhead beim Start eines Containers. Trotzdem laufen die Anwendungen in einem Container isoliert und beeinträchtigen sich nicht gegenseitig. Am Anfang der Containerisierung steht immer ein Docker-File (Listing 1). Es beschreibt wie, aufsetzend auf einem Basis-
Image (Zeile 1), die eigene Anwendung in das Dateisystem des Docker-Image gebracht (Zeilen 4-6) und mit welchem Befehl die Anwendung gestartet (Zeile 10) wird.

(Listing 1)
1 FROM qaware/alpine-k8s-ibmjava8:8.0-3.10
2 MAINTAINER QAware GmbH <qaware-oss@qaware.de>
3
4 RUN mkdir -p /app
5 COPY build/libs/zwitscher-service-1.0.1.jar
6
7 /app/zwitscher-service.jar
8 COPY src/main/docker/zwitscher-service.conf /app/
9
10 EXPOSE 8080
   CMD /app/zwitscher-service.jar

In (Listing 2) sind die nötigsten Docker-Befehle aufgelistet um aus einem Docker-File ein Docker-Image zu erzeugen, dieses lokal zur Ausführung zu bringen, zu kontrollieren und es am Ende in einer Remote-Docker-Registry zu veröffentlichen. Diese Befehle (und noch ein paar mehr) gehören zum Grundwissen
und Handwerkszeug eines Cloud-nativen Entwicklers.

(Listing 2)
1 $ docker build -t zwitscher-service:1.0.1 .
2
3 $ docker run --name zwitscher-service --rm -d \
4     -e „PORT=8080“ -p 8080:8080 zwitscher-
5 service:1.0.1
6
7 $ docker logs -f zwitscher-service
8 $ docker stop zwitscher-service
9
10 $ docker tag zwitscher-service:1.0.1
11 hitchhikersguide/zwitscher-service:1.0.1
12
   $ docker login -u hitchhikersguide -p <password>
   $ docker push hitchhikersguide/zwitscher-service

Komposition einer cloud-nativen Anwendung

Ein einzelner Microservice allein macht natürlich noch keine Cloud-Native Applikation. Erst im Zusammenspiel aller Teile entsteht ein vollwertiges System. Für ein reibungsloses Zusammenspiel braucht es zusätzliche Infrastrukturbausteine und Dienste, wie in (Abb. 4) exemplarisch dargestellt. Bei der konkreten Auswahl der benötigten Dienste und Bausteine lohnt erneut ein Blick auf die Cloud-Native-Landkarten.

Cloud-native Infrastruktur-Bausteine und Dienste. (Abb. 4)

Cloud-native Infrastruktur-Bausteine und Dienste. (Abb. 4)

Das Microservice-Chassis dient als Laufzeitumgebung für die Service Endpoints. Doch welche Technologie ist hier nun die Richtige? Die Antwort liegt auf der Hand: Jeder nach seiner Fasson! Denn sowohl mit Java EE, als auch mit Spring Boot oder dem Lagom-Framework lassen sich Microservice-basierte Systeme mit mehr oder weniger vergleichbarem Aufwand sehr gut umsetzen.

Die Service-Discovery dient der Registrierung und Suche von Service Endpoints. Alle Services registrieren sich an einer zentralen Registry um später über ihren Namen von Clients gefunden zu werden. Im einfachsten Fall wird DNS für den Lookup verwendet. Für etwas mehr Komfort bieten Bausteine, wie etwa Consul, erweiterte Funktionen wie Health-Checks, Load-Balancing und Hochverfügbarkeit.

Über Infrastrukturbausteine zur Configuration-and-Coordination werden den Endpoints und Microservices-Cluster-weite Konfigurationswerte bereitgestellt, wie etwa Secrets oder anderweitig umgebungsabhängige Werte.

Das Thema Monitoring und Diagnostizierbarkeit ist für Cloud-Native-Anwendungen von enormer Bedeutung. Durch die hohe Verteilung ist es in Fehlersituation nicht mehr so einfach, den genauen Verlauf einer Anfrage durch das System nachzuvollziehen. Auch ein Debugging des Gesamtsystems ist nicht mehr
möglich. Es braucht also weitere Infrastrukturbausteine für die Bereiche Logging, Monitoring und Tracing.

Ein API-Gateway regelt den externen Zugriff auf die Service-Endpoints des Systems. Über Routendefinition wird geregelt, welche eingehenden Requests an welchen Service-Endpoint weitergeleitet werden. Häufig steht das API-Gateway hierfür mit der Service-Discovery in Verbindung und es bietet zusätzliche Funktionen zur Authentifizierung, Autorisierung, Monitoring und Traffic-Management an.

Orchestrierung mit einem Cluster Betriebssystem

Die Anwendungs- und Infrastrukturbausteine werden nun auf einem Cluster-Betriebssystem wie Kubernetes gesamthaft zur Ausführung gebracht. In (Abb. 5 [k8sconcepts]) sind die wichtigsten Konzepte und Begriffe dargestellt die häufig verwendet werden. Für eine detaillierte Beschreibung der Architektur sowie aller unterstützten Konzepte wird auf die ausführliche Online-Dokumentation verwiesen.

Wichtige Kubernetes Konzepte und Begriffe. (Abb. 5)

Wichtige Kubernetes Konzepte und Begriffe. (Abb. 5)

Im Zentrum stehen die Pods. Diese beinhalten einen oder mehrere Container und bilden die kleinste Deployment- und Laufzeiteinheit in Kubernetes. Das Replica-Set überwacht den Zustand der Pods und stellt sicher, dass immer genug Pods im Cluster laufen. Das Deployment bietet die Möglichkeit für deklarative
Updates der Pods und Replica-Sets. Der Service dient als logische Abstraktion für den Zugriff auf eine Menge an Pods, die über Labels ausgewählt werden.
Die Definition der benötigten Pods, Deployments und Services erfolgt über eine einfache YAML- oder JSON-Datei. Mit dieser Datei wird anschließend die Anwendung per Kubernetes-CLI (Listing 3 [k8scmds]) auf dem Cluster angelegt und ausgeführt (Zeile 1). Die Skalierung der Anwendung wird ebenfalls per einfachem Kommando erledigt (Zeile 3), genauso wie ein Rolling-Update (Zeile 5) der Anwendung auf eine neue Version. Im Fall eines Fehlers kann der Rollout-Prozess mit einem Kommando auch wieder rückgängig gemacht werden.

(Listing 3)
1 $ kubectl apply -f zwitscher-service.yaml
2
3 $ kubectl scale deployment/zwitscher-service --replicas=8
4
5 $ kubectl set image deployment/zwitscher-service \
6              zwitscher-service=hitchhikersguide/zwitscher-service:1.2.3
7
8 $ kubectl rollout status deployment/zwitscher-service
  $ Kubectl rollout undo deployment/zwitscher-service

Keine Magie, aber komplexe Technologie

„Building distributed systems is hard!“ Diese Erkenntnis ist sicher nicht neu. Auch schon in Zeiten von CORBA und Client-Server-Systemen war das so. Die Verteilung bringt schlicht eine Menge an zusätzlicher Komplexität mit sich, die beim Entwurf, der Umsetzung und dem Betrieb eines verteilten Systems mitberücksichtigt werden muss.

Eines hat sich jedoch drastisch weiterentwickelt: die Technik, die heute zur Verfügung steht. Der Cloud-Native-Stack mit seinen verschiedenen Technologien macht die inhärente Komplexität hoch verteilter Systeme nun endlich beherrschbar.

Doch die hohe Abstraktion des Cloud-Native-Stacks ist Segen und Fluch zugleich. Denn sobald Dinge nicht mehr funktionieren wie erwartet, muss man in die Untiefen der jeweiligen Technologie abtauchen. Entwickler und Architekten von Cloud-nativen Systemen brauchen somit zusätzliche Skills und Know-how in
etlichen neuen Technologien, um sich in dieser schönen neuen Welt sicher zu bewegen.

Mario-Leander Reimer

Mario-Leander Reimer ist Cheftechnologe bei der QAware. Er ist Spezialist für den Entwurf und die Umsetzung von komplexen System- und Softwarearchitekturen auf Basis von Open-Source-Technologien. Er beschäftigt sich intensiv mit Technologien rund um den Cloud Native Stack und deren Einsatzmöglichkeiten im Unternehmensumfeld. 

(Visited 56 times, 1 visits today)

Leave a Reply