Skip to content

Jenkins Pipeline CPS、并发与可靠发布全过程

Pipeline不是“把Shell命令写进Jenkinsfile”。Jenkins需要让一条可能运行数小时、跨多个Agent、等待人工审批并经历Controller重启的流程仍能继续,因此它会对Groovy脚本进行CPS转换,在可暂停点保存Continuation和执行状态。不了解这个模型,就会遇到不可序列化对象、@NonCPS误用、input长期占Executor和并发Build覆盖生产环境。

学习目标

  1. 区分Declarative Pipeline、Scripted Pipeline和普通Groovy。
  2. 解释CPS转换、Continuation、暂停点和持久化状态。
  3. 说明Controller重启后哪些Step可恢复、哪些外部操作不能自动回滚。
  4. 区分agent、node、Executor、Workspace和Docker/Kubernetes动态Agent。
  5. 正确使用 @NonCPS,避免非序列化对象跨暂停点。
  6. 设计Stage、post、timeout、retry、input、milestone、lock和并发控制。
  7. 解释parallel/matrix的资源与Workspace隔离。
  8. 安全使用Credentials,避免Groovy插值和日志泄密。
  9. 实现一次构建、多环境推广同一镜像Digest。
  10. 按Queue、Step、Agent、Workspace、制品和部署阶段排查失败。

一、Pipeline解决什么

text
个人记忆中的发布步骤
→ Jenkinsfile进入Git
→ 代码评审
→ Jenkins按阶段执行
→ 保存日志、测试、制品与审批
→ 失败定位和受控回滚

Pipeline的核心价值是可版本化、可重复、可审计,不是单纯减少点击次数。

二、Declarative与Scripted

Declarative

groovy
pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                sh 'mvn clean package'
            }
        }
    }
}

提供固定结构、语法校验、environmentoptionspostmatrix等模型,适合大多数商业流水线。

Scripted

groovy
node('linux') {
    stage('Build') {
        sh 'mvn clean package'
    }
}

更接近Groovy控制流,灵活但更容易把复杂业务、不可序列化对象和动态逻辑塞进Jenkinsfile。

Declarative底层仍会进入Pipeline执行引擎,不等于它绕过CPS。可在Declarative的 script {} 中使用Scripted逻辑,但应保持小而明确。

三、Jenkinsfile从读取到执行

mermaid
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。

普通理解:

text
执行A
→ 在线程栈里记住A之后执行B
→ B之后执行C

CPS理解:

text
执行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重启为什么能继续

mermaid
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内部临时对象。

错误思路:

groovy
def stream = new FileInputStream('large.txt')
sh 'echo pause'
// 恢复时stream无法可靠序列化

应在普通Shell工具中处理文件,或在一次纯计算范围内创建和释放对象,不让它跨Pipeline Step。

九、@NonCPS是什么

groovy
@NonCPS
String summarize(List<Map> rows) {
    rows.collect { it.name }.sort().join(',')
}

@NonCPS让该方法按普通Groovy/JVM方式执行,不经过CPS转换,适合短小、纯内存、无Pipeline Step的计算。

限制:

  • 不能在里面调用 shechonodeinput等Pipeline Step。
  • 输入输出应简单、可序列化。
  • 不能做长时间阻塞IO。
  • 方法执行中Controller重启无法从中间Continuation恢复。

不要看到序列化错误就给大段代码加 @NonCPS。应先减少复杂对象和把工作移到脚本/工具中。

十、CPS与普通Groovy调用边界

CPS转换代码调用普通Java/Groovy方法通常可行;普通非CPS代码反过来调用CPS Pipeline Step会产生方法不匹配、异常行为或难以理解的结果。排序闭包、第三方库回调等历史上也可能暴露CPS方法不匹配问题,具体随Pipeline插件修复变化。

原则:Jenkinsfile负责流程编排,复杂计算进入经过测试的库或外部程序。

十一、agent与node什么时候占Executor

groovy
pipeline {
    agent any
    stages { ... }
}

顶层 agent any通常让流水线长时间占用一个Agent/Executor,即使某些阶段只是等待审批。

更节省资源:

groovy
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内部:

groovy
stage('Deploy') {
    steps {
        input '批准生产发布?'
        sh './deploy.sh'
    }
}

可能长期占着Workspace和Executor,具体取决于Declarative结构和Agent作用域。

更清晰的设计是单独Approval Stage且顶层 agent none,审批完成后再进入需要生产Agent的Deploy Stage:

groovy
stage('Approval') {
    steps {
        timeout(time: 30, unit: 'MINUTES') {
            input message: '批准生产发布?'
        }
    }
}

stage('Deploy') {
    agent { label 'prod-deployer' }
    steps { sh './deploy.sh' }
}

还要限制谁能批准,记录审批人,并防止旧Build晚于新Build发布。

十三、Stage如何拆分

推荐按可观察的质量/交付边界:

text
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执行什么

groovy
post {
    always {
        junit 'target/surefire-reports/*.xml'
        cleanWs()
    }
    success {
        echo 'success'
    }
    failure {
        echo 'failure'
    }
    aborted {
        echo 'aborted'
    }
}

post适合测试报告、通知、清理和事故证据保存。清理前先归档需要的日志、Dump和部署结果,不能在failure第一步就删除现场。

cleanWs等Step来自插件,目标实例是否安装需验证。

十五、timeout的真实作用

