Jenkins 生产环境雪崩排查实战:Groovy CPS 陷阱引发的 Metaspace 溢出与 K8S Agent 调度风暴

结论先行:Jenkins Pipeline 复杂的 Groovy 闭包会导致 CPS(Continuation Passing Style)频繁进行 AST 转换,耗尽 Master Metaspace 触发 OOM。同时,K8S 插件在 Master 假死断连时产生的 Agent 创建风暴,会瞬间击穿 K8S API Server。本文通过重构 Shared Library 剥离 CPS 逻辑,并引入 JCasC 固化 K8S 动态 Agent 限流配置,彻底解决百级别并发构建下的系统雪崩问题。

1. 故障现场:Master 假死与 K8S API Server 告警

排查过程中接到告警,CI/CD 集群 P99 构建排队时间从平时的 5 秒飙升至 30 分钟以上。登录控制台发现 Jenkins UI 响应极其缓慢,部分页面直接 502。 联动监控大盘,发现了两个极度异常的指标:

  1. Jenkins Master JVMMetaspace 使用率在两小时内呈阶梯式上涨,直至 100% 触发 Full GC,单次 GC 停顿(STW)超过 12 秒。

  2. K8S 控制平面:API Server QPS 突增,尤其是针对 namespaces/jenkins/podsPOSTDELETE 请求,导致 API Server CPU 飙升,etcd 出现选主告警。

进入 Jenkins Master 容器抓取现场:

# 查看 JVM 内存状态
jstat -gcutil $(pgrep java) 1000 5
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT    GCT   
  0.00 100.00  32.14  89.45  99.98  98.71  23412  145.312  425  312.411  457.723

# 生成 Heap Dump 和 Thread Dump(保留案发现场)
jcmd $(pgrep java) GC.heap_dump /tmp/jenkins_oom.hprof
jcmd $(pgrep java) Thread.print > /tmp/jenkins_threads.txt

日志中大量抛出 java.lang.OutOfMemoryError: Metaspace,同时伴随着 Kubernetes client: failed to create pod ... Read timeout。很明显,JVM 已经处于频繁 GC 的濒死状态。

2. 为什么 Groovy CPS 机制会吃光 Master 的 Metaspace?

把 Heap Dump 拖到 MAT(Memory Analyzer Tool)里分析,发现 ClassLoader 数量异常庞大,且绝大多数是由 com.cloudbees.groovy.cps.NonCPS 和 Pipeline 脚本动态生成的类。

Jenkins Pipeline 的底层运行机制基于 CPS(Continuation Passing Style)。为了让 Pipeline 在 Jenkins Master 重启后还能从断点恢复继续执行,Jenkins 必须能够将当前执行的堆栈状态序列化到磁盘。 这就导致了一个致命陷阱:你在 Jenkinsfile 里写的每一行看似普通的 Groovy 代码,都会被 CPS 转换引擎解析重写为可以被序列化的 AST(抽象语法树)对象。

在某次业务线提交的 Shared Library 中,发现了一段类似这样的代码:

// 反面教材:在 CPS 方法中进行大量不可序列化对象的循环操作
def processComplexJson(String jsonStr) {
    def jsonSlurper = new groovy.json.JsonSlurperClassic()
    def data = jsonSlurper.parseText(jsonStr)
    // 这里的 data 树结构非常复杂,且在循环中调用了 pipeline step
    data.items.each { item ->
        if (item.name.matches(".*-service-.*")) { // 正则 Matcher 不可序列化
            echo "Processing ${item.name}"       // 调用了 CPS 步骤
            // 复杂的处理逻辑...
        }
    }
}

原理解析

  1. JsonSlurper 解析出的复杂对象模型、java.util.regex.Matcher 等对象是不可序列化的。

  2. 当闭包 .each {} 内部混合调用了 Pipeline 原生 step(如 echo, sh)时,Jenkins 会尝试保存整个上下文。

  3. 每次执行构建,CPS 引擎为了处理这些无法直接解析的代码,会动态生成大量的匿名类加载到 Metaspace 中。由于这些类持有 Pipeline 的执行上下文(强引用),无法被 GC 快速回收。

  4. 并发一高,Metaspace 迅速被打爆。Master 发生长达十几秒的 STW。

雪崩链条: Master STW -> JNLP Agent (运行在 K8S Pod 中) 的心跳超时 -> Jenkins 认为 Agent 已死,触发重连或重新分配 -> K8S Plugin 疯狂向 API Server 发起创建 Pod 请求 -> API Server 被打满 -> 旧 Agent 还在跑,新 Pod 不断创建 -> K8S 节点资源耗尽。

3. 核心修复:Shared Library 与 K8S Agent 调优实践

针对上述问题,我们从代码重构和配置加固两方面进行落地。当前环境为 Jenkins 2.414.3 LTS,Kubernetes Plugin 4136.v464303c7379d。

