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 线程)全部被挂起。
-
JNLP 心跳超时,Controller 单方面标记 Agent 为
Offline。 -
由于 Controller 处于假死状态,Kubernetes Plugin 无法及时向 K8s APIServer 发出
DELETE Pod的请求。 -
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 的复杂环境里,容易踩到以下暗坑:
-
AST 转换与动态生成类:如果在 Shared Library 中重度使用闭包(Closures)、动态方法拦截(
methodMissing)或隐式类型转换,Groovy 编译器会在运行时生成大量的匿名内部类(例如CallSiteArray)。 -
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 恢复机制可能会失效。