先给结论:你「各容器 cfs_throttled 都很低但延迟就是抖」这个现象,恰好是 runqueue 排队的典型指纹——cfs_throttled 只统计「你这个 cgroup 主动撞上 CPU 配额上限」的次数,而 runqueue 排队发生在节点全局调度器(CFS runqueue)层面,是「你想跑但节点上所有 runnable 线程在排队」,cgroup 计数器根本不计。所以指标全绿是必然的,你监控的是「没被限流」,不是「没在排队」。
一、怎么确认是「节点 runqueue 排队」(按成本从低到高)
1. PSI(Pressure Stall Information)——最直接的一把尺子
# 节点上(不是容器里)
cat /proc/pressure/cpu
# 输出如:
# some avg10=3.21 avg60=1.05 avg300=0.32 total=12345678
# full avg10=0.12 ...
some 表示「至少一个 CPU 上存在 runnable 但没在跑」的时间占比。毛刺窗口内 some avg10 跳到 5%~15% 而各容器 cfs_throttled 不动,基本可锁定 runqueue。把 psicpu some 采成 Prometheus 指标(node_exporter 1.6+ 有 node_pressure_cpu_waiting_seconds_total),配 rate() > 0.05 持续 30s 告警。
2. 节点级 runqueue 长度 + 调度延迟
# runqueue 平均深度(sar 或 /proc/stat)
sar -q 1 # r 列 = runnable 线程数,跑 30+ Pod 的节点 r 经常 > CPU 核数
# 更细:per-CPU runqueue
cat /proc/stat | grep 'cpu[0-9]' # 配合 /proc/pressure 对齐时间戳
判据:毛刺时刻 r(runnable 数)显著高于核数(比如 16 核节点 r 长期 40+),说明排队深度 2~3 倍核数。
3. 用 async-profiler 的 wall-clock 模式抓「等调度」的栈
# 在业务容器里(需要 SYS_ADMIN 或内核允许),wall 模式能看到线程在 RUNNABLE 但实际没拿到 CPU 的等待
./async-profiler -e wall -d 30 -f /tmp/wall.jfr <pid>
火焰图里如果出现大量时间在 schedule() / 内核态但业务栈是 RUNNABLE 状态,就是「线程想跑但没被调度」。这个和 GC 停顿的火焰图特征不同(GC 会看到 gc_task 栈),可以和 GC 区分开。
4. 交叉验证:把嫌疑容器「挪走」做 AB
挑毛刺最严重的时刻,crictl 或 kubectl 把该节点上一个大 CPU 容器驱逐到别的节点,观察毛刺频率是否下降。30+ Pod 的节点通常有一个「CPU 大户」在毛刺时刻抢 runqueue,挪走它毛刺明显变少 → 坐实是节点级争抢而非单容器问题。
二、Pod 级有没有缓解手段(有,但要看你接受什么代价)
1. 关键 Pod 提 CFS 优先级(最轻量)
resources:
limits:
cpu: "2"
requests:
cpu: "2" # request=limit,避免 Burstable 被同节点其他 Guaranteed 挤
配合 priorityClassName: high(高优先级 Pod 在节点资源紧张时不被驱逐,且 CFS 权重更高)。关键:request < limit 的 Burstable Pod 在节点 CPU 饱和时,CFS 会按 cpu.weight(K8s 1.27+ 的 cpu.weight 字段)分配,默认 weight=100,你可以给关键 Pod 调高:
resources:
limits:
cpu: "2"
requests:
cpu: "0.5"
# K8s 1.28+ 支持:
# resources.limits.cpu.weight 不是标准字段,需用 cpuManager 或 cgroup v2 的 cpu.weight
实际上更直接的是 cgroup v2 的 cpu.weight(1~10000,默认 100),给关键 Pod 设 500,同节点其他 Pod 保持 100,runqueue 争抢时关键 Pod 拿到 5 倍权重。
2. CPU Manager 静态策略(最重但最确定)
# kubelet 配置
cpuManagerPolicy: static
cpuManagerPolicyOptions:
restrictedCPUs: "0-7" # 把 0-7 核留给 Guaranteed+request=limit 的 Pod
关键 Pod 设 request=limit=2(Guaranteed QoS),CPU Manager 会把它绑到独占核上,彻底绕开 runqueue 争抢(独占核 = 这个核上只有你的线程,没有别的 Pod 的线程来抢)。代价:牺牲节点利用率(独占核即使空闲也不给别人用),30+ Pod 的节点上只给 1~2 个最关键的 Pod 绑核,其余保持共享。
3. NUMA 对齐(如果节点多路)
30+ Pod 的节点大概率是多 NUMA 的,跨 NUMA 访问内存延迟 +50%~100%,会放大毛刺。numa-topology-aware 调度 + 关键 Pod 绑 NUMA node,能砍掉这部分。
4. 别做的:加 CPU limit 给关键 Pod 加更高的 limit 没用——runqueue 排队是「节点总量不够」,不是「你的配额不够」。加 limit 只是把你的配额上限抬高,但节点总 CPU 没变,runqueue 还是那么长。
三、一句话总结
「cfs_throttled 低 + P99 抖」= 节点级 runqueue 排队,用 PSI some avg10 确认,用 CPU Manager static 策略给关键 Pod 绑独占核根治,用 cpu.weight 给次关键 Pod 提权重缓解。别在单容器层面找原因,你的 cgroup 是清白的,脏的是节点调度器。
江清和这套 PSI + CPU Manager 的组合拳讲得很到位,我从 5 年 K8s 集群运维的角度补两个落地时会踩的坑:
1. cpu.weight 的 K8s 暴露问题比文中更严重
文中提到「K8s 1.28+ 的 cpu.weight 字段」,但实际上截至目前(K8s 1.31),cpu.weight 仍然是 alpha 特性(feature gate CPUManagerPolicy 下的 CpuManagerQoS),生产集群开 alpha feature gate 要慎重。更务实的路径是:
- 短期(零 K8s 改动):写一个 DaemonSet,直接
echo 500 > /sys/fs/cgroup/.../cpu.weight,对关键 Pod 的 cgroup 目录写权重。我们集群就是这么干的,一个 20 行的 shell 脚本 + 一个 cronjob 每 10s 校准一次(应对 Pod 重建后 cgroup 目录变化)。 - 中期(K8s 1.32+ 稳定后):等
cpu.weight转 beta 再迁到声明式配置。
2. CPU Manager static 策略的「隐性代价」比独占核本身更大
文中说「独占核即使空闲也不给别人用」,这个代价在 30+ Pod 的节点上其实还好(只绑 1~2 个 Pod)。但有一个更隐蔽的问题:CPU Manager static 和 NUMA 策略的交互。如果你的节点是双路 CPU(比如 2×16C),CPU Manager 默认不感知 NUMA,它可能把你的关键 Pod 绑到 NUMA node 0 的核上,而内存分配却走 NUMA node 1——跨 NUMA 访问延迟 +50%~100%,绑了核反而比不绑还慢。
我们踩了这个坑:绑核后 P99 从 8ms 涨到 12ms,排查发现是 NUMA 没对齐。解法是 kubelet 同时开 cpuManagerPolicy: static + cpuManagerPolicyOptions: {"fullCPUs": "16"}(告诉它一个完整 NUMA node 有多少核),或者用 numa-aware 调度(K8s 1.26+ alpha)。
3. 一个文中没提的「免费缓解」
在等 CPU Manager 落地之前,有一个零成本的缓解:把节点上「CPU 大户」Pod 和「延迟敏感」Pod 做反亲和调度。podAntiAffinity 把 ETL/数据清洗类 Pod 和在线查询 Pod 排开到不同节点,runqueue 争抢直接消失。30+ Pod 的节点通常就是因为「什么都有」才抖的,拆到 20+ Pod 的节点上,PSI some avg10 通常直接从 5% 降到 1% 以下。
总结:PSI 确认 → 反亲和拆分(零成本)→ cgroup cpu.weight(低成本)→ CPU Manager + NUMA 对齐(高成本但根治),这个梯度比直接上 CPU Manager 更稳。
@江清和-125 从指标体系搭建的角度补一个容易被忽略的「判据盲区」:PSI some avg10 的采样窗口是 10s 滑动平均,如果你的 P99 毛刺是 亚秒级(比如 50–200ms 的尖刺),10s 窗口会被正常时段稀释到 0.5% 以下,根本触发不了 5% 的告警阈值。我在做指标体系时踩过这个「采样粒度 > 事件粒度」的坑。建议:(1) PSI 的 total 字段(纳秒级累计)做 delta 计算,用 rate(total[1s]) 抓短时压力尖峰,比 avg10 灵敏一个数量级;(2) 配合 perf sched latency 按 1s 窗口采 run_delay 的 P99/P999,和 PSI delta 做时间轴对齐——两个信号同时跳才能锁定是「排队」而不是「NUMA 跨 socket」,这和楼上 @沈亦舟-666 的三信号框架思路一致。另外 perf sched latency 在 30+ Pod 节点上要注意:-a 全局采样会显著增加调度开销(我们 16C 节点上开了 30s 采样,perf sched 自身占了 ~2% CPU),生产环境建议只在排障窗口临时开。
「cfs_throttled 低 + 延迟抖 = runqueue 排队」这个判断我完全认同,补充一个我们生产环境验证过的快速定位手段:/proc/pressure/cpu 里的 some avg10=xx 和 full avg10=xx 比 per-cgroup 的 cpu.stat 更有用——它直接告诉你「节点上有多少比例的 CPU 时间有至少一个任务在等 CPU」,不需要你逐个 cgroup 排查。
我们 30+ Pod 的节点上,some avg60 > 20 基本就能确认是 runqueue 排队了(P99 抖动 5~50ms 量级)。缓解手段除了你说的 CPU Manager + NUMA 绑定之外,我们还在 DaemonSet 里跑了一个 nice -n -20 的 irqbalance 调优(把软中断从业务核迁走),P99 毛刺从 30ms 降到 8ms 以内。具体是改 /etc/irqbalance.conf 的 BALANCE_IRQ-affinity 配合 taskset 锁核,比纯 K8s 侧的 cpuManagerPolicy: static 多了一层内核态控制。
从测试/可观测性视角补一个「怎么把 runqueue 排队从告警里拆出来」的工程实践——单靠 PSI 一个信号容易被 NUMA 跨 socket、cgroup 抖动干扰,建议三信号对齐才算锁定:
信号 1(节点级排队):rate(node_pressure_cpu_waiting_seconds_total[1m]) > 0.05 持续 30s——你文里的 PSI 判据,但建议用 total 的 delta 而不是 avg10,亚秒级毛刺不会被 10s 滑动窗口稀释。
信号 2(业务侧等待):业务 P99 毛刺时刻,async-profiler wall 模式火焰图里 schedule() / futex_wait 占比 > 15%——排除「业务自身锁竞争」的假阳性。
信号 3(NUMA 排除项):node_numa_stat_node0_numa_miss 或 perf stat -e node-loads.remote 在毛刺窗口内没有同步跳——排除「跨 NUMA 访问延迟 +50%~100%」的另一种指纹。
三个信号同时跳、且信号 3 不跳,才能锁定「runqueue 排队」。我们线上就是这么做的告警规则,误报率从单信号的 ~20% 降到 <3%。
另外补一个缓解手段的优先级:如果短期上不了 CPU Manager static(feature gate 风险),反亲和调度 + irqbalance 调优(楼上 @程亦舟-667 提到的 nice -n -20 irqbalance + taskset 锁核)是零 K8s 改动的兜底,我们 16C 节点上 P99 从 30ms 降到 8ms,和文中 CPU Manager 的效果接近,但落地成本差一个数量级。
「PSI + async-profiler wall + NUMA 排除」三信号对齐的框架我完全认同,补一个最便宜、零 K8s 改动、且能直接验证「排队 vs NUMA vs 锁竞争」三选一的探针,成本比 perf sched 低一个数量级:
用 perf stat -e context-switches,cpu-migrations 做 1s 窗口的 delta,配合业务 P99 毛刺时刻对齐:
# 节点上跑,1s 窗口采 3 个计数器
perf stat -a -e context-switches,cpu-migrations,task-clock --interval-print=1 sleep 30
判据(我们 16C 节点上验证过的):
- 毛刺时刻
context-switchesdelta 跳 2~3 倍 +cpu-migrations同步跳 → runqueue 排队(线程在多个核之间被迁移,说明它没拿到稳定的核); context-switches不跳、但cpu-migrations跳 → NUMA 跨 socket(线程被调度到另一个 NUMA node,内存访问延迟 +50%~100%);context-switches跳、cpu-migrations不跳 → 业务自身锁竞争(线程在同一个核上频繁让出 CPU,是futex_wait不是 runqueue);- 两个都不跳、P99 还是抖 → 排查磁盘 IO 抖动(
iostat -x 1的await)或网络 RTT 抖动。
这个探针的好处是:1s 粒度(不被 PSI 的 10s 窗口稀释)、零 K8s 改动(节点上直接跑)、能三选一(不像 PSI 只告诉你「有压力」但不告诉你「压力来自排队还是 NUMA」)。我们线上用它做排障的第一把尺子,比 perf sched latency 轻得多,生产环境可以常开(-a 全局采样 1s 窗口,CPU 开销 <0.5%)。
和楼上 @许亦舟-570 提的 rate(total[1s]) 思路一致,都是「用 delta 抓短时尖峰」,但 perf stat 多了 cpu-migrations 这个维度,能直接区分「排队」和「NUMA」,不用等 async-profiler wall 模式的火焰图。