标签: JVM调优

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

  • 深入 JVM 陷阱排查:DirectByteBuffer 堆外泄漏引发的 OOM Killer 与 G1 停顿实战

    堆外内存(DirectByteBuffer)泄漏是高并发网关的隐形杀手。现象通常是 JVM 堆内存充裕,但进程却被 Linux OOM Killer 无情强杀。本质原因是 NIO 产生的 ByteBuffer 壳对象晋升至 G1 老年代,未达到 Mixed GC 阈值,导致底层 Cleaner 无法执行回收。解决之道:必须显式限制 -XX:MaxDirectMemorySize 触发 Jvm 内部 OOM 实施自保,下调 G1 的 InitiatingHeapOccupancyPercent 提前回收,并配合 pmap 与 JMX 精准追踪堆外分配。

    凶案现场:安静死去的网关节点

    排查过程中,某高吞吐 API 网关集群(基于 Spring Cloud Gateway / Netty,JDK 11.0.17)频繁出现节点无响应。监控面板显示,节点在崩溃前 CPU 和 Load Average 均无异常,YGC 频率平稳,堆内存使用率仅徘徊在 40% 左右。但容器却频繁发生重启。

    登录宿主机查阅系统日志,直接抓到了真凶:

    $ dmesg -T | grep -i oom
    [xxx] java invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
    [xxx] Out of memory: Killed process 13452 (java) total-vm:14853240kB, anon-rss:8192340kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:21340kB oom_score_adj:0
    

    anon-rss(匿名常驻内存)高达 8GB,而该容器的物理内存上限即为 8GB。JVM 的启动参数配置了 -Xms4G -Xmx4G,按理说加上 JVM 自身的 MetaSpace、CodeCache 和线程栈,怎么也不该吃掉额外的 4GB 内存。

    追踪隐形内存:NMT 的盲区

    遇到堆外内存泄漏,标准动作是开启 Native Memory Tracking(NMT):

    -XX:NativeMemoryTracking=detail
    

    随后使用 jcmd 查看:

    $ jcmd 13452 VM.native_memory summary
    Total: reserved=5342MB, committed=4842MB
    -                 Java Heap (reserved=4096MB, committed=4096MB)
    -                     Class (reserved=256MB, committed=128MB)
    -                    Thread (reserved=120MB, committed=120MB)
    -                      Code (reserved=250MB, committed=64MB)
    ...
    

    陷阱出现:NMT 统计的 committed 总内存仅不到 5GB,与 OS 层面 pmap 或 top 看到的 8GB 存在巨大缺口。

    这是 JVM 调优中极其容易踩坑的地方:NMT 无法监控由 Unsafe.allocateMemory() 直接分配的内存,而 java.nio.DirectByteBuffer 的底层恰恰就是基于 Unsafe 实现的。NMT 只能管 JVM 内部的 C++ 代码(如 JIT 编译器、GC 结构等)分配的内存。

    我们改用 OS 层面的 pmap 对照,抓出大块内存地址:

    $ pmap -x 13452 | sort -k 3 -n -r | head -n 10
    Address           Kbytes     RSS   Dirty Mode  Mapping
    00007f8a10000000 4194304 4194304 4194304 rw---   [ anon ]  <-- 堆内存 4G
    00007f8840000000   65536   65536   65536 rw---   [ anon ]  <-- 巨大的 64M 连续匿名内存块
    00007f8844000000   65536   65536   65536 rw---   [ anon ]
    ...
    

    配合 Arthas 读取 JMX 的 java.nio.BufferPool.direct 属性,确认了 Direct Memory 的实际使用量已经飙升到了 3.5GB。

    为什么堆内壳对象存活会导致物理内存被掏空?

    这里必须深挖 DirectByteBuffer 的生命周期与 G1 垃圾回收机制之间的致命耦合。

    DirectByteBuffer 在 Java 堆内只是一个几十字节的“壳对象”,它内部包含一个 address 指针,指向真正在 OS 层面申请的数百兆甚至上 G 的物理内存。 当这段物理内存需要被释放时,必须依赖 JVM 的 ReferenceHandler 线程处理挂在壳对象上的 Cleaner(继承自 PhantomReference)。

    死亡链路如下:

    1. 网关瞬间承接突发流量(如大文件下载、大 Payload 报文)。Netty 分配了大量堆外 ByteBuffer。

    2. 突发流量导致 YGC 频繁,部分 DirectByteBuffer 壳对象未被及时回收,熬过了 -XX:MaxTenuringThreshold,晋升到了 G1 老年代。

    3. 壳对象非常小,即使有上万个晋升到老年代,也只占了几 MB 的堆内存。

    4. JDK 11 中 G1 触发并发标记(Concurrent Mark,随后才是 Mixed GC 回收老年代)的默认条件是自适应的,通常堆使用率达到 45%(InitiatingHeapOccupancyPercent)才会介入。

    5. 堆内 4G 空间极其宽裕,老年代迟迟达不到触发线。

    6. 堆内的壳对象不死,挂在它身上的 Cleaner 就不触发,堆外几十 G 的物理内存就一直被占着不放。

    7. 最终,OS 物理内存耗尽,OOM Killer 直接介入,一刀斩断 Java 进程。

    源码层面,在 java.nio.Bits.reserveMemory() 中,如果堆外内存不足,JVM 会调用 System.gc() 尝试触发 Full GC 救场。但很多运维规范中为了防止停顿,习惯性配置了 -XX:+DisableExplicitGC,这直接屏蔽了这道最后的防线,加速了进程的死亡。

    防御性调优实战

    针对这种场景,不能靠盲目扩大内存,必须通过严苛的参数限制和 GC 调优,让问题在 JVM 内部以异常形式抛出,而不是被 OS 直接 Kill。

    1. 显式限制 Direct Memory

    这是最重要的防御性配置。如果不配,最大堆外内存默认等同于最大堆内存(-Xmx)。

    -XX:MaxDirectMemorySize=2G
    

    强制限制后,当堆外超限时,JVM 会抛出 java.lang.OutOfMemoryError: Direct buffer memory。这不仅能保护宿主机,还能让业务代码触发重试或熔断降级。

    2. G1 提前介入清理老年代

    打破 G1 老年代因堆内内存充足而“躺平”的僵局。关闭自适应 IHOP,强制调低并发标记的触发阈值,让 G1 尽早清理掉老年代的 ByteBuffer 壳对象。

    -XX:-G1UseAdaptiveIHOP
    -XX:InitiatingHeapOccupancyPercent=35
    

    这样当整个堆内存使用到达 35% 时,G1 就会开始并发标记并执行 Mixed GC,及时释放堆外的内存孤岛。

    3. 保留 System.gc(),但使其并发执行

    绝不要粗暴地使用 -XX:+DisableExplicitGC。对于依赖堆外内存的 Netty/NIO 架构,显式的 System.gc() 是 Bits.reserveMemory() 触发内存回收的关键机制。 建议改为:

    -XX:+ExplicitGCInvokesConcurrent
    

    当触发 System.gc() 时,不再进行会导致整个应用停顿(STW)的 Full GC,而是启动一次并行的 Concurrent GC 周期。

    常见问题 (FAQ)

    Q1:为什么 NMT 没发现泄漏,但 top 命令里的 RES 却居高不下,且 JMX 里的 DirectMemory 并不高? 这种情况通常不是 DirectByteBuffer 导致的,而是 glibc 的 malloc 内存碎片和内存池机制机制导致。JVM 中的 JIT 编译器、JNI 调用等在频繁申请/释放小块内存时,glibc 为了性能不会立即返还给 OS,而是缓存在 Arena 中。 解法:在启动脚本中加入环境变量限制 Arena 数量:export MALLOC_ARENA_MAX=4。

    Q2:JIT 编译(CodeCache)是否也会导致堆外内存 OOM? 会。在 JDK 11 及以后,分层编译(Tiered Compilation)极其激进。对于复杂的微服务应用,默认的 240M CodeCache 可能被打满,或者在未打满时由于碎片导致 JIT 停止工作,引发 QPS 断崖式下跌。 排查:可以通过 jcmd Compiler.codecache 查看。若使用率超 80%,需增加 -XX:ReservedCodeCacheSize=512M。CodeCache 是受 NMT 监控的,可以在 NMT Compiler 分类中明确看到。

    Q3:Netty 自身有对象池机制,还会引发 DirectMemory 泄漏吗? 会。Netty 的 PooledByteBufAllocator 自己维护了一套内存池结构(Chunk/Page)。如果是业务代码忘记调用 ReferenceCountUtil.release(msg),导致的属于内存池泄漏,Netty 提供了 ResourceLeakDetector(配置 -Dio.netty.leakDetection.level=ADVANCED)会在日志中打印泄漏轨迹。 但如果是网关积压(消费过慢,生产过快),Netty 不断向底层申请新的 Chunk,哪怕没有忘记 release,也会打爆物理内存,这时依然需要依靠 -XX:MaxDirectMemorySize 进行兜底。

  • 深入 JVM 堆外内存排查:Netty DirectByteBuffer 泄漏引发的 OOM-Kill 与 G1 延迟回收机制解析

    排查某核心网关 OOM-Kill 时确认,堆内存仅使用 40%,容器却因 RSS 物理内存超限被内核干掉。根因是底层 Netty 大量分配 DirectByteBuffer,而 G1 垃圾回收跟不上堆外内存分配速度,PhantomReference 未及时触发 Cleaner 回收。解决方案:显式配置 -XX:MaxDirectMemorySize 拦截溢出,开启 NMT 追踪,并修正代码中未 release() 的 ByteBuf 泄漏。

    故障现场:消失的 Pod 与飙升的 RSS

    近期某基于 Netty 的 RPC 网关集群频繁发生 Pod 重启。监控大盘显示,该服务在 QPS 触达 5000 左右时,P99 耗时从平稳的 15ms 突增至 200ms+,随后实例直接失联。

    登录宿主机执行 dmesg -T,拿到内核级的死亡判决书:

    [Fri Oct 20 10:14:23] java invoked oom-killer: gfp_mask=0xd0, order=0, oom_score_adj=974
    [Fri Oct 20 10:14:24] Task in /kubepods/burstable/pod-xxx killed as a result of limit of /kubepods/burstable/pod-xxx
    [Fri Oct 20 10:14:24] memory: usage 8388608kB, limit 8388608kB, failcnt 3241
    [Fri Oct 20 10:14:24] Memory cgroup out of memory: Kill process 12345 (java) score 1000 or sacrifice child
    

    这是典型的 Linux Cgroup OOM-Kill。核对该 Pod 的资源配置:Limit 设为 8G,而 JVM 核心参数配置如下(基于 OpenJDK 11.0.17):

    -Xms4G -Xmx4G -XX:+UseG1GC -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails
    

    堆内存(4G)加上 Metaspace(256M)和线程栈,理论上 JVM 吃掉的物理内存撑死在 5G 左右。剩下的 3G 空间去哪了?毫无疑问,堆外内存泄漏。

    抽丝剥茧:利用 NMT 与 pmap 锁定真凶

    排查堆外内存,第一步永远是开启 Native Memory Tracking (NMT)。在启动参数中追加 -XX:NativeMemoryTracking=detail,并在应用 RSS 达到 7G 时,抓取内存分布快照:

    jcmd <pid> VM.native_memory summary
    

    输出结果的重点片段如下:

    Native Memory Tracking:
    Total: reserved=7345MB, committed=7120MB
    -                 Java Heap (reserved=4096MB, committed=4096MB)
    -                     Class (reserved=260MB, committed=128MB)
    -                    Thread (reserved=120MB, committed=120MB)
    -                      Code (reserved=250MB, committed=40MB)
    -                        GC (reserved=180MB, committed=180MB)
    -                  Internal (reserved=2320MB, committed=2320MB)  <-- 重点在这里
    ...
    

    NMT 明确指出 Internal 部分占用了 2.3G。在 JVM 语境下,DirectByteBuffer 申请的直接内存往往会反映在 Internal 或 Other 区域(取决于具体的 JDK 版本和 Unsafe 分配逻辑)。

    进一步通过 OS 级别工具验证,抓取 pmap -x | sort -n -k3,发现大量 64MB 大小的匿名内存块(anon)。这是典型的 glibc malloc Arena 内存分配特征,高度吻合 Java 通过 Unsafe.allocateMemory 绕过 JVM 堆直接向 OS 拿内存的行为。

    立刻打一个 Heap Dump,用 MAT(Memory Analyzer Tool)分析,直接查看 java.nio.DirectByteBuffer 实例,发现堆内虽然只有不到 50MB 的 DirectByteBuffer 对象,但它们持有的 capacity 总和高达 2.5G!

    为什么 G1 无法及时回收 DirectByteBuffer 引发的堆外内存溢出?

    很多人会有疑问:DirectByteBuffer 虽然分配在堆外,但 Java 堆内依然有它的代理对象。既然堆内对象失去引用,为什么 G1 没有把它们回收掉,进而释放堆外内存?

    这涉及底层 DirectByteBuffer 的分配与回收机制。直接看 JDK 源码 java.nio.DirectByteBuffer 的构造函数:

    DirectByteBuffer(int cap) {
        super(-1, 0, cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        Bits.reserveMemory(size, cap); // 记录分配的直接内存大小
    
        long base = 0;
        try {
            base = unsafe.allocateMemory(size); // 真正的 OS 级别 malloc
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        // 绑定 Cleaner (底层是 PhantomReference)
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    }
    

    堆外内存的释放依赖于 sun.misc.Cleaner。它是一个 PhantomReference(虚引用)。只有当 G1 垃圾回收器发现 DirectByteBuffer 对象不再可达,才会将对应的 Cleaner 放入 ReferenceQueue,随后由后台的 ReferenceHandler 线程执行 Deallocator.run() 调用 Unsafe.freeMemory() 归还给 OS。

    惨案的根本原因在于 G1 的触发时机:

    1. 业务流量大,DirectByteBuffer 对象快速晋升到 Old Gen(老年代)。

    2. JVM 配置了 4G 的大堆,而 DirectByteBuffer 的 Java 层对象非常小(几十字节)。

    3. G1 的 Concurrent Mark(并发标记)阶段默认需要老年代使用率达到 InitiatingHeapOccupancyPercent(默认 45%)才会触发。

    4. 由于对象极小,老年代迟迟达不到 45% 的阈值,G1 根本觉得不需要执行 GC。

    5. 堆内非常空闲,堆外却已经被 OS 级的 malloc 撑爆,最终触发 Linux OOM-Killer 绞杀。

    更致命的是,如果没有显式设置 -XX:MaxDirectMemorySize,JDK 默认的直接内存上限几乎等于堆内存的最大值(-Xmx)。这意味着 JVM 认为可以申请最多 4G 的堆外内存,完全忽略了容器 8G 的硬限制。

    防御性加固与最佳实践落地

    明确了机制,修复方案就不应该仅仅是“改 Bug”,而是要从架构和 JVM 配置上进行系统级加固。

    1. 锁死 MaxDirectMemorySize,让异常暴露在 JVM 层

    永远不要依赖 OS OOM-Killer 来终结应用,那会导致现场完全丢失。必须在启动参数中显式限制直接内存:

    -XX:MaxDirectMemorySize=1536M
    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/data/logs/
    

    当直接内存超过 1.5G 时,JVM 的 Bits.reserveMemory() 会主动抛出 java.lang.OutOfMemoryError: Direct buffer memory,同时触发 HeapDump,精准保留第一案发现场。

    2. Netty 代码层的内存泄漏防御

    在业务逻辑中,通过 Netty ByteBuf 读取数据后,由于各种异常分支未调用 ReferenceCountUtil.release(msg),导致内存泄漏。 除了修复代码逻辑,必须开启 Netty 的高级内存泄漏检测:

    -Dio.netty.leakDetection.level=PARANOID
    

    该参数会强制追踪所有 ByteBuf 的生命周期,一旦发现未释放的 Buffer 被 GC 垃圾回收,直接在日志中打印泄漏时的创建堆栈。实战中靠这一行配置揪出了 3 个隐藏极深的 try-catch 遗漏点。

    3. 主动触发系统 GC (针对特定场景)

    如果在特定的古老系统(不支持显式 release)中,可以通过 JNI 或 System.gc() 的策略干预。其实 Bits.reserveMemory() 源码中,在申请不到内存时会主动调用 System.gc() 尝试触发 Full GC 来拯救堆外内存。但如果你配置了 -XX:+DisableExplicitGC,这条退路就被切断了。结论:在使用大量 Direct Memory 的场景中,慎用 -XX:+DisableExplicitGC。

    常见问题 (Q&A)

    Q1:排查时,为什么有时候 NMT (Native Memory Tracking) 抓不到堆外内存的异常? 如果第三方库(如 JNA、直接调用 C/C++ 动态链接库的 JNI 模块)直接调用了 OS 的 malloc 或 mmap,这部分内存是绕过 JVM Unsafe API 的,NMT 无法感知,它只会将其归类为 OS 级别的未追踪占用。此时只能通过 Linux 原生的 pmap、strace 或 eBPF 工具去追踪 malloc 相关的系统调用。

    Q2:如果把 G1 换成 ZGC,能解决这种堆外内存泄漏问题吗? 不能。ZGC 和 G1 一样,都是并发收集器。虽然 ZGC 的停顿时间极短(亚毫秒级),但它的回收触发同样依赖堆内对象的分配速率。如果 DirectByteBuffer 的分配速度远高于 ZGC 能够处理并把 PhantomReference 推入队列的速度,依然会导致堆外内存无限膨胀。核心解法依然是规范 -XX:MaxDirectMemorySize 和代码层的释放。

    Q3:遇到 glibc 导致的假性内存泄漏(MALLOC_ARENA_MAX)怎么判断? Linux 默认配置下,glibc 会为每个线程分配独立的内存池(Arena)以避免锁竞争(最大数量通常是 CPU 核心数 * 8)。在 Netty 这种多线程高并发场景下,会产生大量的 64M 内存块,表现为 top 命令下 VIRT 和 RES 飙高,但 JVM NMT 显示正常。可以通过设置环境变量 MALLOC_ARENA_MAX=4 限制内存池数量,如果 RSS 显著下降,则证明是 glibc 碎片化引起的内存虚高,而非真正的内存泄漏。

  • 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 JVM:Metaspace 使用率在两小时内呈阶梯式上涨,直至 100% 触发 Full GC,单次 GC 停顿(STW)超过 12 秒。

    2. K8S 控制平面:API Server QPS 突增,尤其是针对 namespaces/jenkins/pods 的 POST 和 DELETE 请求,导致 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 会自动将其替换,实现配置与凭据解耦。