精通 Jenkins Pipeline — part3 (Declarative Pipeline and its implementation)

c9s
15 min readDec 15, 2018

--

Jenkins Artwork

在繼續討論 Declarative Pipeline 之前,我們需要先來看看完整的 Declarative Pipeline 長什麼樣子。

一個最基本的 Declarative Pipeline 的第一層包含了幾個區塊 (Section) 與 指令 (Directive): options, agent, parameters, environment, stages, post

每種區塊 (Section) 都有各自的目的,如 agent 定義了執行工作的環境類型與 Provision 的參數,parameters 定義了一個 pipeline 的 input 有哪些,並可以 render 這些參數的輸入成 UI,讓你可以從 UI 輸入一些客製化的數值,或選擇一些預先建置的選項,而 environment Directive 區塊可讓你預先定義環境變數,stages Section 則讓你定義 pipeline 中的各個不同的階段。

Declarative Pipeline Overview

一個實際在營運的 Pipeline 長的大概像這個樣子:

下面挑幾個 section 與 directive 跟讀者們說明一下。

Agent section

以 Agent 來說,下方的寫法可以讓 Jenkins 為你的建置工作找到一個 label 為 ec2 的 node ,連線到該 ec2 node 的 ssh agent ,然後直接在上面運行你的建置 (Build):

pipeline {
agent {
node {
label 'ec2'
}
}
}

上面的 Declarative Pipeline 寫法,其實等同於:

node("ec2") {}

但 node agent 用法會有什麼問題呢?你必須要確保每台 Node 提供的執行環境、套件、工具、環境變數等等都是一樣的,否則很容易因為環境不同而導致問題發生。

要避免環境不一致的問題,就只能用容器的技術來解決了,要怎麼做呢?如果你要定義一個 docker agent ,讓你的測試在 docker container 裡面進行,那麼你可以這樣寫:

pipeline {
agent {
docker {
image 'maven:3-alpine'
label 'my-defined-label'
args '-v /tmp:/tmp'
}
}
...
}

如上方的寫法,Jenkins 會為你找到任何一個 Jenkins slave node ,然後連線到該 node 的 ssh agent ,並且使用該 ssh agent 啟動一個 image 為 maven:3-alpine 的 docker container ,並在裡面執行命令。

如果你沒有要用 public image ,想從一個自己客製化的 Dockerfile 提供環境,也做得到:

agent {
// Equivalent to "docker build -f Dockerfile.build --build-arg version=1.0.2 ./build/
dockerfile {
filename 'Dockerfile.build'
dir 'build'
label 'my-defined-label'
additionalBuildArgs '--build-arg version=1.0.2'
args '-v /tmp:/tmp'
}
}

(由於我們都是在討論 declarative pipeline ,為節省版面 pipeline {} 區塊就不寫上了。)

如此,Jenkins pipeline 就會自動從你定義的 Dockerfile 建置一個 image ,並從這個 image 建立一個新的容器讓你執行工作。

Stages

Stages section 可以讓你定義多個 Stage,如:

stages {
stage('Setup') { }
stage('Build') { } stage('Test') { } stage('Release') { } stage('Deploy') { }
}

請注意,這邊的 Stage 並非 Scripted Pipeline 的 stage,所以不能直接在裡面寫 Groovy,如果要寫,必須要寫成:

stage('Setup') {
steps {
script {
// Groovy here
}
}
}

Options

Options directive 提供了一般你從 Job configuration 頁面可以設定的一些參數,如 timeout, build discard, concurrent build 的等等:

options {
buildDiscarder(logRotator(numToKeepStr: '1'))
disableConcurrentBuilds()
checkoutToSubdirectory('foo')
timeout(time: 1, unit: 'HOURS')
timestamps()
}

Stage

Declarative Pipeline 的 Stage section 提供了讓你針對 Stage 定義不同 Directive 的功能,譬如:

stage('Setup') {
agent { node { label "ec2" } }
post {
success { /* success event handler */ }
aborted { /* aobrted event handler */ }
}
steps {
sh "ls -la"
script {
// Groovy here
}
}
}

When

when directive 可以讓你定義什麼條件才執行這個 Stage,譬如 Branch, Tag 或自訂的 Expression,譬如

