Softwareentwickler / Software Developer
Bild von Kränen auf einer Baustelle

Continuous Integration | Was ist die Jenkins-Pipeline?


Schon seit Beginn der Ausbildung benutze ich auf der Arbeit den Build-Server Jenkins. Dies ist ein Tool, womit Projekte gebaut werden können, um Continuous Integration sicherzustellen. Damit ist gemeint, dass automatisiert überprüft werden kann, ob nach einer Änderung am Code Fehler entstanden sind oder Test fehlschlagen. Auch kann man verschiedene statische Code-Analysen ausführen. Im Folgenden möchte ich die sogenannte Jenkins Pipeline vorstellen, eine Groovy-DSL, mit der ein solcher Build konfiguriert werden kann.

Zuerst einmal muss dabei erwähnt werden, dass zwischen der Declarative Pipeline und der Scripted Pipeline unterschieden werden muss. Unter der Haube funktionieren beide gleich, sie sehen jedoch ein wenig anders aus. Die Declarative Pipeline besitzt einen fest vorgeschriebenen Aufbau, an den man sich halten muss. Das macht es deutlich einfacher, wenn man sich einarbeiten möchte, jedoch ist der Funktionsumfang dadurch eingeschränkt. Um eine spätere Migration zu vermeiden, möchte ich deswegen von Beginn an auf die Scripted Pipeline setzen.

Was ich jetzt machen möchte, ist einen Build aufzusetzen, der durch einen Push in ein SCM-Repository ausgelöst wird und aus verschiedenen Schritten, die ich gleich noch nenne, besteht. Dazu werde ich Java als Programmiersprache und Gradle als Build-Tool verwenden. Die Anwendung ist eine Web-Anwendung, die mit Java EE 7 umgesetzt wurde und in einem JBoss Application-Server läuft. Sowhl Jenkins als auch JBoss laufen auf einer Linux-Maschine, auf der Debian 9 installiert ist.

Zu Beginn möchte ich anhand dieses Screenshots von der Open Blue Ocean Web-Oberfläche von Jenkins den Ablauf des Builds erklären.

Screenshot der Pipeline in der Open Blue Ocean Oberfläche von Jenkins

Das Bauen beginnt mit den Checkout des Codes aus dem Git-Repository. Danach wird der Source-Code kompiliert. Anschließend werden parallel die Integrations- und Unit-Tests ausgeführt, bevor die statische Codeanalyse läuft. Dort werden die Tools JDepend, PMD, Checkstyle, JaCoCo und Findbugs eingesetzt. Waren die bisherigen Schritte erfolgreich, soll ein automatisches Deployment erfolgen. Abschließend werden die Test- und Codeanalyse-Ergebnisse veröffentlicht.

Zuerst einmal beginnen wir mit dem Grundgerüst. Dafür wird ein Node erstellt. Dieser belegt einen Executor-Slot von Jenkins und stellt einen Workspace zur Verfügung. Danach werden die oben genannten Schritte definiert. In jedem dieser Schritte wird dann der Name jeweils ausgegeben, da ein leerer Stage-Block nicht erlaubt ist. Das sieht dann folgendermaßen aus:

node{
    stage('Checkout'){
        echo 'Checkout'
    }
    stage('Kompilieren'){
        echo 'Kompilieren'
    }
    stage('Tests'){
        echo 'Tests'
    }
    stage('Code-Analyse'){
        echo 'Code-Analyse'
    }
    stage('Deployment'){
        echo 'Deployment'
    }
    stage('Post-Build'){
        echo 'Post-Build'
    }
}

Checkout

Damit hätten wir im Prinzip schon fast alles, um bei einem Build den oben gezeigten Screenshot zu erhalten. Natürlich müssen wir jetzt aber noch die Funktionalität einbauen. Dabei gehen Schritt für Schritt vor und beginnen mit dem Checkout. Dieser kann als Einzeiler umgesetzt werden. Dabei muss grundsätzlich nur die URL des SCM-Repositorys angegeben werden. Optional kann aber auch noch der Branch – standardmäßig auf master gesetzt – oder die Credentials angegeben werden, wenn es sich nicht um ein öffentliches Repository handelt.

stage('Checkout'){
    git branch: master, credentialsId: 'abcdefg', url: 'http://url.smc.repo'
}

Kompilieren

Für die nächsten Schritte verwende ich einige Gradle-Plugins und selbst definierte Tasks, deswegen an dieser Stelle einmal die build.gradle-Datei.

buildscript {
    repositories {
        // Repositorys
    }
}

dependencies {
    // Projekt-Abhängigkeiten
}

apply plugin: 'java'
apply plugin: 'jdepend'
apply plugin: 'pmd'
apply plugin: 'checkstyle'
apply plugin: 'findbugs'
apply plugin: 'jacoco'

// Evtl. Konfigurationen zu den Tools

task jdepend {
    group 'Verification'
    description 'Runs JDepend'
    
    dependsOn "jdependMain"
    dependsOn "jdependTest"
    dependsOn "jdependIntegrationTest"
}

task pmd {
    group 'Verification'
    description 'Runs PMD'

    dependsOn "pmdMain"
    dependsOn "pmdTest"
    dependsOn "pmdIntegrationTest"
}

task checkstyle {
    group 'Verification'
    description 'Runs Checkstyle'
    
    dependsOn "checkstyleMain"
    dependsOn "checkstyleTest"
    dependsOn "checkstyleIntegrationTest"
}

task findbugs {
    group 'Verification'
    description 'Runs FIndbugs'
    
    dependsOn "findbugsMain"
    dependsOn "findbugsTest"
    dependsOn "findbugsIntegrationTest"
}

configurations {
    integrationTestCompile.extendsFrom testCompile
    integrationTestRuntime.extendsFrom testRuntime
}

sourceSets {
    integrationTest {
        java {
            compileClasspath += main.output
            runtimeClasspath += main.output
        }
        java.srcDir file('src/integrationTest/java')
        resources.srcDir file('src/integrationTest/resources')
    }
}

task integrationTest(type: Test) {
    group = 'Verification'
    description = 'Runs the integration tests.'
    dependsOn 'assemble', 'integrationTestClasses'
    classpath = sourceSets.integrationTest.runtimeClasspath
    testClassesDir = sourceSets.integrationTest.output.classesDir
    outputs.upToDateWhen { false }
}

Mit dieser Grundlage ist der nächste Befehl, das Kompilieren des Quellcodes sehr einfach. Dazu wird lediglich auf der Powershell folgender Gradle-Befehl ausgeführt.

stage('Kompilieren'){
    sh 'gradle compileAllJava'
}

Tests

Die Test-Stage sieht sehr ähnlich aus. Um den Build ein wenig zu beschleunigen, wollen wir die Integrations- und Unit-Tests nicht nacheinander, sondern parallel laufen lassen. Dafür stellt die DSL einen solchen Befehl zur Verfügung.

stage('Tests'){
    parallel(
        Tests: { 'gradle test' },
        Integrationstests: { sh 'gradle integrationTest' }
    )
}

Codeanalyse

Diese Nebenläufigkeit nutzen wir dann auch bei der Codeanalyse. Wieder werden im Prinzip lediglich (teils selbstgeschriebene) Gradle-Tasks auf der Powershell aufgerufen.

stage('Code-Analyse'){
    parallel(
        Checkstyle: { sh 'gradle checkstyle' },
        FindBugs: { sh 'gradle findbugs' },
        JDepend: { sh 'gradle jdepend' },
        PMD: { sh 'gradle pmd' },
        JaCoCo: { sh 'gradle jacoco' }
    )
}

Deployment

Hat bis hierhin alles geklappt, kann ich mir sicher sein, dass meine Anwendung durch meine Änderungen nicht kaputt gegangen ist. Das Kompilieren funktioniert, alle Abhängigkeiten sind richtig konfiguriert und die Tests sind auch grün. Deswegen können wir in diesem Schritt soweit gehen, dass wir ein Continious Deployment einrichten. Das heißt, wenn der Build nicht fehlschlägt, wird die alte Version automatisch durch die neue ausgetauscht.

In meinem Beispiel nutzen wir wie oben schon erwähnt als Application-Server eine JBoss-Instanz. Diese liefert von Haus aus die JBoss CLI, ein Kommandozeilen-Tool für den Server. Mit diesem lässt sich die WAR-Datei ganz einfach ersetzen.

stage('Deployment'){
    sh 'gradle war'

    try{
        sh "sudo JBOSS_HOME/bin/jboss-cli.sh --connect --controller='HOST:PORT' --command='undeploy ${env.JOB_NAME}-1.0.0-SNAPSHOT.war'"
    }
    catch (Exception ex) {
        echo "No Deployment found"
    }
    sh "sudo JBOSS_HOME/bin/jboss-cli.sh --connect --controller='HOST:PORT' --command='deploy ${env.WORKSPACE}/build/libs/${env.JOB_NAME}-1.0.0-SNAPSHOT.war'"
}

