Jenkinsfile
Jenkinsfile
是一个文本文件,其定义了 Jenkins 的流水线。文本支持两种语法:声明式和脚本式。将 Jenkinsfile
放入项目源代码进行版本管理的好处:
流水线:整个持续交付的流水线。Pipeline 的代码定义了整个构建、测试、交付的流程。
pipeline
代码块是声明式语法中的关键部分。
代理:由 master 节点控制的一台机器或容器,负责执行任务。
节点:一个 Node 就是一个执行 Pipeline 的 Jenkins 机器。
node
代码块是脚本式语法中的关键部分。
阶段:stage
代码块定义了 Pipeline 中不同的任务子集(比如 “Build”、“Test”、“Deploy” 阶段)。
步骤:在 Stage 中的单个任务,定义了 Jenkins 在特定的步骤做的工作,比如使用 sh
步骤执行 Shell 命令 make
。
pipeline {
agent any
stages {
stage('Build') {
steps {
//
}
}
stage('Test') {
steps {
//
}
}
stage('Deploy') {
steps {
//
}
}
}
}
agent any
:在任一代理上执行此 Pipelinestage('Build')
:定义“Build”阶段steps {}
:“Build”阶段执行的步骤。当我们说一个插件扩展了 Pipeline DSL,其含义就是这个插件定义了一种新的步骤。node {
stage('Build') {
//
}
stage('Test') {
//
}
stage('Deploy') {
//
}
}
node
:在任一可用的代理上执行 Pipeline。stage('Build') {}
:定义“Build”阶段。在脚本式语法中 stage
代码块是可选的。不过明确写上的话可以让每个阶段的任务更清晰。pipeline {
agent any
options {
skipStagesAfterUnstable()
}
stages {
stage('Build') {
steps {
sh 'make'
}
}
stage('Test'){
steps {
sh 'make check'
junit 'reports/**/*.xml'
}
}
stage('Deploy') {
steps {
sh 'make publish'
}
}
}
}
声明式语法相对于脚本式的优势:
在许多独立的模块/步骤中,两种语法的写法一样。
扩展阅读:Pipeline 语法(官网)
多分支流水线可以在仓库的每个分支上检测 Jenkinsfile
并分别运行流水线。这就允许你在不同的分支上配置不同的流水线,以满足不同环境、场景的需要。
从 2.5 版本开始,流水线内建支持与 Docker 交互。
可以在单个阶段(Stage)也可以在整个流水线上将 Docker 作为执行环境。
// Jenkinsfile(声明式)
pipeline {
agent {
docker { image 'node:7-alpine' }
}
stages {
stage('Test') {
steps {
sh 'node --version'
}
}
}
}
流水线运行时,Jenkins 自动启动相应的容器并在容器中执行步骤(Steps):
[Pipeline] stage
[Pipeline] { (Test)
[Pipeline] sh
[guided-tour] Running shell script
+ node --version
v7.4.0
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
许多构建工具会从外部下载依赖包并缓存到本地以备复用。但是因为容器是以“干净”的文件系统启动,所以会因为不能复用缓存而使后续的流水线执行效率大打折扣。
流水线支持添加传递给 Docker 的自定义参数,允许用户挂载外部的 Docker Volume 来缓存数据。下面的示例缓存了 ~/.m2
目录,避免了 Maven 在多次运行流水线期间反复下载相同的依赖包。
// Jenkinsfile(声明式)
pipeline {
agent {
docker {
image 'maven:3-alpine'
args '-v $HOME/.m2:/root/.m2'
}
}
stages {
stage('Build') {
steps {
sh 'mvn -B'
}
}
}
}
程序依赖多种不同的技术栈很常见。在流水线中使用 Docker 可以让 Jenkins 在不同阶段调用多种技术类型的容器。下面的例子展示了同时使用 Java 作为后端和 JavaScript 作为前端的情况:
// Jenkinsfile(声明式)
pipeline {
agent none
stages {
stage('Back-end') {
agent {
docker { image 'maven:3-alpine' }
}
steps {
sh 'mvn --version'
}
}
stage('Front-end') {
agent {
docker { image 'node:7-alpine' }
}
steps {
sh 'node --version'
}
}
}
}
流水线支持从仓库的 Dockerfile 构建并运行容器。使用 agent { dockerfile true }
语句会从 Dockerfile
中构建一个新的 Docker 镜像:
# Dockerfile
FROM node:7-alpine
RUN apk add -U subversion
// Jenkinsfile(声明式)
pipeline {
agent { dockerfile true }
stages {
stage('Test') {
steps {
sh 'node --version'
sh 'svn --version'
}
}
}
}
可以在 Pipeline Syntax 查看更多
agent
语句的用法。
默认情况下,Jenkins 认为任何代理服务器都可以运行基于 Docker 的流水线。但是当某些代理服务器是 macOS、Windows 这类无法运行 Docker 守护进程的系统时,这个默认的设置就会有问题。在 Manage Jenkins > Configure System 的页面找到 Pipeline Model Definition 配置块,其中有个 Docker Label 的配置项。在这里可以定义具有哪些标签的代理服务器可以运行基于 Docker 的流水线。
假定一个集成测试套件依赖于本地的 MySQL 数据库。使用脚本式流水线中的 withRun
方法,Jenkinsfile 就可以将一个 MySQL 作为 Sidecar 容器运行了:
node {
checkout scm
/* 为与 MySQL 服务器通信,这个流水线将 3306 端口映射到了宿主机 */
docker.image('mysql:5').withRun('-e "MYSQL_ROOT_PASSWORD=my-secret-pw" -p 3306:3306') { c ->
/* 等待 MySQL 服务启动 */
sh 'while ! mysqladmin ping -h0.0.0.0 --silent; do sleep 1; done'
/* 运行依赖 MySQL 的测试 */
sh 'make check'
}
}
这个例子可以扩展为同时使用两个容器:一个运行 MySQL 的“Sidecar”容器和另一个提供执行环境的容器,两个容器之间通过 Docker container link 连接。
node {
checkout scm
docker.image('mysql:5').withRun('-e "MYSQL_ROOT_PASSWORD=my-secret-pw"') { c ->
docker.image('mysql:5').inside("--link ${c.id}:db") {
/* 等待 MySQL 服务启动 */
sh 'while ! mysqladmin ping -hdb --silent; do sleep 1; done'
}
docker.image('centos:7').inside("--link ${c.id}:db") {
/* 运行依赖 MySQL 的测试,并且假设 MySQL 主机名是 `db` */
sh 'make check'
}
}
}
上面这个例子用 withRun
生成一个对象,这个对象的 id
属性就是所运行容器的 ID。流水线通过把 ID 作为 link 参数传递给 inside
方法成功地将两个容器联系起来。
id
属性也可以用在流水线退出前导出容器日志的场景下:
sh "docker logs ${c.id}"
build()
方法可以从仓库的 Dockerfile
文件构建镜像。
使用 docker.build("my-image-name")
语句的好处是,脚本式流水线可以将这个语句的返回值用于后续流水线的调用。比如说:
node {
checkout scm
def customImage = docker.build("my-image:${env.BUILD_ID}")
customImage.inside {
sh 'make test'
}
}
返回值也可以用 push()
方法将 Docker 镜像发布到 Docker Hub 或自定义仓库:
node {
checkout scm
def customImage = docker.build("my-image:${env.BUILD_ID}")
customImage.push()
}
push()
方法还可以添加一个可选的 tag
参数,使推送的镜像 tag
与本地构建的不同:
node {
checkout scm
def customImage = docker.build("my-image:${env.BUILD_ID}")
customImage.push()
customImage.push('latest')
}
默认情况下,build()
方法以当前目录下的 Dockerfile
进行构建。通过将一个目录作为 build()
方法的第二个参数可以改变这个行为。在下面这个例子中 Dockerfile 的路径是 ./dockerfiles/test/Dockerfile
:
node {
checkout scm
def testImage = docker.build("test-image", "./dockerfiles/test")
testImage.inside {
sh 'make test'
}
}
build()
方法的第二个参数还可以是 docker build 命令的其他参数。用这种方式传递参数的时候,字符串的最后一个值必须是 Dockerfile 的路径。
下面这个例子通过使用 -f
选项改写了默认的 Dockerfile
文件名:
node {
checkout scm
def dockerfile = 'Dockerfile.test'
def customImage = docker.build("my-image:${env.BUILD_ID}", "-f ${dockerfile} ./dockerfiles")
}
流水线会使用 ./dockerfiles/Dockerfile.test
构建镜像 my-image:${env.BUILD_ID}
。
默认情况下,Docker Pipeline 插件通过 /var/run/docker.sock
与本机的 Docker 进程通讯。要想与其他的 Docker 服务器通讯,可以使用 withServer()
方法。
node {
checkout scm
docker.withServer('tcp://swarm.example.com:2376', 'swarm-certs') {
docker.image('mysql:5').withRun('-p 3306:3306') {
/* do things */
}
}
}
withServer()
的第一个参数是远程 Docker 服务器的 URI。第二个参数是预先在 Jenkins 凭据中配置好的 Docker Host Certificate Authentication
类型凭据的 ID,这个参数根据实际情况可以无需添加。
默认的镜像仓库是 Docker Hub。要使用自定义的仓库,在脚本式流水线中可以将步骤放在 withRegistry()
方法中:
node {
checkout scm
docker.withRegistry('https://registry.example.com', 'credentials-id') {
def customImage = docker.build("my-image:${env.BUILD_ID}")
/* 推送镜像到自定义仓库 */
customImage.push()
}
}
该方法可以接受两个参数。第一个参数是自定义仓库的 URL 地址;‘第二个参数是 Jenkins 凭据中 Username/Password
类型的凭据 ID,是可选的。
当组织内的项目日渐增多时,流水线就会出现一些相同的模式。在项目间共享部分流水线可以降低代码的冗余,保持其 “DRY”原则。
流水线支持创建“共享库”,保存到源码仓库以供其他流水线加载。
共享库由一个名称、一个源码获取方式(比如通过 SCM)以及一个可选的默认版本共同定义。名称应该简短,因为它会在脚本中被用到。
版本可以是任何 SCM 理解的东西,比如对于 Git 来说,分支、Tag 标签、Commit 哈希都是可行的。你可以声明流水线脚本是否需要显式请求共享库。此外,如果你在 Jenkins 配置中提供了一个版本,你可以禁止脚本选择不同的版本。
(root)
+- src # Groovy 源码文件
| +- org
| +- foo
| +- Bar.groovy # org.foo.Bar 类
+- vars
| +- foo.groovy # 全局变量 foo
| +- foo.txt # 全局变量 foo 的帮助文件
+- resources # 资源文件(仅限外部库)
| +- org
| +- foo
| +- bar.json # org.foo.Bar 的静态帮助数据
src
目录类似标准的 Java 源码目录结构。这个目录在执行流水线的时候会加到 classpath 中。
vars
目录保存作为流水线变量的脚本文件。文件名就是流水线中的变量名。所以如果你有一个文件叫 vars/log.groovy
,其中定义了函数 def info(message)…
,你就可以在流水线中通过 log.info "hello world"
使用这个函数。你可以在文件中添加任意数量的函数。
每个 .groovy
文件的 basename 是一个 Groovy 标识符(类似 Java),按惯例用驼峰法则命名,如 camelCased
。如果有对应的 .txt
文档,其内容可以是系统配置好的标记格式(所以尽管后缀是 .txt
,实际内容可以是 HTML、Markdown 等)。这个文档只会在 Jenkins 的全局变量参考页面(${YOUR_JENKINS_URL}/pipeline-syntax/globals
)显示。
resources
目录允许 libraryResource
步骤从外部库载入相关的非 Groovy 文件。
根据不同的使用场景,可以有多种定义共享库的方式。
全局共享库可以给系统中所有流水线使用,通过 Manage Jenkins
> Configure System
> Global Pipeline Libraries
添加和管理。
全局共享库被认为是“可信的”:可以运行 Java、Groovy、Jenkins 内部 API、第三方库的任何方法。这就让你可以将单独的不安全 API 封装到更高级别的安全 API 中来定义库。请注意,任何可以推送提交到这个源码仓库的人可以不受限制地访问 Jenkins。你需要 Overall/RunScripts 权限来配置这些库(一般分配给 Jenkins 管理员)。
每个文件夹都可以有关联的共享库。
文件夹级共享库是“不可信的”:它们和常规的流水线一样运行在 Groovy 沙盒中。
某些插件提供了在运行中定义库的方式。比如 Github Branch Source 插件提供了一个“Github Organization Folder”项,该项允许脚本使用不受信任的库,例如 github.com/someorg/somerepo
,无需任何其他配置。在这种情况下,将使用匿名签出指定 GitHub 仓库的 master
分支。
标记了 Load implicitly 的共享库可以允许流水线立即使用这个库中定义的类或全局变量。要获取其他的共享库,Jenkinsfile
需要使用 @Library
标注库名称:
@Library('my-shared-library') _
/* 使用版本标识, 如分支、标签等 */
@Library('my-shared-library@1.0') _
/* 单条语句访问多个共享库 */
@Library(['my-shared-library', 'otherlib@abc1234']) _
标注可以写在 Groovy 脚本允许的任何位置。当引用类库时(有 src/
目录)通常使用 import
语句:
@Library('somelib')
import com.mycorp.pipeline.somelib.UsefulClass
对于仅定义全局变量(
vars/
)的共享库或仅需要全局变量的Jenkinsfile
,标注的模式@Library('my-shared-library') _
可以保持代码简洁。符号_
用于取代不必要的import
语句。不建议
import
全局变量/函数,因为这会强制编译器将字段和方法解析为static
,即使它们是实例。在这种情况下,Groovy 编译器会抛出奇怪的错误信息。
库会在脚本执行前的编译阶段解析并载入。这允许 Groovy 编译器理解静态类型检查中使用的符号的含义,并允许它们在脚本中的类型声明中使用,例如:
@Library('somelib')
import com.mycorp.pipeline.somelib.Helper
int useSomeLib(Helper helper) {
helper.prepare()
return helper.count()
}
echo useSomeLib(new Helper('some text'))
不过全局变量是在运行时解析的。
在 2.7 以上版本的 Pipeline Shared Groovy Libraries Plugin 插件中有一个用于在脚本中加载(非隐式)库的新选项: library
步骤可以在构建的任何时候动态载入一个库。
如果只想载入全局变量或函数(从 /vars
目录),那么语法相当简单:
library 'my-shared-library'
这样,这个库的所有全局变量都将对此脚本可用。
使用 /src
目录中的类要复杂些。尽管 @Library
标注在编译前准备了脚本的“classpath”,但是在 library
步骤运行的时候脚本已经编译好了。因此你不能 import
或“静态”引用库中的类型。
然而你可以动态使用库的类(无需类型检查),从 library
步骤的返回值通过完全限定名称访问它们。static
方法可以用类似 Java 的语法调用:
library('my-shared-library').com.mycorp.pipeline.Utils.someStaticMethod()
你也可以访问 static
域,调用构造函数,就像它们是名为 new
的静态方法一样:
def useSomeLib(helper) { // dynamic: cannot declare as Helper
helper.prepare()
return helper.count()
}
def lib = library('my-shared-library').com.mycorp.pipeline // preselect the package
echo useSomeLib(lib.Helper.new(lib.Constants.SOME_TEXT))
当勾选“Load implicitly”时,或流水线只通过名称引用库时(比如 @Library('my-shared-library') _
),共享库会使用“Default version”。如果没有定义“Default version”,流水线应提供一个版本,比如 @Library('my-shared-library@master') _
。
如果启用了“Allow default version to be overridden(允许覆盖默认版本)”,那么 @Library
标注就可以覆盖库定义的默认版本。这也就允许勾选了“Load implicitly”的库按需载入不同的版本。
使用 library
步骤的时候也可以提供版本:
library 'my-shared-library@master'
由于这是一个常规的步骤,版本可以计算出来,而不像标注一样只能使用常量,比如:
library "my-shared-library@$BRANCH_NAME"
最好使用 Modern SCM 选项,可以选择支持检出任意名称版本的 SCM 插件。最新版本的 Git 和 Subversion 插件都支持这个选项。
如果在 library
步骤只提供了库名(后面可能有 @
版本),Jenkins 会根据名称查找预定义的库(或者在 github.com/owner/repo
的例子中会载入自动化库)。
你也可以动态提供获取方式,这种情况下库可以不用在 Jenkins 中预定义。比如:
library identifier: 'custom-lib@master', retriever: modernSCM(
[$class: 'GitSCMSource',
remote: 'git@git.mycorp.com:my-jenkins-utils.git',
credentialsId: 'my-private-key'])
最好参考Pipeline Syntax以获取 SCM 的精确语法。
注意在这些情况下必须提供库版本。
最基本的,合法的 Groovy 代码 都可以使用。
库的类不能直接调用如 sh
或 git
之类的步骤。不过它们可以在封闭类的范围之外实现方法,后者再调用流水线的步骤。比如:
// src/org/foo/Zot.groovy
package org.foo
def checkOutFrom(repo) {
git url: "git@github.com:jenkinsci/${repo}"
}
return this
然后可以在流水线中这样调用:
def z = new org.foo.Zot()
z.checkOutFrom(repo)
这种方法有限制,比如无法声明超类。
或者可以使用 this
显式传递 steps
给库类、构造函数或方法:
package org.foo
class Utilities implements Serializable {
def steps
Utilities(steps) {this.steps = steps}
def mvn(args) {
steps.sh "${steps.tool 'Maven'}/bin/mvn -o ${args}"
}
}
像上面这样,在类上保存状态时,类必须实现 Serializable
接口。这确保了使用该类的流水线(如下例所示)可以在 Jenkins 中正确挂起和恢复。
@Library('utils') import org.foo.Utilities
def utils = new Utilities(this)
node {
utils.mvn 'clean package'
}
如果库需要访问全局变量,比如 env
,应该以类似的方式显式地将这些变量传递到库类或方法中。
而不是将许多变量从脚本化流水线传递到库中,
package org.foo
class Utilities {
static def mvn(script, args) {
script.sh "${script.tool 'Maven'}/bin/mvn -s ${script.env.HOME}/jenkins.xml -o ${args}"
}
}
上面的示例显示了被传递给一个 static
方法的脚本,由脚本式流水线以下面的方式调用:
@Library('utils') import static org.foo.Utilities.*
node {
mvn this, 'clean package'
}
在内部,vars
目录中的脚本按需实例化为单例。为了方便起见,可以在一个 .groovy
文件中定义多个方法。
// vars/log.groovy
def info(message) {
echo "INFO: ${message}"
}
def warning(message) {
echo "WARNING: ${message}"
}
// Jenkinsfile
@Library('utils') _
log.info 'Starting'
log.warning 'Nothing to do!'
注意,如果你希望将全局中的字段用于某个状态,请如下添加标注:
@groovy.transform.Field
def yourField = [:]
def yourFunction....
声明式流水线不允许对 “script” 块之外的对象进行方法调用(JENKINS-42360)。上面的方法需要放到 script
命令内部调用:
// Jenkinsfile
@Library('utils') _
pipeline {
agent none
stage ('Example') {
steps {
/* 这个方法会调用失败 */
// log.info 'Starting'
script {
log.info 'Starting'
log.warning 'Nothing to do!'
}
}
}
}
共享库还可以定义全局变量,其行为类似于内置步骤,例如 sh
或 git
。共享库中定义的全局变量必须以全小写或“驼峰式”(camelCased)命名。
比如要定义 sayHello
,那么需要创建文件 vars/sayHello.groovy
并实现 call
方法。call
方法允许全局变量以下面这种像步骤一样的方式调用:
// vars/sayHello.groovy
def call(String name = 'human') {
/* 这里可以调用步骤 */
echo "Hello, ${name}."
}
流水线就能调用这个全局变量:
sayHello 'Joe'
sayHello() // 使用默认参数调用
如果用块调用,call
方法可以接受一个 Closure。应该明确定义类型以阐明该步骤的意图,比如:
// vars/windows.groovy
def call(Closure body) {
node('windows') {
body()
}
}
流水线可以像使用内置支持块的步骤一样使用这个变量:
windows {
bat "cmd /?"
}
如果你有很多相似的流水线,那么全局变量的机制就是一个很好用的构建高级 DSL的工具。比如说,所有 Jenkins 插件以相同的方式构建和测试,所以我们可以写一个名为 buildPlugin
的步骤:
// vars/buildPlugin.groovy
def call(Map config) {
node {
git url: "https://github.com/jenkinsci/${config.name}-plugin.git"
sh 'mvn install'
mail to: '...', subject: "${config.name} plugin build", body: '...'
}
}
假设脚本以全局共享库或文件夹级共享库加载,那么 Jenkinsfile
将相当简单:
buildPlugin name: 'git'
还有一个使用 Groovy 的 Closure.DELEGATE_FIRST
的“构建模式”,可以让 Jenkinsfile
更像配置文件而不是程序,但这种方式更复杂更易犯错,因此不推荐。
@Grab
标注可以用于使用第三方 Java 库,一般可以在 Maven Central 的信任库中找到。
@Grab('org.apache.commons:commons-math3:3.4.1')
import org.apache.commons.math3.primes.Primes
void parallelize(int count) {
if (!Primes.isPrime(count)) {
error "${count} was not prime"
}
// …
}
参考 Grape 文档 查看更详细的用法。
第三方库默认缓存在 Jenkins master 的 ~/.groovy/grapes/
目录。
外部库可以使用 libraryResource
步骤加载 resources/
目录中的附件。参数是一个相对路径,和 Java 资源加载类似:
def request = libraryResource 'com/mycorp/pipeline/somelib/request.json'
该文件作为字符串加载,适合传递给某些 API 或使用 writeFile
保存到工作区。
如果你在使用不可信库进行构建的过程中发现一个问题,只需要点击 Replay 链接编辑一个或多个库的资源文件,然后看构建结果是否符合预期。当你对结果满意时,点击构建状态页面的 diff 链接,将 diff 应用到库的仓库并提交。
你可以在共享库中定义声明式流水线。下面的例子会根据构建编号的奇偶来执行不同的声明式流水线:
// vars/evenOrOdd.groovy
def call(int buildNumber) {
if (buildNumber % 2 == 0) {
pipeline {
agent any
stages {
stage('Even Stage') {
steps {
echo "The build number is even"
}
}
}
}
} else {
pipeline {
agent any
stages {
stage('Odd Stage') {
steps {
echo "The build number is odd"
}
}
}
}
}
}
// Jenkinsfile
@Library('my-shared-library') _
evenOrOdd(currentBuild.getNumber())
目前只能在 vars/*.groovy
文件的 call()
方法中定义完整的流水线。在一个构建中,只能使用一个声明式流水线,使用第二个声明式流水线的尝试将以失败而告终。