Hilla-Anwendungen in Produktion ausliefern. Teil 3: CI/CD

René Wilby | 30.09.2024 Min. Lesezeit

Artikelreihe

Dies ist der dritte von vier Artikeln, die verschiedene Aspekte der Auslieferung von Hilla-Anwendungen in Produktion beschreiben:

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:

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:

Erweiterte Pipeline

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.