Dazu lasse ich mir von Gradle zuerst einmal die neue WAR-Datei bauen. Danach versuche ich die alte Version vom Application-Server zu entfernen. Der try-catch-Block dient lediglich dazu, dass ein Build-Abbruch verhindert wird, weil aus unterschiedlichen Gründen kein Deployment vorhanden ist. Dazu wird die JBoss CLI gestartet und eine Verbindung mit dem JBoss, der auf dem Port des angegebenen Hosts lauscht, eingerichtet. In meinem Fall wäre das ‘localhost’. Als Kommando wird dann das undeployen mitgegeben. Der anschließende Befehl für das Deployment sieht fast genau gleich aus und unterscheidet sich nur im mitgegebenen Befehl. Hier muss noch der Pfad von der neuen WAR-Datei angegeben werden. Bei mir ist es so konfiguriert, dass diese nach der Ausführung von ‘gradle war’ unter build/libs liegt.

Veröffentlichen der Ergebnisse

Nachdem der eigentliche Build abgeschlossen ist, müssen die Ergebnisse aus dem dritten und vierten Schritt noch veröffentlicht werden, damit wir nachher über die Jenkins-Oberfläche diese Informationen einsehen können. Dies funktioniert mit den Jenkins-Plugins sehr einfach.

stage('Post-Build'){
    junit allowEmptyResults: true, testResults: 'build/test-results/*.xml'
    jacoco classPattern: '**/build/classes/main/**', execPattern: '**/build/jacoco/**.exec', sourcePattern: '**/src/main/java/**'
    pmd canComputeNew: false, defaultEncoding: '', healthy: '', pattern: '**/build/reports/pmd/*.xml', unHealthy: ''
    findbugs canComputeNew: false, defaultEncoding: '', excludePattern: '', healthy: '', includePattern: '', pattern: '**/build/reports/findbugs/*.xml', unHealthy: ''
    checkstyle canComputeNew: false, defaultEncoding: '', healthy: '', pattern: '**/build/reports/checkstyle/*.xml', unHealthy: ''
}

Fehlerfall

Bei all diesen Schritten kann ein Fehler auftreten, der bewirkt, dass der Build fehlschlägt. Wenn ein solches Verhalten Auftritt, soll es auf jeden Fall dazu kommen, dass der Build rot wird. Zusätzlich soll eine Mail-Benachrichtigung eingerichtet werden. Dazu brauchen wir zuerst einmal um den gesamten oben geschrieben Code ein try-catch-Konstrukt.

node{
    try{
        // stages
    }
    catch (Exception e) {
        // Wie soll die Exception behandelt werden?
    }
}

In den catch-Block werden dann die Anweisungen formuliert, die den Fehlerfall behandeln sollen. Am Ende dieses Blocks muss auf jeden Fall die Exception wieder geworfen werden, damit Jenkins erkennt, dass ein Fehler auftritt und der Build fehlgeschlagen ist. Vorher können wir erneut in einem Einzeiler eine Mail mit dem Betreff, dass ein Build nicht funktioniert hat, an eine beliebige E-Mail senden.

catch (Exception e) {
    mail bcc: '', body: '', cc: '', from: 'Jenkins Server', mimeType: 'text/plain', replyTo: '', subject: 'Jenkins-Build fehlgeschlagen. #${env.BUILD_NUMBER} (${env.BRANCH_NAME})', to: 'hellmann.jonas@web.de'
    throw e
}

Wann soll gebaut werden?

Eine letzte Sache fehlt mir jetzt noch. Zu Anfang habe ich gesagt, dass ich den Build nach einem Push in das SCM-Repository startet will. Zusätzlich soll eingestellt werden, dass es täglich mindestens einmal gebaut wird. Dies lässt sich über einen Properties-Block am Anfang der Datei definieren.

properties([
    [$class: 'ScannerJobProperty', doNotScan: false], 
    pipelineTriggers([
        cron('''@weekly'''), 
        pollSCM('''@yearly''')
    ])
])

Das ‘@yearly’ kann dabei ignoriert werden, da es keine Auswirkungen hat und nur dort steht, weil irgendetwas eingetragen werden muss.

Fazit

Auch wenn es vielleicht ein wenig kompliziert aussieht, ist es das nicht unbedingt. Ich bin bei null gestartet und hatte dieses Skript innerhalb von 1-2 Tagen lauffähig eingerichtet. Viele Funktionalitäten wie das Auschecken von Git, das Veröffentlichen der Test-Ergebnisse oder auch die Parallelität liefert Jenkins schon von sich aus und macht einem das Leben noch mal einfacher. Mir gefällt die Jenkins Pipeline sehr gut und in Verknüpfung mit der Open Blue Ocean Oberfläche lässt sich ein fehlgeschlagener Build anhand der verschiedenen Steps ganz einfach analysieren. Außerdem kann man diese Konfiguration auch für neue Projekte ganz einfach übernehmen und die nervigen Konfigurationen in der Oberfläche vom Jenkins gehören der Vergangenheit an.

Das abschließende Jenkinsfile könnt ihr hier finden: Template-Repository von Jonas Hellmann

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert