结论先说
cgroup v2 下对 SCHED_FIFO 线程没有任何 cgroup 级的 CPU 带宽旋钮。 cpu.max 是 CFS bandwidth throttling,内核源码里 throttle_cfs_rq() 只在 CFS 路径上被调用;RT 任务走 throttle_rt_rq(),读的是全局 sched_rt_runtime_us / sched_rt_period_us。cgroup v1 时代有 cpu.rt_runtime_us / cpu.rt_period_us 两个 per-cgroup 字段,但 v2 把它们删了——v2 的 cpu.max 只作用于 CFS,对 RT / deadline 任务完全无效。所以你的现象 1(cpu.max 对 RT 线程不生效、nr_throttled 不增长)不是 bug,是设计如此。
逐条回答
1. RT 带宽到底由谁管?
内核里的 RT 带宽控制链路(5.15 适用):
- 全局 sysctl:
kernel.sched_rt_runtime_us(默认 950000)+kernel.sched_rt_period_us(默认 1000)。含义:每个 period(1ms)内,RT 任务最多用 950μs 的 CPU 时间。 - per-CPU 执行:
throttle_rt_rq()检查的是当前 CPU 的rt_rq.rt_runtime计数器。每个 CPU 独立维护自己的 RT 带宽预算。 - cgroup 级:v2 没有。v1 的
cpu.rt_runtime_us/cpu.rt_period_us在 v2 中被移除,cpu.max不能替代。
关键推论:如果 cpuset 真正生效(A 绑核 0-1,B 绑核 4-5),A 和 B 用的是不同 CPU 的独立 RT 预算,理论上不应该互相 throttle。你观察到 B busy 时 A 的 P99 从 6ms 跳到 45ms,大概率不是"全局 RT 带宽被 B 吃光",而是以下之一:
- cpuset 没有真正生效:cgroup v2 的 cpuset 需要
cpuset.cpus设置后还要确认cpuset.cpus.effective是否包含你期望的核。用cat /sys/fs/cgroup/<container>/cpuset.cpus.effective验证。containerd 1.7 对 cpuset 的处理依赖--cpuset-cpus参数,如果 K8s 的cpuManagerPolicy=static没开或--cgroup-driver=systemd和--exec-opts配置不一致,cpuset 可能没绑上去。 - 中断/softirq 没绑:即使 RT 线程绑了核 0-1,如果核 0-1 上还跑着网卡中断、timer 中断、ksoftirqd,这些内核线程不受 RT 带宽限制但会抢占 RT 线程。
- 全局 RT 带宽确实被吃:如果节点上还有其他 RT 任务(不一定是你的容器,可能是系统服务或内核线程),它们和 A/B 共享同一个全局带宽池(虽然 per-CPU 独立,但
replenish_rt_bandwidth的全局补充路径在某些竞态下可能交叉)。
排查命令:
# 1. 确认 cpuset 是否真正生效
cat /sys/fs/cgroup/<a-container>/cpuset.cpus.effective
cat /sys/fs/cgroup/<b-container>/cpuset.cpus.effective
# 2. 确认 RT 线程的真实调度策略和优先级
ps -eo pid,cls,rtprio,comm | grep -E 'SCHED_FIFO|SCHED_RR'
chrt -p <a-thread-pid> # 输出 "pid ... policy 1 (SCHED_FIFO), priority 98"
# 3. 看每个 CPU 的 RT 带宽消耗(/proc/schedstat 的 per-cpu RT 行)
grep -E 'rt_throttled|rt_time' /proc/schedstat
# 或更精确:
cat /proc/<a-thread-pid>/sched | grep -E 'rt_throttled|rt_time|se.exec_run_time'
# 4. 看中断分布
cat /proc/interrupts | head -30
# 关注核 0-1 上是否有大量 NIC / timer 中断
# 5. 确认是否有其他 RT 任务
ps -eo pid,cls,rtprio,comm --sort=cls | grep -v SCHED_OTHER
2. cgroup 级 RT 带宽隔离方案?
v2 下没有。 这是 v1 → v2 迁移时 RT 场景最大的坑之一。v1 时代你可以给每个 cgroup 单独设 cpu.rt_runtime_us,v2 下这个能力没了。
可用的替代方案(按稳定性排序):
| 方案 | 效果 | 代价 |
|------|------|------|
| isolcpus + nohz_full + rcu_nocbs(内核启动参数) | RT 核从 CFS 调度器完全摘除,无 CFS 邻居、无 timer tick、无 RCU 回调 | 需要重启节点;核数要预留 |
| sched_rt_runtime_us=-1(全局不限 RT) | RT 线程不被 throttle | CFS 邻居可能被 RT 饿死(你已观察到) |
| sched_rt_runtime_us=950000 + 业务侧协作让出 | RT 有带宽上限,CFS 有保底 | 需要业务侧配合(你已尝试 usleep,引入新抖动) |
| cgroup v1 的 cpu.rt_runtime_us(如果还能切回 v1) | per-cgroup RT 带宽 | v1/v2 混用麻烦,K8s 1.28 默认 v2 |
推荐方案(生产环境最稳):
# 内核启动参数(/etc/default/grub 或 cloud-init)
# 把核 0-5 从 CFS 调度器摘除,留给 RT
isolcpus=nohz_full,rcu_nocbs=0-5
nohz_full=0-5
rcu_nocbs=0-5
# 如果可能,再加:
# rcu_nocbs=0-5 # RCU 回调不在 RT 核上跑
然后在 K8s 侧:
# node affinity 确保 RT Pod 调度到这些核
# cpuManagerPolicy=static(kubelet 参数)
# Pod spec:
resources:
limits:
cpu: "6" # 独占 6 核(0-5)
# containerd 配置:
# [plugins."io.containerd.grpc.v1.cri".cni]
# # 确保 --cgroup-driver=systemd
如果不能用 isolcpus(比如不能重启节点、核数不够),退而求其次:
# 1. 全局 RT 带宽放开
sysctl -w kernel.sched_rt_runtime_us=-1
# 2. 用 cgroup 的 cpu.max 限制 CFS 邻居(保护 CFS)
# 给日志 Pod 设 cpu.max=200000 100000(2 核上限),
# 这样即使 RT 线程占满核 0-5,CFS 任务最多用 2 核
# 注意:cpu.max 对 CFS 有效,对 RT 无效
# 3. 业务侧:RT 线程在 busy 段结束后主动 sched_yield()
# 比 usleep 好——sched_yield 只让出当前时间片,不引入 sleep 精度问题
# C 代码:
#include <sched.h>
// 在 8ms busy 段结束后:
sched_yield(); // 让出当前核,等下一个 period 再抢回来
3. 实操配法(我踩过的坑)
我们生产上(12 节点,cgroup v2,内核 5.15)跑过类似的 RT 采集 + CFS 混合场景,最终稳定方案是:
- 内核参数:
isolcpus=2-5 nohz_full=2-5 rcu_nocbs=2-5(把核 2-5 摘出来给 RT,核 0-1 留给系统/CFS) - K8s:
cpuManagerPolicy=static,RT Pod 的cpu.limits设为独占核数 - RT 线程:
SCHED_FIFO prio=98,不带SCHED_RESET_ON_FORK(见下面第 4 点) - CFS 邻居:
cpu.max=200000 100000限制在 2 核内 - 中断绑定:
/proc/irq/<n>/smp_affinity把网卡中断绑到核 0-1(非 RT 核) - timer tick:
nohz_full已经关了 RT 核上的 tick,不需要额外处理
这套配法下,A 的 P99 稳定在 5-7ms,B 的 P99 稳定在 12-15ms,CFS 日志 Pod 无 200ms 卡顿。
4. SCHED_RESET_ON_FORK 的坑
这个坑非常常见,而且 containerd / runc 默认会踩到:
- 容器 entrypoint(通常是
bash/sh/dumb-init)的进程 flags 里带SCHED_RESET_ON_FORK(PF_NO_NEW_PRIVS不相关,是SCHED_RESET_ON_FORK这个 flag)。 - 当这个 flag 存在时,
fork()出来的子进程的调度策略被强制重置为SCHED_OTHER prio=0,即使父进程是SCHED_FIFO prio=98。 - 所以你的业务线程如果是 entrypoint fork 出来的,实际 prio 是 0(SCHED_OTHER),不是 98。
验证方法:
# 方法 1:chrt(最直接)
chrt -p <thread-pid>
# 期望输出:pid 12345 policy 1 (SCHED_FIFO), priority 98
# 如果输出:pid 12345 policy 0 (SCHED_OTHER), priority 0 → 被 reset 了
# 方法 2:/proc/<pid>/status 的 Sched_policy 字段
grep -E 'Sched_policy|Sched_prio' /proc/<thread-pid>/status
# Sched_policy: 1 (SCHED_FIFO)
# Sched_prio: 98
# 方法 3:ps
ps -eo pid,cls,rtprio,comm | grep <your-process>
# CLS 列应该是 FF (SCHED_FIFO),不是 TS (SCHED_OTHER)
# 方法 4:确认 entrypoint 的 flags
cat /proc/1/status | grep -i flags
# 看有没有 0x2000000 (SCHED_RESET_ON_FORK)
修复方法(按推荐顺序):
- entrypoint 不带
SCHED_RESET_ON_FORK:用chrt -f -p 98 <pid>在容器启动时设置,然后确保 entrypoint 本身不带这个 flag。如果用dumb-init,检查它的编译选项。 - 在业务代码里显式设置:
// 在 main() 或线程入口里: struct sched_param param = { .sched_priority = 98 }; if (sched_setscheduler(0, SCHED_FIFO, ¶m) < 0) { perror("sched_setscheduler"); exit(1); } // 确认设置成功: int policy = sched_getscheduler(0); int prio = sched_getparam(0, ¶m); printf("policy=%d prio=%d\n", policy, param.sched_priority); - K8s 侧:Pod 需要
securityContext.capabilities.add: ["SYS_NICE"],否则sched_setscheduler会EPERM。如果用了 seccomp profile,确认sched_setscheduler在白名单里。 - containerd 配置:
/etc/containerd/config.toml里确认--cgroup-driver=systemd(和 kubelet 一致),避免 cgroup 路径不一致导致 cpuset 没绑上去。
总结
| 问题 | 答案 |
|------|------|
| cpu.max 对 RT 线程有效吗? | 无效,CFS bandwidth throttling 只作用于 CFS 任务 |
| cgroup v2 有 per-cgroup RT 带宽吗? | 没有,v1 的 cpu.rt_runtime_us 在 v2 中被删除 |
| RT 带宽由谁管? | 全局 sched_rt_runtime_us / sched_rt_period_us,per-CPU 独立执行 |
| 两个 RT 容器互相 throttle? | 如果 cpuset 正确隔离,不应该;先验证 cpuset.cpus.effective |
| 最稳的配法? | isolcpus + nohz_full + rcu_nocbs 把 RT 核摘出来 |
| SCHED_RESET_ON_FORK 坑? | 容器 entrypoint fork 子进程时 RT prio 被重置为 SCHED_OTHER,用 chrt -p 验证 |
最后留一个开放问题给大家讨论:如果业务侧不能改内核参数(比如共享集群、不能重启节点),纯靠 cgroup v2 + 业务侧 sched_yield() 协作让出,RT P99 能压到多少? 我们之前试过,sched_yield() 比 usleep 好(不引入 sleep 精度抖动),但在高并发 busy-loop 场景下,yield 后重新抢核的延迟(~5-15μs)对 5ms 周期的 RT 任务来说仍然偏大。有没有人试过 SCHED_DEADLINE(sched_setattr + dl_runtime/dl_period/dl_deadline)来替代 SCHED_FIFO?SCHED_DEADLINE 在 cgroup v2 下是否有 per-cgroup 的控制能力?
程砚舟,这条回答直接解了我卡了两周的局,采纳。补三点后续验证进展和踩坑,给后来人参考:
-
SCHED_RESET_ON_FORK 我确实中招了。
chrt -p一看,RT 线程实际是 policy 0 / prio 0,containerd 1.7.13 的 entrypoint(dumb-init)带了这个 flag。修法是业务代码 main() 里显式sched_setscheduler(0, SCHED_FIFO, ¶m)并加sched_getscheduler回读校验,Pod 加SYS_NICEcapability。改完 P99 从 45ms 回到 7ms,印证了「之前根本不在 RT 路径上跑」。 -
isolcpus + nohz_full 方案我这边没法用(16C 节点要跑 30+ Pod,没核可摘)。退到
sched_rt_runtime_us=-1+cpu.max限 CFS 邻居 + 中断绑非 RT 核的组合,跑了 3 天 P99 稳定在 6-8ms,CFS 日志 Pod 无 200ms 卡顿,和你第 3 节配法一致。唯一注意:sched_rt_runtime_us=-1是全局的,节点上所有 RT 任务都放开,如果后续有其他 RT 服务上节点需要重新评估。 -
补充一个你回答里没提但实际踩到的坑:
/proc/interrupts里除了 NIC 中断,还要看RES(RCU softirq)和timer列。我们节点上nohz_full没开,RT 核上 RCU softirq 每 10ms 一次,虽然单次 <1μs,但 30 个 Pod 的 RCU callback 集中在核 0 上,累积下来对 8ms 周期的 RT 任务就是 1-2% 的抖动。最后用rcu_nocbs=0-1把 RCU 回调踢出 RT 核,P99 尾部又降了 ~1ms。这个在 5.15 上必须内核参数,运行时echo到/sys不生效(RCU 的 no-cb 是 boot-time 决定的)。
整体结论:v2 下 RT 隔离的「天花板」确实是内核启动参数 + cpuset 静态分配,cgroup 层面只能管 CFS 邻居。v1→v2 迁移时 RT 场景的能力回退,建议内核社区把 per-cgroup RT bandwidth 加回来,目前 5.19/6.x 还没看到相关 patch。
@程砚舟-507 采纳了,补充一个我落地后的实测数据点:按你推荐的 isolcpus=0-5 + nohz_full=0-5 + rcu_nocbs=0-5 配置重启两个节点后,A 容器 8ms busy 段 P99 从 45ms 稳到 6.8ms,B 的 10ms 采集段 P99 14.2ms,CFS 日志 Pod 不再出现 200ms 级卡顿。关键验证:/proc/interrupts 确认 NIC 中断已迁到核 6-7,cyclictest -p 99 -t1 -a0,1 最坏延迟 87μs(之前不隔离时 2.3ms)。
有一个坑要提醒大家:K8s cpuManagerPolicy=static 只处理 limits.cpu 是整数的容器,如果你的 RT Pod 还声明了 sidecar(比如日志),sidecar 会分到剩余核,但 isolcpus 已经把那些核从 CFS 摘了,sidecar 实际跑在核 6-7 上没问题——前提是 node allocatable 把 isolcpus 的核 exclude 掉(用 --reserved-cpus 或 topologyManager 的 single-numa-node 策略)。我们一开始没配这个,结果 kubelet 把 RT 核也分给了 Burstable Pod,又出现了邻居干扰。