Über unsMediaKontaktImpressum
François Fernandès & Matthias Seifert 12. Mai 2022

Vom Code in die Cloud

Die Deployments von Cloud-Anwendungen sind häufig technologisch überladen, langsam und vor allem schwer zu verstehen. Die Verwendung unnötig komplizierter Deployment-Pipelines erschwert Änderungen oder das Finden von Fehlern. Es geht einfacher, leichter verständlich und zuverlässiger. Schauen wir uns an, wie!
 

Die Verwendung von Continuous Integration (CI) findet sich eigentlich in jedem modernen Projekt. Seltener ist Continuous Delivery (CD) zu finden, sprich nicht nur der Bau der Anwendung, sondern auch ein kontinuierlicher Rollout. Gerade beim vollständig automatisierten Deployment hapert es häufig. Die gebaute Anwendung wird dann zwar in einem Maven-Repository abgelegt, muss aber manuell ausgerollt werden. Hier bietet Kubernetes deutliches Verbesserungspotenzial. Mit der konsistenten Struktur von Workload in Kubernetes-Clustern und dem API-getriebenen Ansatz von Kubernetes lassen sich Automatisierungen von Deployments einfach, schnell und vor allem zuverlässig realisieren. Der gewählte Lösungsweg sollte dabei immer zu dem eigentlichen Projekt passen und die Komplexität des Projekts nicht unnötig erhöhen. Viel zu häufig sieht man umfangreiche Helm-Charts, die mit jedem Deployment ausgerollt werden, wobei eigentlich nur das jeweilige Container-Image aktualisiert wird.

In diesem Artikel zeigen wir ein einfaches, zuverlässiges und gleichzeitig leicht verständliches Setup. Erklärtes Ziel ist dabei, dass jeder Commit auf den Master-Branch automatisch die Anwendung kompiliert, testet, ein Container-Image baut und in einem Kubernetes-Cluster ausgerollt. Eine Übersicht über das angestrebte Setup ist in Abb. 1 dargestellt.

Für Code-Hosting, Continuous Integration und Container-Registry wird das Angebot von GitLab verwendet. Dieses bietet den Vorteil, dass es einfach zu bedienen und in vielen Fällen kostenlos ist. Die Cloud-Zielumgebung ist ein Kubernetes-Cluster bereitgestellt durch die Google Kubernetes-Engine (GKE). Das beschriebene Setup ist exemplarisch zu verstehen und nicht auf die verwendeten Anbieter beschränkt. Mit kleinen Anpassungen kann diese Pipeline auch auf anderen Plattformen verwendet werden. Der Code zur Anwendung sowie die finale Build- und Deployment-Konfiguration sind im GitLab-Projekt verfügbar [1].

Das Beispielprojekt

Um die Deployment-Pipeline ausführen zu können und auch einen echten Service in die GKE auszurollen, wird ein Beispielservice verwendet. Dabei handelt es sich um einen PDF-Renderservice, der über einen REST-Endpunkt eine Datei und eine Seitennummer entgegennimmt und die gewünschte Seite des Dokuments als PNG zurückgibt.

Diese Spring-Boot-Anwendung verwendet Apache PDF-Box, um das hochgeladene PDF einzulesen und die gewünschte Seite in ein BufferedImage zu rendern. Die Konvertierung des BufferedImage in ein PNG übernimmt eine MessageConverterBean. Tritt während des Renderings ein Fehler auf, wird durch einen ExceptionHandler im Controller eine dafür passende Antwort generiert. Auch wenn das Projekt und dessen Funktionalität durchaus spannend ist, stehen der Bau und das Deployment der Anwendung im Mittelpunkt der Betrachtung. Als Build-System kommt Maven zum Einsatz. Der Aufschrei ist natürlich auch beim Verfassen des Artikels zu hören. Selbstverständlich lassen sich die im folgenden beschriebenen Schritte auch mit Gradle abbilden.

Bau des Container-Image

Um diese Spring-Boot-Anwendung in der Google Kubernetes Engine ausrollen zu können, muss diese als Container-Image in der GitLab-Registry hinterlegt werden. Zur Generierung von Container-Images gibt es viele Möglichkeiten. Einer der gebräuchlichsten Wege ist, unter Verwendung des Spring-Boot-Maven-Plugins ein ausführbares JAR zu erzeugen, das dann über einen Docker-Build in ein Container-Image verpackt wird. Der notwendige Ablauf ist schematisch in Abb. 3 dargestellt.

Dieser Ansatz erfordert ein Dockerfile ähnlich dem in Listing 1 gezeigten, sowie einen zusätzlichen Schritt in der CI-Pipeline – den docker build.
 
Listing 1: Ein exemplarisches Dockerfile

FROM openjdk:11
ADD pdf2png.jar pdf2png.jar
EXPOSE 8000
CMD java -jar /pdf2png.jar

Das Beispiel verwendet openjdk:11 als Basis. Das JAR der Anwendung – pdf2png.jar – wird in das Wurzelverzeichnis kopiert. Beim Start des Containers wird dann der Befehl java -jar /pdf2png.jar ausgeführt. Dieses Vorgehen bringt allerdings gleich mehrere neue Aufgaben mit sich. Neben der Entwicklung und Versionierung unserer eigentlichen Anwendung muss nun zusätzlich das dazugehörige Dockerfile entwickelt und gepflegt werden.

In diesem Fall muss damit zwingend ein Docker Daemon auf dem CI-System installiert und für den Build-Job verfügbar sein. Auf den ersten Blick klingt das unkritisch, allerdings basiert Docker selbst auf Konzepten des Linux-Kernels. Damit interagiert der Build mit systemnahen Komponenten, was die Stabilität und Sicherheit des CI-Systems beeinflussen kann.

Ein weiterer Aspekt ist das erzeugte Image selbst. Abb. 4 zeigt einen Blick auf das erzeugte Container-Image. Das Kopieren der JAR-Datei resultiert in einem Layer mit einer Größe von 25MB, der mit jedem Build neu erzeugt und nach jedem Build auf dem Ziel-System heruntergeladen werden muss. Auch wenn es in diesem Fall nur wenige Megabyte sind, erreichen komplexe Anwendungen schnell mehrere hundert Megabyte, die bei jedem Release auf jedes Zielsystem übertragen werden müssen.

JIB to the rescue

Besser wäre also, ein Container-Image zu bauen, ohne ein explizites Dockerfile zu schreiben (und dieses zu pflegen) und ohne einen Docker Daemon auf dem CI-System installieren zu müssen. Noch besser wäre es, die Layer des Container-Image so aufzubauen, dass nicht für jede kleine Änderung an der Code-Basis mehrere hundert Megabyte für den neuen Layer verteilt werden müssen.

All das liefert das JIB-Maven-Plugin [2]. Das JIB-Maven-Plugin ist Teil der Google-Container-Tools und wird als Teil des Maven-Builds ausgeführt. Das Besondere hierbei ist: das JIB-Maven-Plugin benötigt kein explizites Dockerfile und erfordert keinen Docker-Daemon auf dem CI-System.

Statt im Dockerfile erfolgt die Konfiguration des Container-Images in der Konfiguration des JIB-Plugins in der pom.xml des Projekts. Listing 2 zeigt die für dieses Projekt verwendete Konfiguration.

Listing 2: JIB-Konfiguration in der pom.xml

<plugin>
  <groupId>com.google.cloud.tools</groupId>
  <artifactId>jib-maven-plugin</artifactId>
  <version>3.2.0</version>
  <executions>
    <execution>
      <phase>deploy</phase>
      <goals>
        <goal>build</goal>
      </goals>
    </execution>
  </executions>
  <configuration>
    <to>
      <image>registry.gitlab.com/dxfrontiers/from-code-to-cloud/backend</image>
      <tags>
        <tag>latest</tag>
        <tag>${project.version}</tag>
      </tags>
      <auth>
        <username>${env.CI_REGISTRY_USER}</username>
        <password>${env.CI_REGISTRY_PASSWORD}</password>
      </auth>
    </to>
  </configuration>
