使用公司自建 Jenkins、Gogs、Registry 实现持续集成

2023-06-25, 星期日, 17:45

DevOps培训

一个小型的开发团队会希望在许多工作中引入自动化,这包括:

  1. 发布版本构建制品
  2. 部署(特别是现在这种访问 ECS 特别麻烦的情况下)
  3. 回滚到旧版本

使用持续集成可以比较方便地解决问题 1,而问题 2 和问题 3 则可以依靠制品库让事情变得简单起来(特别是目前原则上不允许「持续部署」的情况下(原则上))。

本示例将向你展示以下内容:

  • 使用声明式语法编写 Jenkinsfile
  • 在特定环境的容器中构建制品
  • 将制品打包为容器镜像并提交到制品库
  • 利用 Webhook 配置自动化构建流程,允许用户通过提交 git tag 触发上述工作

使用 Dorian 的代码作演示之用,这是一个公开项目,可在公司内部 Gogs 上获取,结构如下:

.
├── Dockerfile
├── Dockerfile-rootful
├── Jenkinsfile
├── pom.xml
├── settings.xml
├── src
└── target
    ├── dorian-0.0.1-SNAPSHOT.jar
...

其中 Dockerfile 用于构建 rootless 容器,如果生产环境使用的是早期版本的 Docker,则推荐使用 Dockerfile-rootful,后者出于安全考虑在镜像中定义了专门的用户 app

settings.xml 的作用与 $HOME/.m2/settings.xml 相同,在执行 mvn 命令时使用 --settings ./settings.xml 参数可以覆盖默认配置,实现不同项目的仓库配置隔离。

pom.xml 中有使用 org.codehaus.mojo:exec-maven-plugin 实现 mvn package 默认打包容器镜像的操作。不过在本示例中我们会单独执行制作镜像的步骤,因此不会用到这个插件。

Jenkinsfile 是使用声明式语法编写的 Jenkins pipeline 定义文件,符合如下结构:

pipeline {
    agent any
    environment {
        // set env vars
    }
    stages {
        stage {
            steps {
                // do some ops
                ...
            }
        }
        stage {
            steps { ... }
        } ...
    }
}

编写 Jenkinsfile

完整的 Jenkinsfile 见内部 Gogs 上的 Dorian 项目。

Step 1. 定义与初始化环境变量

environment {
    // 注意,这是一篇公开文章
    // registry 地址等信息做了化名处理,实际使用请参考项目代码
    GIT_REPOSITORY = 'https://gogs.ddrpa.cc/yufan/dorian.git'

    PROJECT_NAME = 'dorian'
    MAVEN_ARTIFACT = 'dorian-0.0.1-SNAPSHOT.jar'

    DOCKERFILE_PATH = 'Dockerfile'
    DOCKER_BUILD_CONTEXT = '.'
    DOCKER_REGISTRY_HOST = 'registry.ddrpa.cc'

    IMAGE_NAME = "${PROJECT_NAME.toLowerCase()}"
}

在这里设置环境变量等内容,如果其他 Java 项目想使用类似的构建流程,只需修改 Git 地址和 Java 制品名称等变量。

IMAGE_VERSION = "${REF?:'latest'}"
GIT_CHECKOUT_BRANCH = "${REF?'refs/tags/'+REF:'*/master'}"

你可能会问 MAVEN_ARTIFACT 为什么也写成一个常量了?因为要在构建镜像阶段获取 project.version 需要手动解析 pom.xml,这太麻烦了,而且反正我们用 git tag 来标记版本号了,pom 里那个就不重要了。

开发人员推送一个新的 git tag 时,Gogs 将会向 Jenkins 发送请求,根据下文将会提到的配置,构建环境中会出现值等于 tag nameREF 变量,设置 IMAGE_VERSION=${REF}GIT_CHECKOUT_BRANCH=refs/tags/${REF}$

在 Jenkins 上手动触发构建时则不会初始化 REF 变量,因此这两个变量的赋值使用了三元表达式,当 REF 未定义时使用默认值 latest*/master

如果是非公开项目,需要在 Jenkins 中预先配置好 credentials,可以把 ID 也写入配置文件。

GIT_CREDENTIALS_ID = 'gogs-yufan'

Step 2. 检出代码

使用 GitSCM 插件检出代码。如果是非公开项目,在 userRemoteConfigs 中添加 credentialsId 配置:

stage("Checkout") {
    steps {
        checkout([
            $class: 'GitSCM',
            branches: [[name: "${GIT_CHECKOUT_BRANCH}"]],
            extensions: [],
            userRemoteConfigs: [[
                // credentialsId: "${GIT_CREDENTIALS_ID}",
                url: "${GIT_REPOSITORY}"]]])
    }
}

检出路径为 $HOME/.jenkins/workspace/dorian-starter-pack,大部分情况下不需要关注这个信息。

Step 3. 构建 Java 制品

stage("Build jar file") {
    agent {
        docker {
            image 'docker.io/library/maven:3-eclipse-temurin-17'
            reuseNode true
            args '-v $HOME/.m2:/root/.m2'
        }
    }
    steps {
        sh 'mvn --settings ./settings.xml -Ddockerfile.skip=true clean package'
    }
}

项目使用 Spring Boot 3.0 框架,依赖 JDK 11 及以上版本,而构建节点本身只安装了 JDK 8,因此需要在 JDK 11+ 版本的 Maven 容器中构建。本系统已经安装了 Docker 插件,使用 agent docker 可以指定一个容器节点。

