Wie baut man eine CI/CD Pipeline mit Jenkins auf?

Cedric Gottschalk, Ronja Brauchle

Im Rahmen der Vorlesung “System Engineering und Management (143101a)” haben wir es uns zum Ziel gesetzt, mehr über CI/CD Pipelines zu lernen und eine eigene Pipeline für ein kleines Projekt aufzusetzen. Wir haben uns dabei entschieden, Jenkins für die CI/CD Pipeline einzusetzen und eine kleine ToDo App mit dem Framework Flutter zu entwickeln. Im Verlauf des Projektes sind wir dabei auf unterschiedliche Probleme gestoßen, die es zu beseitigen galt.

Jenkins

Jenkins ist ein Open-Source-Tool für Continuous Integration (CI), Continuous Delivery/Deployment (CD), Automatisierung und DevOps. Mit Jenkins lassen sich CI/CD-Pipelines erstellen, um Software-Builds, Tests und Deployments zu automatisieren. Als flexibler Automatisierungsserver kann Jenkins sowohl als einfacher CI-Server als auch als umfassender Continuous-Delivery-Hub eingesetzt werden. Durch eine Vielzahl an Plugins lässt sich Jenkins in weitere Tools der kontinuierlichen Integration und Bereitstellung integrieren.

Weitere Informationen zu Jenkins können unter https://www.jenkins.io/ gefunden werden.

CI/CD ist eine Praktik in der Softwareentwicklung, um Prozesse zu automatisieren und Software schneller bereitzustellen. Bei Continuous Integration werden Codeänderungen automatisch und regelmäßig in ein gemeinsames Repository integriert. Continuous Delivery heißt, dass die Anwendung jederzeit bereit für die Bereitstellung ist. Zusammen werden diese Praktiken als CI/CD-Pipeline bezeichnet.

Wer mehr zum Thema CI/CD lesen möchte, kann sich den Artikel Continuous Integration von Martin Fowler anschauen.

Warum Jenkins?

Wir haben uns für Jenkins entschieden, da es ein weit verbreitetes Open-Source-Tool und damit kostenlos ist. Ein großer Vorteil ist die ausführliche Dokumentation, die sowohl für uns als Einsteiger als auch für die Integration in GitLab hilfreich ist – ein essenzielles Kriterium, da unser Projekt auf GitLab liegt. Durch Plugins lässt sich Jenkins zudem flexibel erweitern und unterstützt die Integration der gängigen DevOps und Cloud-Anbieter, einschließlich GitLab. Zudem ist Jenkins plattformunabhängig und läuft auf Windows, macOS und Linux.

Installation und Ersteinrichtung

Für den Betrieb von Jenkins wurde eine Virtuelle Maschine (VM) mit dem Betriebssystem Debian gewählt. Die Installation von Jenkins ist über ein extra APT Repository möglich. Eine Anleitung zur Installation und Ersteinrichtung findet sich in der Dokumentation von Jenkins. Um einen sicheren Zugriff auf Jenkins über das öffentliche Internet mittels HTTPS zu ermöglichen, wurde nginx als Reverse Proxy konfiguriert und ein Zertifikat von Let’s Encrypt verwendet. Das Anfordern der Zertifikate und die Konfiguration von nginx für HTTPS wurde dabei von dem Tool certbot übernommen.

Pipelines in Jenkins

Jenkins bietet verschiedene Typen und Möglichkeiten eine Pipeline zu erstellen. Wir haben für unser Projekt den in Jenkins als “Pipeline” bezeichneten Typ gewählt. Bei diesem wird die Pipeline durch eine Datei in unserem Git Repository, einer sogenannten “Jenkinsfile”, definiert. Hierfür werden zwei Syntax Varianten, Declarative und Scripted, von Jenkins angeboten. Wir haben uns für die Variante Declarative entschieden.

Eine solche Pipeline besteht aus Stages, die nacheinander abgearbeitet werden. In diesen Stages können sogenannte Steps verwendet werden, um z.B. Kommandozeilenbefehle auszuführen.

Das folgende Beispiel gibt den Text “Hello World” aus:

