Artikelreihe
Dies ist der dritte von vier Artikeln, die verschiedene Aspekte der Auslieferung von Hilla-Anwendungen in Produktion beschreiben:
- Teil 1: Production-Build
- Teil 2: Docker-Images
- Teil 3: CI/CD
- Teil 4: Serverless Deployment
Automatisierung
CI/CD-Systeme sind ein fester Bestandteil moderner Softwareentwicklung. Die kontinuierliche Integration von Code-Änderungen in den Haupt-Branch wird oft dafür genutzt, um den aktuellen Stand einer Anwendung auf dem Haupt-Branch zu bauen, statisch zu analysieren, automatisch zu testen, nach Schwachstellen zu durchsuchen und zu taggen. So erhalten Entwickler:innen regelmäßig Feedback zum Zustand des Codes auf dem Haupt-Branch. Die kontinuierliche Auslieferung der Anwendung in verschiedene Umgebungen, wie bspw. Test oder Produktion, erweitert den Feedback-Zyklus um zusätzliche Testszenarien und Rückmeldungen von Anwendern. Viele Tätigkeiten entlang der kontinuierlichen Integration und der kontinuierlichen Auslieferung können von CI/CD-Systemen automatisiert werden, so dass sie beliebig oft und schnell wiederholt werden können.
Diese Vorteile von CI/CD-Systemen greifen natürlich auch bei der Entwicklung von Hilla-Anwendungen. Daher ist es ratsam, für die professionelle Entwicklung von Hilla-Anwendungen ein passendes CI/CD-System zu implementieren. Es existieren viele unterschiedliche Möglichkeiten ein CI/CD-System zu realisieren und die Auswahl des passenden Tools hängt von vielen unterschiedlichen Faktoren ab. So ist beispielsweise GitHub Actions ein sehr beliebtes CI/CD-System für Organisationen, die ihre Entwicklung stark auf GitHub ausgerichtet haben. Ein anderes Beispiel für ein CD/CD-System ist Jenkins.
Im weiteren Verlauf wird ein einfaches CI/CD-System für eine Hilla-Anwendung auf Basis von OpenShift und Tekton gezeigt.
CI/CD am Beispiel OpenShift und Tekton
OpenShift ist eine Plattform für den Betrieb von Container-basierten Anwendungen. Es basiert auf Kubernetes und wird von Red Hat entwickelt. Tekton ist ein Open-Source-Framework zur Erstellung von CI/CD-Systemen. Tekton kann als CI/CD-System in Kubernetes-Umgebungen zum Einsatz kommen. In OpenShift kommt Tekton in Form des OpenShift Pipelines Operators zum Einsatz.
Tekton stellt verschiedene Ressourcen, wie bspw. Pipelines, Tasks, Trigger und EventListener zur Verfügung. Das Zusammenspiel dieser Ressourcen und ihre Konfiguration erfolgt über Custom Resource Definitions (CRD) für Kubernetes in Form von YAML-Dateien.
Eine einfache Tekton-Pipeline
Eine einfache Tekton-Pipeline für eine Hilla-Anwendung könnte aus folgenden Tasks bestehen:
- Code auschecken
- Anwendung kompilieren
- Tests ausführen
Die Implementierung der einzelnen Tasks kann ganz individuell erfolgen. Alternativ können auch bestehende Tasks aus dem Tekton Hub oder so genannte Cluster Resolver verwendet werden. Die zu verwendenden Tasks werden in einer Pipeline ausgeführt. Die nachfolgende Grafik zeigt die Ausführung von 3 Tasks in Reihe in einer Pipeline:
Die zugehörige YAML-Datei für diese Pipeline kann beispielsweise wie folgt aussehen:
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: hilla-production-build-pipeline
namespace: hilla
spec:
params:
- default: 'git@scm.example.com:projects/hilla-production-build.git'
name: repo
type: string
- default: master
name: branch
type: string
tasks:
- name: git-checkout
params:
- name: url
value: $(params.repo)
- name: revision
value: $(params.branch)
- name: subdirectory
value: $(context.pipelineRun.name)
taskRef:
kind: Task
name: git-clone
workspaces:
- name: output
workspace: shared-workspace
- name: maven-compile
params:
- name: MAVEN_IMAGE
value: 'maven:3-eclipse-temurin-21-alpine'
- name: CONTEXT_DIR
value: $(context.pipelineRun.name)
- name: GOALS
value:
- clean
- compile
runAfter:
- git-checkout
taskRef:
kind: Task
name: maven
workspaces:
- name: maven-settings
workspace: maven-settings
- name: source
workspace: shared-workspace
- name: maven-local-repo
workspace: maven-local-m2
- name: maven-test
params:
- name: MAVEN_IMAGE
value: 'maven:3-eclipse-temurin-21-alpine'
- name: CONTEXT_DIR
value: $(context.pipelineRun.name)
- name: GOALS
value:
- test
runAfter:
- maven-compile
taskRef:
kind: Task
name: maven
workspaces:
- name: maven-settings
workspace: maven-settings
- name: source
workspace: shared-workspace
- name: maven-local-repo
workspace: maven-local-m2
workspaces:
- name: shared-workspace
- name: maven-settings
- name: maven-local-m2
Der Task git-checkout
verwendet git-clone. Er klont ein Git-Repo und checkt den gewünschten Branch in ein Workspace-Verzeichnis aus. Dieses Verzeichnis steht allen Tasks innerhalb einer Pipeline-Ausführung (PipelineRun
) zur Verfügung. Nach dem Checkout werden Tasks mit maven ausgeführt. Dieser Task stellt eine Ausführungsumgebung für Maven bereit. Zunächst wird der Task als maven-compile
mit den Maven Goals clean compile
ausgeführt, um die Anwendung zu kompilieren. Anschließend wird der Task als maven-test
ausgeführt, um die automatiserten Tests auszuführen. Beide Tasks verwenden das gleiche Workspace-Verzeichnis und haben somit Zugriff auf den ausgecheckten Code.
Erweiterung für Production-Build
Die gezeigte Pipeline lässt sich flexibel um zusätzliche Tasks erweitern. Das Erzeugen des Production-Builds einer Hilla-Anwendung in Form einer ausführbaren JAR-Datei kann im Anschluss an den Task maven-test
in einem neuen Task maven-package
stattfinden:
- name: maven-package
taskRef:
name: maven
params:
- name: MAVEN_IMAGE
value: 'maven:3-eclipse-temurin-21-alpine'
- name: CONTEXT_DIR
value: $(context.pipelineRun.name)
- name: GOALS
value:
- -DskipTests
- package
- -Pproduction
workspaces:
- name: maven-settings
workspace: maven-settings
- name: source
workspace: shared-workspace
- name: maven-local-repo
workspace: maven-local-m2
runAfter:
- maven-test
Alternativ kann der Production-Build der Hilla-Anwendung auch als Native Image erzeugt werden. Auch hierfür kann der bereits bekannte Task maven
verwendet werden. Der Parameter MAVEN_IMAGE
muss hierfür allerdings ein Image verwenden, welches die Erstellung eines Native Image mit GraalVM unterstützt:
- name: maven-package
taskRef:
name: maven
params:
- name: MAVEN_IMAGE
value: 'ghcr.io/graalvm/native-image-community:21.0.2'
- name: CONTEXT_DIR
value: $(context.pipelineRun.name)
- name: GOALS
value:
- -DskipTests
- package
- -Pproduction
- -Pnative
- native:compile
workspaces:
- name: maven-settings
workspace: maven-settings
- name: source
workspace: shared-workspace
- name: maven-local-repo
workspace: maven-local-m2
runAfter:
- maven-test
Erweiterung für Erstellung des Docker-Images
Der nächste Schritt ist die Erweiterung der Pipeline, um einen Task, der auf Basis des erzeugten Production-Builds ein Docker-Image erstellt. OpenShift bietet hierfür unterschiedliche Ansätze. Im weiteren Verlauf wird der Ansatz verfolgt, auf Basis eines Dockerfile
ein Docker-Image zu bauen. Die Erstellung der dafür erforderlichen BuildConfig und der Start des eigentlichen Builds erfolgt in einem eigenen Task:
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: build-docker-image
namespace: hilla
spec:
params:
- name: context-dir
type: string
steps:
- env:
- name: HOME
value: '/tekton/home'
- name: CONTEXT_DIR
value: $(params.context-dir)
- name: WORKSPACE_SOURCE_PATH
value: $(workspaces.source.path)
image: registry.redhat.io/openshift4/ose-cli-rhel9:v4.16
name: build-docker-image
computeResources:
requests:
cpu: 25m
memory: 128Mi
script: |
#!/usr/bin/env bash
set -eu
cd ${WORKSPACE_SOURCE_PATH}/${CONTEXT_DIR}
BUILD_NAME="hilla-production-build"
oc delete buildconfigs --selector=build=${BUILD_NAME}
oc delete imagestreams --selector=build=${BUILD_NAME}
oc new-build --binary=true --strategy=docker --name=${BUILD_NAME}
oc start-build ${BUILD_NAME} --from-dir=. --follow --wait
securityContext:
runAsNonRoot: true
runAsUser: 65532
workspaces:
- name: source
Der Task verwendet ein Image, welches das Kommandozeilen-Tool oc enthält. Zunächst erzeugt der Task eine neue BuildConfig
. Hierfür wird die CLI oc
mit dem Kommando new-build verwendet. Der Parameter --binary=true
führt zu einem Build, der den erzeugten Production-Build als Input verwendet. Die erstellte BuildConfig
wird anschließend mit dem Kommando start-build referenziert, um den Build zu starten. Alle Dateien aus dem aktuellen Verzeichnis werden dafür in den Build-Prozess kopiert und stehen darin für die Erstellung des Docker-Images zu Verfügung.
Der neue Task build-docker-image
wird nach dem Task maven-package
in die bestehende Pipeline aufgenommen. So ist sichergestellt, dass sich im geteilten Workspace sowohl das erforderliche Dockerfile
(aus dem ausgecheckten Repository) als auch der erzeugte Production-Build (aus dem vorherigen Task) befinden:
- name: build-docker-image
taskRef:
name: build-docker-image
params:
- name: context-dir
value: $(context.pipelineRun.name)
workspaces:
- name: source
workspace: shared-workspace
runAfter:
- maven-package
Der Task build-docker-image
weist natürlich noch einige Unzulänglichkeiten auf. Es fehlt derzeit zum Beispiel noch an einer dynamischen Vergabe des Namens für einen Build inkl. einer passenden Versionierung. Die zu verwendende Version kann aus der pom.xml
ausgelesen werden. Dies kann in einem separaten Task determine-version
erfolgen:
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: determine-version
namespace: hilla
spec:
params:
- name: context-dir
type: string
results:
- name: version
description: Value of project.version in pom.xml
steps:
- env:
- name: HOME
value: '/tekton/home'
- name: CONTEXT_DIR
value: $(params.context-dir)
- name: WORKSPACE_SOURCE_PATH
value: $(workspaces.source.path)
image: eclipse-temurin:21-jre
name: determine-version
computeResources:
requests:
cpu: 50m
memory: 256Mi
script: |
#!/usr/bin/env bash
set -eu
cd ${WORKSPACE_SOURCE_PATH}/${CONTEXT_DIR}
VERSION=$(./mvnw org.apache.maven.plugins:maven-help-plugin:3.5.0:evaluate -Dexpression=project.version -q -DforceStdout)
echo ${VERSION} | tee $(results.version.path)
securityContext:
runAsNonRoot: true
runAsUser: 65532
workspaces:
- name: source
Die ermittelte Version wird als Result veröffentlicht und kann in nachfolgenden Tasks verwendet werden. Der neue Task determine-version
wird daher in der Pipeline nach dem Task maven-package
und vor dem Task build-docker-image
eingebunden:
- name: determine-version
taskRef:
name: determine-version
params:
- name: context-dir
value: $(context.pipelineRun.name)
workspaces:
- name: source
workspace: shared-workspace
runAfter:
- maven-package
Anschließend wird der Task build-docker-image
um die Parameter build-name
und build-version
erweitert:
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: build-docker-image
namespace: hilla
spec:
params:
- name: context-dir
type: string
- name: build-name
type: string
- name: build-version
type: string
steps:
- env:
- name: HOME
value: '/tekton/home'
- name: CONTEXT_DIR
value: $(params.context-dir)
- name: BUILD_NAME
value: $(params.build-name)
- name: BUILD_VERSION
value: $(params.build-version)
- name: WORKSPACE_SOURCE_PATH
value: $(workspaces.source.path)
image: registry.redhat.io/openshift4/ose-cli-rhel9:v4.16
name: build-docker-image
computeResources:
requests:
cpu: 25m
memory: 128Mi
script: |
#!/usr/bin/env bash
set -eu
cd ${WORKSPACE_SOURCE_PATH}/${CONTEXT_DIR}
oc delete buildconfigs --selector=build=${BUILD_NAME}
oc delete imagestreams --selector=build=${BUILD_NAME}
oc new-build --binary=true --strategy=docker --name=${BUILD_NAME}
oc start-build ${BUILD_NAME} --from-dir=. --follow --wait
securityContext:
runAsNonRoot: true
runAsUser: 65532
workspaces:
- name: source
Bei der Verwendung des Tasks build-docker-image
in der Pipeline werden die beiden neuen Parameter gesetzt. Der Parameter build-name
erhält einen projektspezifischen Wert und der Parameter build-version
erhält das Ergebnis des Tasks determine-version
als Wert:
- name: build-docker-image
taskRef:
name: build-docker-image
params:
- name: context-dir
value: $(context.pipelineRun.name)
- name: build-name
value: hilla-production-build
- name: build-version
value: $(tasks.determine-version.results.version)
workspaces:
- name: source
workspace: shared-workspace
runAfter:
- determine-version
Erweiterung für Veröffentlichung des Docker-Images
Das erstellte Docker-Image muss natürlich noch in einer passenden Container-Registry veröffentlicht werden. Die dafür erforderliche Konfiguration kann dem Kommando new-build
übergeben werden. Dazu wird der Task build-docker-image
in build-and-push-docker-image
umbenannt und erweitert:
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: build-and-push-docker-image
namespace: hilla
spec:
params:
- name: context-dir
type: string
- name: build-name
type: string
- name: build-version
type: string
steps:
- env:
- name: HOME
value: '/tekton/home'
- name: CONTEXT_DIR
value: $(params.context-dir)
- name: BUILD_NAME
value: $(params.build-name)
- name: BUILD_VERSION
value: $(params.build-version)
- name: WORKSPACE_SOURCE_PATH
value: $(workspaces.source.path)
image: registry.redhat.io/openshift4/ose-cli-rhel9:v4.16
name: build-and-push-docker-image
computeResources:
requests:
cpu: 25m
memory: 128Mi
script: |
#!/usr/bin/env bash
set -eu
cd ${WORKSPACE_SOURCE_PATH}/${CONTEXT_DIR}
oc delete buildconfigs --selector=build=${BUILD_NAME}
oc delete imagestreams --selector=build=${BUILD_NAME}
oc new-build --binary=true --strategy=docker --name=${BUILD_NAME} --to-docker=true --to=corporate.registry.example.com/${BUILD_NAME}:${BUILD_VERSION}
oc start-build ${BUILD_NAME} --from-dir=. --follow --wait
securityContext:
runAsNonRoot: true
runAsUser: 65532
workspaces:
- name: source
Die zusätzlichen Parameter --to-docker
und --to
führen dazu, dass das erstellte Docker-Image im Anschluss mit dem Namen ${BUILD_NAME}
und dem Tag ${BUILD_VERSION}
in der Docker-Registry corporate.registry.example.com
veröffentlicht wird. Die Pipeline muss nun noch angepasst werden, damit sie auch den umbenannten Task build-and-push-docker-image
verwendet. Die Pipeline hat nun folgenden Aufbau:
Fazit
Anhand des CI/CD-Systems Tekton in OpenShift wurde mit Hilfe von Tasks und einer Pipeline exemplarisch gezeigt, wie die Erstellung eines Production-Builds einer Hilla-Anwendung und die Erstellung und Veröffentlichung eines darauf aufbauenden Docker-Images erfolgen kann. Der erreichte Grad an Automatisierung erlaubt eine kontinuierliche Auslieferung der Hilla-Anwendung in Produktion ohne manuelle Arbeitsschritte.
Teil 4 der Artikelserie beschreibt, wie eine Hilla-Anwendung, die als Production-Build in einem Docker-Image vorliegt, als Serverless Deployment möglichst effizient und skalierbar in Container-Umgebungen betrieben werden kann.