</plugin>

Mit dieser Konfiguration wird beim Aufruf von mvn deploy ein Container-Image für die Anwendung erstellt und mit zwei Tags versehen: "latest" und die Version aus der pom.xml. Anschließend wird das Container-Image mit diesen Tags direkt in die GitLab Container Registry hochgeladen. Die Anmeldedaten für die GitLab Container Registry kommen aus Umgebungsvariablen, die durch die GitLab CI-Pipeline bereitgestellt werden.
 
Es sticht besonders ins Auge, dass im Gegensatz zum Dockerfile kein Base-Image definiert ist. Trotzdem läuft der Build durch das gebaute Container-Image. JIB verwendet standardmäßig ein Java-Base-Image des Distroless-Projekts [3]. Wie der Name andeutet, handelt es sich dabei um ein Container-Image, das keine (Linux-)Distribution enthält, sondern nur die wirklich benötigten Bestandteile. Und eine Java-Anwendung benötigt: Java – mehr nicht. Images wie openjdk:11 enthalten dagegen eine mehr oder weniger vollständige (Linux-)Distribution mit verschiedenen Shells und teilweise sogar Paket-Management-Tools wie apt oder rpm.

JIB wählt aber nicht nur das Base-Image geschickt aus. Auch bei der Strukturierung der Layer des Container-Images verfolgt das JIB Maven-Plugin eine geschickte Strategie. Während das handgeschriebene Dockerfile nur einen Layer mit der Java-Anwendung enthält, erzeugt das JIB Maven-Plugin für die gleiche Applikation drei Layer im Docker-Image, wie in Abb. 6 schematisch dargestellt.

Die Reihenfolge der Layer orientiert sich dabei daran, wie wahrscheinlich sich diese ändern. Am häufigsten ändern sich in einer Applikation die kompilierten Klassen. Diese bilden deswegen den obersten Layer. Etwas weniger häufig werden Ressourcen wie die application.yml eines Spring-Boot-Projekts geändert, gefolgt von den Abhängigkeiten. Aus diesem Grund sind diese Teile der Applikation im Container-Image tiefer geschichtet. Durch diese geschickte Anordnung der Layer wird der Rollout bei Updates durch die geringere Downloadgröße beschleunigt, da nur die Layer übertragen werden müssen, die sich tatsächlich geändert haben.

Auch wenn JIB mit dem Distroless Base-Image von Haus aus eine effiziente Basis für Java-Applikationen wählt, gibt es Anwendungsfälle, in denen ein anderes Base-Image verwendet werden soll oder weitere Ressourcen in das Image eingebunden werden müssen. Das JIB Maven-Plugin steht einem Docker-Build hinsichtlich der Mächtigkeit in nichts nach. Nahezu alles, was in einem Docker-Build möglich ist, ist auch mit JIB realisierbar. Ein Blick in die gute Dokumentation des JIB Maven-Plugins lohnt sich in jedem Fall.

GitLab-Build

Mit einem funktionierenden Applikations-Build auf Basis von Maven muss nun eine GitLab CI-Pipeline eingerichtet werden, um den Build bei Änderungen an der Code-Basis anzustoßen. Die Einrichtung der Pipeline ist sehr einfach gehalten, da lediglich eine .gitlab-ci.yml im entsprechenden Format vorliegen muss. Das Format dieser Datei sowie die GitLab CI im Allgemeinen ist ausführlich dokumentiert [5].

Für die weitere Betrachtung sind zwei wesentliche Konzepte von GitLab CI wichtig: Stages und Jobs. Bei Stages handelt es sich um die verschiedenen Phasen einer Pipeline. Beispiele wären build, test, integration-test und deploy. Die Stages sind grundsätzlich frei definierbar und werden sequentiell durchlaufen. Das zweite wichtige Konzept sind Jobs. Jobs führen die eigentliche Arbeit der Pipeline aus. Jeder Job wird dabei in einem eigenen Container gestartet, dessen Image frei gewählt werden kann. Damit ergibt sich eine hohe Flexibilität, die im Folgenden zum Einsatz kommen wird. Jeder Job ist einer Stage zugeordnet, eine Stage kann jedoch mehrere Jobs enthalten. Enthält eine Stage mehrere Jobs, werden diese parallel ausgeführt.