pipeline {
	agent any

	stages {
    	stage('Hello') {
        	steps {
            	echo 'Hello World'
        	}
    	}
	}
}
Groovy

Erstellen einer ersten Pipeline

Wie bereits erwähnt, sollte unsere Pipeline in Form einer Jenkinsfile in unserem Projekt Repository definiert sein. Damit Jenkins von GitLab immer dann benachrichtigt wird, wenn eine Pipeline gestartet werden soll, haben wir in GitLab die Jenkins Integration aktiviert. Damit diese auch in Jenkins korrekt funktioniert, mussten wir das GitLab Plugin für Jenkins installieren. 

Nach der Konfiguration der Integration sowohl in GitLab als auch Jenkins, haben wir eine Jenkinsfile angelegt und eine Pipeline definiert, um die Integration zu testen. Diese gibt nur eine kurze Textnachricht aus und ändert am Ende den Status der Pipeline im GitLab UI. 

Die Jenkinsfile sah zu diesem Zeitpunkt wie folgt aus:

pipeline {
    agent any

    stages {
        stage('Build') {
            steps {
                echo 'Jenkinsfile Test'
            }
        }

        // Needed to update the GitLab Pipeline UI / Status
        stage('notify-gitlab') {
            steps {
                echo 'Notify GitLab'
                updateGitlabCommitStatus name: 'build', state: 'pending'
                updateGitlabCommitStatus name: 'build', state: 'success'
            }
        }
    }
}
Groovy

Bauen des Projektes

Nachdem wir die GitLab Integration von Jenkins eingerichtet hatten, war der nächste Schritt eine Pipeline zu erstellen, mit welcher unsere ToDo App gebaut und theoretisch deployed werden könnte. Um hier nicht direkt auf der VM die notwendigen SDKs und Tools installieren zu müssen, haben wir uns dazu entschieden, dass wir Docker verwenden wollen, um einer Pipeline Stage eine bestimmte Umgebung bereitzustellen.

Für Jenkins gibt es ein Plugin, welches es ermöglicht, Docker zu verwenden. Beim Testen dieses Plugins trat allerdings schon das erste Problem auf. In Kombination mit Rootless Docker funktioniert das Plugin nicht korrekt. Abgesehen davon hatte das Plugin zu diesem Zeitpunkt schon seit Monaten keine Updates mehr erhalten. Wir haben uns daher entschieden, Docker “manuell” durch die direkte Nutzung des CLI zu verwenden. Um das Ganze möglichst einfach zu machen, haben wir die einzelnen Befehle, die notwendig für den Build des Projektes sind, in ein eigenes Shell-Skript ausgelagert.

Wir haben die Build Stage entsprechend angepasst, sodass diese zunächst das Build-Skript ausführt und das Ergebnis in Jenkins als Artefakt speichert. Das verwendete Docker Image wird über eine Umgebungsvariable definiert.

Damit sieht unsere neue Build Stage “Build Web App” wie folgt aus:

stage('Build Web App') {
    environment {
        DOCKER_IMAGE = 'ghcr.io/cirruslabs/flutter:3.24.5'
    }
    steps {
        // Build Web App
        sh 'chmod 740 build.sh' // Make sure the build script is executable
        sh 'docker run --rm -v .:/build --workdir /build $DOCKER_IMAGE ./build.sh web'

        // Archive build artifact
        sh 'zip -r build/web.zip build/web'
        archiveArtifacts artifacts: 'build/web.zip', fingerprint: true, onlyIfSuccessful: true
    }
}
Groovy

Deployment der Web App

Um die Web App zu deployen, haben wir die Pipeline um eine Deployment Stage erweitert. Diese verwendet das in der Build Stage erzeugte Artefakt und deployed dieses über SSH auf eine weitere VM mit einem einfachen Web Server. Zur einfacheren Verwendung von SSH in der Pipeline haben wir das Plugin SSH Agent verwendet. Dieses erlaubt es, Credentials für die Verbindung mit der VM via SSH in Jenkins zu hinterlegen und in der Pipeline direkt auf diese Credentials zuzugreifen. Die Befehle für das eigentliche Deployment haben wir ebenfalls in ein Skript ausgelagert. Dieses kopiert lediglich das ZIP-Archiv auf die VM und entpackt dieses dort in den Root Ordner des Web Servers.

