在流水线中使用 Docker

Using Dokcer with Pipeline

:对于构建代理节点都是由 Docker 容器构成的 Jenkins 环境,在流水线中使用 Dockerfile 需要解决在容器中运行容器(DinD)的问题,这在 Docker 安装方式 小节中曾讨论到。

许多组织都使用 Docker 来统一其跨机器的构建和测试环境,并为部署应用程序提供一种有效的机制。从 Pipeline 2.5 及以上版本开始,Pipeline 内建了对从 Jenkinsfile 中与 Docker 进行交互的支持。

本节将介绍在 Jenkinsfile 中使用 Docker 的基本知识,但不包括 Docker 的基本原理,那可以在 Docker入门指南 中阅读。

执行环境的定制

Customizing the execution environment

Pipeline 旨在轻松地使用 Docker 镜像作为单个 阶段 或整个 Pipeline 的执行环境。这意味着用户可以为他们流水线定义所需的工具,而不必手动配置代理。实际上,任何可以 打包在 Docker 容器中的工具,只需对 Jenkinsfile 进行小的编辑,就可以轻松使用。

// Jenkinsfile (声明式 Pipeline)
pipeline {
    agent {
        docker { image 'node:18.16.0-alpine' }
    }
    stages {
        stage('Test') {
            steps {
                sh 'node --version'
            }
        }
    }
}
切换至脚本化 Pipeline
// Jenkinsfile (脚本化 Pipeline)
node {
    /* Requires the Docker Pipeline plugin to be installed */
    docker.image('node:18.16.0-alpine').inside {
        stage('Test') {
            sh 'node --version'
        }
    }
}

当这个流水线执行时,Jenkins 将自动启动指定的容器并在其中执行所定义的步骤:

[Pipeline] stage
[Pipeline] { (Test)
[Pipeline] sh
[guided-tour] Running shell script
+ node --version
v16.13.1
[Pipeline] }
[Pipeline] // stage
[Pipeline] }

工作区同步

Workspace synchronization

简而言之:如果保持工作区与其他阶段的同步是很重要的,那么请使用 reuseNode true。否则,docker 化的阶段则可以在任何其他代理或同一代理上运行,不过是在临时工作区。

默认情况下,对于容器化阶段,Jenkins 会这样做:

  • 挑选出任何一个代理;

  • 创建新的空工作区;

  • 克隆流水线代码到其中;

  • 将这个新的工作区挂载到容器中。

如果咱们有多个 Jenkins 代理,那么咱们的容器化阶段可在其中任何一个上启动。

reuseNode 被设置为 true 时:将不会创建新的工作区,而当前代理的当前工作区将被挂载到容器中,并且容器将在同一节点启动,因此整个数据将被同步。

// Jenkinsfile (声明式 Pipeline)
pipeline {
    agent any
    stages {
        stage('Build') {
            agent {
                docker {
                    image 'gradle:6.7-jdk11'
                    // Run the container on the node specified at the
                    // top-level of the Pipeline, in the same workspace,
                    // rather than on a new node entirely:
                    reuseNode true
                }
            }
            steps {
                sh 'gradle --version'
            }
        }
    }
}
切换至脚本化 Pipeline
// Jenkinsfile (脚本化 Pipeline)
// 选项 “reuseNode true” 目前在脚本化流水线中不支持。

缓存容器的数据

Caching data for containers

许多构建工具都会下载外部依赖项,并将其缓存在本地,以便将来重新使用。由于容器一开始是以 “干净” 的文件系统创建的,这可能会导致流水线速度变慢,因为他们可能不会在随后的流水线运行之间利用磁盘缓存。

Pipeline 支持添加传递给 Docker 的自定义参数,从而允许用户指定要挂载的自定义 Docker 卷,这可用于在流水线历次运行之间在代理上缓存数据。下面的例子将利用 maven 容器在流水线各次运行之间缓存 ~/.m2,从而避免在流水线的后续运行中重新下载依赖。

// Jenkinsfile (声明式 Pipeline)
pipeline {
    agent {
        docker {
            image 'maven:3.9.0-eclipse-temurin-11'
            args '-v $HOME/.m2:/root/.m2'
        }
    }

    stages {
        stage('Build') {
            steps {
                sh 'mvn -B'
            }
        }
    }
}
切换至脚本化 Pipeline
// Jenkinsfile (脚本化 Pipeline)

node {
    /* Requires the Docker Pipeline plugin to be installed */
    docker.image('maven:3.9.0-eclipse-temurin-11').inside('-v $HOME/.m2:/root/.m2') {
        stage('Build') {
            sh 'mvn -B'
        }
    }
}

使用多个容器

Using multiple containers

