Aufsetzen einer Spring-Boot-Microservice-Architektur am Beispiel von PCF und Kubernetes

Spring Boot Tutorial: In 10 Schritten zur Microservices-Architektur

Michael Gruczel

© Shutterstock / Vector1st

Nachdem wir im ersten Teil dieses Tutorials gezeigt haben, wie man mithilfe von Spring Boot einen REST-basierten Service aufsetzt, wollen wir uns hier ganz dem Thema Microservices widmen. Welchen 10 Prinzipien sollte man beim Bau von Microservices folgen? Welche Frameworks helfen dabei in der Spring-Boot- bzw. Spring-Cloud-Welt? Wie implementiert man das Ganze im Zusammenspiel mit Kubernetes und Cloud Foundry?

Microservices mit Spring Boot – das Tutorial

Einen Monolithen in Microservices zu zerschneiden, oder von Anfang an auf Microservices zu setzen, hat viele Vorteile. Unter anderem erhält man schnellere Entwicklungs- und Testzyklen, unabhängige Releases und Deployments und die Möglichgkeit, Domänen-bezogene, crossfunktionale und ganzheitlich verantwortliche Teams aufzusetzen, was die organisatorische Voraussetzung für DevOps ist.

Allerdings geht mit den hunderten oder tausenden von verschiedenen Services eine andere Komplexität einher, die es zu bewältigen gilt: die Orchestrierung und Konfiguration der verschiedenen Services. Bei einer Handvoll von Services, deployt in wöchentlichen Zyklen, kann man sich eventuell noch mit DNS-Einträgen oder dem händischen Zuweisen von Ressourcen aushelfen. Bei Anpassungen im Minutentakt braucht es allerdings automatische Lösungen.

In diesem Tutorial zeigen wir, welchen grundlegenden Prinzipien man beim Bau von Microservices folgen sollte und welche Frameworks in der Spring-Boot- bzw. Spring-Cloud-Welt dabei helfen. Veranschaulichen wollen wir die Möglichkeiten anhand kleiner Beispiele in Kubernetes und Pivotal Cloud Foundry.

Alle hier verwendeten Code-Beispiele und Artefakte können unter https://github.com/michaelgruczel/article-spring-boot-cloud-native-code-samples gefunden werden. Wenn in einem Beispiel auf einen Ordner source/chat-app verwiesen wird, dann ist die folgende Adresse gemeint: https://github.com/michaelgruczel/article-spring-boot-cloud-native-code-samples/tree/master/source/chat-app. Wer die Beispiele selber direkt in PCF und/oder Kubernetes ausführen möchte, ist eingeladen, das Repository auszuchecken oder den Inhalt herunterzuladen.

Kubernetes und Pivotal Cloud Foundry

Ich habe mich entschieden, die Konzepte in diesem Tutorial innerhalb von Kubernetes und PCF zu demonstrieren, um die Ideen und Frameworks innerhalb einer PaaS- und einer CaaS-Lösung aufzeigen zu können. Persönlich bin ich davon überzeugt, dass die meisten Firmen sich für eine PaaS oder CaaS entscheiden sollten, um die Aufwände für den Betrieb einer IaaS-Lösung in den Teams gering zu halten. Die hier aufgezeigten Frameworks und Konzepte funktionieren in einer IaaS-Umgebung oder auf reiner Hardware aber ebenso.

Das Tutorial ist in einer Art geschrieben, die es dem Leser ermöglicht, die Beispiele selber Schritt für Schritt in PCF und/oder Kubernetes auszuführen. Ich empfehle, jeweils die Erklärungen zu beiden Plattformen zu lesen, selbst wenn nur eine davon zur Nachahmung herangezogen wird.

Cloud Foundry

Cloud Foundry (Open Source) ist eine der verbreitetsten PaaS-Lösungen (vermutlich zusammen mit Open Shift). Für die Beispiele in diesem Tutorial verwende ich die kostenpflichtige Pivotal-Variante, weil ich mir damit ein wenig Code ersparen kann. Die gleichen Lösungen sind prinzipiell aber auch in der Open-Source-Variante mit etwas mehr Aufwand realisierbar. Zum Ausprobieren der Beispiele in PCF empfehle ich die Registrierung eines freien Testaccounts (mit einem freien Budget von 87$) auf https://run.pivotal.io/.

Kubernetes

Zudem stelle ich die Beispiele innerhalb von Kubernetes vor. Kubernetes ist eine der komplettesten CaaS-Lösungen am Markt und auch eine sehr häufig verwendete. Auch hier empfehle ich zur Nachahmung einen freien Account, einzurichten beim Google-Cloud-Container-Engine-Angebot unter https://cloud.google.com/container-engine/.

Beide Lösungen kann man aber auch lokal in virtuellen Maschinen in einem reduzierten Umfang ausprobieren oder die Plattformen selber irgendwo installieren.

Microservices-Prinzip 1: Abhängigkeiten explizit deklarieren und isolieren