Die Deployment Stage sieht zu diesem Zeitpunkt wie folgt aus:

stage('Deploy Web App to DEV') {
    steps {
        // Get build artifact for deployment
        unarchive mapping: ['build/web.zip': 'build/web.zip']

        // Deploy Web App via SSH
        sshagent(['todo-list-cicd-dev']) {
            sh 'chmod 740 deploy.sh' // Make sure the deploy script is executable
            sh './deploy.sh dev'
        }
    }
}
Groovy

Das Pipeline Status Problem

Mit den zwei obenstehenden Stages haben wir eine grundlegende Version unserer Pipeline erfolgreich aufgesetzt. Allerdings mussten wir hier feststellen, dass mit dieser Konfiguration in der Jenkinsfile der Status der Pipeline im GitLab UI nicht korrekt aktualisiert wurde. Dabei konnte es z.B. vorkommen, dass GitLab gar keinen Status erhalten hat, wenn es zu einem Fehler während der Ausführung einer Pipeline Stage kam. Dies hat zur Folge, dass GitLab Merge Requests den Status nicht kennen und dieser nicht mehr als Anforderung für einen Merge verwendet werden kann. 

Ein Blick in die Dokumentation des GitLab Plugins hat uns dabei gezeigt, dass sich dieses Problem relativ einfach lösen lässt. Das GitLab Plugin sendet zwar nicht ohne weiteres Statusupdates, es ist aber möglich, durch bereitgestellte Pipeline Steps, die Pipeline so zu konfigurieren, dass ein automatisches Update gesendet wird. Um für ein automatisches Statusupdate zu sorgen, können die Steps einer Stage innerhalb des Steps “gitlabCommitStatus” platziert werden. Außerdem kann mit dem Step “updateGitlabCommitStatus” der Status manuell angepasst werden.

Neben dem eigentlichen Problem konnten wir auch die Darstellung unserer Pipeline im GitLab UI verbessern. Ursprünglich wurde diese in GitLab nur als ein einziger CI/CD Job dargestellt. Mit der Option “gitlabBuilds” lassen sich aber beliebig viele Stages/Jobs im GitLab UI anzeigen.

Mit diesen neuen Erkenntnissen haben wir die bisherige Pipeline angepasst. Wie unten zu sehen ist, haben wir jeweils einen Job für unsere beiden Stages definiert sowie in den beiden Stages den Step “gitlabCommitStatus” für die automatische Aktualisierung eingebaut.

// Define pipeline stages to ensure name consistency
def buildWebApp = 'Build Web App'
def deployWebAppDev = 'Deploy Web App to DEV'
def builds = [buildWebApp, deployWebAppDev]

// Actual pipeline definition
pipeline {
    agent any

    options {
        gitlabBuilds(builds: builds)
    }

    stages {
        // Build Web App
        stage(buildWebApp) {
            environment {
                DOCKER_IMAGE = 'ghcr.io/cirruslabs/flutter:3.24.5'
            }
            steps {
                gitlabCommitStatus(buildWebApp) {
                    - snip -
                }
            }
        }

        // Deploy Web App
        stage(deployWebAppDev) {
            steps {
                gitlabCommitStatus(deployWebAppDev) {
                    - snip -
                }
            }
        }
    }
}
Groovy

Konflikte beim Deployment

Neben dem obenstehenden Problem traten auch Fehler beim Deployment auf. Bei diesen sind sich zwei oder mehr parallel laufende Pipelines beim Deployment in die Quere gekommen. Dazu kam es, da unsere Deployment Stage immer ausgeführt wurde, unabhängig von dem Git Branch, auf den gepusht wurde. Für dieses Problem hatten wir zwei mögliche Lösungsansätze. Wir konnten

  1. die Deployment Stage so konfigurieren, dass diese manuell gestartet werden muss oder
  2. die Deployment Stage nur für einen bestimmten Branch (dem main Branch) ausführen.

Wir haben uns für die zweite Option entschieden, da die VM, auf die wir deployen, vom Staging Konzept her eine DEV Umgebung sein soll. Wir haben dafür eine entsprechende Bedingung für die Ausführung in der Jenkinsfile hinterlegt.

