cgroup CPU 节流:容器延迟周期性抖动

容器 CPU 使用率仅三成,p99 却周期性抖动,根因是 CPU 配额在 100ms 周期内被瞬时打满后遭 cgroup 节流。调大 limit 并设置 GOMAXPROCS 后恢复。

问题背景

公司有一套 Kubernetes 集群,跑着十几个微服务。其中一个用 Go 写的 API 服务,3 个副本,每个副本的资源配置是 requests 500m、limits 1 CPU。节点是 32 核的物理机,整体 CPU 利用率长期不到 30%,资源看起来很宽裕。

这个服务上线一年多,平均延迟一直不错。上个月业务侧做了一次促销活动,流量涨了大约一倍,用户开始反馈“接口一阵一阵地慢”:大部分请求几十毫秒就返回,但每隔一段时间会冒出一批一两秒的请求。监控上 CPU 使用率仍然只有三成左右,没有 OOM,没有重启,日志里也没有报错,看起来不像资源不够。

故障现象

  • 平均延迟变化不大,p99 从 200ms 涨到 1.5s 左右,呈周期性尖峰。
  • 尖峰出现的时间间隔很规律,大约每 100ms 就有一次小的停顿,流量越大越明显。
  • 容器 CPU 使用率按分钟平均只有 0.3 核左右,远低于 1 核的 limit;节点 CPU 也很空闲。
  • 没有 OOMKilled、没有探针失败、没有重启;下游数据库和缓存的延迟都正常。
  • GC 日志里的停顿很短,不足以解释秒级的 p99。
  • 把副本数从 3 加到 6,平均负载降了,但尖峰仍然存在,只是单次影响小了一点。

排查过程

第一步排除下游。看链路追踪,慢请求的时间几乎都花在服务自身的排队和调度上,调用数据库、Redis 的耗时正常;GC 日志的最大停顿只有几毫秒,也对不上。问题在容器内部的 CPU 调度。

第二步看常规指标。Grafana 上容器的 CPU 使用率是 0.3 核左右,节点 CPU 30%,怎么看都不像被限流。但“平均使用率”是分钟级的 rate,会把几百毫秒内的突发抹平;如果突发在极短时间内把配额打满,平均值仍然可以很低。

第三步查 cgroup 的节流计数。进容器读 /sys/fs/cgroup/cpu.stat(cgroup v2;v1 在 /sys/fs/cgroup/cpu/cpu.stat),重点关注三个字段:nr_periods、nr_throttled、throttled_usec。这台容器的 nr_throttled 约占 nr_periods 的 55%,throttled_usec 累计很大——也就是说,超过一半的调度周期里,这个 cgroup 被内核强行停过。

第四步确认配额。cat /sys/fs/cgroup/cpu.max 输出 100000 100000,即每 100ms 周期最多使用 100ms CPU,正好是 1 核。Kubernetes 的 limits.cpu: 1 落到 cgroup 就是这条配额,和节点的 32 核没有关系。

第五步解释机理。这个 Go 服务没有设置 GOMAXPROCS,旧版本 Go 运行时会按宿主机核数初始化,容器里看到 32 个核,于是开了 32 个 P(逻辑处理器)。流量突发时,几十个 goroutine 同时跑,几毫秒内就把 100ms 的配额烧光;内核从此刻起冻结整个 cgroup,直到下一个 100ms 周期开始。期间所有可运行的线程都在等配额,请求排队,表现为周期性尖峰。平均值只有 0.3 核,但瞬时需求远超 1 核,这正是“CPU 没跑满却被节流”的原因。

第六步验证。把 limit 从 1 核调到 2 核,同时给容器加 GOMAXPROCS=2,重新压测:nr_throttled / nr_periods 从 55% 降到 2% 以下,p99 回到 200ms 以内。只调 GOMAXPROCS 不调 limit 也能缓解,但并发能力会下降;只调 limit 不调 GOMAXPROCS,突发仍然偏大。两个一起改效果最好。

解决方案

  1. 按实测重新定 limit:拉出过去两周的 CPU 使用分布,按 p95 加上突发余量设置 limit。这个服务从 1 核调到 2 核,request 保持 1 核不变,调度密度和成本都可控。
  2. 让运行时感知配额:给 Go 容器设置 GOMAXPROCS 等于 CPU limit(或使用 automaxprocs 自动读取 cgroup 配额)。Go 1.25 起默认感知 cgroup 配额,但存量镜像还是旧版本,不能假设运行时已经处理好。Java 服务同理,关注 ActiveProcessorCount 和 GC 线程数。
  3. 降低突发强度:检查是否有一批请求同时触发重计算、缓存穿透或大批量 GC;用连接池、限流、批量拆分把尖峰摊平。但这是辅助手段,不能替代正确的配额。
  4. 补监控:把 container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total 做成面板和告警,阈值例如 5 分钟窗口内超过 25%。只看 CPU 使用率永远发现不了这个问题。
  5. 不要靠调大 period 解决:把 CFS period 从 100ms 调到 1s,节流次数会变少,但每次停顿更长,尾延迟可能更差。正确的方向是配额与需求匹配。
  6. 验证:压测到目标 QPS,确认 throttled 比例低于阈值、p99 达标,再看节点整体 CPU 是否有足够余量。

根因分析

直接根因是 容器的 CPU limit 只有 1 核,而服务在突发流量下瞬时并发远超 1 核,100ms 周期内的配额被迅速耗尽,cgroup 被内核节流到下一个周期,请求排队形成周期性延迟尖峰。

更深一层,是“CPU 使用率低就等于资源够”的误判。使用率是时间平均,配额是按周期结算的;一个服务可以平均只用 0.3 核,却在每个周期的头几毫秒需要 3 核,然后被冻结 90 多毫秒。监控只看平均值,正好把这个过程完全藏起来。

还有两个放大因素:一是 Go 运行时没有感知 cgroup 配额,在 1 核的容器里按 32 核开线程,突发更猛;二是 limit 当初是拍脑袋定的,没有压测依据。两者叠加,流量一涨,周期性节流就从“偶尔发生”变成了“持续可见”。

预防措施

  • CPU limit 必须来自压测和真实分布,按 p95 加突发余量设定,禁止凭感觉填 1 核。
  • 所有 Go 服务显式设置 GOMAXPROCS 或引入 automaxprocs;Java 服务确认 ActiveProcessorCount 与 limit 一致。
  • 把 CFS 节流比例纳入基础监控,和 CPU 使用率、内存、重启次数并列,按 Pod 维度展示。
  • 上线前做一次“limit 边界压测”:在目标 QPS 下观察 cpu.stat,确认 throttled 比例可接受,而不是只看平均 CPU。
  • 扩容时同时评估副本数和单副本配额,避免“副本多了、单副本仍然被节流”的假扩容。

总结

CPU limit 不是“最高速度”,而是“每个周期能领多少配额”。容器里看到 32 个核,不代表能用 32 个核;平均值只有 0.3 核,也不代表每个瞬间都够用。这个服务并不是 CPU 不够,而是配额在错误的时间尺度上被瞬间花光,然后在剩下的周期里被按下暂停键。把 cpu.stat 的节流比例当成一等指标,把运行时线程数和配额对齐,比盯着 CPU 使用率猜问题有效得多。

延伸阅读

使用 Hugo 构建
主题 Stack 由 Jimmy 设计