Einige der wichtigsten Prinzipien zur Erstellung einer Microservice-Architektur können von den Regeln der „Twelve-factor App“ (https://12factor.net/de/) abgeleitet werden. Einer der Faktoren fordert das explizite Deklarieren und Isolieren von Abhängigkeiten. Eine Twelve-factor App ist nicht von Systempaketen abhängig.

Dieses Prinzip ist auch für Nicht-Microservice-Architekturen sinnvoll. Während sich aber bei einem Monolithen die Abhängigkeiten zu Systembibliotheken möglicherweise noch beherrschen lassen, wird dies mit einer hohen Anzahl verschiedener Services kompliziert. Ein klassischer Weg, um Abhängigkeiten für Anwendungen zu verwalten, sind Applikationsserver. Diese ergeben heutzutage aber immer weniger Sinn. Applikationsserver brauchen häufig viele Ressourcen, mehr als ein Microservice selber. Das Versprechen, eine Anwendung einmal zu bauen und sie dann in verschiedenen Applikationsservern ohne Anpassungen starten zu können, hat sich in der Praxis selten als haltbar erwiesen.

Häufig müssen Anwendungen auf den spezifischen Applikationsserver hin angepasst werden, manchmal sogar an eine bestimmte Version. Die Idee, Anwendungen schlanker zu machen, indem bestimmte Bibliotheken bereits Teil des Applikationsserver-Klassenpfades sind, hat sich oft eher als Problem herausgestellt. Die genutzten Bibliotheken in einer Development Stage haben sich von der Produktion nicht selten unterschieden. Aufgrund von Lizenzkosten, Performanz oder Aufwänden wurden in den Stages entweder verschiedene Applikationsserver verwendet, oder der Entwickler musste in seiner IDE minutenlang warten, bis seine Codeänderungen lokal deployt waren. Das Paradigma hat sich geändert: Disk Space ist günstig, doch Entwicklungszeit ist teuer.

Entwickler minutenlang auf ein Deployment warten zu lassen, um geänderten Code zu testen, ist heute also nicht mehr akzeptabel. Schnelle Startzeiten, schnelles Feedback und reproduzierbare Ergebnisse über alle Stages sind wichtiger, als Speicher- oder Disk-Nutzung zu reduzieren. Deshalb sollte heute eine Applikation alle seine Abhängigkeiten beinhalten. Jede Abhängigkeit sollte explizit definiert sein. ‚Runs on my machine‘ ist ein Satz, den man heute nicht mehr hören will.

Spring Boot unterstützt dies bis zu einem bestimmten Level. Alle Abhängigkeiten können in einer pom (Maven) oder einer Gradle-Datei definiert werden. Aber noch wichtiger: Applikationen enthalten alle Abhängigkeiten und starten als jar.

Eine Spring-Boot-Anwendung enthält einen Tomcat als Teil der Applikation (andere Server-Optionen sind möglich). Die Architektur und Bibliotheken sind so ausgewählt, um ohne ein volles JEE-Profile auszukommen. Somit läuft die Anwendung in jeder Stage mit den exakt gleichen Bibliotheken. Startzeiten liegen im Sekundenbereich und nicht mehr im Minutenbereich.

Eine Abhängigkeit ist allerdings nicht Teil der Anwendung: die Java Laufzeit-Umgebung. Wir werden sehen, wie mittels Docker und Cloud Foundry auch dies gelöst wird. Der Quellcode zu dem folgenden Beispiel befindet sich unter source/updatedemo. Die Anwendung reagiert auf Anfragen wie http://localhost:8080/version?name=mike.

Nachdem ich das Anwendungsskelett mittels der Webseite http://start.spring.io erstellt habe, musste ich nur noch ein paar Zeilen Code hinzufügen (Listing 1).

@RestController
public class VersionInfoController {

    private static final String template = "Hello, %s! this is version 0.0.1";
    
    @RequestMapping("/version")
    public String version(@RequestParam(value="name", defaultValue="unknown user") String name) {
        return String.format(template, name);
    }
}

Die eigentliche Magie versteckt sich hinter der Annotation @SpringBootApplication in der Klasse mit der main-Methode. Automatisch startet sich ein Tomcat, und alle in den Controllern definierten Interfaces werden verfügbar gemacht (Listing 2).

package mgruc.article;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class UpdatedemoApplication {

 public static void main(String[] args) {
   SpringApplication.run(UpdatedemoApplication.class, args);
  }
}

Das Ergebnis des Buildprozesses ist ein jar, das alle Abhängigkeiten enthält.

In source/updatedemo befindet sich der Quellcode, um dies einmal manuell durchzuführen (Listing 3, der Code ist in source/updatedemo zu finden).

./gradlew build
java -jar build/libs/updatedemo-0.0.1.jar
# open http://localhost:8080/version

Abhängigkeiten explizit deklarieren und isolieren in PCF

Wie deployen wir nun das Ganze nach Cloud Foundry?

Zunächst sollten wir einen Client (https://github.com/cloudfoundry/cli#downloads) installieren, um Befehle gegen eine Cloud-Foundry-Installation zu senden. Dann können wir die gebaute jar deployen (Listing 4, der Einfachheit halber findet sich im Ordner Artefacts bereits eine gebaute Version).

# go to folder artefacts of my repo
# create account at https://run.pivotal.io
cf login -a api.run.pivotal.io
cf push updatedemo -p updatedemo-0.0.1.jar --random-route

Cloud Foundry wird als Teil des Deployments das Artefakt zusammen mit einer Java-Laufzeitumgebung in einen Container paketieren und diesen auf einer der verfügbaren virtuellen Maschinen verteilen. Zudem wird automatisch ein DNS-Eintrag bzw. ein Routing erstellt, so dass der Service direkt per URL aufgerufen werden kann.

In diesem Beispiel habe ich random-route als Parameter angehängt, um Domain-Konflikte zu vermeiden, da ich eine öffentliche PCF-Installation nutze. Eine Route in folgender Form wird deshalb erzeugt und zugewiesen <appname>-<random-name>.<pcf-domain>.

In einer privaten Installation im Firmenumfeld würde man sich vermeintlich auf eine Konvention einigen, um Konflikte zu vermeiden. Die Anwendung wird in sogenannten Buildpacks paketiert. Ein Buildpack kann potenziell komplexe Applikationsserver-Installationen enthalten oder auch einfach nur eine Java-Laufzeitumgebung. Es gibt öffentlich verfügbare vorbereitete Buildpacks, aber man kann natürlich auch selber Buildpacks erstellen.

Jeder Buildpack enthält eine Logik, um zu erkennen, ob er zum enthaltenen Artefakt passt. Denn Cloud Foundry ist ja nicht Java-spezifisch, sondern weitgehend sprachenunabhängig nutzbar. Die automatische Erkennung des richtigen Buildpacks kann durch Angabe eines bestimmten Buildpacks überschrieben werden. In unserem Beispiel wird automatisch ein schlankes Java-Buildpack erkannt. Die App sollte nun öffentlich erreichbar sein.

Die zufällig erzeugte URL kann aus der App-Info extrahiert werden (cf app updatedemo). Die vollständige URL wird wie folgt aussehen: http://updatedemo-<random_part>.cfapps.io/version. Wenn die URL für die App zum Beispiel updatedemo-antiproductivity-editress.cfapps.io wäre, dann könnte sie von außen mittels cURL (http://updatedemo-antiproductivity-editress.cfapps.io/version) aufgerufen werden.

Abhängigkeiten explizit deklarieren und isolieren in Kubernetes

Kubernetes verwendet Docker Container als Artefakt. Um dieses Tutorial nachvollziehen zu können, genügt allerdings ein rudimentäres Wissen über Docker. Man kann sich ein Docker Container als minimalistisches Betriebssystem, orchestriert ausschließlich mit den Bibliotheken, die wirklich gebraucht werden, und einer einzigen Anwendung vorstellen. Unser erstes Beispiel als Docker Container hat eine komprimierte Größe von ca. 88 MB inklusive Betriebssystem und der Java-Spring-Boot-Anwendung. Ein Container kann auf Basis einer Beschreibung gebaut und als Image in einer Docker Registry gespeichert werden.

Ein Docker Container enthält normalerweise nur einen laufenden Prozess und ist isoliert von allen anderen Docker Containern, die auf dem gleichen Host laufen. Das ist natürlich eine massive Vereinfachung, aber ausreichend, um die Beispiele nachzuvollziehen. Die Isolation der Container ermöglicht es, den vollständigen Stack inklusive aller Abhängigkeiten für jede Anwendung explizit zu definieren und zu kontrollieren. Eine Docker-Container-Beschreibung könnte wie Listing 5 aussehen.

FROM frolvlad/alpine-oraclejdk8:slim
VOLUME /tmp
ADD updatedemo-0.0.1.jar app.jar
RUN sh -c 'touch /app.jar'
EXPOSE 8080
ENTRYPOINT [ "sh", "-c", "java -jar /app.jar" ]

In diesem Beispiel haben wir das vorher gebaute jar verwendet (unter artefacts/Dockerfile befindet sich dieses Beispiel). Wenn auf einer Maschine ein Docker Daemon installiert ist, kann der Container wie in Listing 6 gebaut und gestartet werden.

$ docker build -t updatedemo .
$ docker run -d -p 8080:8080 updatedemo
# open http://localhost:8080/version

Die im Container laufende Anwendung ist nun von außen aufrufbar, z.B. mittels cURL  http://localhost:8080/version?name=Mike. Mit einem Docker Hub Account lässt sich das Image samt Anwendung in die öffentliche Docker Registry hochladen (Listing 7).

$ docker images
$ docker tag &lt;id of the image&gt; &lt;your docker id&gt;/updatedemo:v1
$ docker login
$ docker push &lt;your username&gt;/updatedemo:v1

In einer Firmenumgebung würde man in den meisten Fällen eine private Registry aufsetzen. Der Download eines existierenden Containers aus einer Registry erfolgt automatisch (kann aber auch manuell angesteuert werden), wenn der Container lokal nicht verfügbar sein sollte. So kann unser Beispiel auch gestartet werden, wenn der Container nicht lokal gebaut wurde, mittels des Befehls: docker run -d -p 8080:8080 mgruc/updatedemo:v1.

Kubernetes hat einen Kommandozeilenclient (kubectl), um Kommandos gegen eine Kubernetes-Installation auszuführen. Wir wollen die Google Cloud Shell (https://cloud.google.com/container-engine/docs/quickstart und https://cloud.google.com/shell/docs/) für unsere Beispiele nutzen, in der bereits eine Kubernetes-Client-Installation vorhanden ist, so dass wir den Installationsteil hier überspringen können. Um unsere einfache Spring-Boot-Anwendung zu starten, brauchen wir zunächst ein Cluster. Dieses kann mit den Befehlen aus Listing 8 erzeugt werden.

gcloud config set compute/zone us-central1-b
gcloud config list
gcloud container clusters create example-cluster
gcloud auth application-default login

Nun erstellen wir eine Deployment-Beschreibung, um die Anwendung zu starten (Listing 9, updatedemo-v1.yaml, vgl. https://github.com/michaelgruczel/article-spring-boot-cloud-native-code-samples/blob/master/kube-yamls/updatedemo-v1.yaml) und deployen die Anwendung nach Kubernetes (Listing 10).

apiVersion: extensions/v1beta1
    kind: Deployment
    metadata:
      name: updatedemo-deployment
    spec:
      replicas: 1 # just 1 pod
      template: 
        metadata:
          labels:
            app: updatedemo
        spec:
          containers:
          - name: updatedemo
            image: mgruc/updatedemo:v1
            ports:
            - containerPort: 8080

kubectl create -f updatedemo-v1.yaml
kubectl get deployment
kubectl describe deployment updatedemo-deployment
kubectl expose deployment updatedemo-deployment --type="LoadBalancer"
kubectl get pods -l app=updatedemo
kubectl get service updatedemo-deployment

Es kann ein wenig dauern, bis eine externe IP zugewiesen ist. Sobald dies geschehen ist, kann der Service von außen aufgerufen werden: cURL http://<your assigned external ip>:8080/version?name=Mike.

W-JAX
Mike Wiesner

Data- und Event-driven Microservices mit Apache Kafka

mit Mike Wiesner (MHP – A Porsche Company)

Niko Köbler

Digitization Solutions – a new Breed of Software

with Uwe Friedrichsen (codecentric AG)

Prinzip 2: Mit dem Prozess-Modell skalieren

Auch das Skalieren des Prozess-Modells ist eines der Prinzipien einer Twelve-Factor App (https://12factor.net/de/concurrency). Das Prinzip ist auch eines der wichtigsten Anforderungen an Microservices.

Es gab eine Zeit, als Hardware jedes Jahr schneller wurde, so dass Lastanforderungen mit größeren Rechnern gelöst werden konnten. Dabei handelt es sich aber um eine Verschwendung von Ressourcen, da immer die gesamte monolithische Anwendung skaliert wird, auch wenn nur einige Prozesse innerhalb des Monolithen mehr Ressourcen benötigen.

Mit den Lastanforderungen einer globalisierten Welt und einer nicht mehr so schnellen Weiterentwicklung von Hardware skaliert Hardware aber nicht mehr in dem Ausmaße. Hoch verfügbare und performante Systeme sind heute nur noch durch horizontale Skalierung (scale out) realisierbar. Anstatt schnellere Maschinen zu kaufen, skalieren wir, indem wir die Last auf mehr Maschinen verteilen und Prozesse parallel bearbeiten. Software muss in vielen Fällen so gestaltet werden, dass viele Instanzen der gleichen Anwendung zur gleichen Zeit laufen können. Unsere Infrastruktur muss dies gleichermaßen unterstützen.

Mit dem Prozess-Modell skalieren in PCF

Skalierung in PCF ist einfach. Wenn man mehrere Instanzen des gleichen Typs starten will, dann reicht eine einzelne Anweisung in PCF. Der gleiche Sourcecode und das gleiche Buildpack wird verwendet werden, um mehrere weitere Instanzen mit der gleichen Anwendung auf die verfügbaren Maschinen zu verteilen. PCF hat ein internes Routing. So wird sichergestellt dass alle Instanzen unter der gleichen URL verfügbar sind und der Traffic gleichverteilt auf die Instanzen verteilt wird.

Wollen wir unsere Beispiel-App auf drei Instanzen hochskalieren und dann wieder auf eine Instanz herunterskalieren, dann können wir das wie in Listing 11 dargestellt machen. Wir werden später noch sehen, wie wir die Skalierung an Last automatisch anpassen können.

cf scale updatedemo -i 3
cf scale updatedemo -i 1

Mit dem Prozess-Modell skalieren in Kubernetes

Kubernetes bietet Skalierung in vergleichbarer Art und Weise an wie PCF. Im Falle einer Skalierung werden die gleichen Docker Images verwendet und auf verschiedenen Maschinen gestartet. Ebenso ist eine interne DNS/Routing-Lösung vorhanden. Die Skalierung von Instanzen kann durch ein Replication Controller erfolgen. Wenn wir unsere Beispielanwendung auf drei Instanzen skalieren wollten, dann könnten wir dies folgendermaßen durchführen:

kubectl scale --replicas=3 deployment/updatedemo-deployment
kubectl get pods -l app=updatedemo

Für unsere Beispiele wollen wir allerdings beim Deployment-Konzept bleiben, das wir bereits gesehen haben. Das bedeutet, wir werden eine Deploymentbeschreibung definieren, um vier Instanzen unserer App anstatt nur einer zu starten (Listing 13, updatedemo-v1-4-replicas.yaml).

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: updatedemo-deployment
spec:
  replicas: 4 # 4 pods
  template: 
    metadata:
      labels:
        app: updatedemo
    spec:
      containers:
      - name: updatedemo
        image: mgruc/updatedemo:v1
        ports:
        - containerPort: 8080

Auch diese Datei ist bereits im Repository https://github.com/michaelgruczel/article-spring-boot-cloud-native-code-samples/blob/master/kube-yamls/updatedemo-v1-4-replicas zum Hochladen abgelegt. Listing 14 zeigt die Befehle, um das Deployment durchzuführen.

kubectl apply -f updatedemo-v1-4-replicas.yaml
kubectl get pods -l app=updatedemo

Kubernetes wird nun drei weitere sogenannte Pods starten. Ein Pod ist eine Gruppe von einem oder mehreren Containern. Alle Applikationen in einer Pod-Instanz sind co-located, co-scheduled und haben einen geteilten Context. Wenn wir in diesem Beispiel vier Pods anstatt vier Applikationsinstanzen innerhalb eines Pods starten, bedeutet dies umgekehrt, dass die Container verteilt und nicht co-located sind. Wir werden später einen Blick in die Autoscaling-Möglichkeiten von Kubernetes werfen, um automatisch auf Laständerungen reagieren zu können.

Prinzip 3: Nahtlose App Updates

Zwei Gründe, Monolithen in Microservices zu zerschneiden, sind, die Entwicklungsgeschwindigkeit zu erhöhen und die Releasefrequenz zu steigern. Ein Update eines Services sollte deshalb innerhalb von Sekunden und ohne irgendeine für den Kunden ersichtliche Downtime erfolgen. Das hat natürlich auch Auswirkungen auf die Softwarearchitektur-Entscheidungen. Zum Beispiel kann man nicht davon ausgehen, dass eine Datenbankänderung zeitgleich mit dem Update der Anwendung geschieht. So muss die alte Version der Anwendung also auch immer mit der neuen Datenbankversion umgehen können. Das ist normalerweise einfach zu implementieren. Es bedarf zudem einer Infrastruktur, die dies unterstützt. Wir schauen uns das abermals an einem einfachen Beispiel an.

Die Apps updatedemo-0.0.1.jar und updatedemo-0.0.2.jar im Artefakt Ordner sind nahezu identisch, abgesehen von den unterschiedlichen Versionen. Wir werden uns ein Update von Version 0.0.1 zu Version 0.0.2 zur Laufzeit in PCF und Kubernetes anschauen.

Nahtlose App Updates in PCF

Cloud Foundry stoppt normalerweise alle Anwendungsinstanzen, bevor Instanzen der neuen Version gestartet werden. Somit gibt es eine Downtime. Der Grund für dieses Vorgehen ist sicherzustellen, dass auch Anwendungen unterstützt werden, die nicht parallel in verschiedenen Versionen laufen können. Eine Offline-Zeit in Kauf zu nehmen ist für eine solide Microservicearchitektur natürlich nicht akzeptabel.

Die einfachste Möglichkeit, dies zu umgehen, ist ein Blue-Green-Deployment. Das bedeutet, dass es zwei Stages gibt (Blue und Green). Die neue Version der Anwendung wird auf eine Stage deployed, die nicht vom loadbalancer/routing erfasst ist. Sobald die neue Version vollständig deployed ist, schwenkt das Balancing auf die neue Stage.

Ein einfacher Weg, genau das in PCF durchzuführen, ist die Installation eines Plug-ins. PCF wird neue Container mit der neuen Version unser App deployen und dann den Traffic von der alten auf die neuen Container-Instanzen umleiten. Die alten Container-Instanzen bleiben weiterhin verfügbar für den Fall eines notwendigen Rollbacks. Um dieses Plug-in zu verwenden, brauchen wir ein Deployment Manifest (Listing 15, https://github.com/michaelgruczel/article-spring-boot-cloud-native-code-samples/blob/master/artefacts/manifest.yml). Listing 16 zeigt die Installation des Plug-ins, das Blue-Green Deployment und wie die alten Container nach dem Deployment gelöscht werden können.

---
applications:
- name: updatedemo
memory: 1024MB
random-route: true
path: updatedemo-0.0.2.jar

cf add-plugin-repo CF-Community https://plugins.cloudfoundry.org
cf install-plugin blue-green-deploy -r CF-Community
cf blue-green-deploy updatedemo
cf delete updatedemo-old

Das Plug-in führt Kommandos aus, welche man natürlich auch selber ausführen kann. Ohne an dieser Stelle in zu viele Details zu gehen, sei nur angemerkt, dass man das Plug-in nicht zwingend braucht und ohne das Plugin der Deploymentprozess feingranularer kontrolliert werden kann (z.B. Canary Releases).

Nahtlose App Updates in Kubernetes

Um die neue Version unserer App in Kubernetes zu deployen, können wir einfach die Version in unserer Deploymentbeschreibung anpassen (Listing 17, updatedemo-v2-4-replicas.yaml). Auch hier habe ich die Datei wieder im Repo zum Upload in die Google Cloud Shell abgelegt (https://github.com/michaelgruczel/article-spring-boot-cloud-native-code-samples/blob/master/kube-yamls/updatedemo-v2-4-replicas.yaml). Kubernetes wird ein Canary Deployment (Rolling Update) durchführen (dazu Listing 18 ausführen).

Das bedeutet, beide Versionen der Anwendung werden zur gleichen Zeit verfügbar sein, Kubernetes wird jeweils eine bestimmte Menge an Containern austauschen, bis nur noch Instanzen des neueren Docker Images laufen. Der Traffic wird also kontinuierlich von der alten zur neuen Version migriert (siehe Abbildung 1).

Kubernetes erlaubt die Definition einer Strategie für das Update. So kann zum Beispiel die maximale Anzahl der Container mit der neuen Version, die parallel gestartet werden, definiert werden. Ebenso können Tests definiert werden, die verwendet werden, um zu kontrollieren, ob die Anwendung im Container schon vollständig gestartet ist (zum Beispiel ein GET auf eine bestimmte URL, die dann den Status Code 200 zurückgeben muss). Ohne die Definition einer Strategie wird Kubernetes eine Default-Strategie anwenden. In unserem Fall stellt Kubernetes sicher, dass zu jedem Zeitpunkt mindestens drei Container laufen, und es werden parallel bis zu zwei neue Container erstellt. Nach dem Deployment sollte die neue Version nun erreichbar sein (cURL http://<your external ip>:8080/version?name=Mike).

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: updatedemo-deployment
spec:
  replicas: 4 # 4 pods
  template: 
    metadata:
      labels:
        app: updatedemo
    spec:
      containers:
      - name: updatedemo
        <b>image: mgruc/updatedemo:v2</b>
        ports:
        - containerPort: 8080
kubectl apply -f updatedemo-v2-4-replicas.yaml
kubectl get pods -l app=updatedemo
kubectl rollout history deployment/updatedemo-deployment

Abbildung 1: Rolling Update

Prinzip 4: Auto failover & Auto scaling

Auto failover

Eine Nachricht zu bekommen, sobald eine Maschine ausfällt oder eine Anwendung nicht mehr erreichbar ist, mag ein erster Schritt sein. Wenn man aber hunderte oder tausende kleiner Services in Produktion hat, dann möchte man ein System, das sich selber repariert. Im Falle einer ausfallenden Maschine oder einem Absturz einer Anwendung sollte die Anwendung auf einer anderen Maschine ohne merkbare Unterbrechung für den Anwender neu gestartet werden.

Sowohl PCF als auch Kubernetes machen genau das automatisch. Wenn eine Maschine ausfällt, werden Applikationsinstanzen, welche auf der Maschine liefen, auf anderen Maschinen gestartet. Wenn eine Instanz einer Applikation abstürzt, so wird eine neue Instanz gestartet.

Auto scaling

Die Skalierung von Anwendungen sollte sich der Last anpassen. Manchmal hat man den Luxus, Administratoren zu bezahlen, um Monolithen zu überwachen. Aber selbst dann muss man sich mit zwei Problemen auseinandersetzen: Im Falle von Lastspitzen muss man den gesamten Monolithen skalieren, selbst wenn nur ein Prozess die Ressourcen konsumiert. Zum zweiten muss man in der Lagen sein, das Problem zu erkennen und neue Instanzen innerhalb weniger Minuten zu starten. Um diesen Aufwand zu senken, wollen wir neue Instanzen automatisch starten, sobald sich die Last erhöht. Wenn die Lastspitze vorüber ist, wollen wir die Anzahl der Instanzen wieder senken, um Kosten zu sparen. Lösungen wie Kubernetes und PCF unterstützen dies von Haus aus.

Auto scaling in PCF

PCF bietet ein Anwendungs-Autoscaler als Service an. Das Auto-Scaling-Verhalten kann in einer UI basierend auf einigen vordefinierten Metriken (CPU Utilisierung, HTTP-Latenz oder HTTP-Durchsatz) und Zeitvorgaben (vgl. http://docs.pivotal.io/pivotalcf/1-10/appsman-services/autoscaler/using-autoscaler.html) konfiguriert werden. So könnte man zum Beispiel definieren, dass am Wochenende eine Applikation auf bis zu 10 Instanzen skaliert werden soll, falls die CPU-Last pro Instanz über einen bestimmten Wert steigt. Listing 19 zeigt, wie eine Anwendung in PCF an einen Auto Scaler gebunden werden kann. Abbildung 2 ist ein Screenshot aus unserem Beispiel.

cf create-service app-autoscaler standard autoscaler
cf bind-service updatedemo autoscaler
cf restart updatedemo

Abbildung 2: PCF Auto Scaler Management UI

Auto scaling in Kubernetes

Auch in Kubernetes kann Autoscaling von der CPU-Auslastung abhängig gemacht werden. In unserem Beispiel könnten wir sagen, dass die Anwendung zwischen 1 und 3 Instanzen skalieren soll, wenn die CPU Utilization über die 60%-Marke wandert (Listing 20).

kubectl autoscale deployment updatedemo-deployment --min=1 --max=3 --cpu-percent=60
kubectl get hpa
kubectl describe hpa updatedemo-deployment

Es können auch individuelle Metriken anstatt der CPU Utilization verwendet werden. Diese müssen dann von der Anwendung zur Verfügung gestellt werden. Details können unter https://kubernetes.io/docs/user-guide/horizontal-pod-autoscaling/ eingesehen werden. Bevor wir weitermachen, sollten wir etwas aufräumen (Listing 21).

kubectl delete service updatedemo-deployment
kubectl delete deployment updatedemo-deployment
kubectl delete hpa updatedemo-deployment

Prinzip 5: Service Discovery

Wenn Services miteinander interagieren wollen (auf Basis von Rest) und wenn man seine Services dynamisch skalieren möchte, dann bleiben einem im Grunde zwei Möglichkeiten:

Eine Kombination von automatisiertem DNS und Loadbalancing
Jedesmal, wenn eine weitere Instanz eines Services deployed wird, so muss diese unter der gleichen Adresse erreichbar sein wie die anderen Instanzen. Der Traffic sollte zwischen den Instanzen gleich verteilt werden.

Um Wartungsaufwände niedrig zu halten und flexibel zu bleiben, ist hier eine vollständige Automatisierung im Falle von Microservices unumgänglich. Viele Loadbalancer bieten nur unzureichende APIs zur Automatisierung an. Zudem braucht es evt noch eine Lösung, um mögliche Probleme mit DNS Caching zu behandeln. Aus diesem Grund haben Plattformen wie Kubernetes und PCF eigene, voll automatische Lösungen für das Problem.

Service Discovery
Anstatt einen Loadbalancer zu verwenden, können auch die Clients das Balancing selber durchführen. In diesem Fall müssen alle Services alle IPs aller anderen Services kennen, die sie nutzen. Eine einfache Lösung hierfür bietet Service Discovery.

Wenn eine neue Serviceinstanz deployed wird, meldet sich diese bei einer Service Registry an. Wenn eine Serviceinstanz gestoppt wird, meldet sie sich ab. Da Services auch abstürzen können, kann man sich darauf nicht verlassen, so bedarf es zusätzlicher Heartbeats oder Timeouts. Wenn ein Service nun auf einen anderen zugreifen will, so kann er die Discovery nach den IPs aller bekannten Instanzen eines bestimmten Service-Typs befragen und das Balancing selber durchführen.

Diese Lösung erscheint komplexer als die DNS-Lösung, aber sie hat einige Vorteile. Zum Beispiel kann man sehr leicht eine andere Software mit den gleichen Interfaces deployen und diese einfach unter dem gleichen Tag in der Service Registry anmelden, etwa um A/B-Tests durchzuführen. Zudem macht man sich so weiter von der Infrastruktur unabhängig und bewegt mehr Infrastruktur in den Code. Diese Methode funktioniert auch ohne eine PaaS/CaaS -Lösung elegant. Beliebte Service-Discovery-Tools sind Consul oder Eureka.

Für eine Demonstration werde ich zwei neue Anwendungen verwenden:

  • eine Wetter-App (weather-app) gibt Wetter-Daten für bestimmte Städte zurück
  • eine Konzert-App (concert-app) gibt Konzert-Daten für bestimmte Städte zurück und reichert diese mit Wetterdaten an, welche von der Wetter-App bezogen werden.

Ich möchte die Anwendungen unterschiedlich voneinander dynamisch skalieren und natürlich sicher gegen Ausfälle von Maschinen sein. Das bedeutet, ich muss sicherstellen, dass die Apps sich gegenseitig jederzeit dynamisch finden, ohne irgendwelche Konfigurationsanpassungen zu machen. Es gibt zwei Bibliotheken, die hierfür perfekt in die Spring Boot Welt passen.

Eureka

Eureka ist eine Open-Source-Service-Discovery-Lösung aus dem Netflix Open Source Software Center (https://netflix.github.io). Um eine Spring-Boot-Anwendung zu einem Service Discovery Server (Registry) oder Client zu machen, bedarf es nur einer Annotation in der Applikation (@EnableEurekaServer). Listing 22 ist ein Beispiel für einen Server und Listing 23 für einen Client.

@SpringBootApplication
@EnableEurekaServer
public class CfEurekaServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(CfEurekaServerApplication.class, args);
    }
}
//@EnableEurekaClient would work as well but @EnableDiscoveryClient is more abstract
@EnableDiscoveryClient 
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Um sich nun mit anderen Anwendungen zu verbinden, die sich selber bei Eureka angemeldet haben, kann man einfach den Namen des Services anstatt der IP oder des DNS-Eintrags verwenden, da der Discovery Client die konkreten IPs automatisch ermittelt. Anstatt der echten URL (zum Beispiel http://random-route-abcde-weather-app) werden wir also nur http://weather-app als URL verwenden. Der Discovery Client wird alle Services nutzen, welche unter dem Namen weather-app in der Registry angemeldet sind. In unserem Beispiel wird die concert-app die Wetterdaten von der weather-app abfragen.

Ribbon

In dem folgenden Code-Ausschnitt (Listing 24) sehen wir auch bereits die zweite wichtige Bibliothek, genannt Ribbon. Ribbon ermöglicht das clientseitige Loadbalancing und kann einfach per Annotation auf einem RestTemplate eingeschaltet werden (@LoadBalanced-Annotation). Der Client wird dann alle über das RestTemplate ausgeführten Requests an alle bekannten Instanzen verteilen.

Die hier verwendeten Beispiele können auch lokal ausgeführt werden. Hierzu müssen nur die Anwendungen im Ordner /source mittels Gradle gestartet werden. Zunächst sollte man Eureka starten, dann können die weather-app und die concert-app in beliebiger Reihenfolge gestartet werden. Die Anwendungen sind jeweils über den Befehl ‘./gradlew bootRun’ in jeweils einer eigenen Shell startbar. Das Eureka UI wird unter http://localhost:8761/, die Wetter-App unter http://localhost:8090/weather?place=springfield und die Konzert-App unter http://localhost:8100/concerts?place=springfield erreichbar sein.

@RestController
public class ConcertInfoController {

    @LoadBalanced
    @Bean
    RestTemplate restTemplate(){
        return new RestTemplate();
    }

    @Autowired
    RestTemplate restTemplate;

    @RequestMapping("/concerts")
    public ConcertInfo concerts(
         @RequestParam(value="place", defaultValue="") String place) {
        
        // retrieve weather data
        // now retrieve weather data in order to add it to the concert infos 
        Weather weather = restTemplate.getForObject(
                     "http://<b>weather-app</b>/weather?place=" + place, Weather.class);
        ...
    }
}

Service Discovery in PCF

PCF unterstützt Domain-basiertes Routing. Das bedeutet, wenn eine Anwendung deployed wird, ohne Random Routes zu verwenden, dann wird die Anwendung unter ihrem Namen innerhalb der Domain verfügbar. Wenn ich weitere Instanzen starte, dann wird PCF den Traffic automatisch zwischen den Instanzen verteilen.

Wir brauchen also nicht zwangsläufig eine Service-Discovery-Lösung, und wir müssen auch nicht selber DNS und Loadbalancing implementieren. Wenn ich eine Anwendung namens spring-article-updatedemo ohne Random-Route-Anweisung deployen würde, so würde diese erreichbar sein unter http://spring-article-updatedemo.cfapps.io/version.

Diese Lösung ist vermutlich gut genug, um 90% aller Anwendungsfälle abzudecken. Möglicherweise gibt es aber Gründe, warum wir dennoch eine Service-Discovery-Lösung bevorzugen. Wenn wir zum Beispiel gezwungen sind, Random Routes zu verwenden, dann kennen wir diese nicht im Vorwege. In einem solchen Fall ist die Kombination von Eureka mit Ribbon sehr effektiv. Die Lösung funktioniert, wie bereits erwähnt, auch außerhalb jeder CaaS/PaaS-Lösung (zum Beispiel in einer IaaS-Umgebung). Wir können Eureka in Cloud Foundry verwenden, aber da PCF bereits Eureka als Service anbietet, werde ich diesen Service nutzen, anstatt selber Eureka zu deployen (Listing 25).

# folder artefacts
cf create-service p-service-registry standard mgruc-service-registry
cf push mgruc-pcf-weather-app -p weather-app-0.0.1.jar --random-route --no-start
cf push mgruc-pcf-concert-app -p concert-app-0.0.1.jar --random-route --no-start
cf bind-service mgruc-pcf-weather-app mgruc-service-registry
cf bind-service mgruc-pcf-concert-app mgruc-service-registry
cf start mgruc-pcf-weather-app
cf start mgruc-pcf-concert-app
cf app mgruc-pcf-concert-app

Wenn man nun die Konzert-App öffnet, so werden automatisch alle Instanzen der Wetter-App identifiziert, und der Traffic wird gleichmäßig verteilt.

Service Discovery in Kubernetes

Auch Kubernetes bietet eine interne DNS-Lösung, aber wir könnten auch denselben Service-Discovery-Ansatz wie in PCF verwenden (Eureka). Da Kubernetes noch eine andere Lösung anbietet, werde ich aber diese zeigen.

Wir werden eine Instanz der Wetter-App deployen (die Docker Images sind bereits gebaut und in der öffentlichen Docker Registry verfügbar). Wir deployen zunächst eine Instanz der Wetter-App (Listing 26, Deploymentbeschreibung weather-app-v1.yaml in Listing 27).

kubectl create -f weather-app-v1.yaml
kubectl get deployment
kubectl describe deployment weather-app
kubectl expose deployment weather-app --type="LoadBalancer"
kubectl get pods -l app=weather-app
kubectl get service weather-app

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: weather-app
spec:
  replicas: 1
  template: 
    metadata:
      labels:
        app: weather-app
    spec:
      containers:
      - name: weather-app
        image: mgruc/weather-app:v1
        ports:
        - containerPort: 8090

Die Wetter-App ist nun von außen unter http://<external assigned IP>:8090/weather?place=springfield erreichbar. Die Information zum Finden der Wetter-App ist nun automatisch für die anderen Services mittels Umgebungsvariablen verfügbar. Die Variablen WEATHER_APP_SERVICE_HOST und WEATHER_APP_SERVICE_PORT werden nun in alle anderen Services injiziert. Ich habe nun die Service-Discovery-Annotation aus unserer Anwendung entfernt und nutzte stattdessen in der Konzert-App die Variablen (Listing 28).

@RestController
public class ConcertInfoController {

    @Value("${WEATHER_APP_SERVICE_HOST}")
    private String weatherAppHost;
    
    @Value("${WEATHER_APP_SERVICE_PORT}")
    private String weatherAppPort;
    

    @Bean
    RestTemplate restTemplate(){
        return new RestTemplate();
    }

    @Autowired
    RestTemplate restTemplate;

    @RequestMapping("/concerts")
    public ConcertInfo concerts(@RequestParam(value="place", defaultValue="")String place) {
        
        
        // retrieve weather data in order to add it to the concert infos 
        Weather weather = restTemplate.getForObject(
                         "http://" + weatherAppHost + ":" + weatherAppPort + "/weather?place=" 
                        + place, Weather.class);
        ...
        
    }
}

Nun können wir die Konzert-App (concert-app-v1.yaml, Listing 29) mittels des Befehls ‘kubectl create -f concert-app-v1.yaml’ deployen.

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: concert-app
spec:
  replicas: 1
  template: 
    metadata:
      labels:
        app: concert-app
    spec:
      containers:
      - name: concert-app
        image: mgruc/concert-app:v1
        ports:
        - containerPort: 8100   

Die Konzert-App ist in der Lage, die Wetter-App zu finden, weil die Variablen WEATHER_APP_SERVICE_HOST und WEATHER_APP_SERVICE_PORT automatisch von Kubernetes injiziert werden. Dies ist nur eine der Möglichkeiten, eine Service Discovery mit Kubernetes zu implementieren. Nun können wir die App von außen verfügbar machen und die IP abfragen, siehe Listing 30. Der Service ist nun erreichbar von außen unter http://<external assigned IP>:8100/concerts?place=hamburg.

kubectl expose deployment concert-app --type="LoadBalancer"
kubectl get service concert-app

Prinzip 6: Abhängigkeiten als angehängte Ressourcen behandeln

Dieses Prinzip leitet sich ebenfalls aus der Twelve-Factor App ab (https://12factor.net/de/backing-services). Damit einhergehend ist die Idee der Graceful Degradation. Wenn eine Abhängigkeit nicht erreichbar ist oder nicht ordnungsgemäß funktioniert, dann sollte die Anwendung nicht abstürzen, sondern zumindest andere unabhängige Funktionalitäten weiter zur Verfügung stellen.

Wenn wir eine App anbieten, die dem Anwender nur nach einem Log-in Mehrwert bringt und unsere User-Datenbank nicht erreichbar ist, dann mag dies nicht funktionieren. Aber es gibt viele andere Anwendungsfälle, bei denen wir das Prinzip verwenden können. Eine leichtgewichtige Bibliothek für REST-Services in Java ist Hystrix.

Hystrix ist ein Circuit Breaker. Sobald ein Service nicht mehr richtig antwortet, wird die Verbindung zu diesem Service nicht mehr gehalten und Anfragen nicht mehr an diesen weitergeleitet. Wir können für diesen Fall eine Fallback-Methode definieren. Durch Verwendung dieses Prinzips werden Fehler nicht mehr an die nächsten Schichten propagiert, und Schleifen können gebrochen werden. Listing 31 ist ein solches Beispiel.

@RequestMapping("/hello")
    @HystrixCommand(fallbackMethod = "hellofallback")
    public String hello() {
        String aName = .... complex logic to find a name
        return "Hello " + aName;
    }
    
    public String hellofallback() {
    return "Hello world";
    }

In unserem Beispiel kann die Konzert-App immer noch Mehrwert schaffen, auch wenn die Wetter-App nicht mehr funktioniert. Sollte die Konzert-App also beim Aufruf der Wetter-Information bei der Wetter-App einen Fehler erzeugen, so wird immer noch die Konzertinformation zurückgegeben. Der Code hierfür ist unter https://github.com/michaelgruczel/article-spring-boot-cloud-native-code-samples/blob/master/source/concert-app/src/main/java/demo/ConcertInfoController.java zu finden.

Prinzip 7: Die App als einen oder mehrere Prozesse ausführen

Ein weiteres Prinzip der Twelve-Factor App (https://12factor.net/de/processes) ist für Microservices aus offensichtlichen Gründen wichtig, und zwar das Prinzip, Services zustandslos zu halten. Möchte man Services zu jeder Zeit skalieren und rechnet man jederzeit mit einer Migration von Anwendungen zwischen Servern (z.B. Auto Failover), so darf eine Anwendung keinen Zustand halten. Jede Form von Zustand muss außerhalb der Anwendung (in der Regel in einer Datenbank) gehalten werden. Es gibt zwei Architekturentscheidungen, die häufig zu einem Zustand in einer Anwendung führen: Sessions in einem Applikationsserver und lokale Dateien.

Eine Session in eine Datenbank wie Redis zu verschieben, ist einfach. Abgesehen von einigen Abhängigkeiten und der Redis-Datenbank selbst bedarf es lediglich einer Annotation, um dies in Spring Boot zu realisieren. Wenn Spring Security verwendet wird und Web Security aktiviert ist (@EnableWebSecurity), dann lässt sich die Session mit dem Hinzufügen der Klasse aus Listing 32 bewerkstelligen.

@EnableRedisHttpSession
  public class HttpSessionConfig {
}

Um dies zu demonstrieren, werde ich eine weitere App zu der Wetter- und der Konzert-App hinzufügen. Es handelt sich um eine Chat-App. Um die Chat-Anwendung zu nutzen, muss sich der Anwender anmelden. Innerhalb der Chat-App kann der Anwender Nachrichten speichern oder aber auch Wetter- oder Konzert-Informationen abfragen. In diesem Fall leitet die Chat-App die Anfragen entsprechend weiter. Abbildung 3 ist ein Screenshot des sehr einfachen UI für dieses Beispiel.

Abbildung 3: Chat-App UI

Auch dieses Beispiel kann lokal ausgeführt werden. In diesem Fall sollte man Redis in einem Docker-Container starten oder, wenn dies nicht möglich ist, in einer Vagrant Box. Ich habe für diesen Fall eine Vagrant Box mit Redis vorbereitet, welche im Ordner vms/redis zu finden ist. Mit dem Befehl ‚vagrant up‘ (installiertes Virtual Box und Vagrant vorausgesetzt) kann Redis in einer VM gestartet werden.

Der Source Code für die Chat-App ist in source/chat-app zu finden. Zusätzlich zur gestarteten Eureka-Anwendung, der Weather-App und der Concert-App aus dem letzten Beispiel gilt es nun, die chat-app wie gewohnt zu starten (./gradlew bootRun). Mit dem Usernamen ‚homer‘ und dem Passwort ‚beer‚ eingeloggt ist die Chat-Anwendung unter http://localhost:8080 zu erreichen. Nun kann man Nachrichten posten, die genau wie die Session ebenfalls in Redis gespeichert werden. Andere Services können indirekt genutzt werden, zum Beispiel durch Absenden von Nachrichten der Art ‘/weather springfield’. Wenn der Service nicht verfügbar ist, dann wird das Kommando nicht funktionieren, aber alle anderen Funktionen werden nach wie vor verfügbar sein.

Zugriff auf lokale Dateien zu vermeiden kann schwierig sein. Eine Lösung stellt eine Objekt-Storage-Plattform wie zum Beispiel Amazon S3 dar. Zudem gibt es andere Objekt-Storage-Lösungen mit einem zu S3 kompatiblen API. Hier lohnt sich zum Beispiel ein Blick in Minio (https://github.com/minio/minio), wenn man lokal einen S3 Storage emulieren möchte. Anstatt in eine Datei zu schreiben oder aus ihr zu lesen, kann man in der Anwendung einen AWS S3 Java Client verwenden, um in Buckets zu schreiben. Der Code aus Listing 33 soll diese Idee einmal illustrieren. Ich werde diese Funktionalität aber nicht in meinen Beispielen nutzen.

@RestController
    @RequestMapping("/filehandling")
    public class SampleS3Controller {

    private AmazonS3Client client;
    private String bucketName;

    @RequestMapping(value = "upload", method = RequestMethod.POST)
    public void uploadFile(@RequestParam("name") String name,
                            @RequestParam("file") MultipartFile file) {
                try {
                    ObjectMetadata objectMetadata = new ObjectMetadata();
                    objectMetadata.setContentType(file.getContentType());
                    client.putObject(new PutObjectRequest(bucketName, name, file.getInputStream(), 
                        objectMetadata));
                } catch (Exception e) {
                ...
            }
    }
}

Die App als einen oder mehrere Prozesse ausführen in PCF

Wir werden nun die Chat-App nach PCF deployen und sie mit einer Redis-Datenbank verknüpfen (Redis ist als Service verfügbar innerhalb von PCF). Zudem werden wir die Chat-App mit der Wetter- und der Konzert-App verbinden. Listing 34 zeigt, wie dies in PCF realisierbar ist.

# artefact is in artefacts
cf push mgruc-pcf-chat-app -p chat-app-0.0.1.jar --random-route --no-start
cf bind-service mgruc-pcf-chat-app mgruc-service-registry
cf create-service rediscloud 30mb redis
# in case of an internal installation it would be: cf create-service p-redis shared-vm redis
cf bind-service mgruc-pcf-chat-app redis
cf start mgruc-pcf-chat-app
cf app mgruc-pcf-chat-app

Der Name des Redis Service muss entweder Redis sein, oder das Binding muss zum Schema passen, damit die Spring-Boot-Anwendung sich ohne weitere Konfigurationen mit der Datenbank verbindet (vgl. https://github.com/spring-cloud/spring-cloud-connectors/blob/master/spring-cloud-cloudfoundry-connector/src/main/java/org/springframework/cloud/cloudfoundry/RedisServiceInfoCreator.java#L16). Nun kann man sich an der Chat-App anmelden (die URL kann falls nötig mittels ‚cf app mgruc-pcf-chat-app‚ ermittelt werden). Die App speichert Sessions und Nachrichten in der zugewiesenen Redis-Datenbank.

Die App als einen oder mehrere Prozesse ausführen in Kubernetes

Wir werden nun Redis und unsere Chat-App in Kubernetes deployen und die Chat-App nach außen verfügbar machen. In diesem Beispiel verwenden wir ein sehr einfaches Redis Setup (Listing 35, redis-master.yaml und Listing 36). In einer Produktionsumgebung sollte man die Redis-Installation besser clustern.

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: redis
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: redis
        role: master
        tier: backend
    spec:
      containers:
      - name: master
        image: gcr.io/google_containers/redis
        resources:
          requests:
            cpu: 100m
            memory: 100Mi
        ports:
        - containerPort: 6379

kubectl create -f redis-master.yaml
kubectl expose deployment redis --type="LoadBalancer"

Ich habe die Redis-Connection-Information mittels der von Kubernetes zur Verfügung gestellten Umgebungsvariablen zur Chat-App hinzugefügt und verwende diese in den Application Properties der Anwendung. Abgesehen davon unterscheidet sich die Spring-Boot-App nicht von der PCF- oder der lokalen Version. Das Deployment wird mit der Beschreibung aus Listing 37 (chat-app-v1.yaml) und den Befehlen aus Listing 38 ausgeführt.

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: chat-app
spec:
  replicas: 1
  template: 
    metadata:
      labels:
        app: chat-app
    spec:
      containers:
      - name: chat-app
        image: mgruc/chat-app:v1
        ports:
        - containerPort: 8080  

kubectl create -f chat-app-v1.yaml
kubectl expose deployment chat-app --type="LoadBalancer"
kubectl get service chat-app

Die Chat-App ist nun von außen unter http://<external assigned IP>:8080/ zu erreichen. Auch hier ersparen wir uns das S3-Beispiel, aber es sei angemerkt, dass Dateien in Kubernetes auch auf den Host gemountet werden können, da Kubernetes nichts anderes macht als Docker Container zu starten.
Der Code für die Kubernetes-Beispiele ist verfügbar unter /source/docker-….

Prinzip 8: Behandle Admin/Management-Aufgaben genau wie Code-Änderungen

Admin-Aufgaben sind häufig nicht in die Pipeline integriert. Ein Grund ist, dass der Betrieb und die Entwicklung häufig voneinander getrennt sind. Während Softwareänderungen in der Regel versioniert sind und durch eine definierte Pipeline laufen, werden Konfigurationsänderungen und Datenbankänderungen als Ausnahmen behandelt, obwohl diese Änderungen häufig ein viel größeres Risiko darstellen.

Diese Ausnahme gilt oft zumindest für die Datenbanken. Einige Firmen haben dezidierte DBAs, von denen nicht verlangt wird, ihre Änderungen in die gleiche Pipeline zu integrieren und wie Softwareänderungen auszurollen. Deshalb werde ich hier ein Beispiel geben, wie Datenbankänderungen versioniert werden können.

Flyway ist eine Bibliothek zur versionierten Ausführung von Databankänderungen. Spring Boot kann Flyway mit den gefundenen Datensourcen automatisch zum Start der Anwendung ausführen. Flyway unterstützt dabei versionierte Datenbankmigrationen (einmalige Ausführung) und wiederholbare Migrationen (diese werden jedes Mal ausgeführt, wenn sich die Checksumme ändert).

Es bedarf lediglich eines Ordners namens db/migration innerhalb des Klassenpfades (z.B. /resource-Ordner der Anwendung) und SQL-Dateien mit einer Benennung in der Art ‘V1__lorem.sql’, ‘V2__ipsum.sql’. Flyway führt alle Datenbankänderungen, die noch nicht ausgeführt wurden, auf der Datenbank in der Reihenfolge (V[order]__[any name]) aus, wenn die Anwendung deployed wird.

Prinzip 9: Logs als Event Streams behandeln

Wenn klar ist, dass die Anwendungen zu jeder Zeit auf einem anderen Host gestartet werden können (autofailover, autoscaling, …), dass sie jederzeit skalierbar sein sollten, dann wird offensichtlich, dass das lokale Dateisystem nicht zuverlässig ist. Dies bedeutet auch, dass Log-Dateien nicht wasserdicht sind und auch, dass das Logging mit der Anwendung skalieren muss. Die Konsequenz ist, Logs als Streams (12 factor apps – XI. Logs) zu verstehen und diese in verschiedene Kanäle zu streamen und nicht lokal als Logfile zu speichern.

Logs als Event Streams in PCF

Cloud Foundry bündelt System Out, Anwendungsevents und alle Requests in einen großen Stream. An diesen kann man sich manuell anhängen, um die Logs zu sehen (cf logs mgruc-pcf-chat-app [–recent]). Ein besserer Weg besteht darin, die Anwendungslogs in eine Logmanagement-Lösung zu streamen.

PCF kann Logs via Syslog weiterleiten. Wollten wir unsere Logs der pcf-chat-app an einen externen Logstash Agent weiterleiten, so würde dies durch die Befehle aus Listing 39 ermöglicht

cf cups my-logstash -l syslog://&lt;ip_of_your_logstah_host&gt;:&lt;your_logstah_port&gt;
cf bind-service mgruc-pcf-chat-app my-logstash
cf restage mgruc-pcf-chat-app

Das Gleiche funktioniert natürlich auch mit Lösungen wie Splunk, Papertrail oder jedes andere Produkt, das Syslog unterstützt.

Logs als Event Streams in Kubernetes

Es gibt verschiedene Ansätze, auf Logs in Kubernetes zuzugreifen. Ein Weg ist kubectl z.B. ‘kubectl logs deployment/chat-app’. Auch wenn man leicht die Logs mittels kubectl lesen kann, so ist die Out-of-the-Box-Unterstützung, um Logs in eine externe Logging-Lösung zu streamen, nicht so bequem wie in PCF. Man muss sich selber eine Strategie überlegen und diese implementieren.

Einige Optionen sind:

  • Logs direkt von der Anwendung in die Log-Lösung zu streamen, bedeutet die Verwendung eines Log Appenders, der Daten direkt zu Splunk oder anderen Tools sendet.
  • Man kann einen Sidecar Container verwenden, also Logs aus einem Container in einen zweiten Container linken. Dieser sendet dann die Logs mittels eines log-forwarder weiter.
  • Die vermutlich bequemste Lösung ist, einen log-forwarder auf dem Host zu installieren und die Logs aus dem Docker Container an diesen zu senden (oder die Log-Dateien zu mounten).

Prinzip 10: Verteiltes Tracing und Monitoring

Wenn hunderte Services miteinander agieren und sich die Topologie schnell ändert, dann möchte man Wissen, wo die Zeit verloren geht, um Latenzprobleme zu finden oder seltsames Verhalten zu analysieren. Das bedeutet, dass man zusätzlich zum Monitoring der Services noch Monitoring für das Netzwerk der Services braucht.

Es gibt eine Vielzahl von Tools und Cloud Providern, wie zum Beispiel App Dynamics, New Relic und Dynatrace, um dieses Problem zu lösen. Jede Lösung ist vermutlich mächtig genug, um einen eigenen Artikel zu füllen. Ich werde hier stattdessen zwei Open-Source-Frameworks zeigen:

  • Hystrix mit Turbine
  • Zipkin

Ich habe Hystrix bereits als Circuit Breaker vorgestellt. Zusätzlich kann Hystrix aber auch einige wichtige Werte (Anzahl fehlerhafter Anfragen, Antwortzeiten, …) in Dashboards sichtbar machen. Hystrix braucht diese Informationen selber, um zu entscheiden, ob ein Kreislauf offen oder geschlossen sein sollte. Hystrix macht dies in einem Embedded Hystrix Dashboard in jedem Service sichtbar.

Wenn man eine Vielzahl von Circuit Breakern hat, dann möchte man die verschiedenen Dashboards in einem einzelnen Dashboard sichtbar machen. Hier kommt Turbine ins Spiel (Open Source von Netflix). Turbine aggregiert Hystrix Streams in ein einzelnes Dashboard.

Das zweite Framework, das ich zeigen möchte, ist Zipkin. Zipkin ist ein Tracing Framework, um verteilte Aktionen zu überwachen. Das bedeutet konkret, dass Zipkin Requests über mehrere Services sichtbar macht. Zipkin ist verfügbar als lauffähiges JAR basierend auf Spring Boot und kann leicht lokal oder auch als Docker Container gestartet werden (Listing 40).

# as jar
wget -O zipkin.jar 'https://search.maven.org/remote_content?g=io.zipkin.java&a=zipkin-server&v=LATEST&c=exec'
java -jar zipkin.jar
# or as Docker
docker run -d -p 9411:9411 openzipkin/zipkin

Ebenso ist es möglich, eine eigene Spring-Boot-Anwendung mit einer einzelnen Annotation (@EnableZipkinStreamServer) in der Main-Klasse zum Zipkin Server zu machen. Nach dem lokalen Start von Zipkin kann die Oberfläche unter http://your_host:9411 eingesehen werden. Möchte man einen Service gegen Zipkin verbinden, so muss man einen Sampler hinzufügen, der die Daten zu Zipkin schickt. Der einfachste (aber kein besonders performanter) Weg, ist der AlwaysSampler aus Listing 41.

@Bean
public AlwaysSampler defaultSampler() {
  return new AlwaysSampler();
}

In einer Produktionsumgebung würde man die Daten aggregiert an Zipkin senden, um den Netzwerk-Overhead gering zu halten. Abbildung 4 und Abbildung 5 sind Zipkin Screenshots aus dem Beispiel.

 

Abbildung 4: Zipkin-Abhängigkeiten

Abbildung 5: Zipkin Traces

Verteiltes Tracing und Monitoring in PCF

Wer dies einmal selber ausführen möchte, kann diese vorgebauten Artefakte nutzen (Listing 42).

# folder artefacts
# new zipkin service
cf push mgruc-pcf-zipkin -p zipkin.jar --random-route --no-start
cf bind-service mgruc-pcf-zipkin mgruc-service-registry
# new version of apps which export data to zipkin and the hystrix dashboard
cf push mgruc-pcf-chat-app -p chat-app-with-zipkin-0.0.1.jar --random-route --no-start
cf push mgruc-pcf-weather-app -p weather-app-with-zipkin-0.0.1.jar --random-route --no-start
cf push mgruc-pcf-concert-app -p concert-app-with-zipkin-0.0.1.jar --random-route --no-start
# attach hystrix-turbine dashboard
cf create-service p-circuit-breaker-dashboard standard mgruc-circuit-breaker-dashboard
cf bind-service mgruc-pcf-chat-app mgruc-circuit-breaker-dashboard
cf bind-service mgruc-pcf-weather-app mgruc-circuit-breaker-dashboard
cf bind-service mgruc-pcf-concert-app mgruc-circuit-breaker-dashboard
# attach to zipkin
cf app mgruc-pcf-zipkin
cf set-env mgruc-pcf-weather-app SPRING_ZIPKIN_BASE-URL http://&lt;zipkin url&gt;
cf set-env mgruc-pcf-concert-app SPRING_ZIPKIN_BASE-URL http://&lt;zipkin url&gt;
cf set-env mgruc-pcf-chat-app SPRING_ZIPKIN_BASE-URL http://&lt;zipkin url&gt;
# start the apps
cf start mgruc-pcf-zipkin
cf start mgruc-pcf-weather-app
cf start mgruc-pcf-concert-app
cf start mgruc-pcf-chat-app
cf apps

Wenn wir nun die Chat-App verwenden (Nachrichten speichern, Wetter-Daten anfragen usw.), dann wird Zipkin die Abhängigkeiten und Latenzen visualisieren, Eureka wird alle Service-Instanzen zeigen, und am Hystrix Dashboard kann man erkennen, wo eine Kommunikation gerade nicht funktioniert.

Abbildung 6: PCF Service Discovery

Abbildung 7: PCF Hystrix

Der Code, der zusätzlich Zipkin nutzt, ist im weather-app-with-zipkin, concert-app-with-zipkin und chat-app-with-zipkin Ordner des Repositories zu finden.

Verteiltes Tracing und Monitoring in Kubernetes

Im Falle von Kubernetes verwenden wir Zipkin natürlich als Docker Container (Listing 43).

kubectl delete deployment weather-app
kubectl delete deployment concert-app
kubectl delete deployment chat-app

kubectl create -f zipkin.yaml
kubectl expose deployment zipkin --type="LoadBalancer"
kubectl get service zipkin

kubectl create -f weather-app-v2.yaml
kubectl create -f concert-app-v2.yaml
kubectl create -f chat-app-v2.yaml

kubectl get service chat-app
kubectl get service zipkin

Wenn wir nun die Chat-App verwenden (Nachrichten speichern, Wetter-Daten anfragen usw.), dann wird Zipkin die Abhängigkeiten und Latenzen ebenso visualisieren. Ich überspringe hier das Hystrix-Dashboard-Beispiel, da die Konzepte klar sein sollten. Mit Hilfe von Turbine kann dies aber natürlich auch in Kubernetes realisiert werden.

Clean up

Die freien Accounts von Pivotal und Google sollten ausreichen, um diese Beispiele auszuführen und noch vieles mehr. Dennoch empfehle ich das saubere Entfernen aller Komponenten. Die Dienste sind nicht dauerhaft kostenlos.

Clean up in PCF

Innerhalb von PCF sollten die folgenden Befehle (Listing 44) das meiste aus den Beispielen entfernen. Ich empfehle dennoch zu kontrollieren, dass auch wirklich alles weg ist, nachdem diese Befehle ausgeführt wurden.

cf delete updatedemo
cf delete-service autoscaler
cf delete mgruc-pcf-chat-app
cf delete mgruc-pcf-weather-app
cf delete mgruc-pcf-concert-app
cf delete mgruc-pcf-zipkin
cf delete-service redis
cf delete-service mgruc-circuit-breaker-dashboard
cf delete-service mgruc-service-registry
cf delete-service my-logstash

Clean up in Kubernetes

Innerhalb von Kubernetes sollten die folgenden Anweisungen (Listing 45) das meiste aus den Beispielen entfernen. Ich empfehle dennoch auch hier zu kontrollieren, ob nach der Ausführung der Befehle wirklich alles entfernt wurde.

kubectl delete service weather-app
kubectl delete deployment weather-app
kubectl delete service concert-app
kubectl delete deployment concert-app
kubectl delete service chat-app
kubectl delete deployment chat-app
kubectl delete service redis
kubectl delete deployment redis
kubectl delete service zipkin
kubectl delete deployment zipkin
gcloud container clusters delete example-cluster

Fazit

Von monolithischen Anwendungen zu Microservices zu wechseln, ist mit einer großen Änderung der Komplexität verbunden. Zum Glück gibt es eine Vielzahl von Frameworks, die diesen Wechsel leicht machen. In diesem Tutorial konnte ich natürlich nur an der Oberfläche einiger der wichtigsten Frameworks kratzen, um eine ausfallsichere und skalierbare Microservice-Architektur aufzusetzen. Ich konnte hoffentlich trotzdem einige grundlegende Prinzipien aufzeigen und an praktischen Beispielen den Einsatz verdeutlichen.

Geschrieben von
Michael Gruczel
Michael Gruczel
Michael Gruczel ist seit 2008 als Berater und Entwickler im Bereich Java und DevOps tätig. Als Advisory Consultant von DellEmc berät er Firmen in der Modernisierung von Infrastruktur, Software-Stacks, Softwarearchitekturen und der Optimierung von Strukturen. Dazu gehört der volle Entwicklungszyklus: Von der Discovery, über die Entwicklung, das Testen, das Veröffentlichen bis hin zum Betreiben von Software.
Kommentare
  1. Gregor Wolf2017-07-07 08:10:12

    Hallo Hr. Gruczel,

    vielen Dank für diesen umfangreichen und tiefgehenden Beitrag. In den Quelltextabschnitten ist manchmal ein < statt < zu sehen. Vielleicht kann man dies um eine bessere Lesbarkeit zu erreichen noch korrigieren.

    Grüße
    Gregor Wolf

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.