Jenkins / CI/CD
Jenkins 接收代码仓库或人工触发的任务,安排执行节点,保存构建结果,再调用发布工具部署指定制品。Java 服务交付涉及两类状态:构建是否通过,以及目标环境实际运行哪个版本。测试失败要阻断打包;部署失败则要检查是否已经发生切换,决定保留旧实例、恢复旧版本或交给人工处理。
一条流水线怎样获得执行位置
Controller、Agent 与工作区
Controller
├── Job:任务定义、参数、触发方式
├── Queue:等待标签匹配且有空闲 executor 的任务
├── Build:一次运行的编号、步骤状态、日志、结果
└── Credentials:按权限提供给指定任务的凭据
│ 调度与执行通信
▼
Agent:独立 Linux 用户和工具链
├── executor:可并行执行任务的槽位
├── workspace:这次节点分配下的源码与临时输出
└── 外部工具:Git、JDK、Maven、Node、镜像构建器
│ 发布已验证的文件或镜像
▼
Nexus / Harbor → 部署节点 → 应用进程 → 访问入口Controller 需要持久保存任务与运行状态;构建会消耗 CPU、内存、磁盘,还会执行仓库里的脚本。将 Controller 的执行器设为零,把构建放到独立 Agent,可以减少构建对平台本身的影响。Agent 不应挂载 JENKINS_HOME,也不应拥有读取 Controller 文件的系统权限。Controller 隔离说明给出了这项要求。
label 是节点筛选条件,例如 linux-jdk21。多台节点可以拥有相同标签,因此不同 stage 各自申请这个标签,可能落到不同机器。即使碰巧落在同一台机器,也不能把工作区当作长期制品仓库。
顶层 agent 为整次流水线提供节点与工作区上下文。agent none 要求每个需要外部工具的阶段自行申请节点;使用后者时,应显式检出代码、stash/unstash 小文件,或从仓库按坐标和摘要下载制品。只在第一阶段检出代码,后续 Agent 不会自动拥有它的 target 目录。
Pipeline 保存什么
Declarative Pipeline 使用 pipeline / stages / stage / steps 声明结构,sh、checkout、archiveArtifacts 等 step 完成具体动作。Pipeline 语法还定义了节点、条件、超时和收尾行为。
Groovy 控制逻辑由 Jenkins 执行,sh 启动的命令在 Agent 上运行。Groovy 部分经过 CPS 转换,可以保存执行状态,支持暂停和部分恢复;上传、远端重启和数据库迁移则拥有独立状态。Controller 重启后恢复执行,不会撤销此前已发生的外部操作。
将编译、压缩和大文件处理放进外部工具,避免让 Controller 承担重计算。跨 step 保存的变量不能随意持有不可序列化对象,@NonCPS 方法也不能任意调用 Pipeline step。出现相关错误时,按 CPS 限制检查调用位置。
发布脚本需要识别“尚未切换”“已切换但未验证”“恢复中”等状态。发布脚本提供单机执行与恢复示例,Jenkins 调用它并保留返回结果。
建立可登录、可构建的 Jenkins 环境
镜像、运行身份与网络
实验使用 Linux、Docker Engine 和 Compose 插件,镜像固定为 jenkins/jenkins:2.568.3-jdk21。主机需要下载镜像和插件,并留出构建文件与 Jenkins 数据空间;小型实验先准备 4 GiB 内存,再根据 Controller 与 Agent 的实际占用调整。
Java 21 用于启动 Jenkins。业务可以使用另一套构建 JDK,例如 Java 25 服务配套 JDK25/Maven Agent;Controller、agent.jar 和 CLI 的 JVM 则要满足 Jenkins Java 支持策略。
在专用目录创建 compose.yaml:
name: jenkins-lab
services:
controller:
image: jenkins/jenkins:2.568.3-jdk21
ports:
- "127.0.0.1:18080:8080"
volumes:
- jenkins_home:/var/jenkins_home
restart: unless-stopped
mem_limit: 2g
environment:
JAVA_OPTS: "-Xms256m -Xmx1g"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
jenkins_home:宿主操作者需要 Docker 管理权限,这类权限通常具有很高的主机控制能力。容器沿用官方镜像的 jenkins 用户,不挂载 Docker socket。命名卷保存数据,不随容器重建丢失。2 GiB 限制中还要容纳堆外内存、线程和进程开销,不能将最大堆设成整个容器上限。
docker compose config
docker compose up -d
docker compose ps
docker compose logs --tail=80 controller
docker compose exec controller idps 应显示持续运行,日志最终出现完成启动的信息,id 应显示普通容器用户。反复重启时先看权限、Java 或内存错误,暂不安装插件。卷、端口与初始化方式见 Docker 安装说明。
浏览器打开 http://127.0.0.1:18080。Docker 在远端主机上时,通过 SSH 本地转发访问,不为临时安装直接开放公网端口。按解锁页面提示,在自己的终端读取初始口令:
docker compose exec controller \
cat /var/jenkins_home/secrets/initialAdminPassword输出是秘密,不进入共享日志或截图。初始化后创建独立管理员,关闭匿名访问,在“Manage Jenkins → Nodes → Built-In Node”将执行器数量设为零。
Pipeline 实验需要 workflow-aggregator 及其依赖;项目接入还会用到 git、junit、credentials-binding。插件页面确认安装成功后再创建任务。不要同时更新 Core、Java 和全部插件;先核对 插件兼容和管理方式,在副本上验证组合。
注册独立 Agent
准备另一台 Linux 实验机,或与 Controller 文件系统隔离的普通容器。工具链至少包括 JDK21、POSIX shell、sha256sum。由系统管理员创建账号与目录:
sudo useradd --create-home --shell /bin/bash ciagent
sudo install -d -o ciagent -g ciagent -m 0750 /srv/jenkins-agent在“Manage Jenkins → Nodes → New Node”创建永久节点 linux-lab:远程目录 /srv/jenkins-agent,执行器为 1,标签 linux-jdk21,使用“通过连接到 Controller 启动”的方式。节点页面提供启动命令与密钥。
以 ciagent 身份执行以下形式的命令。CONTROLLER_URL 是 Agent 可达的地址;另一台机器上的回环地址指向它自己。正式入口使用经验证的 HTTPS。
export CONTROLLER_URL='https://ci.example.com'
cd /srv/jenkins-agent
curl -q --fail-with-body --location \
"$CONTROLLER_URL/jnlpJars/agent.jar" -o agent.jar
umask 077
read -r -s -p '节点密钥: ' AGENT_SECRET; printf '\n'
printf '%s' "$AGENT_SECRET" > agent.secret
unset AGENT_SECRET
java -jar agent.jar -url "$CONTROLLER_URL" \
-secret @agent.secret -name linux-lab \
-webSocket -workDir /srv/jenkins-agent命令在前台保持连接,页面变为在线后即可构建。长期运行时再用 systemd 以同一普通用户托管。WebSocket 经 HTTP(S) 入口连接,无需传统 Agent TCP 端口;反向代理需要允许 WebSocket 升级。容器启动方式见 Agent 官方项目。
密钥文件应为 0600。持有者可冒用节点;节点名、标签、密钥和工作目录必须对应。节点在线但找不到 javac 时,修复 Agent 工具链,不把构建迁到 Controller 临时运行。
中国大陆、内网和离线环境
下载路径按执行位置分开检查:Docker daemon 拉镜像,Controller 获取插件和更新元数据,Agent 拉取 Git 与构建依赖。为一个进程配置代理,不会自动改变另外两个。
优先通过企业维护的镜像仓库和 Maven/npm 私服分发已核验内容。代理与企业 CA 分别配置到 daemon、Jenkins JVM、Agent JVM 和构建客户端;私有仓库按网络要求进入直连名单。遇到 TLS 错误应修复信任,不关闭证书校验。
离线环境先在同平台联网准备机安装并测试,固定 Core、JDK、全部插件和传递依赖。官方镜像内可用 jenkins-plugin-cli 制作自定义镜像:
FROM jenkins/jenkins:2.568.3-jdk21
COPY plugins.txt /usr/share/jenkins/ref/plugins.txt
RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txtplugins.txt 每行写已验证的“插件ID:精确版本”,同时保存最终依赖清单;只写顶层名称会让下次构建重新选择版本。准备机导出镜像,目标机验证后导入:
docker save -o jenkins-controller.tar company/jenkins:validated-1
sha256sum jenkins-controller.tar > jenkins-controller.tar.sha256
# 文件到达目标机后
sha256sum -c jenkins-controller.tar.sha256
docker load -i jenkins-controller.tar还要准备 Agent 工具链、源码、依赖与目标制品。页面能登录但首次构建卡在下载,通常是只准备了 Controller。传输和隔离复验见 离线部署。
从第一次构建到受控发布
完整 Pipeline 与失败对照
下载 流水线源码。在 Jenkins 创建 Pipeline 任务 price-lab,选择“Pipeline script”,粘贴源码并保留 Groovy Sandbox。实验直接生成 Java 文件,不依赖 Git、Maven 仓库或生产账号。完整代码如下:
pipeline {
agent { label 'linux-jdk21' }
options {
skipDefaultCheckout(true)
disableConcurrentBuilds()
timeout(time: 10, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '10'))
}
parameters {
booleanParam(name: 'FAIL_TEST', defaultValue: false,
description: '只用于实验:让断言失败')
}
stages {
stage('Prepare') {
steps {
deleteDir()
writeFile file: 'Price.java', text: '''
public class Price {
public static long total(long unit, int count) {
if (unit < 0 || count < 0) throw new IllegalArgumentException();
return Math.multiplyExact(unit, count);
}
public static void main(String[] args) {
if (args.length > 0 && args[0].equals("test")) {
long expected = Boolean.parseBoolean(System.getenv("FAIL_TEST")) ? 1 : 600;
if (total(200, 3) != expected) throw new AssertionError("total mismatch");
System.out.println("total test passed");
} else {
System.out.println("total=" + total(200, 3));
}
}
}
'''
sh 'id; pwd; java -version; javac -version'
}
}
stage('Compile and test') {
steps {
sh '''#!/bin/sh
set -eu
mkdir -p classes
javac --release 21 -d classes Price.java
java -cp classes Price test
'''
}
}
stage('Package') {
steps {
sh '''#!/bin/sh
set -eu
mkdir -p dist
jar --create --file dist/price.jar --main-class Price -C classes .
java -jar dist/price.jar
cd dist
sha256sum price.jar > price.jar.sha256
'''
archiveArtifacts artifacts: 'dist/*', fingerprint: true
}
}
}
post {
failure { echo '本次构建失败,不能提升为发布制品。' }
cleanup { deleteDir() }
}
}顶层 Agent 保证三个阶段使用同一工作区。Prepare 删除旧残留并写入程序;Compile and test 编译后验证 200 分乘 3 件为 600 分;只有断言成功才进入 Package。脚本指定 POSIX shell,不依赖节点默认 shell 支持 Bash 选项。
第一次使用默认参数,应出现 total test passed、total=600,结果为 SUCCESS。构建页面提供 JAR 和摘要文件。将它们下载到同一目录,执行:
sha256sum -c price.jar.sha256
java -jar price.jar校验应为 OK,程序输出 total=600。archiveArtifacts 已将文件复制到构建归档,所以清理工作区后仍可下载;长期正式制品进入独立仓库,不依赖 Jenkins 的构建保留期限。
第二次选择“Build with Parameters”,勾选 FAIL_TEST。程序抛出 AssertionError: total mismatch,sh 非零退出,Package 跳过,结果为 FAILURE,没有新的 JAR 归档。上一成功构建的文件仍在,消费端必须选择准确构建编号。项目正式任务不保留绕过测试的发布开关。工作区、步骤和归档的更多用法见 Jenkinsfile 指南。
换成 Git 与 Spring Boot 工程
项目中把 Jenkinsfile 纳入 Git,任务使用“Pipeline script from SCM”,填写仓库、只读凭据、受保护分支和文件路径。Webhook 发起构建,实际提交以检出结果为准;触发重试可能重复,发布仍按提交与制品身份识别。
将原来的 Prepare、Compile and test、Package 三个阶段全部替换为下面三个阶段,不保留 Price 示例的 jar --create。保留顶层 Agent,标签改为已安装 JDK25、Maven3.9.12、Git 的 linux-jdk25-maven;工程提交完整 POM、测试与 .ci/settings.xml。下面按 Spring Boot 部署示例约定,spring-boot-maven-plugin 在构建中生成唯一可运行文件 target/app.jar;其他项目先在 POM 中明确产物名称,再调整归档路径。
stage('Checkout') {
steps {
deleteDir()
checkout scm
script {
env.SOURCE_SHA = sh(script: 'git rev-parse HEAD',
returnStdout: true).trim()
}
sh 'java -version; mvn -version'
}
}
stage('Verify') {
steps {
sh 'mvn -B -ntp -s .ci/settings.xml clean verify'
}
post {
always {
junit testResults: '**/target/surefire-reports/TEST-*.xml',
allowEmptyResults: false
}
}
}
stage('Archive') {
steps {
sh '''#!/bin/sh
set -eu
test -s target/app.jar
mkdir -p dist
cp target/app.jar dist/app.jar
printf '%s\n' "$SOURCE_SHA" > dist/source-commit.txt
cd dist
sha256sum app.jar > app.jar.sha256
'''
archiveArtifacts artifacts: 'dist/app.jar,dist/app.jar.sha256,dist/source-commit.txt',
fingerprint: true, allowEmptyArchive: false
}
}Maven 非零退出会停止后续阶段;JUnit 还会读取报告,将测试失败标为不稳定。若项目特殊配置允许 Maven 零退出但报告仍有失败,应设置 skipStagesAfterUnstable(),否则后续阶段仍可能执行。使用 Failsafe 的工程还要收集其报告。报告缺失可能意味着没有执行测试、路径写错或编译提前失败,不用 allowEmptyResults 隐藏问题。JUnit step列出了结果处理选项。
打包后按工程约定选取唯一的可运行 JAR,不用通配符混入源码包、普通包和多个模块产物。构建、Spring Boot JAR 与镜像制作见 构建工具和 Spring Boot 部署。Node 工程使用已提交锁文件的 npm ci,不在 CI 更新锁文件后继续发布。
凭据在需要时注入
Git 读取、制品发布、镜像推送、目标部署使用不同账号,分别限制仓库、项目与环境。已配置独立镜像构建器的 Agent 可采用以下片段;REGISTRY、IMAGE 来自可信任务定义:
withCredentials([usernamePassword(credentialsId: 'harbor-push',
usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
sh '''#!/bin/sh
set -eu
set +x
export DOCKER_CONFIG="$(mktemp -d)"
trap 'rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG"' EXIT
printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY" \
--username "$REGISTRY_USER" --password-stdin
docker push "$IMAGE"
'''
}临时 Docker 配置避免覆盖其他任务的登录状态。Groovy 使用单引号字符串,秘密由 shell 环境展开。能修改 Pipeline 的人通常可以编写代码外传其可用凭据,因此不可信 PR 不能得到生产密钥,也不能与可信发布共用同一系统用户和可读工作区。日志掩码只减少意外泄露。秘密文件位置与变量处理见 Credentials Binding。
向各环境提升同一份制品
测试、预发、生产从仓库下载相同文件,或按相同镜像 digest 拉取,环境配置在部署时注入。进入生产前重新编译同一提交,可能因依赖或基础镜像变化得到另一份产物。文件摘要与镜像 manifest 的区别见 Harbor。
可信 CI:检出提交 → 测试 → 发布不可变制品
│ 坐标 + 摘要
▼
可信 CD:核准目标 → 获取环境锁 → 部署 → 版本与业务验证
│ 失败
▼
查询切换状态 → 恢复或人工接管生产 CD 定义存在受保护的管理仓库,不能从待发布分支读取任意部署脚本后再给予生产凭据。人工确认展示目标、制品身份、变更和回退版本;参数从允许的候选中选择。input 确认人限制之外,还要控制任务配置、Replay、凭据与目标系统授权。
disableConcurrentBuilds 只串行化同一 Job。不同 Job 发布同一环境时需要共同环境锁,目标端也应有锁,以覆盖手工操作和其他 CI。多台主机交给 Ansible逐批部署;两个版本能并行运行后,用 灰度发布控制请求进入候选版本。
等待审批时可改成 agent none,审批阶段不占节点,部署阶段再申请。此时按身份重新下载制品,把清理放在该 stage 的 post,不能在无工作区上下文的全局 post 执行 deleteDir。顶层超时不包含首次顶层 Agent 分配的等待时间;限制排队需将超时放在节点分配之前。
远端部署超时后,先查询实际版本与切换记录。不要用 retry 包住整段数据库迁移、重启和回退,让不确定的副作用重复发生。
平台维护与失败恢复
排队、断线和文件缺失
长期停在 Still waiting to schedule task 时,先看队列提示,是标签无匹配、节点离线,还是 executor 全被占用。对照 Nodes 页面后,在 Agent 检查:
id
java -version
df -h /srv/jenkins-agent
df -i /srv/jenkins-agent
ps -ef | grep '[a]gent.jar'无进程时按原账号重启 Agent;进程存在却离线时,从日志区分证书、WebSocket、密钥和连接错误。空间耗尽先停接新构建,核对旧工作区和缓存,再清理确认不用的目录。恢复后运行最小任务,复验节点身份和工具版本。
缺少 target 或 MissingContextVariableException: hudson.FilePath 时,检查失败阶段的节点与工作区。上一阶段在另一节点就显式传递制品;发生在全局 post 则把清理移动到有节点的阶段。反复重跑可能偶然成功,不能修复工作区关系。
shell 与依赖下载失败
Ubuntu 的 /bin/sh 可能不支持 pipefail。简单脚本用 POSIX set -eu;需要 Bash 时,将 #!/bin/bash 放在脚本文本第一行,并确保节点已安装它。
如果脚本返回 126 且显示 Permission denied,检查文件执行权限和工作区挂载选项。可用 findmnt -T /srv/jenkins-agent 查看是否包含 noexec。构建工作区需要能够执行受信任的构建脚本,应使用专用、允许执行的工作区;不要为了消除错误放宽整个宿主机文件系统的限制。
依赖下载失败,在报错 Agent 的相同账号下验证具体仓库,示例域名需替换:
curl -q --connect-timeout 5 --max-time 20 \
--fail-with-body --head https://repo.example.com/repository/maven-public/
mvn -B -ntp -s .ci/settings.xml help:effective-settings401/403 查账号及仓库权限,TLS 错误查该 JVM/客户端信任,连接超时查代理和出口。有效配置输出保存在受控位置,分享前检查是否含凭据和私有地址。修复后以原构建命令复测,仓库首页可访问不能代替依赖下载。
备份、升级和恢复
JENKINS_HOME 保存任务、插件、构建记录和加密凭据;凭据恢复还需要相应加密材料。按 Jenkins 备份与恢复说明分开保护数据备份与主密钥,在隔离环境恢复测试。
实验可采用停机冷备。先在页面准备关机,等任务结束,并确认远端没有仍在执行的部署,然后在 Compose 目录运行:
set -eu
umask 077
BACKUP_DIR="$PWD/backup-controller"
mkdir -p "$BACKUP_DIR"
test ! -e "$BACKUP_DIR/jenkins_home"
docker compose stop controller
docker compose cp controller:/var/jenkins_home "$BACKUP_DIR/jenkins_home"
docker compose start controller这份完整实验备份含秘密,不能当作普通附件分享,应进入加密、受限的备份系统。正式方案单独封存主密钥,恢复时由授权人员取回,按原权限还原。复制成功后还需检查文件,并在新卷上试启动。
升级前在副本检查 Java、Core 与插件组合,保存原镜像和升级前数据。新程序可能修改数据格式或插件状态,回退要还原旧数据与旧程序组合,不能只改镜像标签。恢复实例暂不连接生产 Agent,不开自动触发器,先检查权限、节点定义和测试任务,再恢复业务连接。
构建归档、日志与依赖缓存分别设置保留策略,观察磁盘、队列等待和失败率。构建保留数量限制会删除旧归档,因此回退制品与平台恢复备份都应放在各自受保护的存储位置。
权威资料与规范地址
Jenkins 安装与平台
- Java 支持:https://www.jenkins.io/doc/book/platform-information/support-policy-java/
- Docker 安装:https://www.jenkins.io/doc/book/installing/docker/
- Agent:https://github.com/jenkinsci/docker-agent
- Controller 隔离:https://www.jenkins.io/doc/book/security/controller-isolation/
- 插件管理:https://www.jenkins.io/doc/book/managing/plugins/
- 备份恢复:https://www.jenkins.io/doc/book/system-administration/backing-up/
Pipeline 与凭据
- Pipeline 语法:https://www.jenkins.io/doc/book/pipeline/syntax/
- Jenkinsfile:https://www.jenkins.io/doc/book/pipeline/jenkinsfile/
- CPS 限制:https://www.jenkins.io/doc/book/pipeline/cps-method-mismatches/
- Credentials Binding:https://www.jenkins.io/doc/pipeline/steps/credentials-binding/
- JUnit:https://www.jenkins.io/doc/pipeline/steps/junit/
