Continuous Integration mit Docker und GitLab

GitLab, 2011 als webbasierter Git-Repository-Manager gestartet, hat sich mittlerweile zu einer umfangreichen Softwareentwicklungssuite mit einer Vielzahl an Tools weiterentwickelt. Neben einem Issue-Tracker und einer Docker-Registry enthält es mittlerweile auch einen Continuous-Integration-Server mit einer optionalen Kubernetes-Integration. Der Artikel zeigt, wie eine Build- und Deployment-Pipeline aufgebaut werden kann und welche Einzelschritte ein neues Feature vom Issue-Tracker bis zur Produktionsumgebung durchläuft. Als Beispiel dient dabei eine Spring Boot 2-Anwendung, die als Docker-Image paketiert und anschließend auf einem Docker-Host deployt wird.

Initial ist GitLab als Webplattform zum Verwalten von Git-Repositories entstanden. Im Laufe der letzten Jahre kamen ein Issue-Tracker sowie ein Wiki hinzu. Mit GitLab CI wurden anschließend eine Komponente für Continuous Integration & Delivery und mit der Container-Registry eine Ablage für Docker-Images integriert. Weiterhin kam mit Mattermost ein Chat-Tool zur Zusammenarbeit in Teams und der Kommunikation mit Bots hinzu. GitLab existiert in verschiedenen Produktvarianten. Die Grundversion der Software steht als Open Source zur Verfügung. Neu entwickelte Features werden zumeist erst in den kommerziellen Varianten zur Verfügung gestellt und teilweise nach einiger Zeit in der Open-Source-Variante ergänzt.Die einfachste Möglichkeit, GitLab zu verwenden, ist das Erstellen eines kostenlosen Accounts auf der Cloud-Version gitlab.com. Diese verwenden wir auch im Rahmen dieses Artikels. Dort können auch private Repositories gehostet und Build-Pipelines erstellt werden.

GitLab basiert auf einem Konzept Namens „Idea to Production“ (s. Abb. 1), welches die Einzelschritte beschreibt, die ein neues Feature durchläuft, bis es auf einem Produktivsystem deployt wird. Ziel ist es, diesen Prozess weitgehend zu automatisieren und dem Nutzer dabei gleichzeitig einen nachvollziehbaren Überblick über den aktuellen Status zu bieten. Durch ein schnelles Feedback und einem hohen Grad an Automatisierung soll eine schnelle Umsetzung mit gleichzeitig hoher Codequalität erreicht werden.

Abb. 1: Idee – Entwicklung – Produktion
Abb. 1: Idee – Entwicklung – Produktion

Die Beispielanwendung

Im Rahmen des Artikels verwenden wir eine Spring Boot 2-Anwendung, die mit Maven gebaut wird. Für diese Anwendung soll ein neues Feature entwickelt werden. Dafür wird zunächst ein Ticket im GitLab eigenen Issue-Tracker angelegt. Die anschließende Entwicklung des Features erfolgt auf einem Feature-Branch, der bei jedem Commit gebaut und getestet wird. Für das Ausführen der Tests wird eine Postgres-Datenbank benötigt und bereitgestellt. Nach Abschluss der Entwicklung wird der Feature-Branch in den Master gemergt. Anschließend wird der Master-Branch gebaut und die Anwendung als Docker-Image paketiert. Das Image wird in der GitLab eigenen Docker-Registry abgelegt und mittels Docker-Compose auf einem Docker-Host deployt. Der Quellcode der Anwendung ist hier zu finden.

Erstellung von Issues, Merge-Requests und Feature-Branches

Nach der Erstellung eines Tickets im GitLab eigenen Issue-Tracker, sobald mit der Entwicklung begonnen werden soll, kann über die GitLab-Oberfläche ein sogenannter Merge-Request und ein zugehöriger Git-Feature-Branch erstellt werden (s. Abb. 2). Ein Merge-Request ist quasi ein Antrag, einen Feature-Branch in einen anderen Branch zu mergen. Vor Abschluss der Entwicklung des Features wird dieser Merge-Request jedoch zunächst mit Work-in-Progress (WIP) gekennzeichnet, sodass klar ist, dass daran aktuell noch gearbeitet wird. Der Feature-Branch kann nach jedem Commit automatisch gebaut, getestet und bei Bedarf auch deployt werden. Sofern die verwendete Deployment-Umgebung dies unterstützt, ist es möglich, für einen Feature-Branch eine spezifische Review-Umgebung zu erstellen, auf der der aktuelle Entwicklungsstand unter einer eindeutigen URI erreichbar ist. Nach Abschluss der Entwicklung wird das WIP-Flag aus dem Merge-Request entfernt. Anschließend kann ein Entwickler die Änderungen reviewen, den Feature-Branch mergen und den Merge-Request schließen, was im Ticket entsprechend gekennzeichnet wird.