groovy
timeout(time: 15, unit: 'MINUTES') {
    sh './integration-test.sh'
}

超时会中断Pipeline Step并尝试终止相关任务,但外部系统副作用可能继续:

  • 已提交数据库事务不会回滚。
  • 已发MQ消息不会撤回。
  • 远程SSH脚本可能派生后台进程。
  • Kubernetes rollout可能继续。

脚本必须响应终止信号、设置自己的网络超时,并提供幂等状态检查。

十六、retry为什么危险

groovy
retry(3) {
    sh './deploy.sh'
}

它会重新执行闭包,不理解上一次执行到哪里。若deploy已创建资源、执行DDL或切流量,重试可能重复副作用。

适合重试:

  • 明确幂等的拉取/查询。
  • 临时网络失败且操作可验证。
  • 动态Agent Pod丢失后的完整干净重建,需按插件能力设计。

不应盲目重试:

  • 数据库不可逆变更。
  • 支付、通知。
  • 无幂等的部署脚本。
  • 已部分执行的生产切流。

十七、catchError与结果

groovy
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
    sh './quality-scan.sh'
}

它可以让流程继续并设置Build/Stage结果,但不能为了“流水线绿色”吞掉质量门。UNSTABLE是否允许部署必须显式判断。

直接使用 sh(returnStatus: true) 后忽略非0状态,是高频假成功来源。

十八、disableConcurrentBuilds

groovy
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插件可表达:

groovy
lock(resource: 'production-order-service') {
    sh './deploy-prod.sh'
}

用于保护生产环境、共享测试数据库、硬件设备等。它不能代替应用分布式锁,也不能自动回滚部署。锁范围应尽量只覆盖真正互斥的阶段,避免长时间占锁做编译。

二十一、parallel并行

groovy
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或每分支独立容器。

groovy
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适合测试组合:

groovy
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

groovy
stash name: 'jar', includes: 'target/*.jar'
unstash 'jar'

适合同一Pipeline跨Node传相对小的构建输出。大量文件会占Controller/Artifact Manager CPU、网络和存储,应把正式大制品推到Nexus/Registry。

重启Build或从Stage恢复时是否保留stash,可通过Declarative选项如 preserveStashes按版本能力配置。它不是永久制品保留策略。

二十五、参数必须校验

groovy
parameters {
    choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'prod'])
    string(name: 'IMAGE_DIGEST', defaultValue: '', trim: true)
}

不要把任意字符串直接拼Shell:

groovy
sh "kubectl -n ${params.ENVIRONMENT} ..."

参数可能造成命令注入或部署错误环境。应使用固定choice、格式白名单、受控映射和脚本参数数组/安全引用。

二十六、环境变量边界

environment适合普通配置和构建元数据:

groovy
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插值如何泄密

危险写法:

groovy
withCredentials([string(credentialsId: 'api-token', variable: 'TOKEN')]) {
    sh "curl -H 'Authorization: Bearer ${TOKEN}' https://example"
}

Groovy在Controller侧先插值,Secret可能进入Step参数、进程命令或日志元数据。

更安全方向是使用单引号Groovy字符串,让Shell从环境变量展开,并关闭不必要的命令回显:

groovy
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。

三十、一次构建、多环境推广

mermaid
flowchart TD
    A["固定Commit"] --> B["编译测试"]
    B --> C["构建一次镜像"]
    C --> D["推送Registry并记录Digest"]
    D --> E["测试环境部署同一Digest"]
    E --> F["集成测试与扫描"]
    F --> G["审批"]
    G --> H["生产部署同一Digest"]

不要在测试和生产Stage分别 mvn packagedocker build。重新构建可能因依赖、时间戳、基础镜像Tag和环境产生不同字节。

三十一、最小JDK 8构建Demo

groovy
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等实际插件/工具,不能未经验证直接用于生产:

groovy
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。

三十三、发布失败不等于自动回滚

text
kubectl rollout status失败

只能说明rollout未达到预期,不代表旧版本已恢复。Pipeline必须显式:

  1. 保存变更前版本/Digest。
  2. 识别失败阶段。
  3. 判断数据库与消息格式是否可回滚。
  4. 执行平台回滚或停止放量。
  5. 验证旧版本健康与业务指标。
  6. 保存事故证据。

详细商业回滚见 Jenkins商业发布与回滚

三十四、Controller重启实验

在测试环境运行包含 sleepinput和长 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。审批只决定是否推广,不重新编译。发布前后记录版本、健康和指标,失败显式回滚并验证旧版本。

四十、学习验收

  1. 画出Declarative解析、CPS转换、Flow Node、暂停与恢复。
  2. 制造一个NotSerializableException并正确修复。
  3. 写一个合法@NonCPS纯函数,再演示为什么不能调用Pipeline Step。
  4. 比较顶层agent any与agent none的Executor占用。
  5. 让input等待并证明审批Stage不占生产Agent。
  6. 分别验证timeout对Shell、远程部署和外部副作用的边界。
  7. 设计只重试幂等步骤的Pipeline。
  8. 使用disableConcurrentBuilds、milestone和lock防止旧Build覆盖。
  9. 执行parallel/matrix并计算Executor与测试资源放大。
  10. 证明Groovy双引号插值可能泄露Secret,改为安全作用域。
  11. 构建一次镜像并让测试/生产推广同一Digest。
  12. 在测试Controller重启后验证sleep、input、sh和stash恢复。

关联知识点