结论先行:给容器设
cpu.limits(cgroup v2 的cpu.max)时,K8s 给你的是带宽上限,不是算力保障。你的服务平时只用 0.5 核,被限到 1 核没问题;可一旦瞬时想跑满 1 核,CFS 调度器会按「每 100ms 周期最多给 100ms」硬切,把一次本该 5ms 完成的批处理拖成跨周期等待,P99 直接跳到 200ms。我做了 15 年嵌入式,这本质就是看门狗喂狗节奏被打断——任务没崩,只是饿了一拍。
一、现象:CPU 监控全绿,延迟却出现周期性尖峰
一个 4C8G 的 worker 节点(Kubernetes 1.28.9,containerd 1.7.13,cgroup v2,内核 5.15.0-91),上面一个 Go 网关 Pod,resources.limits.cpu=1、requests.cpu=500m。稳态 8k QPS、P99 6ms,看着很健康。
但压到 15k QPS 时,P99 出现规律性尖峰:每隔约 100ms 附近,有一批请求延迟顶到 180~220ms,P99 从 6ms 跳到 210ms,且尖峰间隔高度规律。更迷惑的是 node_exporter 里节点 CPU 利用率峰值只有 62%,容器 cpu.usage 也远没到 1 核顶格——监控全绿,延迟却在抖。影响面:一次持续 40s 的尖峰,网关 5xx 涨 0.8%,一周复现 3 次。
二、根因:CFS 带宽控制的「周期配额」机制
关键机制讲清楚,否则排查会走偏:
-
cpu.max = "100000 100000"读法:第一个数是本周期最多可运行的纳秒数(配额),第二个是周期长度(默认 100ms)。1 核 = 每 100ms 周期里最多跑 100ms。这不是「给你 1 核」,而是「每 100ms 给你 100ms 的票」。 -
配额耗尽即强制睡眠:CFS 用
cfs_bandwidth在每次调度 tick 里扣配额,一旦本周期扣光,整个 cgroup 的任务被强制 sleep,直到下个周期period_timer唤醒。这段 sleep 不受应用控制,也不计入你的「CPU 利用率」统计——所以监控看着不忙,任务却在等票。 -
批处理的「攒票」效应:你的请求处理是突发型的(一波 30ms 算完),如果这波恰好跨在周期边界上,前 70ms 用掉配额,后 30ms 被切到下个周期——一次 100ms 的逻辑被拉长到 ~200ms 墙钟时间。这就是尖峰间隔「高度规律」的来源:它对齐的是 100ms 的 period,不是你的业务周期。
-
requests 才是保障:
requests.cpu=500m只影响调度时给 Pod 留多少节点算力,不限制运行时。真正卡你的是cpu.max。很多人把 requests 当成「我能用多少」,这是错的——它只是「调度器认账多少」。
一句话:cpu.max 是天花板(预算线),cpu.requests 是地板(保障线),而 CFS quota 的杀伤力在于天花板会主动把任务切走,而不是让它慢慢爬。
三、具体做法:把「限流」从硬切改成软约束
1) 实时服务:放宽 quota,用 requests 做保障 + 节点隔离
resources:
requests:
cpu: "1000m" # 想要多少算力,就把 requests 顶到目标核数
limits:
cpu: "1000m" # 若必须限,先给足;别用 500m 限制一个想跑 1 核的服务
对延迟敏感的网关/控制面,更稳的做法是不写 cpu.limits(只留 requests),把「不超卖」交给节点侧:
# 专用节点池,用污点 + 容忍把实时 Pod 钉到独占节点
kubectl label nodes gw-dedicated-1 dedicated=realtime
kubectl taint nodes gw-dedicated-1 dedicated=realtime:NoSchedule
# Pod spec: tolerations 匹配 + nodeSelector dedicated=realtime
# 该节点只跑实时负载,requests=limits=物理核,彻底绕开 quota 抖动
2) 必须限流时:拉大 period,降低切分频率
cpu.max 的第二个参数默认 100ms。把它调大,单次可连续运行的时间窗变长,批处理更不容易被切在中间:
# cgroup v2 里直接写(周期拉长到 1s,配额 1s = 1 核)
echo "1000000 1000000" > /sys/fs/cgroup/.../cpu.max
注意:拉大 period 会降低调度公平性(突发任务能一口气吃更久),只适合你确定自己就是那个该吃满的服务。
3) 应用层兜底:把处理时间压到远低于 period
最治本的是让单次任务处理 < period(100ms),这样配额根本不会在任务中途耗尽。把大循环拆小、批处理拆 chunk、避免单次同步调用 >50ms,比调内核参数更可控。
四、踩过的坑(反直觉)
-
监控全绿 ≠ 不抖:
cpu.usage统计的是「实际用了多少」,不统计「被切走等了多久」。看尖峰要抓cpu.stat里的nr_throttled和throttled_time,这两个才是实锤:cat /sys/fs/cgroup/.../cpu.stat # nr_periods nr_throttled throttled_time(纳秒) # 若 nr_throttled / nr_periods > 1%,说明每 100 个周期有 1 个被切,延迟抖动实锤 -
cpu.max和cpu.shares是两条独立的线:shares 决定「大家抢的时候你拿多少」,max 决定「你最多能拿多少」。只设 shares 不设 max,你不会被切,但在节点过载时会和邻居抢;只设 max 不设 shares,你平时不切,但过载时优先级低。两者语义完全不同,别混。 -
K8s 1.28 起支持
cpuManagerPolicy: static:对需要独占物理核的延迟敏感 Pod,开 CPU Manager 静态策略(resources.limits.cpu必须为整数核 +node.kubernetes.io/...注解),能彻底绕开 CFS quota——但代价是节点核数被硬划走,弹性变差,适合控制面、不适合弹性网关。
五、结论与可复用清单
结论:cpu.limits(cpu.max)是带宽预算而非算力保障;对延迟敏感服务,它会把突发任务切在周期边界上,造成规律性 P99 尖峰,且监控 CPU 利用率看不出来。
可复用清单:
- 延迟敏感服务:优先「requests=limits=目标核 + 专用节点」,或干脆只设 requests 不设 limits。
- 必须限流时:把
cpu.max的 period 从 100ms 拉长到 1s,降低切分频率(代价是公平性)。 - 治本:让单次任务处理时间 << period(100ms),拆批、拆 chunk。
- 排查实锤:看
cpu.stat的nr_throttled/throttled_time,而非cpu.usage。 - 区分
cpu.shares(过载时抢多少)与cpu.max(最多用多少),两条线独立。 - 极致场景用
cpuManagerPolicy: static独占物理核,接受弹性损失。
一句话:把 cpu.max 当「天花板」而不是「地板」来设计,实时服务的抖动就消了一大半。