你可能有注意到我使用了 args '-v $HOME/.m2:/root/.m2' 挂载 $HOME/.m2 目录,此外 Jenkins Docker 插件会自动挂载当前的工作空间(也就是 /root/.jenkins/workspace/dorian-starter-pack),因此不需要在容器内执行检出代码的操作,待会也不需要把构建好的 Java 制品移出容器。

为了加速 Maven 下载,我使用了华为云的镜像,这个配置通过项目根目录下的 settings.xmlmvn 命令中的 --settings ./settings.xml 参数引入。此外还通过-Ddockerfile.skip=true 跳过了 Maven 插件构建镜像的步骤。

Step 4. 构建容器镜像

stage("Build container image") {
    steps {
        sh "docker build --build-arg JAR_FILE=target/${MAVEN_ARTIFACT} --tag ${IMAGE_NAME}:${IMAGE_VERSION} --file ${DOCKERFILE_PATH} ${DOCKER_BUILD_CONTEXT}"
    }
}

使用 docker build 构建镜像,与本地操作没有什么区别。

Step 5. 上传到制品库

已经事先在 Jenkins 中通过 credentials 配置了 Registry 的凭证,直接在 environment 中引用:

stage("Upload to registry") {
    environment {
        REGISTRY_CREDS = credentials('docker-registry-credentials')
    }
    steps {
        sh('docker login $DOCKER_REGISTRY_HOST -u $REGISTRY_CREDS_USR -p $REGISTRY_CREDS_PSW')
        sh "docker tag ${IMAGE_NAME}:${IMAGE_VERSION} ${DOCKER_REGISTRY_HOST}/${IMAGE_NAME}:${IMAGE_VERSION}"
        sh "docker push ${DOCKER_REGISTRY_HOST}/${IMAGE_NAME}:${IMAGE_VERSION}"
    }
}

由于 docker-registry-credentials 是一个「用户名 - 密码」格式的凭据,credentials() 函数的输出赋值给 REGISTRY_CREDS 后会创建两个变量 —— REGISTRY_CREDS_USRREGISTRY_CREDS_PSW,内容就和字面意思一样。

公司自建的镜像 Registry 没有像 Docker Hub 那样提供浏览的功能,因此镜像的版本得自己记着(这就是为什么 git tag 会复用到镜像的 tag 上(如果你上 Nexus 去找的话也不是不可以))。

由于 latest 标签是允许覆盖的,你比较勇的话也可以一直打 latest 版本。

这里使用的 sh() 函数是一个 Groovy 方法,环境变量在函数内部才被解释为真正的值,可以防止在构建日志中泄漏秘密。

在 Jenkins 上新建 pipeline 流水线

没什么好说的,新建项目,直接转到 Pipeline 配置,把刚才写的 Jenkinsfile 的内容粘贴到 Script 一栏。

你也可以设置 Jenkins 自动读取项目代码中的 Jenkinsfile。

设置 Webhook 自动触发构建

在 Gogs 上设置

当开发者创建分支或标签时,Gogs 会向指定的 URL 发送 HTTP POST 请求。

由于 Jenkins 上所有项目都通过同一个地址(JENKINS_URL/generic-webhook-trigger/invoke)触发构建,我们需要为指定项目生成一个标识符,通过 URL 中的 token 查询参数传递。

由于我们希望通过 git tag 管理版本发布,设置只有创建分支或标签的时候触发 Webhook。

一旦 Gogs 发起过 Web 钩子调用,在配置页面下方就会出现调用历史记录,可以查看 Gogs 发送和接收了什么数据,用来辅助调试(还可以再次发送该请求)。

开发者通过 git tag -a 0.0.1 -m "git tag test" && git push origin --tags 创建并推送了 0.0.1 标签,在 Webhook 配置界面下方可以看到触发了一次事件。

请求体内容摘录如下:

{
  "ref": "0.0.1",
  "ref_type": "tag",
  "default_branch": "master",
  ...
}

在项目的 Gogs 主页也可以看到有一个 0.0.1 版本产生了。

在 Jenkins 上设置

在项目的 Configure -> General 中勾选 Generic Webhook Trigger

点击 Post content parameters 中的 Add 按钮,添加 Webhook 请求体的解析逻辑。之前设置了 Webhook 请求体类型为 application/json,Jenkins 可以通过 JSONPath 指定如何解析 JSON 结构取值并赋给相应变量。

设置变量 REF_TYPE$.ref_type 赋值,然后通过 Optional filter 配置当 REF_TYPE 的值匹配正则表达式 ^(tag)$ 时才触发构建,这样新建分支事件时就不会触发构建了。

同理设置 $REF = $.ref(= '0.0.1'),在这里创建的变量可以在 pipeline 中使用,用来初始化其他变量。

Token 一栏填写之前生成的唯一标识符。

关于版本号的建议

参考 语义化版本 2.0.0 这篇文章:

版本格式:X.Y.Z,版本号递增,规则如下:

  1. 主版本号 X:当你做了不兼容的 API 修改,
  2. 次版本号 Y:当你做了向下兼容的功能性新增,
  3. 修订号 Z:当你做了向下兼容的问题修正。

先行版本号及版本编译信息可以加到「X.Y.Z」的后面,作为延伸。

从制品库拉取镜像

拉取镜像:

podman login registry.ddrpa.cc -u jack-the-dog -p <registry-password>
podman pull registry.ddrpa.cc/dorian:0.9.4

使用 Rootless 容器是一个比较好的安全习惯:

podman run --userns=keep-id --rm \
  -p 8888:8080 \
  -e DB_HOST=<db-host> \
  -e DB_PASSWORD=<db-root-password> \
  registry.ddrpa.cc/dorian:0.9.4

curl -v http://localhost:8888/ship