featured image

May 24, 2018 / by Johannes Schnatterer / In Software Craftsmanship

Coding Continuous Delivery — Hilfreiche Tools für die Jenkins Pipeline

+++Der Originalartikel kann hier heruntergeladen werden:Zeitschriftenartikel, veröffentlicht in Java Aktuell 01/2018.+++

Nachdem in den ersten beiden Teilen dieser Artikelserie Grundlagen und Performance von Jenkins Pipelines thematisiert wurden, beschreibt dieser Artikel nützliche Werkzeuge und Methoden: Mit Shared Libraries kann Wiederverwendung über verschiedene Jobs hinweg und Unit Testing des Pipeline Codes realisiert werden. Außerdem bietet der Einsatz von Containern mittels Docker auch hier seine Vorzüge.

Im Folgenden werden die Pipeline-Beispiele aus den ersten beiden Artikeln sukzessive erweitert, um die Features der Pipeline zu zeigen. Dabei werden die Änderungen jeweils in declarative und in scripted Syntax realisiert. Den aktuellen Stand jeder Erweiterung kann man bei GitHub (siehe Jenkinsfile Repository GitHub). nachverfolgen und ausprobieren. Hier gibt es für jeden Abschnitt unter der in der Überschrift genannten Nummer jeweils für declarative und scripted einen Branch, der das vollständige Beispiel umfasst. Das Ergebnis der Builds jedes Branches lässt sich außerdem direkt auf unserer Jenkins-Instanz (siehe Triology Open Source Jenkins) einsehen.

Wie in den ersten beiden Teilen, werden auch in diesem die Features des Jenkins Pipeline-Plugins anhand eines typischen Java Projekts gezeigt. Als Beispiel dient damit auch hier der kitchensink Quickstart von WildFly.

Da dieser Artikel auf den Beispielen aus dem ersten Artikel aufbaut, setzt sich die Nummerierung aus dem ersten Teil fort. Dort wurden mit simpler Pipeline, eigenen Steps, Stages, Fehlerbehandlung und Properties/Archivierung, Parallelisierung und Nightly Builds sieben Beispiele gezeigt. Deshalb handelt es sich bei Shared Libraries um das achte Beispiel.

Shared Libraries (8)

In den Beispielen aus dieser Artikelserie gibt es bereits einige selbst geschriebene Steps, wie beispielsweise ‘mvn()’ oder ‘mailIfStatusChanged()’. Diese sind nicht projektspezifisch und könnten aus dem ‘Jenkinsfile’ ausgelagert und damit auch in anderen Projekten wiederverwendet werden. Für das Auslagern gibt es bei Jenkins Pipelines derzeit zwei Möglichkeiten

  • load step: Lädt eine Groovy Script Datei aus dem Jenkins Workspace (also dem gleichen Repository) und evaluiert sie. Damit lassen sich dynamisch weitere Steps nachladen.
  • Shared libraries: Erlauben das Einbinden von externen Groovy Scripts und Klassen.

Der ‘load ‘Step unterliegt gewissen Einschränkungen:

  • Es können nur Groovy Scripts, keine Klassen geladen werden (siehe Groovy scripts vs. classes. Dadurch können in diesen Scripts beispielsweise nicht ohne weiteres zusätzliche Klassen geladen werden und Vererbung ist nicht möglich. Damit die Scripts in der Pipeline verwendet werden können, muss jedes Script mit ‘return’ ‘this;’ enden.
  • Man kann nur Dateien aus dem Workspace verwenden. Dadurch ist keine Wiederverwendung über Projekte möglich.
  • Die über diesen Step geladenen Scripts werden in dem im ersten Artikel beschriebenen „Replay“-Feature nicht angezeigt. Dadurch sind sie schwerer zu entwickeln und debuggen

Shared Libraries unterliegen diesen drei Einschränkungen nicht, wodurch sie wesentlich flexibler sind. Deshalb wird ihre Verwendung im Folgenden näher beschrieben.

Aktuell muss eine Shared Library aus einem eigenen Repository geladen werden. Das Laden aus dem zu bauenden Repository ist momentan noch nicht möglich, wird es aber möglicherweise in Zukunft (siehe cps-global-lib-plugin Pull Request 37).Dies wird es zukünftig ermöglichen, das ‘Jenkinsfile’ auf verschiedene Klassen/Scripts aufzuteilen. Dadurch kann die Wartbarkeit erhöht werden und die Möglichkeit für das Schreiben von Unit Tests geschaffen werden. Außerdem ist dies für die Entwicklung von Shared Libraries hilfreich, weil diese in ihrem eigenen ‘Jenkinsfile’ verwendet werden können.

Das Repository jeder Shared Library muss eine bestimmte Verzeichnisstruktur aufweisen:

  • src enthält Groovy Klassen
  • vars enthält Groovy Scripts und Dokumentation
  • resources enthält weitere Dateien

Zudem ist ein ‘test’ Verzeichnis für Unit Tests und ein eigener Build empfehlenswert.

Um die Komplexität des ‘Jenkinsfile’s aus den Beispielen zu reduzieren und die Funktionalität für andere Projekte wiederverwendbar zu machen, wird im Folgenden exemplarisch ein Step in eine Shared Library ausgelagert. Für den Step ‘mvn’ legt man beispielsweise im Repository der Shared Library im Verzeichnis vars eine Datei ‘mvn.groovy’ an (siehe Listing 1). Diese enthält die aus dem ersten Teil dieser Artikelserie bekannte Methode.

  def call(def args) {
      def mvnHome = tool 'M3'
      def javaHome = tool 'JDK8'
      withEnv(["JAVA_HOME=${javaHome}", "PATH+MAVEN=${mvnHome}/bin:${env.JAVA_HOME}/bin"]) {
          sh "${mvnHome}/bin/mvn ${args} --batch-mode -V -U -e -Dsurefire.useFile=false"
      }
  }

Listing 1
Im Groovy Script in Listing 1 wird diese Methode allerdings nach Groovy-Konvention ‘call()’ genannt. Technisch gesehen legt Jenkins für alle ‘.groovy’ Dateien im Verzeichnis ‘vars’ eine globale Variable an und benennt sie entsprechend des Dateinamens. Ruft man diese Variable jetzt mit dem Call-Operator () auf, wird implizit deren Methode ‘call()’ aufgerufen Groovy call operator.Da bei Groovy die Klammern beim Aufruf optional sind, bleibt der Aufruf der Steps in scripted und declarative Syntax wie bisher, beispielsweise: ‘mvn’ ‘‘test’’.

Um die Shared Library in der Pipeline zu verwenden, gibt es mehrere Möglichkeiten. Zunächst müssen die Shared Libraries in Jenkins bekannt gemacht werden. Dazu gibt es folgende Möglichkeiten der Definition von Shared Libraries:

  • Global: Muss durch einen Jenkins Administrator in der Jenkins Konfiguration eingestellt werden. Dort definierte Shared Libraries sind in allen Projekten verfügbar und werden als vertrauenswürdig behandelt. Das heißt, dass sie alle Groovy Methoden, interne Jenkins APIs, etc. ausführen dürfen. Hier ist also Vorsicht geboten. Man kann dies allerdings auch nutzen, um beispielsweise die unter Nightly Builds beschriebenen Aufrufe zu kapseln, für die sonst Script Approval notwendig wäre.
  • Folder/Multibranch: Kann von entsprechend berechtigten Projektmitgliedern für eine Gruppe von Build Jobs eingestellt werden. Dort definierte Shared Libraries gelten nur für zugehörige Build Jobs und werden nicht als vertrauenswürdig behandelt. Das heißt, sie laufen in der Groovy Sandbox, wie normale Pipelines auch.
  • Automatisch: Plugins wie das Pipeline GitHub Library Plugin (siehe Github Branch Source Plugin) erlauben das automatische Definieren von Libraries innerhalb von Pipelines, die in einem GitHub Organization Folder definiert sind. Dadurch können Shared Libraries ohne vorherige Definition im Jenkins direkt in ‘Jenkinsfile’s verwendet werden. Auch diese Shared Libraries laufen in der Groovy Sandbox.

Im Falle unseres Beispiels bietet sich die Verwendung des GitHub Branch Source Plugins an, da das Beispiel bei GitHub verfügbar und so keine weitere Konfiguration in Jenkins notwendig ist. Sowohl in den scripted als auch declarative Syntax Beispielen können die ausgelagerten Steps (beispielsweise ‘mvn’) durch das Einbinden der Shared Library in der ersten Zeile des Scripts wie folgt definiert werden: @Library('github.com/triologygmbh/jenkinsfile@e00bbf0') _
Dabei ist github.com/triologygmbh/jenkinsfile der Name der Shared Library und nach dem @ steht die Version, in diesem Fall ein Commit Hash. Hier könnte man auch einen Branch- oder Tag-Namen verwenden. Es ist empfehlenswert einen definierten Stand (Tag oder Commit anstelle von Branches) zu verwenden, um deterministisches Verhalten zu gewährleisten. Da die Shared Library in jedem Build neu aus dem Repository abgerufen wird, besteht sonst die Gefahr, dass eine Änderung an der Shared Library ohne Änderung des eigentlichen Pipeline Scripts oder Codes Auswirkung auf den nächsten Build hat. Dies kann zu unerwarteten Ergebnissen führen, deren Ursache schwer zu finden ist.

Alternativ kann man Libraries dynamisch (mittels des ‘library’ Steps) laden. Diese können dann erst nach dem Aufruf des Steps verwendet werden.

Wie oben erwähnt, kann man in Shared Libraries außer Scripts auch Klassen anlegen (im ‘src’ Ordner). Liegen diese in Packages können sie über Import Statements nach der ‘@Library’ Annotation angegeben werden. Diese Klassen können in scripted Syntax überall in der Pipeline instanziiert werden, in declarative Syntax nur innerhalb des ‘script’ Steps. Ein Beispiel hierfür ist die Shared Library des Cloudogu Ecosystems Cloudogu ces-build-lib.

Außerdem bieten Shared Libraries die Möglichkeit Unit Tests zu schreiben. Für Klassen ist dies häufig mit Groovy Bordmitteln möglich (siehe Cloudogu ces-build-lib). Für Scripts bietet sich die Verwendung von JenkinsPipelineUnit (siehe JenkinsPipelineUnit) an. Mit diesem Framework kann man Scripts laden und einfach Mocks der eingebauten Pipeline Steps definieren. Wie ein Test für den in Listing 1 beschriebenen Step aussehen kann, zeigt Listing 2.

@Test
void mvn() {
    def shParams = ""
    helper.registerAllowedMethod("tool", [String.class], { paramString -> paramString })
    helper.registerAllowedMethod("sh", [String.class], { paramString -> shParams = paramString })
    helper.registerAllowedMethod("withEnv", [List.class, Closure.class], { paramList, closure ->
        closure.call()
    })
    def script = loadScript('vars/mvn.groovy')
    script.env = new Object() {
        String JAVA_HOME = "javaHome"
    }
    script.call('clean install')
    assert shParams.contains('clean install')
}

Listing 2
Dort wird überprüft, ob der übergebene Parameter korrekt an den ‘sh’ Step weitergereicht wird. Die Variable ‘helper’ wird der Test Klasse dabei durch das Framework mittels Vererbung bereitgestellt. Wie man in Listing 2 sieht, kommt hier viel Mocking zum Einsatz: die ‘tool’ und ‘withEnv’ Steps, sowie die globale Variable ‘env’ werden gemockt. Daran zeigt sich schon, dass der Unit Test nur die grundlegende Logik prüft und natürlich nicht den Test in einer echten Jenkins-Umgebung ersetzt. Diese Integrationstests kann man derzeit noch nicht automatisiert ausführen. Für die Entwicklung von Shared Libraries bietet sich das im ersten Artikel beschriebenen „Replay“-Feature an: Neben dem ‘Jenkinsfile’ kann man hier auch temporär die Shared Library verändern und ausführen. Dadurch vermeidet man viele unnötige Commits ins Repository der Shared Library. Dieser Tipp wird auch in der umfangreichen Dokumentation zu Shared Libraries beschrieben (siehe Jenkins Shared Libraries).

Zusätzlich zum Auslagern von Steps kann man ganze Pipelines in Shared Libraries definieren (see Standard build example),und so beispielsweise seine Stages standardisieren.

Zum Abschluss des Themas noch einige Open Source Shared Libraries:

  • Offizielle Beispiele mit Shared Library und Jenkinsfile (siehe Shared Library demo). Enthält Klassen und Scripts.
  • Shared Library, die von Docker Inc. für die Entwicklung verwendet wird (siehe Shared Library Docker). Enthält Klassen und Scripts.
  • Shared Library, die vom Firefox Test Engineering verwendet wird (siehe Shared Library Docker). Enthält Scripts mit Unit Tests und Groovy Build.
  • Shared Library des Cloudogu Ecosystem (siehe Cloudogu ces-build-lib). Enthält Klassen und Scripts mit Unit Tests und Maven Build.

Docker (9)

Durch die Verwendung von Docker in Jenkins Builds können Build- und Testumgebung vereinheitlicht und Anwendungen deployt werden. Außerdem können, wie bereits im ersten Artikel dieser Serie erwähnt, durch die Isolierung Port-Konflikte bei parallelen Builds verhindert werden. Ein weiterer Vorteil ist, dass weniger Konfiguration in Jenkins notwendig ist. Auf Jenkins muss nur Docker bereitgestellt werden. Die Pipelines können dann benötigte Tools (Java, Maven, Node.js, PaaS-CLIs, etc.) einfach per Docker-Image beziehen.

Damit man in Pipelines Docker nutzen kann, muss natürlich ein Docker Host verfügbar sein. Dies ist ein Infrastruktur-Thema, welches außerhalb von Jenkins zu lösen ist. Auch unabhängig von Docker ist es empfehlenswert, in Produktion den Build Executor getrennt vom Jenkins Master zu betreiben, um die Last zu verteilen und Reaktionszeiten der Jenkins Webanwendung nicht durch Builds zu verlangsamen. Das gilt auch bei der Bereitstellung von Docker auf den Build Executors: Der Docker Host des Masters (wenn vorhanden) sollte getrennt vom Docker Host der Build Executors sein. Auch dies stellt sicher, dass die Jenkins Webanwendung unabhängig von den Builds reaktionsfreudig bleibt. Außerdem bietet die Trennung der Hosts zusätzliche Sicherheit, da im Falle von Container Breakouts (siehe Security concerns when using Docker)kein Zugriff auf den Jenkins Host möglich ist.

Wenn man einen speziellen Build Executor mit Docker aufsetzt, ist es empfehlenswert darin auch direkt den Docker Client zu installieren und im PATH verfügbar zu machen. Alternativ kann man den Docker Client auch als Tool in Jenkins installieren. Dieses Tool muss dann (wie Maven und JDK in den Beispielen im ersten Artikel dieser Serie) explizit in der Pipeline Syntax angegeben werden. Dies ist derzeit jedoch nur in scripted und nicht mit declarative Syntax möglich (siehe Pipeline Syntax – Tools).

Sobald Docker eingerichtet ist, bietet die declarative Syntax die Möglichkeit entweder die gesamte Pipeline oder einzelne Stages innerhalb eines Docker Containers auszuführen. Das dem Container zugrunde liegende Image kann entweder aus einer Registry gezogen werden (siehe Listing 3) oder aus einem ‘Dockerfile’ gebaut werden.

pipeline {
    agent {
        docker {
            image 'maven:3.5.0-jdk-8'
            label 'docker'
        }
    }
    //...
}

Listing 3
Durch die Verwendung des ‘docker’ Parameters in der ‘agent’ Section wird die gesamte Pipeline innerhalb eines Containers ausgeführt, der aus dem angegebenen Image erstellt wird. Das in Listing 3 verwendeten Image sorgt dafür, dass die Executables von Maven und des JDK im ‘PATH’ bereitgestellt werden. Man kann damit hier ohne weitere Konfiguration von Tools in Jenkins (wie Maven und JDK in den Beispielen im ersten Artikel dieser Serie) beispielsweise folgenden Step ausführen: ‘sh ‘mvn test’’.

Das in Listing 3 gesetzte Label bezieht sich in diesem Fall auf den Build Executor. Dies bewirkt, dass die Pipelines nur auf Build Executors ausgeführt werden, bei denen ein Label ‘docker’ gesetzt ist. Insbesondere, wenn man verschiedene Build Executors hat, ist diese Best Practice hilfreich. Denn, wird diese Pipeline auf einem Build Executor ausgeführt, auf dem kein Docker Client im ‘PATH’ verfügbar ist, scheitert der Build. Ist jedoch kein Build Executor mit dem Label verfügbar, bleibt der Build in der Queue.

Ein weiterer Punkt, den man bei in Container ausgeführten Builds oder Steps bedenken muss, ist das Speichern von Daten außerhalb der Container. Da jeder Build in einem neuen Container ausgeführt wird, sind die darin enthaltenen Daten bei der nächsten Ausführung nicht mehr verfügbar. Jenkins sorgt zwar dafür, dass der Workspace als Working Directory in den Container gemountet wird. Dies geschieht jedoch nicht für beispielsweise das lokale Maven Repository. Während der in den Beispielen bisher verwendete ‘mvn’ Step (basierend auf den Jenkins Tools) das Maven Repository des Build Executors verwendet, legt der Docker Container ein Maven Repository im Workspace jedes Builds an. Das kostet zwar etwas mehr Speicher und der jeweils erste Build wird langsamer. Dafür schließt man unerwünschte Seiteneffekte aus, wie beispielweise, wenn zwei gleichzeitig laufende Builds eines Maven Multi Module Projekts sich gegenseitig Snapshots im gleichen lokalen Repository überschreiben. Wenn trotzdem das Repository des Build Executors verwendet werden soll, sind einige Anpassungen am Docker Image notwendig (siehe Cloudogu ces-build-lib – Docker).Was man eher vermeiden sollte, ist das Ablegen des lokalen Maven Repositories im Container. Dies würde dazu führen, dass alle Dependencies bei jedem Build neu aus dem Internet geladen werden, was wiederum die Dauer jedes Builds deutlich verlängern würde.

Das in Listing 3 in declarative Syntax beschriebene Verhalten lässt sich auch in scripted Syntax realisieren, wie Listing 4 zeigt.

node('docker') {
    // ...
    docker.image('maven:3.5.0-jdk-8').inside {
            // ...
     }
}

Listing 4
Wie in declarative Syntax (siehe Listing 3), kann man auch in scripted Syntax Build Executors mittels Label auswählen. In scripted Syntax (Listing 4) erfolgt dies als Parameter des ‘node’ Steps. Hier wird Docker über eine globale Variable angesprochen (siehe Global variable reference Docker). TDiese Variable bietet noch weitere Features, unter anderem

  • bestimmte Docker Registries zu verwenden (unter anderem für Continuous Delivery auf Kubernetes hilfreich, was im dritten Teil dieser Serie beschrieben wird),
  • einen bestimmten Docker Client (definiert als Jenkins Tool, wie oben beschrieben) zu verwenden,
  • Images zu bauen, mit Tag zu versehen und in eine Registry zu schieben, sowie
  • das Starten und Stoppen von Containern.

Die ‘docker’ Variable unterstütz nicht immer die neuesten Docker Features. Beispielsweise fehlt das Bauen von Multi-Stage Docker Images (siehe Jenkins issue 44609). In diesem Fall kann man auf den CLI Client von Docker zurückgreifen, beispielsweise so: sh 'docker build ...'.

Beim Vergleich von Listing 3 mit Listing 4 zeigt sich deutlich der Unterschied zwischen beschreibender (declarative) und imperativer (scripted) Syntax. Statt deklarativ am Anfang anzugeben welcher Container zu verwenden ist, wird imperativ festgelegt ab wo etwas in diesem Container auszuführen ist. Dadurch ist man jedoch auch flexibler: Während man in declarative Syntax darauf beschränkt ist die ganze Pipeline oder einzelne Stages in Containern auszuführen, kann man in scripted Syntax beliebige Abschnitte in Containern ausführen.

Wie schon mehrfach erwähnt, kann man in declarative Syntax allerdings auch innerhalb des ‘script’ Step, scripted Syntax ausführen oder eigene Steps aufrufen, die in scripted Syntax geschrieben sind. Das Auslagern wird im Folgenden genutzt, um den ‘mvn’ Step in der Shared Library (Listing 1) von Jenkins Tools auf Docker umzustellen (vergleiche Listing 5).

def call(def args) {
    docker.image('maven:3.5.0-jdk-8').inside {
        sh "mvn ${args} --batch-mode -V -U -e -Dsurefire.useFile=false"
    }
}

Listing 5
Nach der Aktualisierung der Shared Library, wie in Listing 5 beschrieben, läuft jeder ‘mvn’ Step in den scripted und declarative Pipeline-Beispielen dann ohne weitere Änderung in einem Docker Container.

Zum Abschluss noch ein fortgeschrittenes Docker-Thema. Die scripted Pipeline Syntax lädt fast dazu ein, Docker Container zu schachteln, also „Docker in Docker“ auszuführen. Dies ist nicht ohne weiteres möglich, da in einem Docker Container zunächst kein Docker Client verfügbar ist. Allerdings kann man mit ‘docker.withRun()’ (siehe Dokumentation Pipeline Docker)mehrere Container gleichzeitig ausführen.

Es gibt jedoch auch Builds, die Docker Container starten, beispielsweise mit dem Docker Maven Plugin (siehe Docker Maven Plugin). Damit können unter anderem Testumgebungen hochgefahren oder UI-Builds ausgeführt werden. Für diese Builds muss man tatsächlich „Docker in Docker“ verfügbar machen. Dafür ist es jedoch nicht naheliegend einen weiteren Docker Host in einem Docker Container zu starten, auch wenn das möglich wäre (siehe Do Not Use Docker In Docker for CI). Stattdessen kann man den Docker Socket des Build Executors in den Docker Container des Builds mounten. Auch bei diesem Vorgehen sollte man sich jedoch gewissen sicherheitsrelevanten Einschränkungen bewusst sein (siehe Never Expose Docker Socket). Hier wird die oben erwähnte Trennung des Docker Hosts des Masters von den Docker Hosts der Build Executors noch wichtiger. Damit der Zugriff auf den Socket möglich ist, sind außerdem einige Anpassungen am Docker Image notwendig. So muss der User, mit dem der Container gestartet wird, in der docker Gruppe sein, um Zugriff auf den Socket zu bekommen. Dazu müssen im Image User und Gruppe erzeugt werden (siehe beispielsweise Cloudogu ces-build-lib – Docker).

Fazit und Ausblick

Dieser Artikel beschreibt zum einen wie man die Wartbarkeit der Pipeline durch Auslagerung von Code in eine Shared Library verbessern kann. Dieser Code ist dann auch wiederverwendbar und seine Qualität kann durch Unit Tests geprüft werden. Außerdem wird Docker als Werkzeug vorgestellt, mit dem Pipelines in einheitlicher Umgebung, isolierter und unabhängiger von der Konfiguration der jeweiligen Jenkins-Instanz ausgeführt werden können.

Diese nützlichen Werkzeuge schaffen die Grundlage für den vierten Teil, in dem die Continuous-Delivery-Pipeline vollendet wird.


Johannes Schnatterer

- Solution Architect -

Johannes ist Continuous Delivery Enthusiast, fokussiert auf Software Qualität, hat einen ausgeprägten Open Source Enthusiasmus und ist überzeugt, dass prägnante Dokumentation entscheidend sein kann.

©2018 Cloudogu GmbH. Alle Rechte vorbehalten.
Impressum | Datenschutzerklärung

Cloudogu™, Cloudogu EcoSystem™ und das Cloudogu™ Logo sind eingetragene Marken der Cloudogu GmbH, Deutschland.