代码库依赖多种不同技术已经变得越来越普遍。例如,某个代码仓库可能同时拥有基于 Java 的后端 API 实现 基于 JavaScript 的前端实现。结合 Docker 和 Pipeline,就允许某个 Jenkinsfile 通过结合 agent {} 指令,而对不同阶段使用 多种 类型的技术。

// Jenkinsfile (声明式 Pipeline)
pipeline {
    agent none

    stages {
        stage('Back-end') {
            agent {
                docker { image 'maven:3.9.0-eclipse-temurin-11' }
            }

            steps {
                sh 'mvn --version'
            }
        }

        stage('Front-end') {
            agent {
                docker { image 'node:18.16.0-alpine' }
            }

            steps {
                sh 'node --version'
            }
        }
    }
}
切换至脚本化 Pipeline
// Jenkinsfile (脚本化 Pipeline)

node {
    /* Requires the Docker Pipeline plugin to be installed */

    stage('Back-end') {
        docker.image('maven:3.9.0-eclipse-temurin-11').inside {
            sh 'mvn --version'
        }
    }

    stage('Front-end') {
        docker.image('node:18.16.0-alpine').inside {
            sh 'node --version'
        }
    }
}

使用 Dockerfile

Using a Dockerfile

对于需要更多定制执行环境的项目,Pipeline 还支持从源代码仓库中的 Dockerfile 构建和运行一个容器。与使用 “现成,off-the-shelf” 容器的 早先方法 相比,使用 agent { dockerfile true } 语法将从 Dockerfile 中构建一个新的镜像,而不是从 Docker Hub 中拉取一个。

以一个定制程度更高的 Dockerfile 重用上面的示例:

// Dockerfile
FROM node:18.16.0-alpine

RUN apk add -U subversion

通过将其提交到源代码仓库的根目录,Jenkinsfile 就可以被修改为基于这个 Dockerfile 构建一个容器,然后使用该容器运行所定义的步骤:

// Jenkinsfile (声明式 Pipeline)
pipeline {
    agent { dockerfile true }

    stages {
        stage('Test') {
            steps {
                sh 'node --version'
                sh 'svn --version'
            }
        }
    }
}

agent { dockerfile true } 语法支持一些其他选项,这些选项在 管道语法 小节有更详细的描述。

在 Jenkins 流水线中使用 Dockerfile

在 Jenkins 流水线中使用 Dockerfile

指定 Docker 标签

Specifying a Docker Label

默认情况下,Pipeline 会假设任何已配置的 代理 都能够运行基于 Docker 的流水线。而对于有着不能运行 Docker 守护程序的 macOS、Windows 或其他代理的 Jenkins 环境,这种默认的设置就可能会出现问题。Pipeline 在 系统管理,Manage Jenkins 页面和 文件夹,Folder 级别上提供了一个全局选项,用于指定哪些代理(按照 标签,Label)用于运行基于 Docker 的流水线。

配置 Docker 标签

macOS 用户的路径设置

Path setup for macOS users

(暂略过)

脚本化流水线的高级用法

Advanced Usage with Scripted Pipeline

运行 “挎斗,sidecar” 容器

Running "sidecar" containers

在流水线中使用 Docker 可以成为运行其上可能依赖的构建或一组测试的服务的有效方式。类似于 sidecar 模式,Docker 的流水线可以 “在后台” 运行一个容器,同时在另一个容器中执行工作。利用这种挎斗方式,流水线可以为每次流水线运行提供一个 “干净” 的容器。

请设想一个假想的集成测试套件,其依赖于本地 MySQL 数据库的运行。使用 Docker Pipeline 插件 对脚本化流水线支持中所实现的 withRun 方法,Jenkinsfile 就可以将 MySQL 作为一个挎斗运行:

node {
    checkout scm
    /*
     * In order to communicate with the MySQL server, this Pipeline explicitly
     * maps the port (`3306`) to a known port on the host machine.
     */
    docker.image('mysql:8-oracle').withRun('-e "MYSQL_ROOT_PASSWORD=my-secret-pw"' +
                                           ' -p 3306:3306') { c ->
        /* Wait until mysql service is up */
        sh 'while ! mysqladmin ping -h0.0.0.0 --silent; do sleep 1; done'
        /* Run some tests which require MySQL */
        sh 'make check'
    }
}

这个示例可以更进一步,同时利用两个容器。一个 “挎斗” 运行 MySQL,另一个通过使用 Docker 的 容器链接 特性提供 执行环境

node {
    checkout scm
    docker.image('mysql:8-oracle').withRun('-e "MYSQL_ROOT_PASSWORD=my-secret-pw"') { c ->
        docker.image('mysql:8-oracle').inside("--link ${c.id}:db") {
            /* Wait until mysql service is up */
            sh 'while ! mysqladmin ping -hdb --silent; do sleep 1; done'
        }

        docker.image('oraclelinux:9').inside("--link ${c.id}:db") {
            /*
             * Run some tests which require MySQL, and assume that it is
             * available on the host name `db`
             */
            sh 'make check'
        }
    }
}

