标签: Pipeline

  • 深入 Jenkins 陷阱排查:共享库全局变量滥用引发的并行构建串量与生产发布雪崩实战

    近期处理了一起极其荒谬且破坏力极强的 P0 级生产事故。核心业务团队在做 Monorepo(单体代码仓库)微服务大版本发布时,15 个微服务通过 Jenkins Pipeline parallel 并发构建部署,结果上线后核心链路全面崩溃,Nginx Ingress 瞬间爆出海量 502。

    排查发现,根本不是 K8s 调度问题或 Harbor 镜像源拉取故障,而是 订单服务(Order)的 Pod 里,居然跑着用户服务(User)的镜像。最终定位的罪魁祸首:Jenkins Shared Library(共享库)中滥用了脚本级全局变量,在 parallel 并发执行下触发了典型的线程安全问题,导致变量相互覆盖(串量)。

    一句话结论:在 Jenkins vars/ 共享库脚本中定义方法外部的全局变量,其实例在同一个 Pipeline 运行期的 parallel 阶段是完全共享的。并发读写这些变量必然导致数据污染。修复方案:必须将所有状态变量严格限定在 call() 方法内部,强制实现共享库的无状态化。

    事故现场与离谱的报错

    排查过程中,我们查看了 K8s 集群的实时状况。订单服务(Order)的 Pod 陷入了 CrashLoopBackOff:

    $ kubectl logs order-deployment-5f6b9c9d-8x2a -n prod-ns
    Error: Invalid or missing Spring Boot configuration. 
    Application 'user-service' failed to start.
    

    日志显示这个 Pod 内部启动的居然是 user-service。去检视 Jenkins 构建日志,发现了令人窒息的一幕:

    [Pipeline] // 并行分支: build-order-service
    [Pipeline] sh (docker build)
    + docker build -t harbor.corp.com/prod/order-service:v1.2.3-45 .
    ...
    [Pipeline] // 并行分支: build-user-service
    [Pipeline] sh (docker build)
    + docker build -t harbor.corp.com/prod/user-service:v1.2.3-45 .
    ...
    [Pipeline] sh (helm upgrade) # 此时 order 分支的 helm 部署命令
    + helm upgrade order-service ./chart --set image=harbor.corp.com/prod/user-service:v1.2.3-45
    

    注意看最后一行,order-service 的分支在执行 Helm 部署时,注入的 Image 参数竟然变成了 user-service 的镜像!

    原理剖析:全局变量是万恶之源

    去翻看负责 CI/CD 脚本的开发人员写的 Jenkins Shared Library 代码,简直触目惊心:

    // 错误代码示例:vars/standardBuilder.groovy
    def currentImage = ""       // 【致命错误】脚本级全局变量
    def deployYaml = ""         // 【致命错误】脚本级全局变量
    
    def call(Map config) {
        currentImage = "${config.imageName}:${config.tag}"
        deployYaml = "${config.appName}-deployment.yaml"
    
        // 模拟耗时构建操作
        sh "docker build -t ${currentImage} ."
        sh "docker push ${currentImage}"
    
        // 部署阶段
        sh "helm upgrade ${config.appName} ./chart --set image=${currentImage}"
    }
    

    而在他们的 Jenkinsfile 中,调用方式是这样的:

    parallel(
        'order': { standardBuilder(appName: 'order', imageName: 'order-service', tag: 'v1.2') },
        'user':  { standardBuilder(appName: 'user',  imageName: 'user-service',  tag: 'v1.2') }
    )
    

    为什么这绝对不可原谅?

    很多刚接触 Jenkins Pipeline 的人把它当成简单的 Bash 脚本写,缺乏基本的“防御性编程”思维。在 Jenkins CPS(Continuation Passing Style)引擎的底层实现中,vars/standardBuilder.groovy 并不是每次调用都重新实例化的纯函数。 整个文件在编译后会成为继承自 CpsScript 的一个类。在同一个 Pipeline Run 中,vars/ 下的单例对象是被缓存的,文件顶层的 def currentImage 变成了这个单例对象的一个实例属性(Field)。

    当 parallel 启动多个分支(在 Jenkins 中是底层的绿色线程 Green Threads)并发调用 standardBuilder() 时,执行时序就变成了:

    1. 分支 A (Order):写入 currentImage = "order-service:v1.2"

    2. 分支 B (User):写入 currentImage = "user-service:v1.2" (此时实例属性被覆盖)

    3. 分支 A (Order):执行完 docker push,准备执行 helm upgrade,读取 currentImage,读到了 "user-service:v1.2"。

    这不仅导致了严重的生产环境发版错乱,如果是带有危险操作(如 DB 变更脚本)的流水线,这种串量能直接把数据库干废。

    进阶踩坑:CPS 序列化风暴与 OOM

    除了上述的并发污染问题,在脚本级定义变量还会引发另一个经典灾难:NotSerializableException 报错与 Jenkins Master 内存泄漏。

    如果有人在这个全局作用域放了一些非序列化对象(比如 JSON 解析结果、HTTP Client 实例):

    import groovy.json.JsonSlurper
    
    def parsedConfig = null // 脚本级变量
    
    def call() {
        def slurper = new JsonSlurper() // JsonSlurper 返回的是非序列化的 LazyMap
        parsedConfig = slurper.parseText(readFile('config.json'))
        sh "echo 'Building...'"
    }
    

    Jenkins 的 CPS 引擎为了保证在 Master 节点重启后能够恢复 Pipeline 的执行状态,会在遇到每一个 Pipeline Steps(如 sh, echo, sleep)时,暂停当前线程,并将整个 CpsScript 对象及其绑定的变量树完整序列化并落盘(存储到 build.xml 中)。 如果 parsedConfig 是一个不可序列化的对象,流水线会当场 Crash。如果是一个体积庞大的 Map,并发数一高,每次执行 sh 都要把几 MB 的数据做深拷贝和 XML 序列化,会导致 Jenkins Master 的 CPU 瞬间打满,触发频繁的 GC 停顿甚至 OOM(OutOfMemoryError)。

    最佳实践与防御性加固

    对于重度依赖 Jenkins 驱动核心 CI/CD 的团队,必须在架构层面掐断这种低级失误。

    1. 强制无状态化共享库(Pure Functions)

    所有共享库 vars/ 下的方法,必须是纯函数,严禁在 call() 之外定义任何非 final 变量。 修正后的代码:

    // 正确代码:vars/standardBuilder.groovy
    def call(Map config) {
        // 变量作用域严格限制在方法内部(闭包内本地变量),每次调用独立分配栈空间
        def currentImage = "${config.imageName}:${config.tag}"
        def deployYaml = "${config.appName}-deployment.yaml"
    
        sh "docker build -t ${currentImage} ."
        sh "docker push ${currentImage}"
        sh "helm upgrade ${config.appName} ./chart --set image=${currentImage}"
    }
    

    2. IO 操作与数据解析严格剥离(@NonCPS)

    如果必须进行复杂的 JSON 解析或遍历操作,必须封装为独立的函数并打上 @NonCPS 注解,确保这部分逻辑由 JVM 原生线程直接执行,不参与 CPS 的序列化。 注意: @NonCPS 方法内部绝对不能调用任何 Jenkins Pipeline 原生步骤(如 sh, readFile)。

    @NonCPS
    def parseMyConfig(String jsonStr) {
        // 纯 Groovy/Java 原生执行,无需考虑序列化问题
        return new groovy.json.JsonSlurperClassic().parseText(jsonStr) 
    }
    
    def call() {
        def jsonContent = readFile('config.json')
        def configMap = parseMyConfig(jsonContent)
        // 继续后续流程...
    }
    

    3. K8s Dynamic Agent 的工作空间硬隔离

    除了内存里的变量,物理层面的 Workspace 同样容易发生并发冲突。在 parallel 执行阶段,尽量确保每个并行任务申请独立的 K8s Pod (Agent),或者显式使用 dir 指令分配子目录,避免同一节点上的 I/O 踩踏:

    parallel(
        'order': { 
            node('k8s-agent') { // 分配独立的 Pod
                checkout scm
                standardBuilder(...)
            }
        }
    )
    

    同类问题速查(排查清单)

    1. Pipeline 并行分支行为异常/串数据:立刻检查 vars/ 目录下的共享库文件,全局搜索是否存在未被包含在方法内的 def、String、Map、List 定义,或误用了 @Field。

    2. 构建日志报 java.io.NotSerializableException:检查脚本中是否将 Matcher(正则匹配对象)、JsonSlurper 返回结果、Connection 等非序列化对象保留在了方法外层,或跨越了 sh/echo 等 Pipeline Step 的边界。

    3. Jenkins Master CPU 极高或 Full GC 频发:检查 Jenkins 系统管理中的线程转储(Thread Dump),搜索 CpsThread 和 xstream 相关的堆栈,大概率是 Pipeline 定义了超大数组/Map并在执行 Steps 时引发了海量序列化开销。

    4. 并发构建文件损坏/拉错代码:检查 parallel 代码块,是否多个并发逻辑复用了同一个 node 并且没有使用 dir('sub-dir') 隔离 Workspace。

  • 深入 Jenkins 陷阱排查:Shared Library 隐式类加载泄漏引发的 Metaspace OOM 与 K8S Agent 孤儿 Pod 实战

    Jenkins Controller 频繁发生 Metaspace OOM 且伴随 K8s 出现大量孤儿 Agent Pod,根因往往是 Shared Library 中非标准化 Groovy 脚本引发 GroovyClassLoader 内存泄漏,导致 Remoting 线程卡死。修复方案:精简共享库闭包逻辑,限制 -XX:MaxMetaspaceSize=2G,并在 K8s Pod Template 中强制配置 activeDeadlineSeconds 进行防御性兜底。

    近期在排查一个日均承载数万次 Pipeline 构建的核心 Jenkins 集群(版本 2.440.1,Kubernetes Plugin 4136.v40097782b,JDK 17)时,遇到一个极其顽固的级联故障。

    监控大盘显示,Jenkins Controller 的 P99 响应时间周期性飙升至 30 秒以上,Load Average 突破 50。更棘手的是,K8s 集群中遗留了数百个状态为 Running 的 JNLP Agent Pod,但 Jenkins 控制台却显示这些节点处于 Offline 状态。典型的“脑裂”现场。

    现场取证与链路溯源

    登录 Controller 宿主机,首先抓取进程指标。通过 jstat 观察 JVM 内存分布,直接锁定了肇事者:

    $ jstat -gcutil $(pgrep -f jenkins.war) 1000 5
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
      0.00   0.00   2.34  89.45  99.99  98.12   4123  124.321   145  432.145  556.466
      0.00   0.00   1.21  89.45  99.99  98.12   4123  124.321   146  435.210  559.531
    

    注意 M(Metaspace)这一列,使用率死死卡在 99.99%,FGC(Full GC)次数在短时间内疯狂上涨,且单次 Full GC 耗时极长。Controller 已经陷入了 Full GC 死亡螺旋。

    查看 Jenkins 的 catalina.out 或系统日志,证实了推断: java.lang.OutOfMemoryError: Metaspace

    为什么 Metaspace OOM 会导致 K8s 孤儿 Pod 泛滥?

    Jenkins 依赖 TCP/WebSocket 上的 Remoting 协议与 K8s Agent 里的 agent.jar 通信。当 Controller 陷入持续的 Full GC 停顿时,所有的工作线程(包括负责心跳检测的 Channel Reader/Writer 线程)全部被挂起。

    1. JNLP 心跳超时,Controller 单方面标记 Agent 为 Offline。

    2. 由于 Controller 处于假死状态,Kubernetes Plugin 无法及时向 K8s APIServer 发出 DELETE Pod 的请求。

    3. K8s 侧的 Pod 因为没有设置超时机制,其内部的 agent.jar 陷入无限等待,最终导致集群计算资源被僵尸 Pod 占满,引发调度雪崩。

    为什么 Shared Library 的高频调用会引发 Metaspace 膨胀?

    Jenkins Pipeline 的底层核心是 Groovy CPS(Continuation Passing Style)引擎。在处理 Shared Library 时,Jenkins 使用 CpsGroovyShell 将 Groovy 代码编译为 Java 字节码。

    通常情况下,JVM 会在类加载器失去所有引用时回收其加载的类(Class Unloading)。但在 Jenkins 的复杂环境里,容易踩到以下暗坑:

    1. AST 转换与动态生成类:如果在 Shared Library 中重度使用闭包(Closures)、动态方法拦截(methodMissing)或隐式类型转换,Groovy 编译器会在运行时生成大量的匿名内部类(例如 CallSiteArray)。

    2. CPS 序列化链条泄漏:Jenkins 会在每次 Pipeline 暂停(如执行 sh、sleep 等 block 步骤)时,将整个执行上下文序列化到磁盘(program.dat)。如果你的 Groovy 脚本在全局作用域持有大对象引用,或者通过 @Field 定义了静态缓存,不仅会导致序列化文件暴增,还会导致对应的 GroovyClassLoader 实例被 CPS 引擎死死抓住,无法被 GC Roots 释放。

    每一次新的 Pipeline 构建,都会触发新的类加载。随着构建量增加,Metaspace 被这些永远无法卸载的“一次性”类塞满,最终引爆 OOM。

    架构级防御与修复方案

    运维和架构的职责不仅是找到 Bug,更要在系统层面构建防御纵深。面对这类问题,需要从 JVM、配置管理(JCasC)和流水线规范三个维度进行整改。

    1. JVM 边界隔离与调优

    绝对不能让 JVM 的 Metaspace 无限增长(JDK 8+ 默认 MaxMetaspaceSize 是无上限的)。必须给 Controller 划定严格的物理边界,并开启 G1 的类卸载特性。

    修改 Jenkins 启动参数(针对 JDK 17):

    JAVA_OPTS="-server \
      -Xms8G -Xmx8G \
      -XX:MetaspaceSize=1024m \
      -XX:MaxMetaspaceSize=2048m \
      -XX:+UseG1GC \
      -XX:MaxGCPauseMillis=200 \
      -XX:+UnlockExperimentalVMOptions \
      -XX:+ParallelRefProcEnabled \
      -Xlog:gc*=info,gc+metaspace=trace:file=/var/log/jenkins/gc.log:time,uptime,level,tags:filecount=10,filesize=50M \
      -Dgroovy.use.classvalue=true" 
    

    注:groovy.use.classvalue=true 是针对 Groovy 2.4+ 的一个关键开关,能有效缓解内存泄漏,但在某些旧版 Jenkins 插件中可能引发兼容性问题,需在预发环境验证。

    2. K8s Pod Template 兜底机制(JCasC 防御性配置)

    绝对不要信任外部系统的状态同步。在通过 JCasC 定义 Kubernetes Pod Template 时,必须加入 activeDeadlineSeconds 参数。这样即使 Jenkins Controller 完全宕机,K8s 也会在超时后由 Kubelet 亲手超度这些孤儿 Pod。

    核心 jenkins.yaml JCasC 片段提取:

    jenkins:
      clouds:
        - kubernetes:
            name: "kubernetes"
            serverUrl: "https://kubernetes.default"
            namespace: "jenkins-agents"
            jenkinsUrl: "http://jenkins-master.jenkins.svc.cluster.local:8080"
            templates:
              - name: "golang-builder"
                label: "golang"
                nodeUsageMode: EXCLUSIVE
                # 核心防御1:无论构建成功与否,强制清理 Pod
                podRetention: "never"
                # 核心防御2:Pod 在 K8s 侧的绝对存活上限(例如 7200 秒/2小时)
                activeDeadlineSeconds: 7200
                # 核心防御3:如果 Controller 失去心跳,Agent 主动退出
                idleMinutes: 10
                containers:
                  - name: "jnlp"
                    image: "jenkins/inbound-agent:3192.v713e3b_039fb_e-4"
                    args: "^${computer.jnlpmac} ^${computer.name}"
                    envVars:
                      - envVar:
                          key: "JENKINS_AGENT_WORKDIR"
                          value: "/home/jenkins/agent"
    

    3. Pipeline 代码降维打击

    Jenkins Pipeline 的定位是“调度器”,而不是“计算引擎”。 强制推行规范:严禁在 Shared Library 中编写复杂的纯 Groovy 数据处理逻辑(如大规模 JSON 解析、复杂的集合遍历、正则轮询)。 反模式:在 Groovy 中使用 JsonSlurper 解析 10MB 的依赖分析报告。 最佳实践:使用 sh 步骤调用预置在容器镜像里的 Python/Go 脚本进行解析,仅通过标准输出(stdout)将极简的结果(如 Exit Code 或一个轻量级状态字符串)回传给 Pipeline 控制流。

    常见问题 (Q&A)

    Q1:K8s 动态 Agent Pod 经常在拉起时握手超时(JNLP connection timeout),但 Pod 本身是 Running 状态,如何排查? 通常有两个原因:一是 K8s APIServer 到 Jenkins Master 的网络存在高延迟或 Ingress 代理配置了不合理的 Timeout;二是 Controller 侧的 TCP Backlog 已满。建议在 JCasC 中开启 WebSocket 协议(websocket: true),替代传统的基于单独 TCP 端口的 JNLP 协议,复用 HTTP(S) 链路可穿透绝大多数网络屏障,并在 Ingress 侧配置 WebSocket 长连接支持。

    Q2:在使用 Shared Library 时,加上 @NonCPS 注解能解决内存泄漏吗? 不能直接解决 Metaspace 泄漏,但能解决序列化报错(NotSerializableException)。@NonCPS 告诉 Jenkins 引擎不要对该方法进行 CPS 转换和挂起状态记录。但必须极其小心:绝不能在 @NonCPS 方法内部调用任何 CPS 步骤(如 sh, echo, build),否则会破坏 CPS 状态机,导致 Pipeline 莫名其妙地卡死(Hang)在执行节点,且没有任何报错日志。

    Q3:通过 JCasC 动态重新加载配置(Reload Configuration)时,会中断正在运行的 K8s 动态 Agent 吗? 不会直接中断。JCasC 的 Reload 过程在内存中重建了 Cloud 和 PodTemplate 对象。已经调度并在运行中的 Pod 会维持与 Master 的 Remoting 连接继续执行完毕。但如果此时修改了 JCasC 中的 Jenkins URL 或 WebSocket 设置,依赖这些配置进行重连的离线 Agent 恢复机制可能会失效。

  • 深入 Jenkins 陷阱排查:滥用 CPS 序列化引发的 Master 停顿与 K8s 动态 Agent 调度雪崩实战

    近期排查了一起极其惨烈的 CI/CD 生产事故。故障现象非常直观:Jenkins Master 节点 CPU 长期飙升至 100%,UI 疯狂报 502 Bad Gateway;与此同时,后端的 K8s 构建集群遭到“洪泛攻击”,短时间内被调度了数千个 Pending 状态的 JNLP Agent Pod,直接耗尽了 Node 的 IP 资源池,甚至引发了 K8s API Server 的短暂限流。

    直接抛出结论:这是一起由于在 Jenkins Pipeline 中滥用 Groovy 原生方法处理海量数据,导致 CPS(Continuation Passing Style)状态机序列化引发 JVM Full GC 风暴,进而拖垮 JNLP 心跳机制,最终触发 K8s 动态 Agent 疯狂重试调度的连环雪崩。

    一句话避坑指南:Jenkins Pipeline 是流程控制器,绝不是数据处理引擎。永远不要在标准的 Pipeline 步骤中用 JsonSlurper 解析大体积 JSON 文件,请把脏活累活交给 sh + jq 或者 Python 脚本,或者将其封装在严格隔离的 @NonCPS 共享库函数中。

    故障现场:Master 假死与 K8s 的哀嚎

    排查过程中,告警系统的 99 线延迟指标已经没法看了。登录 Jenkins Master 宿主机,Load Average 直接干到了 80+。老规矩,先看 JVM 状态:

    # 抓取 Jenkins 进程 GC 状态
    jstat -gcutil $(pgrep -f jenkins.war) 1000
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
      0.00   0.00 100.00 100.00  98.12  95.43   4520  120.450  856  1542.120 1662.570
    

    老年代(Old Gen)死死卡在 100%,FGC 次数狂飙,典型的内存泄漏或瞬时巨型对象分配。去捞 Jenkins 系统日志,满屏的 JNLP 断连报错:

    WARNING: Ping thread for channel JNLP4-connect connection from 10.244.x.x:54321 is not responding. Terminating.
    java.util.concurrent.TimeoutException: Ping started at 168xxxxxx hasn't completed by 168xxxxxx
        at hudson.remoting.PingThread.ping(PingThread.java:132)
    

    再切到 K8s 集群,看一眼动态 Agent 的惨状:

    kubectl get pods -n jenkins-agents | awk '{print $3}' | sort | uniq -c
        15 Running
       854 Pending
       230 Terminating
    

    将近一千个 Pod 堆积在调度队列里。由于前一个 Agent 意外断开连接,Jenkins 的 K8s Plugin 认为构建任务尚未结束且 Agent 丢失,于是疯狂向 API Server 发起重新创建 Pod 的请求。

    愚蠢的根因:让 Jenkins 扛下所有

    把 Jenkins Master 堆内存 Dump 下来用 MAT 分析,发现占据 80% 内存的竟然是 java.util.LinkedHashMap 的海量实例,且引用链全部指向 org.jenkinsci.plugins.workflow.cps.CpsThreadGroup。

    去翻最近提交的 Jenkinsfile,看到一段令人窒息的“神仙操作”。某个业务研发为了在 CI 阶段进行微服务全量依赖检查,直接用 HTTP 客户端拉取了一个高达 15MB 的 SBOM(软件物料清单)JSON 文件,然后直接用 Groovy 代码进行解析和循环过滤:

    // 典型的作死代码片段
    def response = sh(script: "curl -s http://internal-api/sbom/full.json", returnStdout: true)
    def jsonSlurper = new groovy.json.JsonSlurperClassic()
    def result = jsonSlurper.parseText(response) // 15MB 的 JSON 字符串!
    
    result.dependencies.each { dep ->
        if (dep.vulnerable) {
            // ...复杂的业务逻辑
        }
    }
    

    为什么这段代码不可原谅?

    很多刚接触 DevOps 的人根本不理解 Jenkins Pipeline 的底层原理。Jenkins 为了实现“即使 Master 重启,Pipeline 也能从中断处恢复”的特性,魔改了 Groovy 的执行引擎,这就是 CPS 转换。

    在 CPS 模式下,Pipeline 脚本中定义的每一个变量、每一个闭包状态,都会在每一步(Step)执行完毕后,被强行序列化成 XML 或二进制数据,写入到 builds//program.dat 文件中。

    当你用 JsonSlurper 把 15MB 的 JSON 转换成嵌套的 Map/List 对象树,并在 .each 循环中迭代时,CPS 引擎会在每一次循环都尝试将这个庞大的对象树克隆、序列化并持久化。 这就意味着,Jenkins Master 的 CPU 正在疯狂地将几百万个小对象进行序列化操作,JVM 堆内存瞬间被撑爆。

    雪崩链路复盘

    这套组合拳最终引发了整个 CI/CD 体系的瘫痪,其技术逻辑链路如下:

    1. CPS 序列化阻塞:巨型 Map 对象导致 program.dat 写入耗时极长,触发 JVM Full GC(STW)。

    2. PingThread 假死:由于 JVM 挂起长达数分钟,Jenkins Remoting 层的 PingThread 无法按时响应 JNLP Agent 的心跳。

    3. Agent 误判掉线:Master 认为 K8s Pod (Agent) 已死亡,主动切断 TCP 连接,并标记 Node 为 Offline。

    4. 调度死循环:Pipeline 并未结束,K8s 动态调度插件(Kubernetes Plugin)根据重试策略,立刻向 K8s 申请新的 Pod。

    5. K8s 资源耗尽:由于 Master 处于濒死状态,新启动的 Pod 即使连上 Master 也会很快再次因心跳超时被踢掉。无限循环,直到 K8s Namespace 的 Quota 爆满或 Node IP 耗尽。

    修复与防御性改造方案

    要解决这个问题,不仅要改掉愚蠢的代码,还要在架构层面进行防御性配置。

    1. 斩断 CPS 序列化链条(推荐做法)

    最符合 Unix 哲学的做法是:把数据处理交给专门的工具。将 JSON 解析逻辑移出 Jenkins JVM,放到 Agent 的容器里执行。

    // 修复后的代码:使用 JQ 在 Shell 层面处理
    def vulnerableDeps = sh(
        script: "curl -s http://internal-api/sbom/full.json | jq -r '.dependencies[] | select(.vulnerable==true) | .name'",
        returnStdout: true
    ).trim()
    

    2. 隔离 Groovy 引擎(妥协做法)

    如果非要在 Jenkinsfile 里写复杂逻辑,必须把这段代码抽离到共享库(Shared Library)中,并加上 @NonCPS 注解。这会告诉 Jenkins:“这段函数不需要持久化状态,当成普通 Java 代码一口气执行完即可。”

    @NonCPS
    def parseLargeJson(String jsonStr) {
        // 这里面的变量不会被持久化到 program.dat
        def jsonSlurper = new groovy.json.JsonSlurperClassic()
        return jsonSlurper.parseText(jsonStr) 
    }
    

    注意:@NonCPS 方法内部严禁调用任何 Jenkins Pipeline 原生 Step(如 sh, echo),否则直接报错。

    3. 限制 K8s 插件的无脑调度(防雪崩配置)

    在 JCasC (Jenkins Configuration as Code) 或者 K8s Plugin 配置中,必须强制设定并发上限,防止雪崩。

    jenkins:
      clouds:
        - kubernetes:
            name: "k8s-cluster"
            containerCapStr: "50" # 绝对不能留空,强制限制该集群最大并发 Pod 数
            podRetention: "never" 
    

    4. 优化 JNLP 心跳超时参数

    为了在网络抖动或短时 GC 时不至于轻易踢掉 Agent,可以在 Jenkins Master 启动参数中适当放宽心跳容忍度(视集群情况而定,作为兜底策略):

    -Dhudson.remoting.PingThread.pingInterval=30000 
    -Dhudson.remoting.PingThread.pingTimeout=120000
    

    排查清单 (Troubleshooting Checklist)

    1. Jenkins Master CPU/内存异常飙升:优先检查正在执行的 Pipeline 是否读取了超大文件、是否在使用循环处理复杂对象树。排查目标文件:$JENKINS_HOME/jobs//builds//program.dat 大小是否异常。

    2. K8s Pod 异常堆积 (Pending/Terminating):检查 K8s Plugin 的 containerCap 限制是否生效,确认是否有死循环 Pipeline 不断触发 Agent 重建。

    3. JNLP 频繁断连报错 (Ping timeout):排查 Master 节点 JVM 是否发生长时间的 FGC (Stop-The-World),或者 Agent 节点与 Master 之间的 K8s Node 网络/Ingress 代理是否丢包。

    4. 共享库安全审计:检查 vars/ 或 src/ 目录下的 Groovy 脚本,凡是涉及 HTTP 请求、JSON/XML 反序列化的工具类函数,是否遗漏了 @NonCPS 注解。

  • 深入 Jenkins Pipeline 雪崩排查:CPS 转换引发的 Master JVM OOM 与 Shared Library 全局变量污染实战

    核心结论:Jenkins Pipeline 中的 Groovy 并非标准 Groovy,底层强制执行 CPS(Continuation Passing Style)转换以支持跨节点和重启的断点续跑。在 Shared Library 中滥用大对象、复杂闭包或未实现 Serializable 的原生 Java 类,会使 Master JVM 在状态序列化时直接 OOM。必须通过 @NonCPS 隔离重度计算逻辑,并结合 JCasC 实现基础设施不可变。

    故障现场:Master 的静默死亡

    排查过程中接到告警,某核心业务构建集群的 Jenkins Master(版本 2.440.1 LTS,JDK 17)Load Average 突然飙升至 80+,UI 完全无响应,所有挂载在 K8S 上的动态 Agent 任务卡死在 Pending 或执行态断联。

    登机排查,直接看 JVM 指标:

    # jstat -gcutil <pid> 1000 5
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT   
      0.00 100.00 100.00  99.98  95.21  92.14  14521  124.512   512  1421.112 1545.624
    

    Old Gen 打满,FGC 极其频繁且回收率几乎为 0。查看业务监控看板,并发构建数仅为平时的高峰期均值(~150 concurrent builds),排除了纯粹的并发量冲击。

    立刻通过 jmap -dump:format=b,file=heap.hprof 抓取现场,并重启服务恢复业务。 MAT 分析 Heap Dump 显示,com.cloudbees.groovy.cps.Next 和 java.util.LinkedHashMap 对象占据了 85% 的堆内存。进一步展开引用链,发现全部指向 workflow-cps 插件的 ProgramData 对象。这说明:Pipeline 的状态持久化机制正在吞噬内存。

    为什么简单的 Groovy 循环会拖垮 Jenkins Master?

    很多人习惯把 Jenkins 当成一个能够运行 Groovy 脚本的普通 Cron Server,这在 Pipeline As Code 时代是致命的认知误区。

    为了实现 Pipeline 可以在 Master 重启后从中断处继续执行(Resiliency),Jenkins 引入了 CPS(Continuation Passing Style)转换。当你编写一段看似普通的 for 循环时,Jenkins 在编译期会对抽象语法树(AST)进行劫持和重写:

    1. 每执行一行代码,CPS 引擎都会将当前作用域内的所有局部变量、调用栈打包成一个 Continuation 对象。

    2. 这些对象会被序列化(基于 XStream)并持久化到磁盘(通常是 builds//program.dat),同时缓存在内存中。

    问题代码最终定位在业务团队近期提交的 Shared Library vars/deployK8s.groovy 中:

    // 典型的夺命代码:全局作用域的大字典解析 + 跨节点持有
    def call(String env) {
        // 1. 读取并解析一个高达 5MB 的 Kubernetes Manifest 集合字典
        def hugeManifestMap = readYaml(file: "manifests/all-services.yaml")
    
        // 2. 在外层作用域遍历
        hugeManifestMap.each { svcName, config ->
            node('k8s-agent') { // 3. 跨越节点上下文
                sh "echo Deploying ${svcName}"
                // ... 复杂的 YAML 替换与 kubectl apply
            }
        }
    }
    

    底层原理解析: 当上述代码执行到 node('k8s-agent') 触发跨节点调度时,Pipeline 会挂起当前线程。此时,CPS 必须保存当前的环境状态以便稍后恢复。而 hugeManifestMap 是闭包外层的局部变量,CPS 不得不把这个 5MB 的嵌套 LinkedHashMap 及其对应的迭代器对象完整序列化。 在 150 个并发任务叠加下,这导致了极其可怕的写放大和内存膨胀:每次 sh 步骤执行,CPS 都要在内存里克隆并序列化这个巨大的上下文,最终瞬间撑爆 Master 的 JVM 堆。

    防御性架构重构与最佳实践

    针对这种滥用 Shared Library 引发的雪崩,必须在代码规范和基础设施配置两方面做防御。

    1. 使用 @NonCPS 隔离不可序列化与重度逻辑

    对于不需要断点续跑的纯计算、数据转换、大对象解析逻辑,强制使用 @NonCPS 注解。被 @NonCPS 标记的方法会在普通的 Java 线程池中作为原生代码执行,不会进行状态序列化。

    修复后的 Shared Library 实践:

    import com.cloudbees.groovy.cps.NonCPS
    
    def call(String env) {
        // 仅在局部获取所需的小数据集合,避免整个大字典逃逸到 CPS 上下文
        List<String> svcNames = extractServiceNames("manifests/all-services.yaml")
    
        for (int i = 0; i < svcNames.size(); i++) {
            def svc = svcNames[i]
            node('k8s-agent') {
                sh "echo Deploying ${svc}"
                // 每次部署仅传递当前需要的字符串对象
            }
        }
    }
    
    @NonCPS
    List<String> extractServiceNames(String filePath) {
        // 这里使用标准的 Java/Groovy 解析逻辑
        // 不会被 CPS 劫持,执行极快,不占用 Pipeline 持久化内存
        def parser = new org.yaml.snakeyaml.Yaml()
        def rawMap = parser.load(new File(filePath).text)
        return rawMap.keySet().toList()
    }
    

    注:在 @NonCPS 方法中绝对不能调用任何 Pipeline Step(如 sh, echo, node),否则会导致 IllegalStateException 或静默失败。

    2. JCasC 声明式治理 Shared Library

    为了避免通过 Jenkins UI 手工配置 Shared Library 带来的不可追溯和版本混乱,我们全面采用 Jenkins Configuration as Code (JCasC) 来固化基础设施。 将全局 Shared Library 配置下沉到不可变的代码仓库中(jcasc/jenkins.yaml):

    unclassified:
      globalLibraries:
        libraries:
        - defaultVersion: "v1.5.2" # 严禁使用 master/main 分支,必须绑定 Tag
          name: "ops-shared-lib"
          retriever:
            modernSCM:
              scm:
                git:
                  credentialsId: "git-bot-token"
                  id: "shared-lib-scm"
                  remote: "https://gitlab.internal.com/devops/jenkins-shared-library.git"
                  traits:
                  - gitBranchDiscovery()
          # 开启缓存以减轻拉取对 Master 的 I/O 压力
          cachingConfiguration:
            refreshTimeMinutes: 1440
    

    配合 K8S Helm Chart 部署 Jenkins,任何配置变更只能通过提交 MR 修改此 YAML 来触发 Pod 滚动更新,彻底掐断了手工污染配置的可能。

    常见问题 (FAQ)

    Q1:Pipeline 中经常出现 java.io.NotSerializableException: java.util.regex.Matcher 报错,如何根治? 这是由于正则表达式的 Matcher 对象内部包含 native 指针引用,无法通过 XStream 序列化。如果代码写成 def matcher = text =~ /pattern/,且该变量跨越了 CPS 步骤(例如在 sh 之前定义并在其后使用),就会报错。 解决思路:将正则匹配逻辑封装到 @NonCPS 方法中返回基础类型(String/Boolean),或者在需要跨步骤时主动置空:matcher = null。

    Q2:Jenkins Master 异常重启后,K8S 上会有大量状态为 Running 的僵尸 Agent Pod,如何自动清理? 在动态 Agent 架构中,Master 宕机会导致 JNLP 长连接断开。如果不做处理,这些 Pod 将长期挂起。 在 JCasC 的 podTemplate 配置中,务必显式设置 activeDeadlineSeconds,并通过 kubernetes-plugin 的清理策略来兜底:

    jenkins:
      clouds:
        - kubernetes:
            name: "k8s-cluster"
            serverUrl: "https://kubernetes.default"
            # 定义全局 Agent Pod 的最长存活时间(例如 2 小时)
            podRetention: "never" 
    

    同时在业务的 yaml 中确保 activeDeadlineSeconds: 7200 兜底,防止挂起任务长期吃空节点计算资源。

    Q3:如何本地单元测试 Jenkins Shared Library,避免每次都要上生产环境试错? 强推 JenkinsPipelineUnit 框架。可以在本地使用 Spock/JUnit 编写测试用例,框架会模拟 CPS 引擎和所有的内置步骤(sh, node, readYaml)。通过模拟返回结果并断言调用栈,可以在本地完成 90% 的逻辑校验,彻底告别在 Jenkins 上盲目触发几十次构建来 debug 的窘境。