In diesem Beispiel genügen zwei Stages für den Bau der Anwendung. Der Maven-Build ist bereits strukturiert und die Aufteilung von Build, Test und dem Bau des Container-Images muss nicht auf mehrere Stages aufgeteilt werden. Die zweite Stage dient dann dem Deployment in einen Kubernetes-Cluster.

Bei jedem Deployment ist eine klare Nachvollziehbarkeit der ausgerollten Applikations-Version unabdingbar. Hier kann Git unterstützen, da jeder einzelne Commit mit einem eindeutigen Commit-Hash versehen ist, der sich hervorragend als Versionsnummer eignet. Das Setzen einer Versionsnummer kann ebenfalls als Teil des Jobs mit dem Maven-Versions-Plugin realisiert werden. Der folgende Aufruf ändert die aktuelle Version der pom.xml zu dem Wert von $VERSION:

mvn org.codehaus.mojo:versions-maven-plugin:2.10.0:set -DnewVersion=$VERSION

Der Commit-Hash, der als Versionsnummer dienen soll, wird dankenswerterweise von GitLab CI ohne weiteres Zutun als Umgebungsvariable CI_COMMIT_SHORT_SHA bereitgestellt. So könnte in dem vorherigen Kommando auch schlicht $VERSION durch $CI_COMMIT_SHORT_SHA ersetzt werden. Für eine bessere Nachvollziehbarkeit soll jedoch auch der Branch, auf dem das Image basiert, als Teil der Versionsnummer aufgenommen werden. Hierfür stellt GitLab CI die Umgebungsvariable CI_COMMIT_REF_SLUG bereit, die den Namen des Branches in URL-kompatibler Form enthält. Eine typische Versionsnummer ist beispielsweise: e194f87c-master. Auf den ersten Blick ist der Commit sowie der Branch erkennbar.

In Listing 3 ist zu sehen, wie die Versionsnummer in der Variables-Sektion der .gitlab-ci.yml erstellt wird. Damit kann diese an beliebigen Stellen in der .gitlab-ci.yml wiederverwendet werden. Das Listing 3 endet mit dem eigentlichen Build-Job, der aus drei Schritten besteht: dem Setzen der Versionsnummer, dem Bau der Anwendung inklusive Ausführung der Tests (mvn clean test) und dem deployment (mvn deploy). Hierbei ist zu beachten, dass das Deployment lediglich das Erzeugen des Artefakts und den Container-Image-Upload betrifft, nicht jedoch das Deployment in einen Kubernetes-Cluster. Dieses kommt etwas später.

Listing 3: Initiale .gitlab-ci.yml

stages:
  - build

variables:
  VERSION: ${CI_COMMIT_SHORT_SHA}-${CI_COMMIT_REF_SLUG}

build:
  stage: build
  image: "openjdk:11"
  script:
    - ./mvnw -s .mvn/settings.xml org.codehaus.mojo:versions-maven-plugin:2.8.1:set -DnewVersion=$VERSION
    - ./mvnw -s .mvn/settings.xml clean test
    - ./mvnw -s .mvn/settings.xml deploy -DskipTests

In Listing 3 fällt außerdem auf, dass bei jedem Maven-Aufruf zusätzlich eine settings.xml angegeben wird. Dies ist notwendig für die Authentifizierung am Maven-Repository von GitLab. In der settings.xml findet sich ein Verweis auf das CI Job-Token, das als HTTP-Header für die Authentifizierung verwendet wird. Mehr dazu finden Sie in der Gitlab-Dokumentation [6].

Damit ist die Anwendung versioniert, gebaut, getestet und als Container-Image bereitgestellt. Bleibt nun nur noch das Deployment, richtig? Nicht ganz.

Aufbau der Zielumgebung

Bevor wir überhaupt mit einem Deployment beginnen können, benötigen wir eine Zielumgebung auf der die Anwendung ausgerollt werden kann. In unserem Beispiel ist das Kubernetes, genauer die Google Kubernetes-Engine, das Cloud-Angebot von Google. Auch wenn die folgenden Beispiele sich auf die Google Kubernetes-Engine beziehen, sind die meisten Schritte für jeden beliebigen Kubernetes-Cluster anwendbar.

Damit es dem GitLab-Build und der Deployment-Pipeline möglich ist, ein Deployment durchzuführen, ist eine Authentifizierung gegen die GKE notwendig. Diese Authentifizierung ist der wesentliche Unterschied zwischen den verschiedenen Anbietern von Kubernetes-Clustern. Je nach Anbieter wird die Authentifizierung über Kubernetes-Bordmittel oder aber durch angebundene Identity-Management-Systeme gelöst. Im Fall der Google Kubernetes-Engine wird die Authentifizierung und Autorisierung über das Google Cloud Identity and Access Management (IAM) realisiert. Im IAM muss ein Service-Account angelegt werden, der entsprechende Berechtigungen für den Zugriff auf den Kubernetes-Cluster erhält. Die notwendigen Schritte werden sehr detailliert von einem Google Engineer beschrieben [8].
 
Als Resultat erhält man eine Kubernetes-Konfiguration kubeconfig.yaml und den Private Key für den Service-Account in Form eines JSON.

Die kubeconfig.yaml enthält in dieser Konfiguration keine geheimen Informationen und kann daher sorgenfrei als Teil des Projekt-Source-Code aufgenommen werden. Im Gegensatz dazu ist der Private Key mit Sorgfalt zu behandeln. Der Authentifizierungs-Mechanismus für die GKE erwartet den Pfad zum JSON mit dem Private Key in der Umgebungsvariable GOOGLE_APPLICATION_CREDENTIALS. Das lässt sich bei GitLab- Pipelines sehr einfach lösen. In den Projekteinstellungen wird die Variable unter Settings > CI/CD > Variables wie in Abb. 7 hinzugefügt. Als Value wird der Inhalt der Private Key Json hinterlegt und den Type auf File gesetzt. Mit der Konfiguration des Type Files wird GitLab angewiesen, den Value vor dem eigentlichen Buildlauf in eine Datei zu schreiben und den Pfad zu dieser Datei in der Umgebungsvariable GOOGLE_APPLICATION_CREDENTIALS zu hinterlegen. Damit ist die spezifische Konfiguration für die GKE erledigt und die folgenden Schritte können wieder auf alle Arten von Kubernetes-Clustern angewandt werden.

Anwendungen in Kubernetes sollten in spezifische Namespaces gruppiert werden. In diesem Fall wird der entsprechende Namespace mittels kubectl create namespace from-code-to-cloud angelegt.

Um vom Kubernetes-Cluster aus auf die Container-Images in der GitLab-Registry zugreifen zu können, wird ein sogenanntes Pull-Secret hinterlegt. Diese Authentifizierungsdaten werden bei GitLab in Form von Deploy-Tokens erstellt, welche unter Settings > Repository > Deploy Tokens angelegt werden können. Abb. 8 zeigt die Erstellung eines passenden Deploy-Tokens für den Benutzernamen gke-pull-token. Wichtig ist, dass das Deploy-Token die read_registry-Berechtigung erhält.

Das erstellte Token wird daraufhin als Secret mit dem Namen gitlab-pull-secret in Kubernetes mit folgendem Kommando hinterlegt:

kubectl create secret docker-registry gitlab-pull-secret \
-n from-code-to-cloud \
--docker-server=registry.gitlab.com \
--docker-username=gke-pull-token \
--docker-password=<TOKEN>

Was nun noch fehlt ist ein initiales Deployment der Anwendung. Dieses Beispiel besteht dabei aus zwei Bestandteilen, einem Kubernetes-"Deployment"-Objekt und einem "Service"-Objekt. Beide finden sich im Unterverzeichnis deploy des Beispielprojekts. Damit kann die initiale Umgebung mit kubectl apply -f deploy/ erstellt werden. Dieses initiale Deployment unterscheidet sich von Projekt zu Projekt, beispielsweise durch unterschiedliche Konfigurationen oder verwendete Volumes. Für die folgenden Schritte sind jedoch zwei Informationen wichtig: der Name des Deployment-Objekts (hier: backend) und der Name des Containers (hier: from-code-to-cloud). 