上面的示例使用了 withRun 所暴露的对象,其有着运行中容器的 ID,可以通过 id 属性获得。使用容器的 ID,流水线就可以通过向 inside() 方法传递自定义的 Docker 参数来创建一个链接。

对于在流水线退出前检查运行中的 Docker 容器的日志,这个 id 属性也很有用:

sh "docker logs ${c.id}"

构建容器

Building containers

为了创建 Docker 镜像,Docker Pipeline 插件 还提供了一个 build() 方法,用于在 Pipeline 运行期间从代码仓库中的 Dockerfile 创建新的镜像。

使用 docker.build("my-image-name") 语法的主要好处是,脚本化流水线可以将返回值用于后续的 Docker 流水线调用,比如说:

node {
    checkout scm

    def customImage = docker.build("my-image:${env.BUILD_ID}")

    customImage.inside {
        sh 'make test'
    }
}

该返回值也可以用来将 Docker 镜像通过 push() 方法发布到 Docker Hub自定义的注册中心,例如:

node {
    checkout scm

    def customImage = docker.build("my-image:${env.BUILD_ID}")
    customImage.push()
}

Docker 镜像的 “标签,tags” 的一个常见用法是为 Docker 镜像的最新验证版本指定 latest 标签。push() 方法接受一个可选的 tag 参数,允许流水线推送有着不同标签的 customImage,例如:

node {
    checkout scm

    def customImage = docker.build("my-image:${env.BUILD_ID}")
    customImage.push()

    customImage.push('latest')
}

build() 方法默认会构建在当前目录下的 Dockerfile。可以通过提供一个包含着 Dockerfile 的目录路径作为 build() 方法的第二个参数来覆盖这一默认行为,例如:

node {
    checkout scm

    def testImage = docker.build("test-image", "./dockerfiles/test") // 1
    testImage.inside {
        sh 'make test'
    }
}
  1. ./dockerfiles/test/Dockerfile 处找到的 Dockerfile 构建 test-image

通过在 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") // 1
}
  1. 从位于 ./dockerfiles/Dockerfile.test 处找到的 Dockerfile 构建 my-image:${env.BUILD_ID}

使用远程 Docker 服务器

Using a remote Docker server

默认情况下,Docker Pipeline 插件 将与本地的 Docker 守护进程通信,通常通过 /var/run/docker.sock 访问。

而要选择非默认的 Docker 服务器,比如 Docker Swarm,就应使用 withServer() 方法。

通过传递一个 URI,及可选地 Jenkins 中预先配置的 Docker 服务器证书认证 的凭据 ID,到这个方法中:

node {
    checkout scm

    docker.withServer('tcp://swarm.example.com:2376', 'swarm-certs') {
        docker.image('mysql:8-oracle').withRun('-p 3306:3306') {
            /* do things */
        }
    }
}

inside()build() 将无法在开箱即用的 Docker Swarm 服务器上正常工作。

为了让 inside() 工作,Docker 服务器和 Jenkins 代理必须使用同一文件系统,以便工作区可以被挂载。

目前,这个 Jenkins 插件和 Docker CLI 都不会自动检测到服务器正远程运行的情况;一个典型的症状是来自嵌套 sh 命令的报错,比如:

cannot create /…@tmp/durable-…/pid: Directory nonexistent

当 Jenkins 检测到代理本身运行在 Docker 容器内时,他会自动将 --volumes-from 参数传递给这个 inside 容器,确保其能与代理共用一个工作空间。

此外,某些版本的 Docker Swarm 不支持自定义注册表,custom Registries。

使用定制注册中心

默认情况下,Docker Pipeline 的集成假定了默认的 Docker Hub 的 Docker 注册中心。

为了使用自定义的 Docker 注册中心,脚本化 Pipeline 用户可以用传入了定制注册中心 URL 的 withRegistry() 方法来包装步骤,例如:

node {
    checkout scm

    docker.withRegistry('https://registry.example.com') {
        docker.image('my-custom-image').inside {
            sh 'make test'
        }
    }
}

对于需要身份验证的 Docker 注册中心,请从 Jenkins 主页添加 “用户名/密码” 凭据项,并使用凭据 ID 作为 withRegistry() 的第二个参数:

node {
    checkout scm

    docker.withRegistry('https://registry.example.com', 'credentials-id') {

        def customImage = docker.build("my-image:${env.BUILD_ID}")

        /* Push the container to the custom Registry */
        customImage.push()
    }
}