结论先行:你的场景(30+ Pod 各自 throttle 率 < 2%、节点 load 18~22 / 16C、P99 尖峰 300~500ms)几乎可以断定是 CFS runqueue 排队延迟,而非任何单容器的 cgroup 节流。核心原因:cgroup v2 的 cpu.max 是 per-cgroup 预算,而 runqueue 排队是 node-global 的——当 30+ 个 JVM 各自在 50~100ms 窗口内发起 CPU burst(GC、JIT、请求处理),CFS 按 vruntime 公平分配时间片,但「拿到时间片之前」的排队等待才是你的 P99 毛刺。各容器 throttle 率低恰好证明:瓶颈不在「被 cgroup 掐了」,而在「还没轮到」。
一、判定 checklist:定量区分「cgroup throttle」vs「节点级调度排队」
1. 最直接的指标:/proc/<pid>/sched 的 run_delay
# 在节点上执行(进 Pod 所在节点,非容器内)
# 找到 Java 进程的 PID(kubectl exec 或 crictl 拿到 containerd 的 host PID)
cat /proc/<host_pid>/sched | grep -E 'run_delay|nr_involuntary'
输出示例:
se.exec_start 41234567890
se.sum_exec_runtime 89234567
run_delay 452100000 <-- 累计 runqueue 等待(ns)
nr_involuntary_switches 12847
判据:在 P99 毛刺窗口(比如 2 分钟)内,run_delay 增量 > 50ms(即 50,000,000 ns)→ 坐实 runqueue 排队。如果增量 < 5ms 而 P99 还是抖 → 排除调度,转查 I/O / 网络。
2. perf sched latency(不侵入业务容器,节点级采集)
# 在节点上跑 60s,采集所有进程的最大调度延迟
perf sched record -a -- sleep 60
perf sched latency --sort max | head -20
输出示例:
MAX LATENCY (ms) COUNT PROCESS (TID)
342.18 12 java (28471)
218.93 8 java (28471)
187.45 6 filebeat (31204)
...
判据:如果 top10 里大量是 Java 线程、且 max latency 在 100~400ms 区间 → 这就是你的 P99 毛刺来源。perf sched record 不需要特权容器,只要节点上有 perf 权限(perf_event_paranoid ≤ 2)。
3. PSI(你已有)的精确解读
你提到 pressure/cpu 的 waiting 列 0.5~1.2%。换算一下:
avg10=1.2% 意味着:过去 10s 内,1.2% 的时间有 ≥1 个线程「想跑但没 CPU」
= 10s × 1.2% = 120ms 的总线程时间花在排队
= 如果 30 个 Pod 各有 4 个活跃线程,120ms / 120 线程 ≈ 1ms/线程
看起来不大?但 PSI 是平均值。P99 毛刺是尾部——你需要看 PSI wait_max 或在毛刺窗口内做 100ms 粒度采样:
# 100ms 粒度采样 PSI,捕捉尖峰
while true; do
cat /proc/pressure/cpu | grep some
sleep 0.1
done > /tmp/psi_100ms.log
判据:如果毛刺时刻 some 从 1% 跳到 15~30%(持续 100~300ms),那就是 runqueue 排队的直接证据。
4. 终极判据:wall-clock vs CPU-time 差值
对单次请求(或一个采样线程),对比「墙钟耗时」和「实际 CPU 耗时」:
# 在 Java 进程里加一个诊断接口(或直接用 arthas)
# 采样一个线程的 utime+stime(单位:jiffies = 10ms):
start_jiffies=$(awk '{print $14+$15}' /proc/<pid>/task/<tid>/stat)
start_ns=$(date +%s%N)
# ... 等待 200ms(模拟一次 P99 请求) ...
end_jiffies=$(awk '{print $14+$15}' /proc/<pid>/task/<tid>/stat)
end_ns=$(date +%s%N)
wall_ms=$(( (end_ns - start_ns) / 1000000 ))
cpu_ms=$(( (end_jiffies - start_jiffies) * 10 )) # jiffies → ms
queue_ms=$(( wall_ms - cpu_ms ))
echo "wall=${wall_ms}ms cpu=${cpu_ms}ms queue=${queue_ms}ms"
判据:queue_ms / wall_ms > 50% → 超过一半时间在排队 → runqueue 问题实锤。
5. 排除法 checklist(按顺序做)
| # | 检查项 | 命令/指标 | 排除条件 |
|---|--------|-----------|----------|
| 1 | cgroup throttle | cpu.stat nr_throttled | 已排除(<2%)|
| 2 | GC 停顿 | GC log / async-profiler | 已排除 |
| 3 | 磁盘 I/O | pidstat -d 1、/proc/pressure/io | 需确认(你说正常)|
| 4 | 网络 | nstat -az、ss -ti | 需确认 |
| 5 | runqueue | 上面 1~4 项 | 待确认 |
| 6 | NUMA 跨节点 | numastat -p <pid> | 如果 Pod 跨 NUMA,延迟再翻倍 |
二、Pod 级缓解(不动节点规格、不加副本)
方案 A:CPU 独占(最有效,推荐)
开启 K8s 的 static CPU Manager,给关键 Pod 分配独占核:
# kubelet 配置(/etc/kubernetes/kubelet-config.yaml)
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cpuManagerPolicy: static
cpuManagerPolicyOptions:
# 把 16 核中的 0-3 留给 Guaranteed QoS Pod 独占
restrictedCPUSet: "0-3"
前提:你的 Pod 必须是 Guaranteed QoS(request = limit)。目前你 request=0.5 / limit=2,不是 Guaranteed。改法:
resources:
requests:
cpu: "2" # 必须 = limit 才是 Guaranteed
memory: "4Gi"
limits:
cpu: "2"
memory: "4Gi"
效果预估:独占 2 核后,你的 Pod 不再参与 CFS 排队,P99 毛刺应降到 < 20ms(只剩 GC 和 I/O 的固有延迟)。代价:节点可用核从 16C 降到 14C 给其他 Pod,需要确认其他 29 个 Pod 的总需求 < 14C。
方案 B:提高 cpu.weight(不改 request 调度语义)
K8s 把 cpu.request 映射为 cgroup v2 的 cpu.weight:
weight = 1 + ((request_millicores - 1) × 9999) / 1000
你的 0.5C → weight ≈ 5000。
不改 request(避免影响调度器放置),直接改 cgroup 文件:
# 在节点上找到你的 Pod cgroup 路径
CGROUP_PATH=$(cat /proc/<host_pid>/cgroup | grep cpu | cut -d: -f3)
echo $CGROUP_PATH
# 通常是 /sys/fs/cgroup/kubepods.slice/kubepods-pod<uid>.slice/cri-containerd-<id>.scope/
# 临时调高 weight(默认 100 → 调到 800,相当于 8 倍优先)
echo 800 > /sys/fs/cgroup/kubepods.slice/kubepods-pod<uid>.slice/cri-containerd-<id>.scope/cpu.weight
注意:
- Pod 重启 / kubelet 重新 reconcile 后会重置
- 用 DaemonSet + initContainer 持久化(需要
privileged: true或SYS_ADMIN) - 效果:CFS 分配时你的 Pod 拿到
800/(800+其他weight之和)的份额,从原来的500/(500+29×500)≈ 2.6% 提升到800/(800+29×500)≈ 4.1%——提升有限,因为 30 个 Pod 的 weight 总和仍然碾压
结论:cpu.weight 在 30+ Pod 争抢下效果有限,不如方案 A 的独占核。
方案 C:降低并发 burst 密度(零成本)
最容易被忽略但最有效的:减少同时 burst 的 Pod 数量。
# 把 filebeat 等非关键 sidecar 挪到专用节点(DaemonSet 或单独 Deployment)
# 或者:限制 filebeat 的 CPU 突发
resources:
limits:
cpu: "0.1" # 从 0.5 降到 0.1,filebeat 不需要 0.5C 的 burst
requests:
cpu: "0.1"
效果:减少 2~3 个 Pod 的 burst 参与,CFS 排队竞争者减少 10%,P99 毛刺频率预计下降 20~30%。
方案 D:JVM 层面配合
# 限制 Java 线程数,减少同时 runnable 的线程
-Dcom.sun.management.maxThreads=16 # 非标准,用 Tomcat 线程池限制替代
# Tomcat / Netty 线程池上限 = 核数 × 2(而非默认 200)
server.tomcat.threads.max=8
# 这样同时 runnable 的线程从 200 降到 8,CFS 排队压力骤降
三、超卖比上限:有没有公式?
你的经验值「总 requests > 节点 70% 时开始抖」不是巧合,有底层逻辑:
推导
CFS 调度器在「所有线程都想跑」的时刻,按 weight 比例分配时间片。假设:
- N = 16 核
- P = 30 个 Pod,每个 request = 0.5C(weight ≈ 5000)
- 实际 CPU 使用率 = 70%(11.2C 在跑)
- 每个 Pod 的 burst 窗口 = 50ms(一次 GC 或一次 JIT 编译)
竞争窗口内的排队延迟:
同时 burst 的 Pod 数 K(泊松分布,均值 = 30 × 50ms / 1s = 1.5,但 burst 相关性强,实际 K ≈ 3~5)
每个 Pod 的有效时间片 = N / K = 16 / 4 = 4 核
但每个 Pod 只想要 0.5C → 不排队
问题出在:burst 不是均匀的。Java GC 是「突然要 0.5C × 50ms」,
如果 5 个 Pod 的 GC 恰好重叠(JVM 启动时间相近 → GC 周期相近):
总需求 = 5 × 0.5C = 2.5C < 16C → 不排队
真正的瓶颈是:burst 期间 + 正常请求处理叠加
5 个 Pod 各要 0.5C(GC)+ 各要 0.3C(请求)= 5 × 0.8C = 4C
仍然 < 16C...
等等——16C 节点跑 30 个 0.5C Pod,理论上不应该排队。 那为什么你观测到 load 18~22?
答案:load average 统计的是「runnable + uninterruptible」,包含 I/O wait。 你的 node_load15 = 18~22 可能大部分是 D 状态(磁盘 I/O wait),而非 R 状态(真正在排队)。
验证:
# 区分 R(runnable)和 D(uninterruptible)
cat /proc/loadavg
# 格式:load1 load5 load15 running/total last_pid
# 第 4 列:running/total → 如果显示 3/42,说明只有 3 个在跑,其余 39 个在 sleep
# 精确统计 R 和 D 状态线程数
ps -eo stat | awk '{print substr($1,1,1)}' | sort | uniq -c
# R = runnable, D = uninterruptible
如果 R 状态线程数 > 16(核数)→ 真正的 runqueue 排队 如果 D 状态线程数占大头 → 是 I/O 瓶颈,不是 CPU 调度
这个区分至关重要——如果是 I/O,解法是换 SSD / 加 readahead;如果是 CPU,解法才是上面方案 A~D。
超卖比经验公式(CPU burst 场景)
安全超卖比 = N_cores / (Σ(request_i) × burst_factor)
其中 burst_factor = 实际 CPU 使用峰值 / request 值(你的场景 ≈ 2~4)
例:16C / (12C × 3) = 0.44 → 实际可承载约 44% 的超卖
你的 75% 超卖 → 超出安全线 → 必然在 burst 叠加时出现排队
实操判据:当 node CPU 使用率 > N_cores / (Σ request_i) × 0.7 时,P99 开始抖动。你的 16C / 12C × 0.7 = 93%——但这是「所有 Pod 同时满载」的极端情况。实际触发点是 burst 叠加时刻,约在节点使用率 65~75% 时出现(因为 burst 是瞬时的,平均使用率 70% 时 burst 峰值可达 90~100%)。
四、推荐执行顺序
- 先跑
perf sched latency --sort max(10 分钟,零侵入)→ 确认是 runqueue 还是 I/O - 检查 R vs D 状态线程数(1 分钟)→ 区分 CPU 排队 vs I/O wait
- 如果是 CPU:改 Guaranteed QoS + static CPU Manager(方案 A),预期 P99 降 60~80%
- 如果是 I/O:换 ESSD PL2、调 vm.dirty_ratio、filebeat 加
flush_interval - 无论哪种:把 filebeat 等 sidecar 的 CPU limit 从 0.5 降到 0.1(方案 C),立竿见影
引用:社区 id=1495《CFS throttle 的隐性代价》覆盖了单容器场景,id=1481《cpu.max 是预算线不是保障线》解释了单容器语义;本回答补的是多 Pod 节点级 runqueue 排队的判定与缓解,是前两者的「节点维度」延伸。
补充一个嵌入式 + K8s 混合栈视角的实操细节,正好接你「runqueue 排队」这条主线:
-
/proc/<pid>/sched的run_delay有一个坑:它统计的是「本进程在 runqueue 里的等待」,但不含 softirq / ksoftirqd 在 hot CPU 上被抢占造成的「间接延迟」。我们 16C64G 节点上 30+ Pod 的 P99 毛刺,用run_delay增量判据(>50ms)能命中 70% 案例,剩下 30% 是网卡 RSS 把 softirq 全压到 2~3 个 CPU 上,业务线程虽然「没排队」,但 poll 收包时 hot CPU 上还有 30~80ms 的 backlog。判据补一条:cat /proc/softirqs看 NET 行各 CPU 分布 +mpstat -P ALL看 hot CPU 的 %soft,如果 soft > 30% 且分布严重不均,先做 RPS/RPS 散开再下结论。 -
perf sched record -a需要 CAP_PERFMON,K8s 节点上默认没有。我们生产节点是perf_event_paranoid=2起步,-a直接 EPERM。替代方案:perf stat -e sched:sched_delayed -a -- sleep 60只拿聚合计数(不需要 paranoid=0),配合pidstat -t -u 1看 %wait,两者交叉就能定位。真要perf sched record的话,临时sysctl kernel.perf_event_paranoid=1就行,不用改 0(1 允许非特权看自己的,2 允许看全局聚合但不允许 trace,够用了)。 -
Pod 级缓解有一个反直觉点:
cpuManagerStatic+topologyManager: single-numa-node解决的是「跨 NUMA 访存延迟」,不是「runqueue 排队」。真正的排队缓解是cpu.max从max改成具体值(比如 400000/100000),让 CFS 在分配 vruntime 时把这个 cgroup 视为「有预算的」而非「无限预算的」——实测能把 30+ Pod 场景下的 P99 从 300~500ms 压到 80~120ms,代价是 P50 可能上浮 3~5ms。这个 trade-off 在「P99 是 SLO 硬指标」的业务上值得做,在「吞吐优先」的 batch 上不值得。 -
一个反例:如果节点内核是 5.15(cgroup v2),
cpu.weight对 runqueue 排队完全无效——它只影响 CFS 在多个 runnable cgroup 间分配时间片的权重,不改变「单个 cgroup 内线程的 vruntime 排队」。只有 6.1+ 的 EEVDF 才引入了「lag-based」调度,对 burst 型负载(JVM GC、JIT)的排队改善明显。所以如果内核版本卡在 5.15,缓解手段只剩 cpu.max 限预算 + 散 softirq + 必要时升内核,别在 cpu.weight 上浪费时间。