上午刚处理完核心交易链路的一个偶发性延迟问题。开发团队看着 Grafana 面板上的数据陷入了自我怀疑:容器的 CPU 使用率一直在 30% 到 40% 左右徘徊,内存也很平稳,但接口的 P99 延迟却会毫无规律地出现数百毫秒的毛刺。他们排查了 JVM GC 日志、数据库慢查询,甚至怀疑到了宿主机的物理网卡丢包上,依然毫无头绪。
如果你在 K8s 环境下运维过高并发、对延迟敏感的微服务(尤其是多线程模型的 Java 或 Go 应用),这种现象其实并不陌生。表象是网络或代码问题,根本原因通常潜伏在 Linux 内核的 CPU 调度机制与 K8s 的资源配额(Quota)模型之间的冲突中。
现场勘测:被掩盖的节流(Throttling)
对于这种 CPU 使用率不高但延迟突增的场景,我的第一反应通常是去看 cgroup 的状态。进入出问题的 Pod,查看 CPU 的统计信息:
# cat /sys/fs/cgroup/cpu/cpu.stat
nr_periods 543210
nr_throttled 18432
throttled_time 943201500000
数据不会撒谎。nr_throttled 表示在这 54万多个调度周期中,有 1.8 万个周期应用被内核强行“限流”了。throttled_time 的单位是纳秒,换算下来,这个容器在运行期间被硬生生按住了 943 秒不能执行任何 CPU 指令。
在 Grafana 上看到的 40% CPU 使用率,是普罗米修斯按 15 秒或 30 秒抓取一次算出来的平均值。而在 Linux 内核微秒级的调度世界里,这个容器早就因为触碰了红线而被频繁挂起了。
深入内核:CFS 调度器与 100ms 陷阱
要理解这个问题,必须剥开 K8s 的 YAML,看看底层的 Linux 完全公平调度器(CFS, Completely Fair Scheduler)。
在 K8s 中,当我们为 Pod 配置了 resources.limits.cpu 时:
resources:
requests:
cpu: "1"
limits:
cpu: "2"
这实际上在宿主机的 cgroup 中映射成了两个关键参数:
* cpu.cfs_period_us:默认 100,000 微秒(100毫秒)。这是 CFS 统计 CPU 使用量的一个周期。
* cpu.cfs_quota_us:由 K8s limits 计算得出。如果你限制了 2 个 CPU,这里就是 200,000 微秒(200毫秒)。
陷阱就在这里。
假设你的应用是一个处理高并发请求的 Java 服务,线程池里有 10 个活跃线程。当一波突发流量打过来时,这 10 个线程会被操作系统调度到 10 个物理核上并行执行。
每个周期你总共有 200ms 的 CPU 时间配额。如果 10 个线程同时满负荷运转,仅仅需要 20毫秒(20ms * 10 = 200ms),你的容器就会把这 100ms 周期内的配额全部耗尽。
接下来的 80毫秒 会发生什么?
内核会无情地将这个 cgroup 下的所有进程剥夺 CPU 执行权(Throttled),直到下一个 100ms 周期到来。
在这漫长的 80 毫秒里,你的应用处于“假死”状态。网络包已经到了网卡,但内核不会唤醒你的应用去读;外部看起来,就是一次毫无征兆的 P99 延迟飙升。而从 1 秒的宏观维度来看,20ms 运行 + 80ms 暂停,CPU 监控曲线上看到的利用率极有可能连 30% 都不到。
历史包袱与内核缺陷
如果你使用的是比较旧的内核版本(比如 4.19 早期或者 3.10 系列),情况会更糟。早期的 CFS 调度器在处理多核环境下的 quota 唤醒时存在设计缺陷(著名的 CFS quota bug)。由于 tg->cfs_rq 的全局时钟同步和本地时钟漂移,常常会导致不必要的限流。
即使后来内核引入了 burst 机制,或者修复了上述时钟漂移 bug,只要你使用了 CPU Limit,这种“微观上的配额耗尽导致宏观上的延迟”的物理定律依然存在。
应对方案与架构抉择
明确了原理,解决起来就有了方向。上午排查完后,我给开发团队提供了以下几种调整策略:
方案一:取消 CPU Limit (当前业界最推崇的最佳实践)
对于延迟极度敏感的核心微服务,不要设置 CPU Limit。
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
# 不设置 CPU limit
memory: "4Gi"
底层逻辑: 当没有 CPU Limit 时,应用只受 cpu.shares(由 requests.cpu 控制)的影响。在宿主机 CPU 资源充足时,应用可以无限压榨空闲 CPU;在宿主机资源紧张时,内核会根据 shares 比例进行相对公平的降级分配,而绝对不会出现强制的暂停(Throttle)。
代价: 对容量规划和调度器的要求更高。你需要确保节点不会过度超卖(Overcommit),或者结合 K8s VPA / HPA 做及时的扩容。
方案二:调小 CFS Period
K8s 默认将 cfs_period_us 写死为 100ms。如果你有权限修改 Kubelet 配置,可以通过开启 CustomCPUCFSQuotaPeriod 并修改 --cpu-cfs-quota-period 参数。
如果我们将周期从 100ms 调小到 10ms,Quota 也成比例缩小。虽然应用依然会耗尽配额,但每次被挂起的时间可能从 80ms 变成了 8ms。P99 延迟的抖动幅度会显著降低。
代价: 调度周期变短意味着系统上下文切换(Context Switch)的开销增加。这是用宿主机的整体吞吐量去换取单个容器的延迟稳定性。
方案三:启用 CPU Manager(绑核)
对于真正的硬核低延迟服务(如网关、高频交易组件),直接绕过 CFS Quota,走 K8s 的 CPU Manager 特性。
在 Kubelet 开启 --cpu-manager-policy=static 后,将 Pod 的 Request 和 Limit 设为相等的整数(Guaranteed QoS):
resources:
requests:
cpu: "4"
limits:
cpu: "4"
底层逻辑: Kubelet 会在底层的 cpuset cgroup 中,将这 4 个物理核独占分配给这个容器。其他进程根本无法调度到这 4 个核上,彻底消除 CFS 调度的干扰。
总结
很多时候,业务开发同学容易陷入“代码没问题 = 系统没问题”的误区。在容器化和云原生时代,应用与操作系统之间隔着 cgroup、namespace 甚至虚拟化的层层黑盒。排查性能瓶颈不能仅仅停留在 JVM 层或者代码逻辑层,理解 Linux 内核底层的调度、内存管理与网络栈,是从现象看透本质的唯一路径。
上午的问题最终通过移除该核心组件的 CPU Limit 并调整 HPA 阈值解决,P99 延迟重新拉成了一条平直的直线。这就是运维架构的魅力——在复杂的系统齿轮中,精准地找到那颗卡住的螺丝。