Jenkins:从单机流水线到可恢复的交付控制面
周一早上,发布流水线没有报错,却有二十多个任务一直显示 Waiting for next available executor。值班同学重启 Jenkins 后,队列暂时消失;半小时后问题再次出现,签名任务还被调度到了普通构建机。真正的故障不是“Jenkins 卡了”,而是 Controller 同时承担构建、Agent 标签没有表达能力边界、队列没有容量预算,团队也没有保存可恢复的配置基线。
Jenkins 不是一个放大版的 Shell 启动器。Controller 持有任务、队列、权限、凭据引用和插件状态,Agent 执行仓库里可能不可信的代码,外部制品库保存可发布对象。把这三个边界分开,后面的安装、排障和扩容才有共同坐标。
先看清一次构建经过哪里
一次 Pipeline 从提交到制品通常经过下面的链路:
Controller 的 executor 应设为 0。它负责调度、保存运行状态和协调 Agent,不应该执行仓库脚本。否则一次内存泄漏、磁盘写满或恶意构建就能拖垮整个控制面。
Agent 上的 executor 数不是 CPU 核数的同义词。一个 JVM 编译任务可能同时吃满多核和数 GB 内存;一个端到端测试可能长期占用浏览器和端口。初始值应从 1 开始,用 CPU、内存、磁盘 I/O、队列等待年龄和任务耗时共同调整。
选择部署形态
单 Controller 与固定 Agent
一台 Controller 加几台长期 Agent 最容易落地,适合任务类型稳定、网络边界固定的小团队。代价是 Agent 工作区会积累依赖、容器、缓存和残留进程,时间越久,构建越难复现。固定 Agent 必须有定期重建机制,不能只做目录清理。
容器化 Controller 与临时 Agent
Controller 使用持久卷,Agent 按任务创建并销毁,是更常见的团队形态。它把工作区污染限制在单次运行内,也能按队列弹性扩缩。代价是镜像供应链、镜像拉取时间、缓存回源和集群容量都成为交付链的一部分。
Kubernetes 上的 Controller 与 Pod Agent
Kubernetes 适合已经具备集群治理能力的团队。Controller 仍是有状态服务,需要持久卷、稳定入口、备份和恢复;Pod Agent 才适合弹性。不要因为 Pod 能重建就把 Controller 当成无状态 Deployment,也不要在同一集群里给构建 Pod 过宽的 ServiceAccount。
多 Controller 分域
当安全等级、插件组合、升级窗口或团队自治差异明显时,多个较小 Controller 往往比一个“全公司超级实例”更可靠。代码签名、移动端构建、普通 CI 和生产发布可以分域;代价是插件基线、身份接入、审计和备份需要平台化复用。
用容器跑通可恢复基线
下面的练习需要 Docker Compose、可访问 Jenkins 更新站点的网络,以及一个不会被生产使用的工作目录。镜像变量使用团队审核过的 Jenkins LTS 镜像标签或摘要;不要长期依赖浮动标签。
Core 版本与 Java 运行时必须成对管理。Controller、Agent 进程、CLI 等 Jenkins 系统组件都要使用该 Core 支持的 Java;应用构建所用的 JDK 则是另一条版本线,可以在 Agent 工具链或构建容器中单独固定。升级前先查目标 LTS 的 Java 支持矩阵,再验证 Controller 和每类 Agent 的 java -version,不能因为业务仍编译 Java 8 就让 agent.jar 也运行在 Java 8 上。
创建 compose.yaml:
services:
controller:
image: ${JENKINS_IMAGE:?set JENKINS_IMAGE to a reviewed LTS image}
container_name: jenkins-lab
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
JAVA_OPTS: >-
-Djenkins.install.runSetupWizard=false
CASC_JENKINS_CONFIG: /var/jenkins_home/casc/jenkins.yaml
JENKINS_ADMIN_ID: ${JENKINS_ADMIN_ID:?set a lab administrator id}
JENKINS_ADMIN_PASSWORD: ${JENKINS_ADMIN_PASSWORD:?set a lab-only password}
volumes:
- jenkins_home:/var/jenkins_home
- ./casc:/var/jenkins_home/casc:ro
volumes:
jenkins_home:127.0.0.1:8080:8080 只允许本机访问,适合实验。生产入口应放在受控反向代理后,配置 TLS、身份认证、请求大小和访问日志。jenkins_home 保存 Controller 状态;删掉容器不会丢数据,删掉卷会。
Jenkins Configuration as Code 插件把系统配置变成可评审 YAML。先准备 casc/jenkins.yaml:
jenkins:
systemMessage: "managed by JCasC"
numExecutors: 0
mode: EXCLUSIVE
disableRememberMe: true
securityRealm:
local:
allowsSignup: false
users:
- id: "${JENKINS_ADMIN_ID}"
password: "${JENKINS_ADMIN_PASSWORD}"
authorizationStrategy:
loggedInUsersCanDoAnything:
allowAnonymousRead: false这段配置仅用于隔离实验。生产系统不要把管理员密码写进 YAML 或 Compose;应由容器 Secret、外部密钥系统或受控启动环境注入,并用企业身份源替代共享本地管理员。
安装 JCasC 和 Pipeline 等插件时,把插件集合固定在镜像构建阶段:
ARG JENKINS_BASE_IMAGE
FROM ${JENKINS_BASE_IMAGE}
COPY --chown=jenkins:jenkins plugins.lock.txt /usr/share/jenkins/ref/plugins.lock.txt
RUN awk -F: 'NF != 2 || $2 == "" || $2 == "latest" { bad=1 } END { exit bad }' \
/usr/share/jenkins/ref/plugins.lock.txt \
&& jenkins-plugin-cli \
--plugin-file /usr/share/jenkins/ref/plugins.lock.txt \
--latest=false构建时必须同时传入带精确版本与摘要的基础镜像,例如 --build-arg JENKINS_BASE_IMAGE=jenkins/jenkins:<core>-jdk21@sha256:<digest>。更新中心和插件站点是发现插件、依赖与安全公告的目录,不是生产锁文件。未写版本时,安装工具默认会解析可用新版本;即使直接依赖写了版本,默认依赖解析也可能选择更高版本。plugins.lock.txt 因此必须记录验证环境得到的精确版本,并使用 --latest=false。格式如下,尖括号要替换为同一 Core 验证通过的真实版本:
configuration-as-code:<exact-version>
workflow-aggregator:<exact-version>
git:<exact-version>
credentials-binding:<exact-version>升级任务先在隔离环境中从受控更新中心解析候选集合,检查安全公告和最低 Core 要求,再把 jenkins-plugin-cli --list 展示的有效集合整理成新的锁文件。生产镜像构建只消费经过评审的锁文件。企业使用更新中心代理时,还要同时代理元数据、插件版本信息和二进制下载地址;只代理下载域名会让解析仍绕回公网。
先构建镜像,再启动:
docker build -t company/jenkins-controller:lab .
export JENKINS_IMAGE=company/jenkins-controller:lab
export JENKINS_ADMIN_ID=lab-admin
export JENKINS_ADMIN_PASSWORD='replace-for-lab-only'
docker compose up -d
docker compose logs -f controller日志出现 Jenkins 已完成初始化后,访问 http://127.0.0.1:8080。若容器反复退出,先看 docker compose logs controller;JCasC 缩进、未知字段、插件缺失和卷权限是最常见的第一证据。
实验结束后,普通停止保留 Home 卷:
docker compose down回退镜像时,把 JENKINS_IMAGE 恢复为已验证的上一摘要,再执行 docker compose up -d;如果新版本已经迁移了插件数据或 Home 结构,应恢复升级前快照,不能让旧镜像直接读取新数据。确认没有恢复价值后才彻底删除实验卷:
docker compose down -v-v 会永久删除实验中的 Controller 状态,生产环境不得把它当作日常重启命令。
把 Agent 变成隔离边界
固定 SSH Agent 的最小过程是:在 Agent 主机建立专用低权限账号,为 Jenkins Agent 进程安装与目标 Core 支持矩阵匹配的 Java,限制工作目录和出站网络;在 Controller 的 Nodes 中创建节点,填写远程工作目录、标签、用途、启动方式和凭据;连接后从节点日志确认连接成功,并在节点系统信息中核对实际 Java。业务编译需要旧 JDK 时,把旧 JDK 作为构建工具调用,不要替换 Agent 进程的 Java。
标签描述的是能力与信任等级,不是随意分组:
linux && amd64 && untrusted
linux && docker && trusted-branch
windows && code-signing普通 Pull Request 只能进入 untrusted 池。签名 Agent 不接收任意任务,不缓存普通仓库工作区,不向构建脚本暴露长期私钥。静态 Agent 的远程目录不要与系统目录、Docker 数据目录或其他账号 Home 重叠。
动态 Agent 需要额外回答五个问题:谁创建实例、使用什么镜像、构建结束如何销毁、缓存在哪里、超时和 Controller 断连后谁回收。只配置“空闲时删除”不足以处理 Controller 宕机留下的孤儿资源。
让第一个 Pipeline 同时证明成功与失败
在测试仓库提交下面的 Jenkinsfile:
pipeline {
agent { label 'linux && untrusted' }
options {
timeout(time: 10, unit: 'MINUTES')
disableConcurrentBuilds()
timestamps()
}
stages {
stage('Verify') {
steps {
sh '''
set -eu
test -f ci/input.txt
grep -qx 'release-candidate' ci/input.txt
printf 'verified commit=%s node=%s\n' "$GIT_COMMIT" "$NODE_NAME"
'''
}
}
stage('Package') {
steps {
sh '''
set -eu
mkdir -p dist
cp ci/input.txt dist/app.txt
sha256sum dist/app.txt > dist/SHA256SUMS
'''
archiveArtifacts artifacts: 'dist/*', fingerprint: true
}
}
}
post {
always {
deleteDir()
}
}
}创建输入并提交:
mkdir -p ci
printf 'release-candidate\n' > ci/input.txt
git add Jenkinsfile ci/input.txt
git commit -m "test: add Jenkins pipeline fixture"
git push成功运行应满足三个不变量:日志含 verified commit= 与实际 Agent 名称;归档中有 app.txt 和 SHA256SUMS;运行结束后工作区被清理。Pipeline 返回 SUCCESS 才是正向证据。
然后把输入改成 unsafe-change 再提交。grep -qx 应返回退出码 1,Verify 阶段失败,Package 不执行,但 post 中的清理仍执行。若流水线仍成功,说明脚本吞掉了退出码或任务没有读取本次提交;若失败后工作区还在,说明 Agent 中断路径没有被覆盖。
这组实验故意不访问外部仓库和凭据,适合在新 Agent 池上先验证调度、退出码、制品和清理语义。
项目接入时把逻辑留在仓库
Jenkinsfile 负责编排,业务构建留在仓库脚本中:
Jenkinsfile
ci/
verify.sh
package.sh
publish.sh
rollback.sh
docs/
delivery-runbook.md开发者应能在与 Agent 等价的容器里运行 verify.sh 和 package.sh。publish.sh 只接受制品摘要和目标环境,不重新构建;rollback.sh 恢复上一份已验证制品,不执行 git checkout 后现场打包。这样更换 Jenkins、Agent 镜像或编排方式时,业务构建仍可复现。
多分支流水线应把 Pull Request 与受保护分支分成不同信任级别。PR 运行测试与扫描,不获得发布凭据;受保护分支生成候选制品;人工或策略审批后,发布任务提升同一摘要。来自 Fork 的 Jenkinsfile 本身就是不可信输入,不能让它决定凭据是否注入。
凭据不是日志脱敏问题
凭据应创建在最低可用层级。Folder 级凭据只提供给该 Folder 的任务,全局凭据会扩大到整个 Controller。构建读取源码、上传制品、签名和生产部署应使用不同身份,分别限制仓库、路径、动作和有效期。
Pipeline 使用凭据时,把绑定范围缩到最小:
withCredentials([string(credentialsId: 'artifact-push-token', variable: 'TOKEN')]) {
sh '''
set +x
./ci/publish.sh --token-env TOKEN dist/SHA256SUMS
'''
}日志掩码只能降低误打印风险,不能防御恶意脚本。脚本可以编码、分片或发往网络,所以不可信分支根本不应拿到 Secret。Agent 出站网络、Folder 权限、凭据作用域和分支保护必须一起工作。
泄漏处置顺序是撤销凭据、停止相关运行、检查下游访问日志、轮换替代凭据,再清理 Jenkins 日志和制品。只删除控制台输出会留下仍然有效的密钥。
插件与 JCasC 的变更闭环
插件运行在 Controller 进程中,升级一个插件可能改变依赖、配置 Schema、Pipeline 步骤和反序列化行为。团队应保存三份基线:Jenkins Core 镜像摘要、插件及实际版本、JCasC 配置。
每次变更按下面的顺序推进:
在生产备份恢复出的隔离实例上加载旧基线。更新一个受控批次的 Core 或插件,不同时修改业务流水线。让 JCasC 加载并检查系统日志中的弃用、未知字段和迁移警告。
运行登录、Agent 连接、排队、凭据绑定、制品归档和安全重启实验。重启一次,证明配置不是只在热加载后有效。记录新镜像摘要、插件清单和回退基线,再进入生产窗口。
JCasC 可以回退配置,却不能自动回退插件数据迁移。插件更新前要确认降级支持;没有明确支持时,回退路径是恢复整个 Controller 备份,而不是把 .jpi 文件换回旧版。
用队列证据做容量设计
“有多少 Agent”不能回答系统是否够用。至少采集:队列长度、最老等待时间、按标签等待时间、在线 Agent 数、忙 executor 数、任务耗时分位数、失败率、Agent 启动耗时和磁盘余量。
容量判断从到达率和服务时间开始。若高峰每分钟进入 4 个任务,平均每个占用 executor 5 分钟,理论并发需求已经接近 20;还要为耗时波动、故障和发布峰值留余量。持续增加 executor 但 CPU 饱和,只会让单任务更慢并放大超时。
标签队列尤其重要。全局 executor 空闲而 code-signing 队列等待,说明瓶颈是稀缺能力,不是总容量。动态 Agent 的扩容冷启动也要计入等待预算,镜像过大和依赖回源会把“弹性”变成新的延迟。
备份必须通过恢复来验收
Controller 的恢复对象包括系统配置、任务、运行记录、插件、用户数据和凭据加密材料。工作区和可从制品库重新获得的大文件可按恢复目标选择是否备份。
备份过程应先进入安静窗口或使用文件系统一致性快照,生成带时间戳的快照,再复制到异地存储。常规备份要保留凭据密文和 secrets/hudson.util.Secret,但必须排除 JENKINS_HOME/secrets/master.key;master.key 由另一权限域单独加密托管。两份材料任何一份缺失都会破坏完整恢复,把它们放在同一存储与同一审批链又会让备份读取者具备解密全部 Jenkins Secret 的条件。
恢复演练不能停在“压缩包能解开”。先在与生产隔离且默认禁止外连的环境挂载新的空数据卷,恢复常规备份并校验 owner、权限和文件数量;再由独立授权人注入对应的 master.key,只允许 Controller 账号读取。使用与快照匹配的 Core 镜像和插件集合启动单个 Controller,先禁止 Webhook、定时任务和生产 Agent 重连,再验证登录、JCasC、测试凭据解密、任务与运行记录。最后连接专用测试 Agent,完成排队、执行和制品归档;演练结束后撤销测试身份并销毁恢复环境。任何时候都不能让恢复实例和生产实例同时写同一个数据卷或消费同一生产触发器。
RPO 由备份频率决定,RTO 由恢复数据量、镜像与插件获取速度、密钥审批和 Agent 重连共同决定。两者都应通过计时演练得出,不能从备份任务成功推断。
升级、回退与故障隔离
Jenkins Controller 不是适合无状态滚动升级的服务。升级前应阅读目标 LTS 的升级指南和 Java 要求,在备份恢复实例上跨过相同版本路径;生产变更时暂停新任务、等待关键任务结束、生成一致性备份,再替换镜像或软件包。
升级成功的证据包括:Controller 达到稳定状态,插件依赖无错误,JCasC 无未知字段,关键 Agent 在线,代表性 Pipeline 完成,队列与错误率回到基线。若失败,停止新实例并恢复旧 Core、旧插件和旧 Home 快照;不要让新旧 Controller 同时写同一个 JENKINS_HOME。
Controller 高可用的首要能力是可恢复,不是同时启动两个实例争抢同一数据目录。需要更短 RTO 时,应优化备份、镜像分发、配置即代码、外部制品和 Agent 重建速度,并根据所用发行方式评估受支持的容灾方案。
典型故障的第一证据
任务一直排队
先看队列原因、要求的标签、对应节点是否在线、executor 是否被占满。Waiting for next available executor 可能是容量不足;There are no nodes with the label 是标签不匹配;节点在线但磁盘保护触发时,继续加任务只会扩大故障。
Agent 反复离线
先看节点日志和 Agent 进程退出原因,再检查 Java、网络、时钟、磁盘和工作目录权限。若只有大任务断连,应把 Agent OOM、Controller 心跳超时和网络空闲超时分开验证。
插件升级后无法启动
从 Controller 日志定位第一个依赖或类加载错误,核对 Core 与插件基线。不要批量删除插件试错;在恢复副本上重现并决定前滚依赖还是恢复完整快照。
流水线成功但制品不可追溯
检查归档步骤是否真的执行、文件匹配是否为空、摘要是否与上传对象一致、外部仓库是否允许覆盖同版本。成功状态只证明步骤返回零,不证明制品不可变或可回滚。
恢复后凭据不可用
若任务能恢复但所有 Secret 解密失败,优先检查加密材料是否来自同一 Controller 快照,以及文件 owner 和权限。不要在生产实例上重新录入一遍凭据掩盖恢复链缺失。
长期治理基线
每个 Controller 要有明确 owner、使用团队、数据等级、恢复目标和升级窗口。平台团队维护 Core、插件、JCasC、身份、备份和观测;业务团队维护 Jenkinsfile、仓库脚本、测试和发布回滚;安全团队定义不可信构建、凭据和签名边界。
每月审查未使用插件、全局凭据、长期离线节点、队列热点、孤儿任务、磁盘增长和失败趋势。每季度至少恢复一次代表性备份,并从不可信分支验证生产凭据不可见。成本核算同时包含 Controller、Agent 计算、缓存与制品存储、网络流量、插件升级验证和人工值守。
退出 Jenkins 时,先把业务逻辑从 Pipeline DSL 收回仓库脚本,再迁移凭据、制品索引和发布审计。最后冻结旧实例、导出任务与运行证据、撤销所有服务身份,确认没有生产回滚依赖后再删除 Controller 数据。
上线检查
Controller executor 为 0,仓库脚本只在隔离 Agent 执行。Agent 标签表达能力与信任等级,签名和生产发布节点不接收普通任务。Core、插件和 JCasC 都有可追踪基线,升级经过恢复副本验证。
正向与反向 Pipeline 都验证了退出码、制品和清理路径。PR、受保护分支和发布任务使用不同凭据边界。制品带源提交、运行编号和不可变摘要,发布只提升同一份制品。
队列等待、标签容量、Agent 启动、磁盘和失败率持续观测。普通备份与 Controller 密钥分权保管,恢复演练验证过凭据与 Agent。插件失败、Agent 污染、凭据泄漏和错误发布都有停止与恢复入口。
owner、升级窗口、RPO、RTO、成本和退出迁移已经落到团队职责。