Deployment

Die initiale Version des Projekts ist nun in Kubernetes ausgerollt und verfügbar. Nun kommt der letzte Schritt der Pipeline: das kontinuierliche Aktualisieren der Anwendung, wenn neue Images ausgerollt werden. Wie bereits eingangs versprochen, soll genau dieser Schritt besonders einfach sein, denn eigentlich ändert sich bei einer neuen Version lediglich das Container-Image. Die Änderung des Container-Images erfolgt mit dem Aufruf von kubectl set image <deployment-name> <container-name>=<image>. Dabei wird nur das Image in einem "Deployment"-Objekt geändert. Kubernetes sorgt dann für das Herunterladen der benötigten Layer, das Starten von neuen Containern und die Dekommissionierung der alten Container, wenn der Start der neuen erfolgreich war.

Um diesen Schritt in der deploy-Stage der GitLab CI-Pipeline auszuführen, ergänzen wir die .gitlab-ci.yml um den deploy-Job, wie in Listing 4 zu sehen.

Listing 4: die finale .gitlab-ci.yml

stages:
  - build
  - deploy

variables:
  VERSION: ${CI_COMMIT_SHORT_SHA}-${CI_COMMIT_REF_SLUG}
  BACKEND_IMG_NAME: registry.gitlab.com/dxfrontiers/inhaltsarbeit/from-code-to-cloud/backend

build:
  stage: build
  image: "openjdk:11"
  script:
    - ./mvnw -s .mvn/settings.xml org.codehaus.mojo:versions-maven-plugin:2.10.0:set -DnewVersion=$VERSION
    - ./mvnw -s .mvn/settings.xml clean test
    - ./mvnw -s .mvn/settings.xml deploy -DskipTests

deploy:
  stage: deploy
  image:
    name: "bitnami/kubectl:1.22.8"
    entrypoint: [ "" ]
  variables:
    NS: "from-code-to-cloud"
    IMAGE: ${BACKEND_IMG_NAME}:${VERSION}
    KUBECONFIG: kubeconfig.yml
  script:
    - kubectl set image deployment/backend -n $NS from-code-to-cloud=$IMAGE

Für den Job wird nun das bitnami/kubectl-Image verwendet, das im wesentlichen nur ein kubectl-Kommando zur Verfügung stellt. Interessant sind insbesondere die in der .gitlab-ci.yml verwendeten Variablen. Die beiden Variablen NS und IMAGE sind nur zur besseren Übersicht extrahiert. Wichtig ist die Umgebungsvariable KUBECONFIG, die den Pfad zur kubectl-Konfiguration enthält. Die kubeconfig.yml liegt, wie bereits erwähnt, im Repository des Projekts.

Und nun?

Das Projekt ist bereit, die Pipeline ist konfiguriert. Fehlt nur noch die nächste Änderung, die direkt in Produktion gehen soll – innerhalb von Minuten, verständlich und nachvollziehbar. Natürlich ist dieses Projekt vereinfacht. Zudem würde derzeit jeder Build zu einem Deployment führen – unabhängig vom Branch des Commits, auf dem der Build beruht. Das lässt sich natürlich mit GitLab CI entsprechend einschränken. Beispielsweise könnten nur Builds des Master-Branches oder Hotfix-Branches ausgerollt werden. Die Möglichkeiten sind vielfältig.

Autoren

François Fernandès

François Fernandès ist Senior Solution Architect bei Digital Frontiers. Er verfügt über langjährige Erfahrungen als Entwickler und Software-Architekt.
>> Weiterlesen

Matthias Seifert

Matthias Seifert ist als Consultant bei Digital Frontiers tätig. Sein Schwerpunkt liegt auf agiler Softwareentwicklung, vorwiegend im Umfeld von Java und Spring.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben