In dem Continuous Integration System Jenkins gibt es zwei verschiedene Pipelinearten.
Scripted Pipelines
- Basiert auf Groovy
- Imperativ -> Man gibt Jenkins eine genaue Anweisungen was zu tun ist. Ist im Grunde ein normales Script was ausgeführt wird.
- Komplexe Lösungen mit Schleifen, Bedingungen und Fehlerbehandlungen möglich
Deklarative Pipelines
- Strukturierter und einfacher zu lesen Syntax
- Weniger flexibel
Mir ist aufgefallen, dass die meisten immer deklarativen Pipelines für ihre Projekte verwenden. Dies liegt vermutlich auch daran, dass man, wenn man danach sucht, oftmals auch mehr Anleitungen dafür findet. Allerdings finde ich das recht schade, weil die Scripted Pipeline verschiedene Sachen erleichtert und gleichzeitig niemandem etwas aufzwingt. Und da diejenigen, die mit solchen Pipelines arbeiten, meist Programmierer oder Vergleichbares sind, können sie auch ohne Probleme eine Scripted Pipeline lesen.
Um die Unterschiede zwischen den Pipelines zu zeigen, werde ich Codebeispielen verwenden. Diese Beispiele werden zwar einfach gehalten, aber ich werde nicht alles erklären. Dieser Beitrag ist mehr als Inspiration gedacht, Scripted Pipelines zu nutzen und nicht als Anleitung/Grundkurs. Die Dokumentation zu den meisten Codeelementen findet ihr in der Jenkins Dokumentation.
In dem folgenden Beispiel ist eine einfache Pipeline zu sehen, die den Text „Mache etwas“ ausgibt und dann 5 Sekunden wartet
// Scripted
node() {
scmVars = checkout scm
stage('Build') {
echo "Mache etwas"
sh "sleep 5"
}
}
// Deklarativ
pipeline {
agent any
stages {
stage('Build') {
steps {
echo 'Mache etwas'
sh 'sleep 5'
}
}
}
}
Beide Pipelines machen in diesem Fall komplett dasselbe. Sie bekommen einen Build-Prozessor (Executors) zugewiesen mit einem Workspace. Dann laden sie sich das Git-Repo herunter und führen anschließend in einer Stage den „echo“ und den „sh“ Befehl aus.
Bei der Scripted Pipeline sieht man genau was passiert. Wir bekommen mit „node“ einen Build-Prozessor und rufen dann mit „checkout scm“ das Git -Repo ab.
Diese Schritte sind bei der deklarativen Pipeline nicht sofort ersichtlich, da sie automatisch passieren. Das „agent any“ macht diese beiden Schritte gemeinsam. Der Agent Block kann noch mehr, aber später mehr dazu.
Zusammenspiel mit Docker
Wir bei Flavia verwenden für viele Projekte Docker und daher findet es auch im Build-Prozess unserer Anwendungen seinen Einsatz. Häufig will man Docker nutzen, um in seiner Jenkins-Pipeline eine Sammlung an Anwendungen oder Ähnliches bereitzustellen während der Laufzeit. Dafür hat Jenkins ein Plugin, um Docker innerhalb der Pipeline zu nutzen und auch weiter Steps innerhalb eines Containers auszuführen.
Hier ist nun ein Codebeispiel, das zeigt, wie die Stage „Build“ vollständig innerhalb eines Docker-Containers ausgeführt wird, wobei das Image „ubuntu“ verwendet wird.
pipeline {
agent any
stages {
stage('Build') {
agent {
docker {
image 'ubuntu'
}
}
steps {
sh 'sleep 5'
}
}
}
}
Alles recht einfach und schnell. Allerdings gibt es hier auch Einschränkungen.
- Die erste Einschränkung ist, dass man nur pro Stage einen Docker Container verwenden kann. Man kann nicht innerhalb einer Stage auf einen anderen Container wechseln. Das kann nach meiner Erfahrung recht nervig sein, weil man manchmal vielleicht nur einen einzigen Befehl in Docker ausführen will, aber den Rest vielleicht nicht.
- In diesem Beispiel ist für die Pipeline ein Agent konfiguriert und in der Build Stage ist ein Agent konfiguriert. Im Standardverhalten bekommt die Build Stage einen kompletten neuen Workspace Ordner und einen Build Prozessor zugeordnet. Dadurch sind alle Änderungen, die man in früheren Stages gemacht hat, nicht mehr vorhanden und man muss sie erst mit den Befehlen stash und unstash in den Workspace kopieren. Wenn man jetzt mit parallelen Stages arbeitet, die alle einen eigenen Docker Agent haben, dann hat man zügig mehrere Build-Prozessoren belegt. Das kann den Nachteil haben, dass man andere Builds auf den Jenkins ggf. blockiert.
Der Docker Agent hat zwar die Option reuseNode, womit er den Build-Prozessor und den Workspace von der Pipeline wiederverwendet, diese Option ist allerdings standardmäßig deaktiviert und wird gerne mal vergessen.
Diese Nachteile hat man nicht, wenn man eine Scripted Pipeline verwendet. Dazu hier einmal das Codebeispiel:
node() {
scmVars = checkout scm
stage('Build') {
docker.image('ubuntu').inside() {
echo "inside container"
}
echo "outside container"
}
}
Im Beispiel sieht man direkt, wie Sachen innerhalb eines Containers auszuführen sind. Wenn man möchte, kann man innerhalb einer Stage auch noch weiter Befehle ausführen, welche nicht im Docker Container passieren müssen. Das Problem mit den mehreren Build-Prozessoren besteht auch hier in keinster Weise. Solang Du nicht noch einmal einen „node“ Block erstellst, bekommst Du auch keinen neuen Build-Prozessor und damit auch keinen neuen Workspace, sondern arbeitest in deinen vorhandenen weiter.
Docker Sitecar
Das Docker Plugin bietet jedoch noch eine weitere nützliche Funktion, die allerdings nur in Scripted Pipeline verfügbar ist und zwar sogenannte Sitecar Container.
Also als einfaches Beispiel: Du benötigst eine Postgres-Datenbank die nebenbei mitläuft, während Du die Tests für dein Backend machst
Code:
node() {
scmVars = checkout scm
stage('Build') {
docker.image('postgres').withRun("-e POSTGRES_PASSWORD=kekse") { database ->
docker.image('ubuntu').inside("--link ${database.id}:database") {
sh 'sleep 20'
}
}
}
}
Hier wird in der Build Stage ein Postgres Container gestartet mit dem Password „Kekse“. Die Informationen dazu landen in der Variable „database“. Im nächsten Schritt starten wir wieder einen Ubuntu-Container und verbinden diesen Container mit dem Postgres-Container. Jetzt können wir im Ubuntu-Container normal arbeiten und haben eine erreichbare Datenbank. Wenn der withRun Block endet, dann wird der Postgres Container automatisch beendet.
Wir haben also eine schöne Lösung für Sitecar-Container, die automatisch aufgeräumt wird. Diese Lösung gibt es in der deklarativen Pipeline leider nicht. Oft sehe ich, wie Entwickler einfach einen Docker Run Befehle direkt ausführen. Diese ist aber keine schöne Lösung, da man gerne mal das Aufräumen vergisst oder es nicht ausgeführt wird, weil die Pipeline vorher einen Fehler hatte.
Weiter Beispiele
Um mal nicht so weiter auszuschweifen, hier einfach mal ein paar weitere Beispiele
Try/catch/finaly
Scripted
node() {
scmVars = checkout scm
stage('Build') {
try {
echo "Now exit 1"
sh "exit 1"
} catch(Exception e) {
echo "Error"
} finally {
echo "This is always"
}
}
}
Deklarativ
pipeline {
agent any
stages {
stage('Build') {
steps {
echo "Now exit 1"
sh "exit 1"
}
post {
failure {
echo "Error"
}
always {
echo "This is always"
}
}
}
}
}
In deklarativen Pipelines gibt es ungünstigerweise keine ordentliche Try/catch/finally Blöcke. Stattdessen stehen nur Post-Steps für Stages und Pipelines zur Verfügung. Die Post-Steps bieten zwar mehrere Abfangmethoden wie always, aborted, failure, success (https://www.jenkins.io/doc/book/pipeline/syntax/#post), diese sind aber fest an Stages gebunden und nicht frei im Code platzierbar.
Schleifen
Scripted Pipelines können auch Schleifen, was deklarative Pipelines nicht können. So kann man unter anderem eine Pipeline bauen, die für mehrere Ordner dieselben Befehle ausführt.
node() {
scmVars = checkout scm
folder = ["Backend", "Frontend"]
folder.each { name ->
stage(name){
dir(name) {
echo "Command for folder ${name}"
sh "sleep 15"
}
}
}
}
Fazit
Das hier waren jetzt nur ein paar wenige Beispiele für die Unterschiede und die Mächtigkeit dieser Pipelines. Ich selbst habe am Anfang nur deklarative Pipelines gelernt, weil die meisten Beispiele immer dafür waren. Mittlerweile nutze und bevorzuge ich allerdings nur noch Scripted Pipelines. Sie erleichtern meiner Meinung nach viele Sachen, da sie mächtiger und anpassbarer sind. Ich habe bereits oft gesehen, wie Projekte mit einer einfachen deklarativen Pipeline angefangen haben und durch steigende Anforderungen über die Zeit immer komplexer und unlesbarer wurden. Da wäre eine Scripted Pipeline einfacher gewesen.
Meine Empfehlung daher: Nutzt Scripted Pipelines 😀