when {
allOf {
branch 'master'
environment name: 'DEPLOY_TO', value: 'production'
}
}

又或者你可以使用 closure 當作條件:

when {
anyOf {
branch 'master'
expression { -> params.Test }
expression { -> env.BRANCH =~ /release-.*/ }
}
}

Steps

steps 必須被放在 Stage section 裡執行,Steps 裡面每行命令都只可以是 Step,不可以是 Groovy Script。 譬如: if (build) { } , def a 這些都是 script 的部分,不能直接使用。

其他的 Section 與 Directive 在Pipeline Syntax 頁面都已經有說明,這邊就不再闡述。

Pipeline Model Parser

上集討論到 Declarative Pipeline 的進入點,我們來看看進入到 Pipeline Model Parser 之後發生了什麼事情。

上集提到的進入點為:

new ModelParser(source, execution).parse();

這邊的 source ,是 Groovy package 裡面的一個 SourceUnit class.

org.codehaus.groovy.control.SourceUnit

查一下定義,原來 Groovy 在 SourceUnit 裡面都幫你寬好好了:

groovy.control.SourceUnit

上面可以看到 astcst,我們常聽到 AST (Abstract Syntax Tree),那 CST 是什麼呢?如果對 CST 不熟的讀者可以看看這篇文章 “Abstract vs Concret Syntax Trees” 這篇文章有詳細說明 CST 與 AST 的差異。

這邊筆者用一個最簡單的解釋,CST 基本上就是 Source Code 的 Parse Tree,Grammar 寫什麼,結構就什麼,完全一對一的文法對比。而 AST 是簡化過後的,更貼近運算模型的一個樹狀結構。

整個 Parser 的進入點,請看 parse method (點這邊看 GitHub 好讀版):

@CheckForNull ModelASTPipelineDef parse(boolean secondaryRun = false) {
return parse(sourceUnit.AST, secondaryRun)
}

所以這邊可以看到 Pipeline Model Parser 只需要 AST。

parse method 裡面的第一行,就是去 ast 裡面所有的 statement 去找尋 pipeline step 的 statement:

def pst = src.statementBlock.statements.find {
return isDeclarativePipelineStep(it)
}

那麼 isDeclarativePipelineStep 裡面如何實作呢?

isDeclarativePipelineStep

上面看得出來,statement 要成為 pipeline step 被 parse 有幾個條件:

  1. method name 是 pipeline (STEP_NAME == “pipeline”)
  2. arguments 只有一個,而且是 block expression
  3. 如果不是 top level (Jenkinsfile) 是在 shared library 裡面,那麼他會另外去看 block 裡面是否有我們想要的 method agent and stages

如果你看過 Jenkins 官方的這篇文章 “Pipeline Templates with Shared libraries” ,你可能會以為 pipeline step 寫在任何地方都可以,其實不能,你只能寫在 call method 裏頭,雖然文章裡面也沒有特別說明,但下面的代碼解釋了一切:

ModelParser.parse method

當上面的 Filter 找不到 pipeline step 的話,他會去所有的 methods 找出符合 call 名稱的 method ,然後在裡面尋找 pipeline step。

Section Syntax Limitation

上面的 pipeline step 找到後,Pipeline Model Parser 就會開始剖析 Pipeline Step 後面的 Block。 對 Parser 來說,Pipeline Block 裡面,其實就是一行一行的 Groovy method call statement,因此:

ModelParser.parsePipelineStep method

上面的代碼就做了每個區塊 (Section) 的驗證。

Agent Implementation

由於 Pipeline Step Parser 做的事情太多了,我們就拿 Agent 的運作機制來看一下就好,首先找到 parseAgent

還記得 agent 的 syntax 嗎? agent section 除了可以直接寫 agent any 之外,還可以在 block 裡面寫 node directive, docker directive 來描述 agent 類型。

在這邊以文法的角度來看,其實就是 agent method call 傳遞一個 block ,block 裡面有一個 statement,statement 呼叫 this.node 或是 this.docker , this.dockerfile

不過 pipeline parser 並不是把 block 裡面的代碼當作 groovy script 直接運行的,也因此:

parseAgent

這邊的 parseAgent 其實就是去看 agent 後面是 block statement 或是 method statement。

如果是 method statement,那就是要 validate agent noneagent any 這樣的文法。

如果後面是 block statement 則進一步看裡面的 agent 參數:

parseAgent (2)

上面可以看到驗證幾件事情:

  1. 只能有一個 agent
  2. 而且 block 不得為空(zero statements)
  3. 雖然看起來像 Groovy Script,但實際上是靜態剖析。

其中 parseClosureMap 就是把 agent 的參數剖析回來,另外也把 agent 的 key 存起來,用來找到 agent 的實作對應。

Kubernetes Agent 為例

如果有用過 Kubernetes Pipeline Plugin,讀者應該有看過 kubernetes agent 的語法:

Kubernetes agent 與其他 agent 的運作機制相對複雜一些,kubernetes agent 混合了 Node Provisioning 與 Jenkins Launcher 的機制。

Kubernetes Agent 首先會先讓 Jenkins 去依照 Node label 找尋合適的 Node,找不到 Node 時,就會看是否有合適的 Provisioner 可以幫忙產生這個 Node,Kubernetes Agent 會利用 user 定義的 YAML 產生一個 POD,並把 jnlp container 加入到這個 Pod,Pod 被啟動後,jnlp 會主動和 Jenkins 連線,註冊自己的 Node,Jenkins 發現這個 Node 存在,就把工作丟給這個 Node (jnlp container) 裡的 executor 去執行。

也就是說,利用 Kubernetes Pod 的機制,把 Pod 當成 Jenkins Node 使用。

Jenkins 提供了一個 interface 叫 DeclarativeAgent,讓 plugin 可以各自去實作不同的 DeclarativeAgent 並定義自己的文法。

而 Kubernetes Agent 的文法被定義在:

org.csanchez.jenkins.plugins.kubernetes.pipeline.KubernetesDeclarativeAgent

KubernetesDeclarativeAgent class 是這樣定義:

而 Kubernetes Agent 參數是這樣被剖析來的:

KubernetesDeclarativeAgent

Class 的最下面定義了這個 agent 的 key:

KubernetesDeclarativeAgent DescriptorImpl

這個 Kubernetes Agent spec 最後在執行時,會轉換成 Pipeline Script,整個整體機制其實複雜,但 Agent 的部分非常容易理解:

其中 script 就是我們的 pipeline workflow script 本身,要利用這個 object 才有辦法執行其他的 step。

如果讀者第二部有仔細看 node 的實作,那麼應該就可以看出這邊就是 podTemplate step 與 node step 的整合,先利用 podTemplate 這個 step 去創建一個 Pod ,接著,調用 Jenkins node step 連線到這個 jenkins slave (via jnlp container) ,然後利用 jnlp container 在裡面 checkout source code ,最後調用 body block 執行。

結論

Declarative Pipeline 與 Scripted Pipeline 的差異

看到這邊,讀者應該已經大致上了解了 Pipeline 的內部機制,就更不容易寫錯 Pipeline 。

#1 Stage section and Stage step

Scripted Pipeline 裡面提供的 stage 其實是 stage step,而 Declarative Pipeline 裡面的 stage 則是 stage section。 雖然語法上一樣,但內部機制卻是通過 parseStage 去一行一行剖析的 Section,且一定要在 steps { } block 裡面才可以寫 step。

#2 Agent 其實是 node step 與 docker exec 與 node provision step 的混合體。

要執行任何的建置,都必須要選擇一個 jenkins slave node 並,checkout source,那麼怎麼去產生 jenkins slave node 或去執行 command ,每種 agent 都是由不同的 component 去達成 agent 定義的功能。

#3 Scripted Pipeline 其實比 Declarative Pipeline 更直覺

但要做到相同的機制,則相對費工,而 Declarative Pipeline 則可以讓每個 Stage 的機制分得更清楚,當 Scripted Pipeline 成長到一個地步,使用 Declarative Pipeline 反而可以讓 Pipeline 不會太凌亂。

--

--

c9s
c9s

Written by c9s

Yo-an Lin, yet another programmer in 21 century

No responses yet