WEBKT

K8s下Java应用GC停顿与CPU飙升关联的bpftrace免重启追踪方案

1 0 0 0

在生产环境中,Kubernetes(K8s)容器内的 Java 应用偶尔会出现瞬时的 CPU 飙升,同时伴随着 GC 停顿时间(Stop-The-World, STW)异常变长。传统的排查手段(如 Arthas、jstack 或 Prometheus 监控)往往存在明显的局限:

  • 监控粒度粗:Prometheus 监控通常是秒级甚至是 15s 级,无法精准对齐毫秒级的 GC 停顿与 CPU 飙升瞬间。
  • 侵入性强:重新配置 JVM 参数以启用 GC 日志或挂载 Profiler Agent 必须重启 Pod,这可能导致瞬时故障现场丢失。
  • 内核态视角的缺失:JVM 内部指标无法体现宿主机 CPU 调度延迟(如 CFS 调度器限制)对 Java 线程的影响。

利用 eBPF 技术,我们可以在不重启容器、不对应用产生可观测侵入的前提下,通过 bpftrace 动态追踪内核态调度事件与 JVM 内部 GC 触发点的关联。本文将详细介绍如何在宿主机上穿透容器边界,精准追踪并关联这两大痛点。


核心诊断思路:寻找“隐形”的 CFS 调度延迟

在 K8s 环境中,Java 应用的 resources.limits.cpu 是通过 Linux 的 CFS(Completely Fair Scheduler)限流机制(cpu.cfs_quota_uscpu.cfs_period_us)实现的。

当 JVM 触发 GC 时,会启动多个并行 GC 线程(ParallelGCThreads),这些线程会瞬间将 CPU 使用率推向峰值。如果此时恰好触发了 CFS 限制,Linux 内核会无情地将该容器内的所有线程挂起(Throttle)。
原本只需 10ms 即可完成的 GC 垃圾回收,由于线程被内核挂起暂停执行,在 JVM 看来,外部呈现的 Safepoint 停顿时间(STW)就会被拉长到数百毫秒甚至数秒。此时 JVM 内部的 GC 日志只会记录“GC 时间超长”,而无法感知到其实是自己被操作系统“冻结”了。

为了证实这一猜测,我们需要通过 bpftrace 同时捕获两个指标:

  1. JVM Safepoint(STW)的起止时间点。
  2. 在 STW 期间,JVM 线程在内核就绪队列(Run Queue)中的等待调度时间(Run Queue Latency)。

步骤一:跨越容器边界定位 JVM 进程与符号表

bpftrace 运行在宿主机内核态,我们需要在宿主机上定位到 K8s 容器内 Java 进程的 Host PID,并找到 JVM 动态链接库 libjvm.so 在宿主机视角下的绝对路径。

编写以下 Bash 脚本(自动化寻找目标 Pod 的 Host PID 以及 libjvm.so 路径):

#!/bin/bash
# 用法: ./find_target.sh <namespace> <pod-name>

NAMESPACE=$1
POD_NAME=$2

if [ -z "$NAMESPACE" ] || [ -z "$POD_NAME" ]; then
    echo "Usage: $0 <namespace> <pod-name>"
    exit 1
fi

# 1. 获取容器的 Container ID (以 containerd 为例)
CONTAINER_ID=$(kubectl get pod "$POD_NAME" -n "$NAMESPACE" -o jsonpath='{.status.containerStatuses[0].containerID}' | cut -d'/' -f3)

if [ -z "$CONTAINER_ID" ]; then
    echo "Error: Cannot find container ID for Pod $POD_NAME"
    exit 1
fi

# 2. 从 container inspect 中提取宿主机视角的 Host PID
HOST_PID=$(crictl inspect --output json "$CONTAINER_ID" | jq '.info.pid')

if [ -z "$HOST_PID" ] || [ "$HOST_PID" == "null" ]; then
    echo "Error: Cannot find Host PID for Container $CONTAINER_ID"
    exit 1
fi

# 3. 寻找 libjvm.so 的绝对路径
LIBJVM_PATH=$(find /proc/"$HOST_PID"/root/ -name "libjvm.so" | head -n 1)

if [ -z "$LIBJVM_PATH" ]; then
    echo "Error: libjvm.so not found in container namespace"
    exit 1
fi

echo "TARGET_PID=$HOST_PID"
echo "LIBJVM_PATH=$LIBJVM_PATH"

步骤二:利用 Uprobes 勾住 JVM GC 关键点

JVM 在进行 GC 前,必须让所有业务线程在安全点(Safepoint)暂停。JVM 内部的核心类 SafepointSynchronize 负责控制这一过程。我们可以通过动态追踪(Uprobes)勾住 libjvm.so 中的以下两个 C++ 符号:

  • 进入 Safepoint 触发点_ZN20SafepointSynchronize5beginEv(即 SafepointSynchronize::begin() 的 Mangled Name)
  • 离开 Safepoint 触发点_ZN20SafepointSynchronize3endEv(即 SafepointSynchronize::end() 的 Mangled Name)

可以通过 nm 工具确认目标 libjvm.so 中是否存在这两个符号:

nm -D /proc/<HOST_PID>/root/path/to/libjvm.so | grep SafepointSynchronize

步骤三:编写 bpftrace 关联分析脚本

编写一个名为 jvm_gc_cpu_tracker.btbpftrace 脚本。该脚本将在 GC Safepoint 期间,实时统计系统 CPU 调度延迟和上下文切换特征。

#!/usr/bin/env bpftrace

/*
 * jvm_gc_cpu_tracker.bt
 * 动态追踪 JVM GC 期间的系统调度延迟
 */

#ifndef BPFTRACE_HAVE_BTF
#include <linux/sched.h>
#endif

BEGIN
{
    printf("===================================================================\n");
    printf("开始监听 JVM Safepoint 状态。按 Ctrl+C 结束并输出分析图表...\n");
    printf("===================================================================\n");
}

/* 1. 拦截 Safepoint 开始事件 */
uprobe:USDT_LIBJVM_PATH:_ZN20SafepointSynchronize5beginEv
{
    @safepoint_start_ns = nsecs;
    @in_safepoint = 1;
}

/* 2. 拦截 Safepoint 结束事件,计算耗时并输出结果 */
uprobe:USDT_LIBJVM_PATH:_ZN20SafepointSynchronize3endEv
{
    if (@safepoint_start_ns > 0) {
        $duration_ms = (nsecs - @safepoint_start_ns) / 1000000;
        
        // 只有当停顿时间大于预设阈值(例如 20ms)时才进行详细打印
        if ($duration_ms > 20) {
            printf("\n[%s] 检测到严重 GC 停顿! 耗时: %d ms\n", strftime("%H:%M:%S", nsecs), $duration_ms);
            
            printf("--- 停顿期间线程在排队等待 CPU 的延迟分布 (单位: 微秒) ---\n");
            print(@runq_latency_us);
            
            printf("--- 停顿期间活跃线程上下文切换次数统计 (Top 5) ---\n");
            print(@context_switches);
        }
        
        // 状态清理,准备下一次统计
        clear(@runq_latency_us);
        clear(@context_switches);
        @safepoint_start_ns = 0;
        @in_safepoint = 0;
    }
}

/* 3. 跟踪线程唤醒事件(当线程进入就绪队列,开始等待被调度运行) */
tracepoint:sched:sched_wakeup
/ @in_safepoint /
{
    // 记录线程进入就绪队列的时间戳
    @runq_enqueue_ns[args->pid] = nsecs;
}

/* 4. 跟踪线程切换事件(当线程实际获取到 CPU 并开始运行) */
tracepoint:sched:sched_switch
/ @in_safepoint /
{
    // A. 统计切换次数,确定是谁在抢占 CPU
    @context_switches[args->prev_comm] = count();

    // B. 计算切入线程的排队等待时间 (Run Queue Latency)
    $enqueue_time = @runq_enqueue_ns[args->next_pid];
    if ($enqueue_time > 0) {
        $latency_us = (nsecs - $enqueue_time) / 1000;
        @runq_latency_us = lhist($latency_us, 0, 10000, 1000); // 0-10ms 直方图
        delete(@runq_enqueue_ns[args->next_pid]);
    }
}

END
{
    clear(@runq_enqueue_ns);
    clear(@safepoint_start_ns);
    clear(@in_safepoint);
}

步骤四:执行追踪与现场还原

因为 bpftrace 脚本内部需要硬编码 libjvm.so 的绝对路径,我们在宿主机上利用一行 Bash 命令完成路径替换并启动追踪:

# 获取动态路径和 PID
eval $(./find_target.sh default my-java-app-6789df-xxxxx)

# 动态替换路径并执行 bpftrace
sed "s|USDT_LIBJVM_PATH|$LIBJVM_PATH|g" jvm_gc_cpu_tracker.bt | sudo bpftrace -

结果场景分析与诊断实例

当 JVM 发生 GC 停顿且 CPU 飙升时,终端会打印如下诊断报表:

[14:23:10] 检测到严重 GC 停顿! 耗时: 342 ms
--- 停顿期间线程在排队等待 CPU 的延迟分布 (单位: 微秒) ---
@runq_latency_us:
[0, 1000)            |                                                    |        0
[1000, 2000)         |*                                                   |       12
[2000, 3000)         |***                                                 |       35
[3000, 4000)         |******                                              |       64
[4000, 5000)         |****************************************************|      482
[5000, 6000)         |******************                                  |      173

--- 停顿期间活跃线程上下文切换次数统计 (Top 5) ---
@context_switches:
[C2 CompilerThre]: 84
[g1_main_gc_0]: 156
[g1_main_gc_1]: 161
[java]: 302

报告解读:

  1. 排队延迟极高:在 runq_latency_us 的直方图中,可以看到大量的线程排队延迟集中在 4000us6000us(即 4ms ~ 6ms)。在正常情况下,就绪队列的等待时间应该在几十微秒以内。这表明当 GC 线程启动时,操作系统根本无法及时为它们分配 CPU 时间片,线程在排队等待。
  2. CFS 限制判定:如果排队延迟异常高,而此时宿主机的整体 CPU 并不繁忙,结合 resources.limits.cpu 设置,可以 100% 确诊该容器被内核进行了 CFS Throttling。GC 线程在执行垃圾回收时被迫停摆,拉长了整个 Safepoint 停顿时间。

生产环境应急优化建议

通过以上 eBPF 零侵入追踪证实是 CFS 调度延迟导致 GC 停顿异常后,可以通过以下几种手段进行针对性修复,而无需修改任何代码:

  1. 启用 JVM 自适应核心数感知
    在 JDK 8u191 之后,JVM 已默认支持 -XX:+UseContainerSupport。如果分配的 limits.cpu 为非整数(例如 2.5),内核会将其向上取整分配 GC 线程数,导致过度的线程争抢。应显式设置合理的核心参数:
    -XX:ParallelGCThreads=2 -XX:ConcGCThreads=1(与 CPU limits 的整数部分对齐)。
  2. K8s 侧关闭 CFS Quota 限流(需集群管理员配合)
    如果应用对延迟极其敏感,可以在 K8s Kubelet 中启用 Static 资源分配策略(--cpu-manager-policy=static),让 Pod 独占物理 CPU 核心,从而彻底规避 CFS 限流带来的 STW 抖动。
  3. 升级 Linux 内核
    旧版本 Linux 内核存在 CFS 调度器的 Bug(即使 CPU 未达到限制也可能由于周期片分配不均发生误限流)。建议升级至 Linux Kernel 5.14+ 或是更新的稳定版内核。
SRE探路者 eBPFbpftraceJava GC

评论点评