Teil 3 - Hands on: Jenkins

TECH BLOG

Der letzte Schritt besteht darin, eine Build & Deployment Pipeline zu erstellen, welche automatisch das Docker Image erstellt und dieses im ECS Cluster startet.

Installieren der Plugins

Jenkins → Manage Jenkins → Manage Plugins


AWS Pipeline Plugin

Github: https://github.com/jenkinsci/pipeline-aws-plugin


AWS SQS Plugin (2.0.1)

Github: https://github.com/jenkinsci/aws-sqs-plugin


Utility Pipeline Plugin

Github: https://github.com/jenkinsci/pipeline-utility-steps-plugin


Zugangsdaten einstellen

Unglücklicherweise verwenden die Plugins nicht dasselbe Format um die Zugangsdaten zu speichern. Daher müssen wir die Zugangsdaten mehrfach in verschiedenen Formaten definieren:

Jenkins → Credentials → System → Global Credentials → Add new credentials (http://localhost:8081/credentials/store/system/domain/_/newCredentials)


CodeCommit Zugang

Erstelle "Username with Password" Zugangsdaten und verwende die CodeCommit HTTPS Zugangsdaten



Simple Queue Service (SQS) Zugang

Erstelle "Secret text" Zugangsdaten und verwende den Access Key als ID und den dazugehörigen Secret Key als Secret.



Die Queue selbst wird an einem anderen Ort im Jenkins konfiguriert:

Jenkins → Manage Jenkins → Configure System → Configuration of Amazon SQS queues: http://localhost:8081/configure (ganz unten)


#


Die URL der Queue kann unter AWS SQS gefunden werden. Klicke dazu auf die Queue. Im Details Tab wird dann die URL angezeigt.


#


Important: Speichern nicht vergessen!


AWS Zugang

Erstelle "Username with Password" Zugangsdaten und verwende den Access Key als Username und den Secret Key als Password.


#


Pipeline erstellen

Jenkins → New Item


#


Build Parameter (Git Tags)

Erstelle einen String parameter namens BUILD_TAG



Build Trigger (SQS)

Nachdem wir den SQS Zugang konfiguriert haben, können wir die Queue verwenden um unseren Build auszulösen.


#


Pipeline schreiben

Nun da wir Jenkins und unseren AWS Account konfiguriert haben, können wir mit dem Schreiben der Pipeline beginnen.

Die Grundstruktur einer Declarative Pipeline sieht folgendermaßen aus:

Structure


pipeline { 
        agent any 
        environment {

               } 
        stages {
               stage("stage one") {
                       steps {

                              }
               }
        }
}

Important!

In den folgenden werden wir:

Ändere diese zu deinem Bucket und Repository.


Environment Variables (Umgebungvariablen)

Um die Pipeline wartbarer zu machen, werden wir 5 Umgebungsvariablen erstellen:

  • ECR - URI des Repositories
  • IMAGE - Imagename der Applikation
  • VERSION - Version die in der pom.xml steht
  • STACK - Stackname die in CloudFormation verwendet werden soll
  • COMMIT - Hash des Commits der gebaut wird


Groovy implementation Quelle erweitern

environment {
       //ecr uri           
       //example ECR = "222222222222.dkr.ecr.eu-central-1.amazonaws.com/springio/gs-spring-boot-docker"
        ECR = "<your uri here>"

       //image name
        IMAGE = "springio/gs-spring-boot-docker"                

       //pom.xml version      
        VERSION = "0.0.0"        

       //Stack name        
       STACK = "MyWebServiceStack"          

       //Commit hash        
       COMMIT = ""    
}


Dadurch ersparen wir uns das mehrfache Eingeben von Links/Namen.


Variablen in Strings verwenden

Jenkins verwendet für die String interpolation Regeln die identisch zu Groovy sind. Groovy’s String interpolation kann für Anfänger verwirrend sein.

Während Groovy Stringdeklarationen mit einfachen oder doppeltem Anführungszeichen unterstützt:


String declaration

def singlyQuoted = 'Hello'
<strong></strong>def doublyQuoted = "World"

Nur der letztere unterstützt die Dollarzeichen ($) basierte String interpolation, zum Beispiel:

String and GString

def username = 'Jenkins'
echo 'Hello Mr. ${username}'  //single quote
<strong></strong>echo "I said, Hello Mr. ${username}" //double quote

Resultiert in:

Output

Hello Mr. ${username}
I said, Hello Mr. Jenkins

Quelle: https://jenkins.io/doc/book/pipeline/jenkinsfile/#string-interpolation

Stages
Checkout Stage

Diese Stage klont das git Repository aus CodeCommit und holt sich eine bestimmte Version, falls definiert.


Entscheiden was geholt werden soll

Wenn ein Tag definiert ist (manueller Build) und dieser existiert im Repository, holen wir diese Version. Andernfalls holen wir die neueste Version.

Falls kein Tag definiert wird (automatisierter Build) holen wir uns die neueste Version.

Nach dieser Entscheidung setzen wir die Umgebungsvariablen COMMIT und VERSION.


Beispiel Stage

Example Stage Quelle erweitern


stage('Checkout') {            
        steps {                
               git url: 'https://git-codecommit.eu-central-
1.amazonaws.com/v1/repos/tutorial_repo',
               credentialsId: 'CodeCommitCredentials' // ID of HTTPS 
CodeCommit credentials
               
               script {
                       if("${BUILD_TAG}" != "") {
                               echo "Searching for tag: ${BUILD_TAG}"
        
                               //output returns:
                               //      the tag if the tag exists
                               //      "" if the tag does not exists

                               def output = bat(script: "@ git tag -l \"${BUILD_TAG}\"", returnStdout: true).trim();
                               echo output
                               
                               if("${output}" == "${BUILD_TAG}") {
                                      echo "Tag found"
                                      echo "Building tag \"${BUILD_TAG}\""
                                      bat "git checkout ${BUILD_TAG}"
                               } else {
                                      echo "Tag \"${BUILD_TAG}\"not found"
                                      BUILD_TAG = "";
                                      echo "Building latest commit"
                               }
                       } else {                        
                               echo "Building latest commit"
                       }

                       //escape % with a second % -> %%
                       COMMIT = bat(script: "@ git log -n 1 --pretty=format:%%h", returnStdout: true)
                       
                       //read version from pom.xml
                       VERSION = readMavenPom().getVersion()
               }
        }
}


Maven Build Stage

Diese Stage baut die .jar Datei und testet die Applikation.

Maven verwenden

Um unsere .jar Dateien zu unterscheiden fügen wir den Tag/Commit zum Imagenamen hinzu:

  • gs-spring-boot-docker-1.0.1.jar → gs-spring-boot-docker-1.0.1-v1.0.1-RC.jar
  • gs-spring-boot-docker-1.0.0.jar → gs-spring-boot-docker-1.0.0-b127d6b.jar

Dabei hilft uns das Maven Versions Plugin


script {
        // VERSION is read from pom.xml
        bat "mvn versions:set -DnewVersion=${VERSION}-${BUILD_TAG}"
}


Testergebnisse anzeigen

https://jenkins.io/doc/pipeline/steps/junit/

https://jenkins.io/doc/book/pipeline/syntax/#post

Das Anzeigen der Testergebnisse sollte immer geschehen, egal ob der Buildprozess fehlschlägt oder nicht. Deshalb wird dieser Step im post-condition block always definiert.


Archivieren der gebauten jar

Wenn der Buildprozess erfolgreich durchlaufen wurde, werden wir die gebaut .jar Datei in dem S3 Bucket archivieren.


Beispiel Stage

Example Stage Quelle erweitern


stage('Build jar with Maven') {
        steps {
               script {
                       // VERSION is read from pom.xml
                       if("${BUILD_TAG}" != "") {
                               bat "mvn versions:set -DnewVersion=${VERSION}-${BUILD_TAG}"
                       } else {
                               bat "mvn versions:set -DnewVersion=${VERSION}-${COMMIT}"
                       }
               }
               bat 'mvn clean install'
        }
        post {
               always {
                       //show junit test results
                       junit 'target/surefire-reports/*.xml'
               } 
               success {
                       //push artifacts to s3 bucket (.jar)
                       script {
                               withAWS(region: 'eu-central-1', credentials: 'AWSCredentials') { // ID of the AWS credentials
                                      //upload built jar to s3 bucket
                                      s3Upload(bucket:'qstutorialbucket', includePathPattern: '**/target/*.jar', path:'builds/')
                               }
                       }
               }
        }
}


Docker Build Stage

Diese Stage baut das Docker Image

Um das Docker Image mit einem eigenen Tag anstelle des Standard-Tags latest zu bauen müssen wir die Option dockerfile.tag im Buildbefehl verwenden:

Example Stage Quelle erweitern


stage('Build docker image') {
        steps{
               script {
                       //always tag docker image with commit hash
                       bat "mvn dockerfile:build -Ddockerfile.tag=${COMMIT}"
                       
                       //if build tag exists, tag image
                       if("${BUILD_TAG}" != "") {
                               bat "mvn dockerfile:tag -Ddockerfile.tag=${BUILD_TAG}"
                       }
               }
        }
}

Docker Tag/Push Stage

Diese Stage fügt dem Docker Image einen weiteren Tag hinzu, sodass wir das Image in das ECR hochladen können

Example Stage Quelle erweitern


stage('Tag/Push docker image') {
        steps {
               script {
                       //login aws with credentials
                       withAWS(region: 'eu-central-1', credentials: 'AWSCredentials') { // ID of the AWS credentials 
                               
                               //get login for ecr (returns command to execute in terminal)
                               def ecrLogin = ecrLogin()

                               //execute command
                               bat ecrLogin
                       }

                       //tag image with commit hash for ecr
                       bat "docker tag ${IMAGE}:${COMMIT} ${ECR}:${COMMIT}"
                       bat "docker push ${ECR}:${COMMIT}"

                       //if build tag exists, tag image for ecr
                       if("${BUILD_TAG}" != "") {
                               bat "docker tag ${IMAGE}:${BUILD_TAG} ${ECR}:${BUILD_TAG}"
                               bat "docker push ${ECR}:${BUILD_TAG}" 
                       }
               }
        }
} 


Deploy Stage

Diese Stage wird unsere CloudFormation starten.

Wenn der Stack noch nicht existiert, starten wir zuerst eine stabile Version des Images in der CloudFormation (definiert im Repository) und versuchen dann das neu gebaute Image aufzuspielen.

Überprüfen ob der Stack existiert

Dieser Check überprüft ob der Stack mit dem Namen der in der Umgebungsvariable STACK definiert ist schon existiert.
retrunStatus gibt den error code des Scripts zurück:

  • 0: erfolgreich
  • jeder andere Code: fehlgeschlagen

Daher überprüfen wir lediglich, ob der Output gleich 0 ist um zu sehen ob der Stack existiert.

Groovy implementation Quelle erweitern


script {
output = bat(script: "aws cloudformation describe-stacks --stack-name ${STACK}", returnStatus: true)
        
        if(output == 0) {
               //Stack exists
        } 
        if(output != 0) {
               //Stack does not exist
        }
}


Eine stabile Version starten

Wenn der Stack noch nicht existiert, starten wir zuerst eine stabile Version des Images. Wenn der Stack schon existiert, können wir diesen Schritt überspringen.

Die stabile Version des Docker Images sollte in der .yaml Datei im Repository definiert sein.

Die Pipeline wird dann diese Konfiguration in den S3 Bucket hochladen, sodass CloudFormation darauf zugreifen kann.

Groovy implementation Quelle erweitern


script {
withAWS('eu-central-1', credentials: 'AWSCredentials') { 
               //upload cloudformation folder to s3 bucket 
               s3Upload(file:'cloudformation/', bucket:'qstutorialbucket', path:'cloudformation/') 

               def output = bat(script: "aws cloudformation describe-stacks --stack-name ${STACK}", returnStatus: true) 
               
               //if stack doesn't exist, create 
               if(output != 0) { 
                       //start cloudformation, deletes stack if failed 
                       cfnUpdate(stack: "${STACK}", url:'https://s3.eu-central-1.amazonaws.com/qstutorialbucket/
cloudformation/master.yaml', onFailure: 'DELETE') 
               }
        }
}


Image Version austauschen

Um die Version des Docker Images auszutauschen müssen wir die .yaml Datei modifizieren, welche die Referenz zum zu verwendenden Docker Image enthält.

Diese Datei ist service.yaml
Um in unserem Stack so eine Änderung durchzuführen, müssen wir folgendes machen:

  • Aktuelle Konfiguration lesen
  • Eine neue Konfiguration mit dem neuen Docker Image erstellen
  • Die neue Konfiguration hochladen, sodass CloudFormation darauf zugreifen kann
  • Den Stack mit der neuen Konfiguration updaten

Groovy implementation Quelle erweitern


script {
//read current service.yaml 
        def yamlAsText = readFile file: 'cloudformation/services/website-service/service.yaml' 

        //replace current image with new one 
        def modifiedYamlAsText = '' 
        
        //if build tag exists, replace image with newly built image tagged with build tag 
        //else, replace image with newly built image tagged with commit hash 
        if("${BUILD_TAG}" != "") { 
               modifiedYamlAsText = yamlAsText.replaceAll(/Image:\s.*/, "Image: ${ECR}:${BUILD_TAG}")
        } else { 
               modifiedYamlAsText = yamlAsText.replaceAll(/Image:\s.*/, "Image: ${ECR}:${COMMIT}")
        } println modifiedYamlAsText 
        
        //write changes to file 
        writeFile file: 'tmpChange.yaml', text: "${modifiedYamlAsText}"

        //upload new service.yaml to s3 
        s3Upload(file:'tmpChange.yaml', bucket:'qstutorialbucket', path:'cloudformation/services/website-service/service.yaml') 

        //update stack 
        cfnUpdate(stack: "${STACK}", url:'https://s3.eu-central-1.amazonaws.com/qstutorialbucket/cloudformation/master.yaml', onFailure: 
'ROLLBACK') 
}


Beispiel Stage

Letztendlich sollte die Stage ähnlich wie diese aussehen:

Example Stage Quelle erweitern


stage('Upload S3/Deploy CloudFormation') {
        steps {
               script {
                       withAWS(region: 'eu-central-1', credentials: 'AWSCredentials') {
                               
                               //upload cloudformation folder to s3 bucket
                               s3Upload(file:'cloudformation/', bucket:'qstutorialbucket', path:'cloudformation/')

                               def output = bat(script: "aws cloudformation describe-stacks --stack-name ${STACK}", returnStatus: true)

                               //if stack doesn't exist, create
                               if(output != 0) {
                                      //start cloudformation, deletes stack if failed
                                      cfnUpdate(stack: "${STACK}", url:'https://s3.eu-central-1.amazonaws.com/qstutorialbucket/
cloudformation/master.yaml', onFailure: 'DELETE')
                               }

                               output = bat(script: "aws cloudformation describe-stacks --stack-name ${STACK}", returnStatus: true)
                               
                               //if stack exists 
                               if(output == 0) {
                                      //make change in service.yaml
                                      //read current service.yaml
                                      def yamlAsText = readFile file: 'cloudformation/services/website-service/service.yaml'
                                      
                                      //replace current image with new one
                                      def modifiedYamlAsText = ''
                                      
                                      //if build tag exists, replace image with newly built image tagged with build tag
                                      //else, replace image with newly built image tagged with commit hash
                                      if("${BUILD_TAG}" != "") {
                                              modifiedYamlAsText = yamlAsText.replaceAll(/Image:\s.*/, "Image: ${ECR}:${BUILD_TAG}") 
                                      } else {
                                              modifiedYamlAsText = yamlAsText.replaceAll(/Image:\s.*/, "Image: ${ECR}:${COMMIT}")
                                      }
                                      println modifiedYamlAsText
                                      
                                      //write changes to file
                                      writeFile file: 'tmpChange.yaml', text: "${modifiedYamlAsText}"
                                      
                                      //upload new service.yaml to s3
                                      s3Upload(file:'tmpChange.yaml', bucket:'qstutorialbucket', path:'cloudformation/services/
website-service/service.yaml')
                                      
                                      //update stack
                                      cfnUpdate(stack: "${STACK}", url:'https://s3.eu-central-1.amazonaws.com/qstutorialbucket/
cloudformation/master.yaml', onFailure: 'ROLLBACK')
                               }
                       }
               }
        }
}


Nun da wir alles konfiguriert haben, löst ein Commit der in das Repository hochgeladen wird unsere Jenkins Pipeline aus.



#

Über den Autor:

Bernhard Gally

Junior Dev Ops Engineer

Bernhard Gally ist neben seinem Bachelor-Studium der Informatik an FH Technikum Wien als Junior Dev Ops Engineer bei Qualysoft tätig.