Abb. 1: Ticket in GitLab
Abb. 1: Ticket in GitLab

Erstellung einer Pipeline

Nach jedem Commit durchläuft das Projekt die Build-Pipeline. Eine Build-Pipeline in GitLab besteht aus Stages und Jobs. Zunächst müssen die Build-Pipelines in der GitLab-Weboberfläche aktiviert werden (Settings -> General -> Permissions -> Pipelines). Die deklarative Beschreibung der eigentlichen Pipeline befindet sich in einer Datei namens „.gitlab-ci.yml“, welche sich mit dem eigentlichen Projekt-Quellcode im Repository befindet. Die Stages definieren die Reihenfolge der Pipeline. Sie werden am Anfang der Datei „.gitlab-ci.yml“ definiert und können dann in den Jobs referenziert werden. Hierdurch wird definiert, in welcher Stage ein Job ausgeführt wird. Ein Job hat genau eine Stage, eine Stage wiederum kann mehrere Jobs enthalten – diese werden dann parallel ausgeführt. Eine Definition von Stages sieht beispielsweise so aus:

stages:
–build
–test
–package
–deploy
Abb. 3: Pipeline
Abb. 3: Pipeline

Aus obigen Stages (und zugeordneten Jobs) wird in der Weboberfläche die in Abbildung 3 gezeigte Darstellung generiert. Ein Job ist ein einzelner Build-Schritt, der sich mindestens aus den folgenden Komponenten zusammensetzt:

  • Der Name eines Jobs muss innerhalb der Datei „.gitlab-ci.yml“ eindeutig sein (hier build_jar).
  • stage: Die Stage, in welcher der Job ausgeführt werden soll. Sind Jobs voneinander abhängig, sollten diese demnach in aufeinanderfolgenden Stages ausgeführt werden.
  • image (optional): Der Name des Docker-Images, welches die Build-Umgebung definiert. Neben vorhandenen Images, zum Beispiel vom Docker-Hub, können auch zuerst eigene Images erstellt werden, welche zusätzliches Tooling enthalten. (hier maven:3-jdk-8). Wenn kein Image im Job selbst definiert worden ist, muss dies global, am Anfang der Datei „.gitlab-ci.yml“ definiert worden sein.
  • script: Hier werden die eigentlich Befehle für diesen Job definiert. Es können mehrere Befehle angegeben werden, die dann nacheinander ausgeführt werden.
  • artifacts (optional): Nachdem ein Job ausgeführt wird, wird der komplette Build-Kontext (der Docker-Container) wieder abgebaut. Manchmal ist es notwendig, dass Ergebnisse des Builds darüber hinaus aufbewahrt werden. Dies lässt sich über das artifacts-Statement konfigurieren.
  • tags (optional): Ähnlich wie bei anderen CI-Systemen lassen sich Jobs auf spezifischen Runnern ausführen. Das Mapping zwischen Jobs und Runnern erfolgt über einen oder mehrere Tags. Werden keine Tags angegeben, so läuft der Build auf sogenannten „shared Runnern“, so diese in GitLab CI definiert worden sind.

GitLab stellt für die Builds einige Umgebungsvariablen bereit, beispielsweise CI_COMMIT_REF_NAME (Branch-Name) oder CI_COMMIT_SHA (git Commit SHA), zusätzlich können in der „.gitlab-ci.yml“ oder über die Weboberfläche weitere Variablen definiert werden. Um sie zu verwenden, muss dem Variablennamen jeweils ein $ vorangestellt werden.

Build-Job

Als ersten Job bauen wir das Projekt und verpacken es in eine jar-Datei:

build_jar:
stage: build
image: maven:3-jdk-8
script:
 – mvn install
artifacts:
 paths:
– target/*.jar
tags:
 – docker

Das resultierende Artefakt kann im Anschluss bequem über die GitLab-Weboberfläche als Datei heruntergeladen werden. Moderne Build-Systeme verwenden zahlreiche Abhängigkeiten, welche erst aus dem Internet heruntergeladen werden müssen, bevor der eigentlich Build-Prozess starten kann. Dies dauert oftmals mehrere Minuten. Da sich diese Abhängigkeiten im Laufe der Zeit selten ändern, lohnt es sich, die bereits heruntergeladenen Dateien zu cachen und jeweils vor dem Start des Jobs dem Kontext wieder zur Verfügung zu stellen, um den Build zu beschleunigen. GitLab CI bietet hier die Möglichkeiten an, Abhängigkeiten entweder auf Job- oder auf Pipeline-Ebene zu cachen. In diesem Beispiel verwenden wir das Cachen auf Pipeline-Ebene: Alle Jobs dieser Pipeline verfügen über einen gemeinsamen Pfad, welcher über alle Jobs hinweg verwendet werden kann. Die Konfiguration dafür findet auf der obersten Ebene in der Datei „.gitlab-ci.yml“ statt:

cache:
key: "$CI_COMMIT_REF_NAME"
untracked: true
paths:
 – .m2/repository

Alle Jobs dieser Pipeline bekommen das Verzeichnis „.m2/repository“ zur Verfügung gestellt. Damit dieses von Maven dann auch verwendet wird, sollte es über eine Variable am Anfang der Pipeline-Definition ebenfalls angegeben werden:

variables:
MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"

Test

Als Nächstes erstellen wir einen Job zum Ausführen von Integrationstests. Für die Tests wird eine Postgres-Datenbank benötigt. Deshalb wird zur Laufzeit des Jobs ein zusätzlicher Docker-Container mit der Datenbank gestartet. Wir verwenden dafür das Standard-Postgres-Image von DockerHub. Dieses bekommt drei Variablen übergeben:

  • den Namen der anzulegenden Datenbank (POSTGRES_DB),
  • den Namen des anzulegenden Benutzers (POSTGRES_USER) und
  • dessen Passwort (POSTGRES_PASSWORD). Die Definition dieser Variablen erfolgt über den Abschnitt
variables:
MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"
POSTGRES_DB: enco
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
...
integration_test1:
stage: test
image: maven:3-jdk-8
services:
 - postgres:9.6
script: 
 - mvn test -Dspring.profiles.active=cibuild -P test_connection_api

Damit steht für den Build eine Datenbank zur Verfügung, die wir aus der Anwendung heraus ansprechen können. Das Spring-Profil cibuild, welches über die Datei „application.yml“ definiert wird, dient dazu, Parameter festzulegen, die spezifisch für die Build-Umgebung sind. Wir hinterlegen dort den Hostnamen postgres der im Docker-Container laufenden Datenbank:

spring:
  profiles: cibuild
db_host: postgres

Tests lassen sich parallel ausführen, indem sie der gleichen Stage (hier test) zugeordnet werden:

integration_test1:
  stage: test
  image: maven:3-jdk-8
  services:
   - postgres:9.6
  script:
   - mvn test -Dspring.profiles.active=cibuild -P test_connection_api
...
integration_test2:
  stage: test
  image: maven:3-jdk-8
  services:
   - postgres:9.6
  script:
   - mvn test -Dspring.profiles.active=cibuild -P test_selling_api

Bisher bietet GitLab keine eigene Möglichkeit, sich den Verlauf der Testabdeckung über verschiedene Builds hinweg in einem Diagramm visualisieren zu lassen. Man kann sich aber die Testabdeckung über die Weboberfläche ausgeben lassen, indem man unter „Settings -> CI/CD -> General Pipeline Settings -> Test coverage parsing“ einen regulären Ausdruck angibt, mit dem die Testabdeckung in der Log-Ausgabe des Builds gefunden werden kann.

Paketieren des Docker-Images

Meistens möchte man aber nicht nur die Anwendung als Archive zur Verfügung stellen, sondern gleich an das Bauen und Paketieren ein automatischen Deployment anhängen. Hierzu bietet es sich an, ein Docker-Image der gebauten Anwendung zu erstellen. Dieses Image wird in der GitLab-Docker-Registry hinterlegt und kann dann für weitere Build-Schritte verwendet werden. Der Build eines Docker-Images wird meistens über ein Dockerfile spezifiziert. Im einfachsten Fall kann diese (z. B. für eine Spring Boot-Anwendung) so aussehen:

FROM openjdk:8-jre-alpine
WORKDIR /app
COPY target/application.jar /app
EXPOSE 8080
CMD java -Dspring.profiles.active=docker -jar application.jar

Das Bauen des Docker-Images und das anschließende Pushen in die Docker-Registry finden ebenfalls über einen definierten Schritt in der Datei „.gitlab-ci.yml“ statt:

docker_push:
  stage: package
  image: docker:latest
  services:
   - docker:dind
  script:
   - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
   - docker build -t $DOCKER_IMAGE_TAGGED .
   - docker push $DOCKER_IMAGE_TAGGED
  dependencies:
   - test

Der Build-Schritt enthält ein paar Variablen, die zur Laufzeit im Build-Prozess zur Verfügung stehen. Die Anmeldung an die GitLab interne Docker-Registry ($CI_REGISTRY) findet über einen Token $CI_JOB_TOKEN statt. Dieser Token ist nur für einen kurzen Zeitraum gültig (per Default bis 5 Minuten nach dem Beenden des Build-Jobs). $DOCKER_IMAGE_TAGGED ist eine selbst definierte Variable:

variables:
  DOCKER_IMAGE_TAGGED: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  DOCKER_IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest

Die Variable referenziert das aktuelle Docker-Image: registry-url/image:version. Die Version ist hier der aktuell $CI_COMMIT_SHA. Hierdurch lässt sich jedes Docker-Image eindeutig einem bestimmten Codestand zuordnen. Build-Jobs lassen sich ebenfalls auf bestimmte Branches (Master oder nicht) beschränken. Oftmals möchte man einen Release-Build speziell taggen (z. B. durch ein Docker-Image mit dem Tag lastest). Dies lässt sich mit der folgenden Konfiguration erreichen:

docker_tag:
  stage: package
  image: docker:latest
  services:
   - docker:dind
  script:
   - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
   - docker build -t $DOCKER_IMAGE_TAGGED .
   - docker push $DOCKER_IMAGE_TAGGED
   - docker tag $DOCKER_IMAGE_TAGGED $DOCKER_IMAGE_LATEST
   - docker push $DOCKER_IMAGE_LATEST
  only:
   - master

Dieser Schritt wird nur bei Änderungen im Master-Branch ausgeführt. Das im vorherigen Schritte gebaute Docker-Image wird aus der Registry gepullt, mit dem latest-Tag versehen und wieder in die Registry gepusht. Im Normalfall sollten hier keine Daten hochgeladen werden müssen, sondern nur der latest-Tag neu referenziert werden.

Verschiedene Pipelines für Master- und Feature-Branches

GitLab CI sieht als Konvention vor, dass alle Änderungen im Code in einem Deployment resultieren. Änderungen an einem Feature-Branch werden als sogenannte Review Application deployt und können dann nach dem Review in den Master-Branch gemerged werden. Änderungen im Master-Branch führen zu einem Deployment in die eigentliche Umgebung – zumeist erst in Staging und danach in Produktion. In der GitLab CI DSL lassen sich Jobs so konfigurieren, dass sie nur auf Änderungen reagieren, die nur, oder nicht im Master auftreten (dazu später mehr). Dies führt zu zwei unterschiedlichen Pipelines, jeweils für ein Deployment aus einem Feature- (s. Abb. 4) oder einem Master-Branch (s. Abb. 5). Das Deployment der Anwendung selbst ist für GitLab CI hierbei vollkommen transparent. Im aktuellen Beispiel wird das Deployment durch docker-compose durchgeführt. Hierbei führt ein docker-compose up -d zu einem neuen Deployment. Ein docker-compose down entfernt ein zuvor getätigtes Deployment wieder. Das Skript „prepare-docker-compose.sh“ sorgt dafür, dass die frisch deployte Anwendung unter der in APP_DEPLOY_URL stehenden URL erreichbar ist.

Abb. 4: Branch-Deployment
Abb. 5: Master-Deployment

Deployment eines Master-Branches

Abschließend möchten wir den Release-Zustand des Images deployen. Das Deployment soll gleichzeitig als Eintrag im Environment-Bereich der Weboberfläche zu finden sein:

variables:
...
  APP_URL: $CI_PROJECT_NAME.example.com
...
deploy_prod:
  stage: deploy
  image: docker-compose:alpine
  variables:
    APP_DEPLOY_URL: $APP_URL
  script: - sh ./scripts/prepare-docker-compose.sh > docker-compose.yml
   - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
   - docker-compose -p $CI_PROJECT_NAME up -d --remove-orphans
  environment:
   name: production
   url: https://$APP_URL
  only:
   - master

Wie bereits erwähnt, wird in diesem Beispiel das Deployment über eine docker-compose-Datei durchgeführt. Die Variable $APP_URL definiert eine URL, unter der alle Deployments des Projektes erreichbar sind. Dieser Link wird sowohl dynamisch in der Datei „docker-compose.yml“ verwendet als auch in der Definition der production-Umgebung.

Deployment von Feature-Branches

Neben dem finalen Deployment eines Master-Branches in Produktion bietet GitLab CI auch die Möglichkeit, einzelne Feature-Branches als Review-Deployments bereitzustellen. Hierzu sind zwei Angaben notwendig:

  • der eigentliche Deployment-Schritt, welcher sehr ähnlich zum Deployment eines Master-Branches ist,
  • ein Schritt, der definiert, wie ein vorhandenes Deployment wieder rückgängig gemacht werden kann.

Letzteres ist sowohl notwendig, wenn ein Review-Deployment manuell beendet wird, als auch dann, wenn ein Feature-Branch in den Master-Branch gemergt wird:

deploy_review:
  stage: deploy
  image: docker-compose:alpine
  variables:
   APP_DEPLOY_URL: $CI_ENVIRONMENT_SLUG.$APP_URL
  script:
   - sh ./scripts/prepare-docker-compose.sh > docker-compose.yml
   - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
   - docker-compose -p $CI_PROJECT_NAME-$CI_COMMIT_REF_SLUG up -d
  dependencies:
   - docker_push
  environment:
   name: review/$CI_COMMIT_REF_NAME
   url: https://$CI_ENVIRONMENT_SLUG.$APP_URL
   on_stop: stop_review_app
  except:
   - master

Im Unterschied zum Deployment eines Master-Branches ist hier die URL des Deployments abhängig vom Branch-Namen. Der Environment-Name review/$CI_COMMIT_REF_NAME bewirkt zum einen, dass alle Review-Deployments in der Weboberfläche in einer Gruppe review zusammengefasst werden, zum anderen gibt die Variable $CI_COMMIT_REF_NAME den aktuellen Namen des Feature-Branches an 3-add-loginvia-account und ermöglicht so eine einfache Zuordnung. $CI_ENVIRONMENT_SLUG ist hier ein String von maximal 64 Zeichen Länge, der sich aus dem Branch-Namen ableitet (z. B. review-3-addlogi-2w6jqu aus dem Branch-Namen 3-add-login-via-account). Weiterhin ist beim Environment eine on_stop-Action angegeben. Diese ist wie folgt definiert:

stop_review_app:
  stage: stop
  image: docker-compose:alpine
  variables:
    APP_DEPLOY_URL: $CI_ENVIRONMENT_SLUG.$APP_URL
  script:
   - sh ./scripts/prepare-docker-compose.sh > docker-compose.yml
   - docker-compose -p $CI_PROJECT_NAME-$CI_COMMIT_REF_SLUG down
  when: manual
  environment:
   name: review/$CI_COMMIT_REF_NAME
   action: stop
  except:
   - master

Diese Aktion führt ein docker-compose down basierend auf dem aktuellen Deployment-Kontext durch. Das when: manual definiert, dass diese Aktion manuell über die Weboberfläche ausgeführt werden kann. action: stop im environment-Bereich definiert weiterhin, dass diese Aktion ebenfalls ausgeführt wird, wenn die spezifische Umgebung gestoppt wird.

Zusammenfassung

GitLab stellt eine komfortable Softwareentwicklungssuite bereit, mit der sich über die Verwaltung von Tickets, dem automatischen Build von Feature-Branches, der flexiblen Konfiguration von gekapselten und reproduzierbaren Build-Umgebungen, der Ablage von Docker-Containern in einer Registry und der Unterstützung von manuellen Review-Schritten für automatische Deployments die meisten Anforderungen an einen Entwicklungs- und Deployment-Prozess abdecken lassen.

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more