3.1 剥离 CPS:使用 @NonCPS 与纯粹的 Java 类

对于 Shared Library 中的数据处理逻辑,必须将纯粹的代码计算Pipeline 执行步骤隔离开。使用 @NonCPS 注解,让 Jenkins 跳过 AST 转换,按标准 JVM 字节码执行。

import com.cloudbees.groovy.cps.NonCPS
import groovy.json.JsonSlurperClassic

// 1. 将耗时的、涉及不可序列化对象的纯计算逻辑标记为 @NonCPS
@NonCPS
List<String> getServicesToProcess(String jsonStr) {
    def services = []
    def jsonSlurper = new JsonSlurperClassic()
    def data = jsonSlurper.parseText(jsonStr)

    for (item in data.items) {
        if (item.name.matches(".*-service-.*")) {
            services.add(item.name)
        }
    }
    return services // 只返回可序列化的基本类型或标准集合
}

// 2. 在 Pipeline 步骤中通过标准 for 循环调用(不要用 .each 闭包混合 pipeline step)
def call(String jsonStr) {
    List<String> targetServices = getServicesToProcess(jsonStr)
    for (int i = 0; i < targetServices.size(); i++) {
        def svc = targetServices[i]
        echo "Processing ${svc}"
        // 执行实际的 pipeline steps...
    }
}

3.2 阻断雪崩:JCasC 固化 K8S Agent 限流配置

为了防止 Jenkins 在网络抖动或自身 GC 时向 K8S 发起 API DDOS 攻击,必须严格配置 K8S Plugin 的容量上限,并改用 WebSocket 代替 TCP JNLP 端口直连。 我们通过 JCasC (Jenkins Configuration as Code) 强制注入以下安全配置:

jenkins:
  clouds:
    - kubernetes:
        name: "kubernetes"
        serverUrl: "https://kubernetes.default"
        namespace: "jenkins"
        jenkinsUrl: "http://jenkins-master.jenkins.svc.cluster.local:8080"
        # 【核心防御】开启 WebSocket,复用 HTTP 端口,避免 K8S LoadBalancer 断流导致心跳丢失
        webSocket: true 
        # 【核心防御】限制全局并发 Pod 数,保护 K8S API Server 和节点资源
        containerCapStr: "200"
        # 限制 API 请求超时时间
        readTimeout: 15
        connectTimeout: 5
        maxRequestsPerHostStr: "32"
        templates:
          - name: "base-maven"
            namespace: "jenkins"
            label: "maven-agent"
            # 限制单种模板的最大并发数
            instanceCapStr: "50" 
            containers:
              - name: "jnlp"
                image: "jenkins/inbound-agent:3148.v532a_7e715ee3-1"
                workingDir: "/home/jenkins/agent"
                resourceRequestCpu: "500m"
                resourceLimitCpu: "2"
                resourceRequestMemory: "1Gi"
                resourceLimitMemory: "2Gi"

同时,调整 Jenkins Master 启动参数,增大 Metaspace 并限制其无序扩张: JAVA_OPTS="-Xms8G -Xmx8G -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=1G -XX:+UseG1GC"

应用上述修复后,P99 排队时间回落至 3 秒,Master 内存泄漏彻底消除,API Server 平稳运行。

4. 常见问题 (FAQ)

Q1: K8S 动态 Agent 频繁出现 JNLP connection timeout 或 offline,是什么原因? 通常有两个原因:一是中间的 Ingress/LoadBalancer 对长连接(默认 50000 TCP 端口)有 idle timeout 清理机制,导致静默断连;二是 Master 的 CPU 或内存被跑满,无法及时响应心跳。 建议解决: 启用 Kubernetes 插件的 webSocket: true 选项,让 Agent 通过 标准的 HTTP 8080 端口使用 WebSocket 与 Master 通讯,这样不仅穿透性好,还能复用 HTTP 的负载均衡和 KeepAlive 策略。

Q2: 在动态 K8S Agent 中构建 Docker 镜像,推荐 DinD (Docker in Docker) 还是 Kaniko? 坚决抵制在 K8S 生产环境中大规模使用 DinD。DinD 需要开启 Pod 的 privileged: true 特权模式,这在任何有底线的运维体系中都是不被允许的,极易引发容器逃逸。 建议解决: 使用 Google 提供的 Kaniko。它完全在用户态执行,无需特权,直接通过解析 Dockerfile 在容器内层层构建镜像文件系统,最后 push 到 Harbor。

Q3: 如何安全地在 JCasC YAML 中管理集群密码和 Secret? 禁止在 JCasC 的 yaml 文件里明文写 Token! 建议解决: 利用 Jenkins 的 Secret 机制结合 K8S 环境变量。在 JCasC 中使用 ${MY_SECRET} 占位符,然后在 Jenkins Master 的 Deployment 中通过 K8S Secret 挂载到环境变量。启动时 JCasC 会自动将其替换,实现配置与凭据解耦。