Perfektes CI/CD mit Jenkins Pipeline auf Google Kubernetes Engine
Warum arbeiten wir mit Jenkins Pipeline? Rund 50 Entwickler arbeiten bei Ackee daran, Apps für unsere anspruchsvollen Kunden zu entwickeln, zu testen und auszuliefern. Dafür nutzen wir verschiedene Methoden. Bei mehreren Git Pushes und Merge Requests pro Stunde benötigen wir einen schnellen und optimierten Prozess. Die CI/CD mittels vielfältiger Methoden und Clouds bzw. anderen Einsatzzielen zu automatisieren ist sehr wichtig für uns. Deshalb nutzen wir die leistungsstarke Jenkins Pipeline mit der Shared Pipeline Library zusammen mit Jenkins und Gitlab.
Unser Backend basiert hauptsächlich auf Kubernetes Cluster sowie auf NodeJS, MongoDB, MySQL und einigen PHP Containern. Das Frontend mit React, Middleman und weiteren Methoden muss schnell getestet und auf den entsprechenden Webservern bereitgestellt werden. Das kann Google Storage Buckets oder FTP Webhosting sein - je nachdem wie der Kunde es haben will.
Auch das Android- und das iOS-Team benötigen eine schnelle und zuverlässige CI/CD. Generell brauchen alle Teams einen Weg um Merge Requests aufzubauen und zu testen. Zudem gibt es ständig den Bedarf, etwas an der Infrastruktur zu automatisieren. Deshalb hat für uns die geskriptete Automatisierungsplattform für das DevOps-Team eine hohe Priorität.
Jenkins Pipeline ist die bessere Alternative
Vor einiger Zeit haben wir Jenkins Freestyle Jobs genutzt, um ein Repository auszuprobieren. Wir haben es mit einfachen Bash Snippets in die Konsole eingebaut und es anderen Servern mittels SSH, Rsync, etc. zur Verfügung gestellt.
Dann kam Jenkins Pipeline. Jenkins Pipeline ist eine Sammlung von Plugins, die das Einbinden und Integrieren von ständig aktiven Liefer-Pipelines zu Jenkins unterstützen. Also eine Alternative zu den alten Freestyle Jobs.
Mit Jenkins Pipeline muss man nur noch eine Pipeline oder einen Multibranch Pipeline Job erstellen und die Pipeline als Code definieren. Der Code kann Teil der Job Definition oder auch direkt als Jenkins File im Projekt Repository enthalten sein.
Beispiel Jenkinsfile:
#!groovy
node('nodejs') {
currentBuild.result = "SUCCESS"
try {
stage('Checkout'){
checkout scm
}
stage('Build'){
sh 'npm install'
}
stage('Test'){
env.NODE_ENV = "test"
sh 'npm run ci-test'
}
stage('Deploy'){
sh 'docker build -t myapp . && docker run -d myapp'
}
stage('Cleanup'){
sh 'npm prune'
sh 'rm node_modules -rf'
slackNotify channel: '#ci-nodejs', message: 'pipeline successful: myapp'
}
}
catch (err) {
currentBuild.result = "FAILURE"
slackNotify channel: '#ci-nodejs', message: 'pipeline failed: myapp'
throw err
}
}
Was Jenkins Pipeline leistet
Die Jenkins Pipeline hat verschiedene Levels, die dank des Visualizer Plugins sichtbar sind. Was die Jenkins Pipeline kann, ist Folgendes:
- Sie baut die Node App mit NPM (“sh” bedeutet Shell Call),
- Sie testet das Ganze
- Sie baut ein Docker Image und verwaltet es
- Sie räumt den Workspace auf und
- sie benachrichtigt Slack über das Pipeline Ergebnis.
slackNotify ist ein Slack Plugin Call. Das bedeutet, dass Plugins die Syntax der Jenkins Pipeline unterstützen müssen, um als hilfreiche Funktion mit einer Auswahl von Parametern zu gelten.
Jenkins Pipeline ist überaus leistungsstark. Sie macht mehr oder weniger alles, was man von ihr will. Da sie die Groovy Sprache nutzt, kann man Java Libraries in der Pipeline verwenden.
Jenkins Shared Pipeline Library
Da wir an vielen Projekten gleichzeitig arbeiten, hätten wir so eine Menge ähnlichen Codes oder das exakt gleiche Jenkinsfile in allen NodeJS und andere Repositorys.
Man stelle sich vor, dass eine einfache Änderung in der slackNotify Funktion Call Syntax oder einer anderen CIi Client Syntax bedeuten würde, dass wir den Pipeline Code in allen Repositorys und Branches ändern müssen!
Das ist übrigens neulich passiert bei GCloud cli API Client Syntax. Die alte Syntax wurde abgelehnt und hat GCloud Docker Push zu GCloud Docker -- push geändert. In unserem Fall wollen wir die Pipeline Logik nicht im Projekt Repository vorfinden. Das einzige, was wir dort haben wollen, ist die Pipeline Konfiguration. Das kann man ganz einfach erreichen, in dem man Jenkins Shared Pipeline Library verwendet. Das ist ein Groovy Git Repository mit der folgenden Ordner Struktur.
(root)
+- src # Groovy source files
| +- org
| +- foo
| +- Bar.groovy # for org.foo.Bar class
+- vars
| +- foo.groovy # for global 'foo' variable
| +- foo.txt # help for 'foo' variable
+- resources # resource files (external libraries only)
| +- org
| +- foo
| +- bar.json # static helper data for org.foo.Bar
Dieses shared Pipeline Repository wird jedes Mal überprüft, wenn ein Pipeline Job startet. Man kann es in sein Jenkinsfile einfügen und dort nutzen. Man kann sogar Branches definieren.
@Library('my-shared-library@development') _
Oder man lädt es implizit. Das kann man in den Jenkins Konfigurationseinstellungen festlegen.
Konfiguration von Jenkinsfile
Wenn man die shared Pipeline Library nutzt, kann das Jenkinsfile im Projekt Repository folgendermaßen aussehen:
PipelineNodejs{
// MODIFY
projectName = 'node-template'
slackChannel = '#ci-nodejs'
appName = 'api' // microservice name, unique in project
cloudProject = [development: 'kube-dev-cluster', master: 'kube-prod-cluster']
buildCommand = 'npm install && npm run postinstall'
// MODIFY ONLY IF YOU KNOW WHAT YOU ARE DOING
nodeImage = 'node:5.12.0'
nodeEnv = "-e NODE_PATH=./app:./config"
nodeTestEnv = '-e NODE_ENV=test -e NODE_PATH=./app:./config'
namespace = [development: "${projectName}-development", master: "${projectName}-master"]
}
Beispiel für eine Jenkins Pipeline
The Jenkinsfile im vorhergehenden Abschnitt ruft eine Funktion PipelineNodeJS auf. Das ist eine globale Funktion, die in der Shared Pipeline Library in vars/PipelineNodeJS.groovy definiert ist.
def call(body) {
// evaluate the body block, and collect configuration into the object
def config = [:]
body.resolveStrategy = Closure.DELEGATE_FIRST
body.delegate = config
body()
properties([disableConcurrentBuilds()])
def agent = config.agent ?: 'nodejs'
node(agent) {
def workspace = pwd()
... code omitted for brevity ...
stage('Test') {
if (config.runTests){
docker.image(nodeImage).inside(nodeTestEnv) {
sh "npm run ci-test"
}
echo "npm run ci-test finished. currentBuild.result=${currentBuild.result}"
... code omitted for brevity ...
if (currentBuild.result == 'UNSTABLE') {
throw new RuntimeException("Tests failed")
}
}
else {
echo 'Tests skipped'
}
... code omitted for brevity ...
Für Android, iOS, React, middleman, etc. gibt es auch andere Pipelines.
Merge Request Builder
Für den Merge Request Flow mit Gitlab skripten wir einen Jenkins Pipeline Job, der das Gitlab Integration Plugin nutzt.
Hier kommt das Merge Request Builder Job Beispiel für iOS:
env.CHANGELOG_PATH = "outputs/changelog.txt"
env.SLACK_CHANNEL = "ci-merge-requests"
env.FASTLANE_SKIP_UPDATE_CHECK = 1
env.FASTLANE_DISABLE_COLORS = 1
env.CHANGELOG = ""
node('ios') {
try {
gitlabBuilds(builds: ["carthage", "pods", "test", "build ipa"]) {
def gemfileExists = fileExists 'Gemfile'
fastlane = "fastlane"
if (gemfileExists) {
fastlane = "bundle exec fastlane"
}
stage('Checkout') {
println env.dump()
withCredentials([string(credentialsId: 'jenkins-gitlab-credentials', variable: 'credentials')]) {
checkout changelog: true,
poll: true,
scm: [$class: 'GitSCM',
branches: [[name: "origin/${env.gitlabSourceBranch}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [[$class: 'WipeWorkspace'],
[$class: 'PreBuildMerge',
options: [fastForwardMode: 'FF',
mergeRemote: 'origin',
mergeStrategy: 'default',
mergeTarget: "${env.gitlabTargetBranch}"]
]
],
submoduleCfg: [],
userRemoteConfigs: [[name: 'origin', credentialsId: credentials, url: env.gitlabSourceRepoSshUrl ]]]
}
}
stage('Prepare') {
sh("security unlock -p ${MACHINE_PASSWORD} ~/Library/Keychains/login.keychain")
if (gemfileExists) {
sh "bundle install --path ~/.bundle"
}
}
gitlabCommitStatus("carthage") {
stage('Carthage') {
sh(fastlane + " cart")
}
}
gitlabCommitStatus("pods") {
stage('Pods') {
sh(fastlane + " pods")
}
}
gitlabCommitStatus("test") {
stage('Test') {
sh(fastlane + ' test type:unit')
junit allowEmptyResults: true, testResults: 'fastlane/test_output/report.junit'
}
}
gitlabCommitStatus("build ipa") {
stage('Build IPA') {
sh(fastlane + " beta")
}
}
}
currentBuild.result = 'SUCCESS'
}
catch (e) {
currentBuild.result = "FAILURE"
throw e
}
finally {
notifyBuild(currentBuild.result,reason)
}
}
Nutze GitLabBuild und GitlabCommitStatus, um die Jenkins Pipeline in Gitlab zu visualisieren und das mergen des MR in Gitlab zu erlauben bzw. zu verbieten. Dieser Job kann global für alle iOS Merge Requests verwendet werden. Setze den WebHook für diesen Jenkins Job von deinem Repository aus - und fertig.
Ein nerviger aber wichtiger Schritt ist der SCM Checkout, bei dem Source und Target Branch zusammengeführt werden. Die ENV Daten (env.gitlabSourceBranch und env.gitlabTargetBranch) werden vom Gitlab Webhook geparst.
Man kann das Rebuilding des Merge Requests leicht wieder einrichten, wenn ein neuer Push auf Source oder Target Branch erfolgt ist.
Ein weiteres cooles Feature ist, dass man die Jenkins Pipeline durch einen Kommentar im MR nochmals ablaufen lassen kann. Wir nutzen die Phrase “rebuild pls” und Jenkins baut den Job automatisch neu auf!
Job DSL und Seed Jobs
Dank des Job DSL Plugins können wir dynamisch Jenkins Pipeline Jobs via Jenkins DSL API generieren.
Einen Job, der andere Jobs generiert, nennt man Seed Job.
Hier kommt ein Beispiel für das Generieren eines iOS Merge Request Jobs:
String scriptPath = "jobs/gitlab"
String jobSuffix = "merge-request-builder"
String mCommentTrigger = "rebuild pls"
def gitlabOn = {
it / 'properties' / 'com.dabsquared.gitlabjenkins.connection.GitLabConnectionProperty' {
'gitLabConnection'('gitlab')
}
}
def platform = 'ios'
def jobName = "$platform-$jobSuffix"
pipelineJob(jobName) {
concurrentBuild(false)
configure gitlabOn
definition {
cps {
script(readFileFromWorkspace("${scriptPath}/${jobName}.groovy"))
sandbox()
}
}
triggers {
gitlabPush {
buildOnMergeRequestEvents(true)
buildOnPushEvents(false)
enableCiSkip(true)
setBuildDescription(true)
commentTrigger(mCommentTrigger)
rebuildOpenMergeRequest('both')
skipWorkInProgressMergeRequest(false)
}
}
}
Die Produktions-Pipeline testen
Jetzt kommt der Moment, in dem wir unsere Jenkins Pipeline aufbauen, testen und ausliefern müssen. CI/CD für die CI/CD-Pipeline.
Yo dawg, I heard you like Inception.
Wir platzieren ein einfaches Jenkinsfile in der Shared Pipeline Library und nutzen es, um die Jobs im vorigen Kapitel “zu säen”.
// use a Pipeline class src/cz/ackee/Pipeline.groovy rather than the global func
def pipeline = new cz.ackee.Pipeline()
node {
properties([
disableConcurrentBuilds()
])
pipeline.checkoutScm()
// set additional envvars and config
pipeline.setEnv()
stage('seed jobs') {
withCredentials([string(credentialsId: 'jenkins-gitlab-credentials', variable: 'gitlabCredentials')]) {
jobDsl targets: 'jobs/**/seed.groovy',
additionalParameters: [credentials: gitlabCredentials]
}
}
stage('test') {
// run basic tests on pipeline like mysql reporting, slack, gcloud, kubectl and gitlab integrations checking
pipeline.envTest()
// test Node.js Pipeline
def obj = build job: '../node-template/master/', propagate: false
if(obj.result == 'FAILURE') throw new RuntimeException("Node.js Pipeline test failed.")
// test React Pipeline
obj = build job: '../react-template/master', propagate: false
if(obj.result == 'FAILURE') throw new RuntimeException("React Pipeline test failed.")
// test Middleman Pipeline
obj = build job: '../middleman-template/master', propagate: false
if(obj.result == 'FAILURE') throw new RuntimeException("Middleman Pipeline test failed.")
}
stage('lint') {
//TODO: add groovy lint
}
}
Weitere großartige Dinge, die man mit der Jenkins Shared Pipeline Library anstellen kann
Alles, was du willst. Wirklich!
Links
Für NodeJS, Android, iOS, React, middleman, etc. gibt es noch weitere Pipelines.