// Deploy Web App
stage(deployWebAppDev) {
    when {
        expression { env.gitlabSourceBranch == 'main' }
    }
    steps {
        gitlabCommitStatus(deployWebAppDev) {
            - snip -
        }
    }
}
Groovy

Mit dieser Anpassung der Deployment Stage kam es allerdings zu einem neuen Problem mit dem GitLab Pipeline Status. Im Fall, dass die Stage übersprungen wird, wird der Status in GitLab nicht auf “skipped” gesetzt. Um das zu verhindern, haben wir einen Workaround eingebaut. Dieser besteht darin, für alle Stages, die übersprungen werden könnten, zu Beginn der Pipeline den Status dieser Stages in GitLab auf “skipped” zu setzen. Wird eine der Stages doch ausgeführt, wird der Status automatisch durch den Step “gitlabCommitStatus” aktualisiert.

Diese Stage für den Workaround sieht wie folgt aus:

// Mark manually triggered stages as skipped
stage('Mark manual stages as skipped') {
    steps {
        updateGitlabCommitStatus name: deployWebAppDev, state: 'skipped'
    }
}
Groovy

Ein ungelöstes Problem

Abgesehen von den beiden obigen Problemen trat ein weiteres auf, als wir versucht haben, eine der Pipelines bzw. eine Stage über das Jenkins UI neu zu starten. Dabei mussten wir feststellen, dass in der Pipeline nur wenige Sekunden nach dem Neustart ein Fehler auftritt. Zu dem Fehler kommt es, da Jenkins die Information, welchen Branch oder Commit es verwenden muss, nicht abspeichert. Diese Informationen liefert GitLab an Jenkins, wenn eine Pipeline über GitLab gestartet wird.

Wir haben uns nur relativ kurz mit dem Problem auseinandergesetzt und auch keine schnelle Lösung gefunden. Da uns dieser Fehler nicht wirklich bei unserem Ziel, eine Pipeline aufzubauen, behindert hat, haben wir uns auch nicht weiter damit befasst. Für den Einsatz in einem realen Projekt müsste dieses Problem wahrscheinlich gelöst werden, da es durchaus dazu kommen kann, dass man eine Pipeline aufgrund eines temporären Fehlers neu starten möchte.

Testen der App

Um Tests zu automatisieren, haben wir eine Test Stage eingebaut. Dafür wurden die Befehle wieder in ein Shell-Skript ausgelagert. In der Pipeline wird das Test-Skript innerhalb eines Docker-Containers ausgeführt, indem die Tests mit –coverage für die Testabdeckung ausgeführt werden. Die JSON-Testdaten werden mithilfe des Tools junitreport in das JUnit XML-Format konvertiert, damit diese später bei der Status Übersicht der Pipeline in Jenkins angezeigt werden können. Um die Code Coverage der Tests visuell darstellen zu können, haben wir das Coverage Plugin von Jenkins verwendet. Die Coverage-Daten der Tests (LCOV-Coveragedatei) werden ins XML-Format umgewandelt, damit diese in Jenkins dargestellt werden können. Die Testresultate werden archiviert und der Codeabdeckungsbericht visuell auf Jenkins dargestellt.

// Test
stage('Test Web App') {
    steps {
        gitlabCommitStatus(testWebApp) {
            sh 'chmod 740 test.sh'
            sh 'docker run --rm -v .:/build --workdir /build $FLUTTER_IMAGE ./test.sh'
        }
    }
    post {
        always {
            // Archive test results for Jenkins
            archiveArtifacts artifacts: 'target/test-results.xml', fingerprint: true

            // Publish coverage report in Jenkins
            recordCoverage(tools: [[parser: 'COBERTURA', pattern: 'target/coverage.xml']])
        }
    }
}
Groovy

Statische Code Analyse 

Als nächste Stage haben wir eine Linting Stage zur statischen Code Analyse implementiert, welche jedoch an den Anfang der Pipeline gesetzt wird, damit kein Build Prozess stattfindet, wenn das Linting fehlschlägt. Die eigentliche Codeanalyse ist in das Shell-Skript lint.sh ausgelagert, welches das Dart-Code-Metrics-Tool aktiviert. Dieses überprüft den Code auf Verstöße gegen Linting-Regeln und Best Practices. Während des Linting-Prozesses werden Warnungen und Fehler gespeichert. Das Ergebnis kann in Jenkins eingesehen werden. Die Stage schlägt nur dann fehl, wenn Fehler auftreten – bei Warnungen wird der Prozess fortgesetzt.

// Linting Stage
stage('Dart Analyze') {
    steps {
        gitlabCommitStatus(lintWebApp) {
            sh 'chmod 740 lint.sh'
            sh 'docker run --rm -v .:/build --workdir /build $FLUTTER_IMAGE ./lint.sh'
        }
    }
    post {
        always {
            archiveArtifacts artifacts: 'lint-results.txt', fingerprint: true
        }
    }
}
Groovy

Alternativen zu Jenkins

Wie oben im Abschnitt Warum Jenkins? beschrieben, hatten wir uns zwar für Jenkins entschieden, waren aber im Verlauf des Projektes bei der Integration von Jenkins in GitLab doch auf ein paar Schwierigkeiten gestoßen. Gibt es eine Alternative, die hier besser gewesen wäre? Das lässt sich pauschal zwar nicht sagen, aber es gibt definitiv sehr viele CI/CD-Tools, sowohl kommerzielle als auch kostenfreie Tools. Zu diesen gehören unter anderem Travis CI, CircleCI, ArgoCD sowie GitHub Actions und GitLab CI/CD. 

Bei letzterem lässt sich zumindest vermuten, dass die Integration in GitLab wohl am besten sein wird, da es sich um einen festen Bestandteil von GitLab handelt. Um herauszufinden, ob wir mit GitLab CI/CD besser unterwegs gewesen wären, müsste man unsere Pipeline zu GitLab CI/CD migrieren. Das wäre vermutlich relativ einfach, da die eigentliche Logik aller Stages der Jenkins Pipeline in Shell-Skripte ausgelagert ist.

Migration zu GitLab CI/CD

Und daher haben wir auch genau das getan und dieselbe Pipeline mit GitLab CI/CD aufgesetzt. Wie zuvor vermutet, war dies auch tatsächlich relativ einfach. Abgesehen davon, dass wir die Pipeline in eine andere Syntax (YAML) übertragen mussten, waren fast keine Anpassungen notwendig. Selbst die Verwendung von JUnit und Cobertura wird von GitLab CI/CD von Haus aus unterstützt. Wir mussten also nicht einmal das Shell-Skript für das Testing anpassen. Wir konnten daher alle unsere Shell-Skripte ohne Veränderung wiederverwenden.

Lediglich bei der Migration der Deployment Stage zu GitLab CI/CD mussten wir uns überlegen, wie wir den für den Zugang zu der VM notwendigen SSH Key bereitstellen können. Dazu konnten wir allerdings ganz einfach GitLab CI/CD Variablen verwenden und vor der Ausführung des Deployments den SSH Agent manuell vorbereiten. Während diese Variablen zwar nicht das sicherste sind, um einen Private Key in GitLab zu hinterlegen, war es für unsere Zwecke aber ausreichend.

Wie im Abschnitt Ein ungelöstes Problem beschrieben, konnten wir die Pipeline oder einzelne Stages einer Pipeline in Jenkins nicht neu starten und damit auch nicht als nur manuell startbar hinterlegen. Dies ist bei GitLab CI/CD nicht der Fall, weshalb wir den Deployment Job hier so konfiguriert haben, dass dieser zusätzlich zur Beschränkung auf den “main” Branch noch manuell gestartet werden muss.

Folgender Ausschnitt der .gitlab-ci.yml Datei zeigt den Test und den Deployment Job:

image: ghcr.io/cirruslabs/flutter:3.24.5

stages:
  - lint
  - build
  - test
  - deploy

variables:
  GIT_DEPTH: 3

# Linting Stage
Dart Analyze (GitLab):
- snip -

# Build Web App
Build Web App (GitLab):
- snip -

