深入 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 注解。