Jenkins Pipeline CPS、并发与可靠发布全过程
Pipeline不是“把Shell命令写进Jenkinsfile”。Jenkins需要让一条可能运行数小时、跨多个Agent、等待人工审批并经历Controller重启的流程仍能继续,因此它会对Groovy脚本进行CPS转换,在可暂停点保存Continuation和执行状态。不了解这个模型,就会遇到不可序列化对象、@NonCPS误用、input长期占Executor和并发Build覆盖生产环境。
学习目标
- 区分Declarative Pipeline、Scripted Pipeline和普通Groovy。
- 解释CPS转换、Continuation、暂停点和持久化状态。
- 说明Controller重启后哪些Step可恢复、哪些外部操作不能自动回滚。
- 区分agent、node、Executor、Workspace和Docker/Kubernetes动态Agent。
- 正确使用
@NonCPS,避免非序列化对象跨暂停点。 - 设计Stage、post、timeout、retry、input、milestone、lock和并发控制。
- 解释parallel/matrix的资源与Workspace隔离。
- 安全使用Credentials,避免Groovy插值和日志泄密。
- 实现一次构建、多环境推广同一镜像Digest。
- 按Queue、Step、Agent、Workspace、制品和部署阶段排查失败。
一、Pipeline解决什么
个人记忆中的发布步骤
→ Jenkinsfile进入Git
→ 代码评审
→ Jenkins按阶段执行
→ 保存日志、测试、制品与审批
→ 失败定位和受控回滚Pipeline的核心价值是可版本化、可重复、可审计,不是单纯减少点击次数。
二、Declarative与Scripted
Declarative
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'mvn clean package'
}
}
}
}提供固定结构、语法校验、environment、options、post、matrix等模型,适合大多数商业流水线。
Scripted
node('linux') {
stage('Build') {
sh 'mvn clean package'
}
}更接近Groovy控制流,灵活但更容易把复杂业务、不可序列化对象和动态逻辑塞进Jenkinsfile。
Declarative底层仍会进入Pipeline执行引擎,不等于它绕过CPS。可在Declarative的 script {} 中使用Scripted逻辑,但应保持小而明确。
三、Jenkinsfile从读取到执行
flowchart TD
A["读取固定Commit中的Jenkinsfile"] --> B["Declarative模型校验/转换"]
B --> C["Groovy CPS转换"]
C --> D["创建Flow Execution与节点图"]
D --> E["执行到需要Agent或异步Step"]
E --> F["保存可恢复执行状态"]
F --> G["Agent执行外部命令"]
G --> H["结果回到Pipeline并进入下一Continuation"]Pipeline不会先生成一份永远不变的完整静态计划。条件、参数、Step结果和动态分支会影响后续Flow Node。
四、什么是CPS
CPS是Continuation-Passing Style。普通函数依靠线程调用栈保存“执行到哪里”;Pipeline需要在暂停、异步等待和Controller重启后继续,因此把大部分Groovy逻辑转换为可显式保存后续动作的Continuation。
普通理解:
执行A
→ 在线程栈里记住A之后执行B
→ B之后执行CCPS理解:
执行A,并把“下一步B”表示成Continuation
→ A完成后调用B Continuation
→ B可能暂停并持久化“下一步C”
→ 恢复后继续C五、暂停点是什么
很多Pipeline Step可能异步或长时间等待:
sh/bat等待外部进程。input等待人工审批。sleep等待定时器。node等待Queue和Executor。build等待下游Job。timeout包围可暂停步骤。
执行到这些位置时,Controller不需要用一个Java线程在内存调用栈里阻塞数小时,而是由Step执行和持久化机制管理状态。
六、Controller重启为什么能继续
flowchart TD
A["Pipeline执行到可暂停Step"] --> B["保存Flow Graph、变量与Continuation"]
B --> C["Controller停止或重启"]
C --> D["重新加载Build执行状态"]
D --> E["重新连接/查询Durable Step"]
E --> F["从后续Continuation继续"]能否恢复取决于:
- Step是否设计为Durable/可恢复。
- Agent和Workspace是否仍存在。
- 外部进程是否继续运行。
- Pipeline状态是否成功持久化。
- 对象是否可序列化。
- 插件和Jenkins版本兼容。
“Pipeline恢复”不等于外部部署事务回滚。kubectl已经改了Deployment、数据库已经执行DDL,这些外部副作用不会因Controller重启自动撤销。
七、Pipeline持久化性能与Durability
Jenkins可有不同Pipeline durability/performance取舍:更频繁保存状态提高异常恢复能力,但增加Controller磁盘IO;更高性能模式可能减少持久化频率,异常崩溃时丢失更多执行进度。
具体选项和默认值随Jenkins/插件版本变化。生产必须结合:
- Controller磁盘性能。
- Pipeline数量和Flow Node规模。
- 可接受恢复点。
- JENKINS_HOME备份。
- 故障演练。
不能为了构建快就盲目选择最低持久化,也不能用慢磁盘支撑最高持久化后抱怨全站卡顿。
八、哪些对象不能跨暂停点
CPS状态需要保存变量。以下对象常不适合跨Step或暂停点长期持有:
- 打开的InputStream/OutputStream。
- 数据库连接和Socket。
- 某些Iterator。
- 正则Matcher。
- 第三方库非Serializable对象。
- Jenkins内部临时对象。
错误思路:
def stream = new FileInputStream('large.txt')
sh 'echo pause'
// 恢复时stream无法可靠序列化应在普通Shell工具中处理文件,或在一次纯计算范围内创建和释放对象,不让它跨Pipeline Step。
九、@NonCPS是什么
@NonCPS
String summarize(List<Map> rows) {
rows.collect { it.name }.sort().join(',')
}@NonCPS让该方法按普通Groovy/JVM方式执行,不经过CPS转换,适合短小、纯内存、无Pipeline Step的计算。
限制:
- 不能在里面调用
sh、echo、node、input等Pipeline Step。 - 输入输出应简单、可序列化。
- 不能做长时间阻塞IO。
- 方法执行中Controller重启无法从中间Continuation恢复。
不要看到序列化错误就给大段代码加 @NonCPS。应先减少复杂对象和把工作移到脚本/工具中。
十、CPS与普通Groovy调用边界
CPS转换代码调用普通Java/Groovy方法通常可行;普通非CPS代码反过来调用CPS Pipeline Step会产生方法不匹配、异常行为或难以理解的结果。排序闭包、第三方库回调等历史上也可能暴露CPS方法不匹配问题,具体随Pipeline插件修复变化。
原则:Jenkinsfile负责流程编排,复杂计算进入经过测试的库或外部程序。
十一、agent与node什么时候占Executor
pipeline {
agent any
stages { ... }
}顶层 agent any通常让流水线长时间占用一个Agent/Executor,即使某些阶段只是等待审批。
更节省资源:
pipeline {
agent none
stages {
stage('Build') {
agent { label 'linux && jdk8' }
steps { sh 'mvn clean verify' }
}
}
}node/agent负责请求Node、Executor和Workspace。sh运行期间通常继续占用该Executor,即使Controller线程并未阻塞。
十二、input为什么可能浪费Executor
如果审批放在已分配Agent的长Stage内部:
stage('Deploy') {
steps {
input '批准生产发布?'
sh './deploy.sh'
}
}可能长期占着Workspace和Executor,具体取决于Declarative结构和Agent作用域。
更清晰的设计是单独Approval Stage且顶层 agent none,审批完成后再进入需要生产Agent的Deploy Stage:
stage('Approval') {
steps {
timeout(time: 30, unit: 'MINUTES') {
input message: '批准生产发布?'
}
}
}
stage('Deploy') {
agent { label 'prod-deployer' }
steps { sh './deploy.sh' }
}还要限制谁能批准,记录审批人,并防止旧Build晚于新Build发布。
十三、Stage如何拆分
推荐按可观察的质量/交付边界:
Checkout
→ Compile
→ Unit Test
→ Static Analysis
→ Package
→ Build Image
→ Scan Image
→ Publish Artifact
→ Deploy Test
→ Integration Test
→ Approval
→ Deploy Production
→ Verify不要把所有命令塞进一个Stage,也不要每条Shell拆一个Stage。Stage应对应可判定成功、可重试或可停止的业务边界。
十四、post执行什么
post {
always {
junit 'target/surefire-reports/*.xml'
cleanWs()
}
success {
echo 'success'
}
failure {
echo 'failure'
}
aborted {
echo 'aborted'
}
}post适合测试报告、通知、清理和事故证据保存。清理前先归档需要的日志、Dump和部署结果,不能在failure第一步就删除现场。
cleanWs等Step来自插件,目标实例是否安装需验证。
十五、timeout的真实作用
timeout(time: 15, unit: 'MINUTES') {
sh './integration-test.sh'
}超时会中断Pipeline Step并尝试终止相关任务,但外部系统副作用可能继续:
- 已提交数据库事务不会回滚。
- 已发MQ消息不会撤回。
- 远程SSH脚本可能派生后台进程。
- Kubernetes rollout可能继续。
脚本必须响应终止信号、设置自己的网络超时,并提供幂等状态检查。
十六、retry为什么危险
retry(3) {
sh './deploy.sh'
}它会重新执行闭包,不理解上一次执行到哪里。若deploy已创建资源、执行DDL或切流量,重试可能重复副作用。
适合重试:
- 明确幂等的拉取/查询。
- 临时网络失败且操作可验证。
- 动态Agent Pod丢失后的完整干净重建,需按插件能力设计。
不应盲目重试:
- 数据库不可逆变更。
- 支付、通知。
- 无幂等的部署脚本。
- 已部分执行的生产切流。
十七、catchError与结果
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh './quality-scan.sh'
}它可以让流程继续并设置Build/Stage结果,但不能为了“流水线绿色”吞掉质量门。UNSTABLE是否允许部署必须显式判断。
直接使用 sh(returnStatus: true) 后忽略非0状态,是高频假成功来源。
十八、disableConcurrentBuilds
options {
disableConcurrentBuilds()
}防止同一Job多个Build同时运行。某些版本支持 abortPrevious: true,用新Build中止旧Build。
边界:
- 只控制同一Job,不自动控制多个Job部署同一环境。
- 旧Build被中止时外部部署可能已发生。
- 长Build可能让Queue堆积。
共享生产环境还需要Lock或发布平台级互斥。
十九、milestone防止旧Build反超
场景:Build 100等待审批,Build 101已验证通过;之后100被批准,如果不控制,旧版本可能覆盖新版本。
milestone可帮助淘汰落后的旧Build,保证较新的Build越过关键里程碑后,旧Build不能再继续越过相同发布点。它不是制品回滚策略,使用前需理解插件/Step语义并实验。
二十、lock保护共享资源
Lockable Resources插件可表达:
lock(resource: 'production-order-service') {
sh './deploy-prod.sh'
}用于保护生产环境、共享测试数据库、硬件设备等。它不能代替应用分布式锁,也不能自动回滚部署。锁范围应尽量只覆盖真正互斥的阶段,避免长时间占锁做编译。
二十一、parallel并行
stage('Tests') {
failFast true
parallel {
stage('Unit') {
steps { sh 'mvn test' }
}
stage('Integration') {
steps { sh './integration-test.sh' }
}
}
}并行缩短总时间,但会放大:
- Executor需求。
- CPU、内存和IO。
- Maven仓库并发。
- 固定端口冲突。
- 测试数据库负载。
- 日志量。
failFast在一个分支失败时尽快中止其他分支,但外部进程和副作用是否停止仍需验证。
二十二、并行分支Workspace隔离
两个分支在同一个Node/Workspace写同一文件会互相覆盖。可使用不同Agent、dir()子目录、stash/unstash或每分支独立容器。
parallel linux: {
node('linux') {
deleteDir()
unstash 'source'
sh './test-linux.sh'
}
}, windows: {
node('windows') {
deleteDir()
unstash 'source'
bat 'test-windows.bat'
}
}动态Map Scripted写法要保持可读,并测试闭包变量捕获。
二十三、matrix
Declarative Matrix适合测试组合:
matrix {
axes {
axis {
name 'JDK'
values '8', '17'
}
axis {
name 'DB'
values 'mysql57', 'mysql80'
}
}
stages {
stage('Compatibility Test') {
steps {
sh './compat-test.sh "$JDK" "$DB"'
}
}
}
}2×2会产生4个组合,轴增加会指数/乘法放大Executor和测试环境。JDK 8与17测试需要真实工具链或容器,不是只改一个字符串环境变量。
二十四、stash与preserveStashes
stash name: 'jar', includes: 'target/*.jar'
unstash 'jar'适合同一Pipeline跨Node传相对小的构建输出。大量文件会占Controller/Artifact Manager CPU、网络和存储,应把正式大制品推到Nexus/Registry。
重启Build或从Stage恢复时是否保留stash,可通过Declarative选项如 preserveStashes按版本能力配置。它不是永久制品保留策略。
二十五、参数必须校验
parameters {
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'prod'])
string(name: 'IMAGE_DIGEST', defaultValue: '', trim: true)
}不要把任意字符串直接拼Shell:
sh "kubectl -n ${params.ENVIRONMENT} ..."参数可能造成命令注入或部署错误环境。应使用固定choice、格式白名单、受控映射和脚本参数数组/安全引用。
二十六、环境变量边界
environment适合普通配置和构建元数据:
environment {
APP_NAME = 'order-api'
REGISTRY = 'registry.example.com'
}不要把密码硬编码在environment。环境变量会被子进程继承,也可能被同Node高权限进程、诊断命令或错误日志读取。
二十七、Credentials类型
常见:
- Secret Text。
- Username/Password。
- SSH Private Key。
- Secret File。
- Certificate等插件类型。
凭据ID是引用,不是Secret本身。应按环境、项目和用途拆分,生产凭据只允许受控Job/Folder使用。
二十八、Groovy插值如何泄密
危险写法:
withCredentials([string(credentialsId: 'api-token', variable: 'TOKEN')]) {
sh "curl -H 'Authorization: Bearer ${TOKEN}' https://example"
}Groovy在Controller侧先插值,Secret可能进入Step参数、进程命令或日志元数据。
更安全方向是使用单引号Groovy字符串,让Shell从环境变量展开,并关闭不必要的命令回显:
withCredentials([string(credentialsId: 'api-token', variable: 'TOKEN')]) {
sh '''
set +x
curl --fail --silent --show-error \
-H "Authorization: Bearer $TOKEN" \
https://example
'''
}Secret仍存在于进程环境和请求中,需隔离Agent、最小权限和短期凭据。Masking不是绝对防泄漏。
二十九、Secret File与Workspace
某些凭据绑定会创建临时文件。若在可被浏览或归档的Workspace子目录绑定,可能泄露。应在受控范围绑定,避免archive整个Workspace,并确保finally/post清理。
最安全的是使用云Workload Identity、短期Token或Secret Manager动态获取,减少长期静态Secret。
三十、一次构建、多环境推广
flowchart TD
A["固定Commit"] --> B["编译测试"]
B --> C["构建一次镜像"]
C --> D["推送Registry并记录Digest"]
D --> E["测试环境部署同一Digest"]
E --> F["集成测试与扫描"]
F --> G["审批"]
G --> H["生产部署同一Digest"]不要在测试和生产Stage分别 mvn package或 docker build。重新构建可能因依赖、时间戳、基础镜像Tag和环境产生不同字节。
三十一、最小JDK 8构建Demo
pipeline {
agent none
options {
timestamps()
timeout(time: 30, unit: 'MINUTES')
disableConcurrentBuilds()
}
stages {
stage('Build and Test') {
agent { label 'linux && jdk8' }
tools {
jdk 'jdk8-fixed-update'
maven 'maven-3-compatible'
}
steps {
checkout scm
sh 'java -version'
sh 'mvn --batch-mode clean verify'
archiveArtifacts artifacts: 'target/*.jar', fingerprint: true
}
post {
always {
junit allowEmptyResults: true, testResults: 'target/surefire-reports/*.xml'
}
}
}
}
}Jenkins自身运行Java可使用现代LTS要求,业务工具链选择固定JDK 8更新。Tool名称需在目标Jenkins配置。
三十二、商业镜像发布Demo
下面示意需要Credentials、Lockable Resources、Docker和kubectl等实际插件/工具,不能未经验证直接用于生产:
pipeline {
agent none
options {
timestamps()
disableConcurrentBuilds()
preserveStashes(buildCount: 3)
}
environment {
APP = 'order-api'
REGISTRY = 'registry.example.com'
}
stages {
stage('Build') {
agent { label 'linux && jdk8 && docker' }
steps {
checkout scm
sh 'mvn --batch-mode clean verify'
script {
env.COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE = "${REGISTRY}/${APP}:${COMMIT}"
}
sh 'docker build --label org.opencontainers.image.revision=$COMMIT -t $IMAGE .'
withCredentials([usernamePassword(
credentialsId: 'registry-publisher',
usernameVariable: 'REGISTRY_USER',
passwordVariable: 'REGISTRY_PASSWORD'
)]) {
sh '''
set +x
echo "$REGISTRY_PASSWORD" | \
docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
docker push "$IMAGE"
docker logout "$REGISTRY"
'''
}
script {
env.IMAGE_DIGEST = sh(
script: 'docker image inspect "$IMAGE" --format="{{index .RepoDigests 0}}"',
returnStdout: true
).trim()
}
writeFile file: 'image-digest.txt', text: env.IMAGE_DIGEST + '\n'
stash name: 'release-metadata', includes: 'image-digest.txt'
}
}
stage('Deploy Test') {
agent { label 'test-deployer' }
steps {
unstash 'release-metadata'
sh './deploy.sh test "$(cat image-digest.txt)"'
sh './verify.sh test'
}
}
stage('Approval') {
steps {
timeout(time: 30, unit: 'MINUTES') {
input message: '批准把已验证Digest发布到生产?'
}
}
}
stage('Deploy Production') {
agent { label 'prod-deployer' }
steps {
unstash 'release-metadata'
lock(resource: 'prod-order-api') {
sh './deploy.sh prod "$(cat image-digest.txt)"'
sh './verify.sh prod'
}
}
}
}
post {
always {
echo "result=${currentBuild.currentResult}"
}
}
}注意:本地 RepoDigests只有在push后且本地元数据更新时才可用,具体Docker行为需验证;更可靠的是从Registry API或签名工具取得并校验Digest。
三十三、发布失败不等于自动回滚
kubectl rollout status失败只能说明rollout未达到预期,不代表旧版本已恢复。Pipeline必须显式:
- 保存变更前版本/Digest。
- 识别失败阶段。
- 判断数据库与消息格式是否可回滚。
- 执行平台回滚或停止放量。
- 验证旧版本健康与业务指标。
- 保存事故证据。
详细商业回滚见 Jenkins商业发布与回滚。
三十四、Controller重启实验
在测试环境运行包含 sleep、input和长 sh的Pipeline,分别执行受控安全重启与异常停止,观察:
- Build是否恢复。
- Agent进程是否继续。
- Durable Task日志是否回传。
- input是否仍存在。
- stash和Workspace是否保留。
- post是否执行。
不得在生产首次实验。
三十五、常见失败排查
Jenkinsfile语法失败
检查Declarative Linter、具体行号、括号和Step所属插件。
NotSerializableException
查哪个变量跨越Pipeline Step,移除Stream/Iterator/Matcher等复杂对象;不要无脑加 @NonCPS。
一直等待Agent
查Queue原因、Label、Node在线、Executor、Lock与Throttle。
Shell成功但流水线失败
查退出码、set -e、管道退出语义、测试报告和post。Shell日志最后一行正常不等于进程退出码0。
流水线成功但部署不可用
部署命令成功不等于readiness、外部流量和业务指标正常;补健康、冒烟、日志和回滚验证。
三十六、商业场景一:input占满所有部署Agent
五条流水线在分配生产Agent后等待审批,五个Executor全部被占,紧急发布也无法执行。修复为 agent none,审批独立Stage,批准后再申请生产Agent,并设置审批timeout。
三十七、商业场景二:retry重复执行DDL
部署脚本先执行DDL,再因健康检查网络抖动失败;外层retry重新执行整个脚本,DDL报重复或产生不可逆变化。治理是数据库迁移版本化、幂等检查、将部署和验证分阶段,重试只覆盖可安全重试步骤。
三十八、商业场景三:旧Build覆盖新版本
Build 100等待审批,101先发布;稍后100获批并发布旧Digest。使用milestone、环境Lock、展示Commit/Digest和审批策略,拒绝落后Build继续生产阶段。
三十九、面试标准回答
Jenkins Pipeline CPS是什么
Jenkins把大部分Pipeline Groovy转换为Continuation-Passing Style,把“下一步做什么”和变量状态持久化,使sh、input、node等异步Step可暂停,并在Controller重启后恢复。状态需要可序列化,所以Stream、Iterator等对象不能跨暂停点长期持有。
@NonCPS什么时候用
用于短小、纯内存、无Pipeline Step的普通Groovy计算。它不经过CPS转换,不能在内部调用sh、node、echo、input等Step,也不能从方法中间恢复。序列化错误应先简化对象,不应给整段流水线盲目加@NonCPS。
input为什么可能占Executor
如果在已分配顶层或Stage Agent的范围内等待input,可能长期持有Executor和Workspace。更好的结构是顶层agent none,审批独立Stage并加timeout,批准后才进入需要生产Agent的Deploy Stage。
timeout和retry能保证部署安全吗
不能。timeout中断Pipeline Step但不会撤销已提交事务、消息和远程副作用;retry重新执行闭包,不知道上一次执行到哪。部署脚本必须幂等,重试只覆盖安全步骤,并保存旧版本与显式回滚验证。
parallel为什么会互相影响
并行分支会同时消耗Executor、CPU、IO和外部测试资源;在同一Workspace写文件会覆盖,即使目录分开也可能共享Maven缓存、Docker daemon、固定端口和数据库。应使用独立Agent/目录、唯一资源名和环境清理。
Pipeline如何保证一次构建多环境发布
固定Commit只构建一次Jar/镜像,推送制品仓库并记录Digest;测试、预发和生产都部署同一Digest。审批只决定是否推广,不重新编译。发布前后记录版本、健康和指标,失败显式回滚并验证旧版本。
四十、学习验收
- 画出Declarative解析、CPS转换、Flow Node、暂停与恢复。
- 制造一个NotSerializableException并正确修复。
- 写一个合法@NonCPS纯函数,再演示为什么不能调用Pipeline Step。
- 比较顶层agent any与agent none的Executor占用。
- 让input等待并证明审批Stage不占生产Agent。
- 分别验证timeout对Shell、远程部署和外部副作用的边界。
- 设计只重试幂等步骤的Pipeline。
- 使用disableConcurrentBuilds、milestone和lock防止旧Build覆盖。
- 执行parallel/matrix并计算Executor与测试资源放大。
- 证明Groovy双引号插值可能泄露Secret,改为安全作用域。
- 构建一次镜像并让测试/生产推广同一Digest。
- 在测试Controller重启后验证sleep、input、sh和stash恢复。