# Test Web App
Test Web App (GitLab):
  stage: test
  script:
    - chmod 740 test.sh
    - ./test.sh
  artifacts:
    paths:
      - target/test-results.xml
      - target/coverage.xml
      - coverage/lcov.info
    reports:
      junit: target/test-results.xml
      coverage_report:
        coverage_format: cobertura
        path: target/coverage.xml
  needs:
    - job: Build Web App (GitLab)
      artifacts: true
  when: on_success

# Deploy Web App to DEV
Deploy Web App to DEV (GitLab):
  stage: deploy
  image: alpine:latest
  before_script:
    # Install SSH and necessary tools
    - apk add --no-cache openssh-client bash
    # Setup SSH agent with private key
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    # Create SSH directory and add known hosts
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  script:
    - chmod 740 deploy.sh
    - ./deploy.sh dev
  needs:
    - job: Build Web App (GitLab)
      artifacts: true
    - job: Test Web App (GitLab)
      artifacts: false
  when: manual
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
YAML

Ausblick

Die Pipeline kann je nach Bedarf durch weitere Stages erweitert werden. Sowohl bei Jenkins als auch bei GitLab CI/CD muss dazu nicht die gesamte Konfigurationsdatei geändert werden. Es muss lediglich eine neue Stage bzw. Job und gegebenenfalls ein dazugehöriges Shell-Skript hinzugefügt werden.

Die wohl offensichtlichste Erweiterungsmöglichkeit besteht wahrscheinlich darin, Stages für das Bauen des Projektes für die anderen von Flutter unterstützten Plattformen Windows, Linux, MacOS, iOS und Android zu erstellen. Zu diesen kann dann gegebenenfalls auch eine Delivery Stage eingebaut werden, die die Software auf irgendeine Weise verfügbar macht.

Weitere mögliche Stages wären zum Beispiel der OWASP Dependency-Check zur Überprüfung der Dependencies des Projektes auf Schwachstellen. Auch wäre es denkbar, einen Vulnerability Scanner einzusetzen, um automatisiert Schwachstellen im Projekt entdecken zu können. Für ein leichteres Deployment könnte außerdem ein Docker Image für die Web App gebaut werden. Falls notwendig, kann zudem in einer Pipeline eine Software Bill of Material (SBOM) generiert werden.

Lessons Learned und Fazit

Im Laufe des Projekts haben wir gelernt, wie ein Jenkins Server konfiguriert und eine Pipeline in Jenkins aufgesetzt wird. Zudem konnten wir Erfahrungen mit der Integration von Jenkins in GitLab sowie der Erstellung von Pipeline-Skripten sammeln. Während der Entwicklung der Flutter-App haben wir zudem die Vorteile einer Pipeline kennengelernt. Durch die Automatisierung von Tests und anderen Prozessen ermöglicht eine Pipeline eine effizientere Arbeitsweise. Darüber hinaus sorgt beispielsweise die Linting-Stage für sauberen und konsistenten Code.

Eine CI/CD Pipeline kann, wenn sie richtig konfiguriert und eingesetzt wird, zur frühzeitigen Fehlererkennung und -behebung beitragen und Fehler können verhindert werden. Zum Beispiel durch das Einbinden der Pipeline in Merge Requests, um die Pipeline als Kriterium für einen Merge zu verwenden. Somit ist für eine höhere Codequalität gesorgt. Außerdem ist Konsistenz garantiert, indem Deployments immer in derselben Umgebung erfolgen. Es ist jedoch entscheidend, die passende Pipeline-Umgebung auszuwählen sowie die Pipeline sauber zu bauen und strukturiert einzusetzen. 

Jenkins ist ein mächtiges Tool, mit dem wir unsere CI/CD Pipeline erfolgreich umsetzen konnten – allerdings nicht ohne Herausforderungen. Insbesondere die Integration in GitLab war sehr (zeit-)aufwendig. Für ein kleines Projekt wie unsere Flutter App wäre GitLab CI/CD eine gute Lösung und völlig ausreichend gewesen – das Pipeline Status Problem hätte uns so beispielsweise keine Zeit gekostet. 


Posted

in

by

Cedric Gottschalk, Ronja Brauchle

Comments

Leave a Reply