深度剖析:K8s环境下CFS Bandwidth Control引发的P99延时毛刺与内核级调优

今天一上午都在配合业务团队排查一个诡异的问题。几个核心的Go服务在白天流量高峰期,频繁出现毫无规律的P99延时尖峰。业务开发查了链路追踪,发现时间全消耗在了服务内部的逻辑处理上,接着他们开始怀疑是宿主机网络丢包或者底层数据库响应慢。
我扫了一眼Prometheus的监控大盘,节点负载不高,网络TCP Retrans也处于极低水位,DB的Slow Log更是干干净净。既然外部依赖没问题,那问题只能出在计算资源的调度上。
当查到容器的CPU使用率时,业务同学指着图表说:“你看,Pod的CPU使用率最高才不到40%,资源非常充裕。”
我没有反驳,只是默默切到终端,找了一台发生过毛刺的Node,直接进入该Pod对应的Cgroup层级,敲下了这行命令:

cat /sys/fs/cgroup/cpu/kubepods/burstable/pod//cpu.stat

输出结果极为刺眼:

nr_periods 145321
nr_throttled 38452
throttled_time 1928430000000

nr_throttled 占比超过了26%。这意味着,在过去的调度周期里,这个容器有四分之一以上的时间被内核强制挂起(Throttled)。这就是典型的“看监控CPU使用率很低,但应用实实在在被卡死”的场景。
很多没有深入过Linux Kernel调度的工程师,对K8s的 limits.cpu 存在严重的误解。K8s的 limits.cpu 底层依赖的是Linux Cgroup的 CFS(Completely Fair Scheduler)Bandwidth Control 机制。
我们来看这背后的两个核心参数:

# cat /sys/fs/cgroup/cpu/kubepods/burstable/.../cpu.cfs_period_us
100000
# cat /sys/fs/cgroup/cpu/kubepods/burstable/.../cpu.cfs_quota_us
200000

默认情况下,cfs_period_us 是100ms(100000微秒),而这里的 cfs_quota_us 是200ms,对应K8s里的 limits.cpu: "2"(2个核)。
Go或者Java这类多线程/Goroutine并发模型,在面对突发流量时,会瞬间唤醒大量线程去抢占CPU。假设在这100ms的周期内,Go runtime唤醒了20个线程并发处理请求,每个线程跑了10ms。那么总的CPU配额在周期刚开始的10ms内,就被这20个线程瞬间耗尽(20 * 10ms = 200ms)。
配额一旦耗尽,内核的 CFS 调度器就会冷酷无情地把这个Cgroup下的所有进程全部移出 Runqueue。接下来的90ms内,这个容器仿佛陷入了时间停止,任何进来的网络请求、等待处理的任务,只能干等。这就是业务代码内部出现诡异耗时的根本原因。在Prometheus看来(通常是15秒或1分钟抓取一次),平均下来的CPU使用率甚至不到1核,完全掩盖了微秒级别的“饥饿”现象。
为了拿到最确凿的证据,我习惯用 eBPF 工具直接观测调度队列的延迟。在宿主机上跑了一下 runqlat

# /usr/share/bcc/tools/runqlat -T -p 
Tracing run queue latency... Hit Ctrl-C to end.
     usecs               : count     distribution
         0 -> 1          : 123      |                                        |
         2 -> 3          : 412      |*                                       |
         4 -> 7          : 1530     |****                                    |
         ...
     65536 -> 131071     : 284      |*                                       |
    131072 -> 262143     : 11       |                                        |

果然,有相当一部分调度的延迟落在了 65ms – 131ms 这个区间。这与 CFS 100ms 周期被限流后的等待时间高度吻合。
既然定位了,如何解决?
针对这种由多线程并发突发(Burst)引起的 CFS Throttling,有几套不同的架构解法,我通常根据集群的具体环境来实施:
方案一:简单粗暴,去掉 CPU Limits
如果在业务节点资源相对充裕的情况下,最佳实践是只设置 requests.cpu,不设置 limits.cpu。这样K8s会将该Pod划分为 Burstable QoS 级别。只要宿主机有空闲CPU,它就能尽情抢占。

resources:
  requests:
    cpu: "2"
    memory: "4Gi"
  limits:
    # 删掉 cpu limits
    memory: "4Gi"

缺点是:如果宿主机混部了大量高负载应用,会导致节点CPU被彻底打满,引发更严重的雪崩。这需要有完善的节点负载监控和驱逐策略做兜底。
方案二:调整 Kubelet 的 CFS Quota Period
如果因为多租户隔离必须限制 limits.cpu,可以通过修改 Kubelet 的配置,把 cpuCFSQuotaPeriod 调小。
修改 /var/lib/kubelet/config.yaml

cpuCFSQuota: true
cpuCFSQuotaPeriod: "10ms" # 默认是 100ms

把周期缩短到10ms,意味着即使配额被瞬间耗尽,容器最多也只会被挂起几个毫秒,而不是之前的90ms。这能极大平滑P99的毛刺。但代价是内核调度器的开销会显著增加,上下文切换(Context Switch)更频繁,总体吞吐量会下降1%~3%。
方案三:内核级调优 – 引入 CPU Burst 特性
这也是我最推荐的高阶玩法。在比较新的内核(比如阿里开源的 Anolis OS,或者内核版本 >= 5.14)中,引入了 CPU Burst 技术。它允许 Cgroup 积累之前周期未使用的 CPU 时间,在应对突发流量时,突破单周期的 Quota 限制。
如果内核支持,直接写 sysfs 即可开启:

# 开启 cfs burst 特性
echo 1 > /sys/fs/cgroup/cpu/kubepods/burstable/cpu.cfs_burst_us

通过调整 cpu.cfs_burst_us 参数,可以在不取消 limits.cpu 的前提下,优雅地解决突发并发带来的 Throttling 问题。
今天最后,我让运维团队批量下发了 Kubelet 配置变更,把核心服务所在节点池的 cpuCFSQuotaPeriod 从100ms切到了20ms,平滑重启后,业务监控上的P99延时曲线瞬间像刀切一样平整。
技术做到深处,没有所谓的玄学。所有的“偶尔卡顿”,本质上都是因为对底层调度机制不够敬畏。趁着中午这段时间把排查过程记录下来,希望以后团队遇到类似现象,能直接把手伸到内核里去找答案,而不是对着一层薄薄的监控面板瞎猜。