文档目录

4.8 容器与编排的干扰:「本地好、上线慢」的头号原因

上一节:4.7 数据与中间件观测 | 下一节:4.9 Lab 4 配套代码:04-toolchain/08-container-interference


一句话结论

容器 CPU limit 会让你的进程被周期性「掐停」——平均 CPU 利用率看起来不高,但延迟会间隔性抖动,P99 明显变差。这是「本地压测很好、上线就慢」的头号原因,而且不看 cgroup 指标几乎不可能发现。


一、用「限速器」理解 CPU 节流

想象你开车,但车上装了一个限速器:

  • 规则:每 100 毫秒的窗口内,最多只能开 10 毫秒的"油门时间"(相当于 CPU limit = 0.1 核)。
  • 你正常行驶时看不出问题(因为大部分时间不需要那么多油门)。

但当你要加速时:

第 1 个 100ms 窗口:你猛踩油门,10ms 用完 → 被强制"断油"剩余 90ms
第 2 个 100ms 窗口:又是一样
...

结果:你的"平均速度"看起来不高(因为大部分时间在滑行),
      但每次踩油门都被强行打断 → 车一顿一顿的。

这就是 CPU 节流:不是「不给你 CPU」,而是「周期性掐断你的 CPU」。

对应到后端

现象 解释
平均 CPU 利用率只有 40% 因为大部分时间被掐停了
P99 间隔性抖动 请求恰好落在被掐停的窗口里
火焰图上看到「什么都没做」的时间 线程被强制暂停
GC 停顿时间变长 GC 线程也被节流
本地无限制 CPU 时完全正常 本地没有 cgroup 配额

二、怎么确认:看 cgroup 的四个值

# cgroup v2(现代 Linux)
cat /sys/fs/cgroup/cpu.max           # 格式:<quota> <period>,例如 "100000 100000" = 1 核
cat /sys/fs/cgroup/cpu.stat          # 关键指标在这里

# cgroup v1(较老)
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
cat /sys/fs/cgroup/cpu/cpu.stat

cpu.stat 的关键字段:

nr_periods 120000          # 总共经历了多少个调度周期
nr_throttled 45000         # 其中有多少个周期被节流   ← 关键
throttled_usec 890000000   # 被节流的总时长(微秒)  ← 关键

判定标准

节流比例 = nr_throttled / nr_periods
被节流时间占比 = throttled_usec / (nr_periods × period_us)

> 25%  → ⚠️ 严重节流,几乎肯定是性能问题的主因
> 5%   → 值得关注
< 1%   → 基本无影响

一条命令快速检查:

awk '/nr_periods|nr_throttled|throttled_usec/ {print}' /sys/fs/cgroup/cpu.stat | \
awk 'NR==1{p=$2} NR==2{t=$2} NR==3{u=$2} END{
  printf "周期数 %d, 被节流 %d 次 (%.1f%%), 被节流时长 %.1f 秒\n", p, t, 100*t/p, u/1e6
}'

在 Prometheus 里监控(生产必备):

# 节流比例
rate(container_cpu_cfs_throttled_periods_total[5m])
  / rate(container_cpu_cfs_periods_total[5m])

# 告警规则:超过 25% 持续 10 分钟
- alert: ContainerCpuThrottled
  expr: |
    rate(container_cpu_cfs_throttled_periods_total[5m])
    / rate(container_cpu_cfs_periods_total[5m]) > 0.25
  for: 10m

三、为什么这个陷阱特别隐蔽

① 本地开发机没有 cgroup 配额 → 一切正常
② 压测环境的容器可能没设 limit → 也正常
③ 生产环境设了 limit → 慢
④ 上线后看监控:CPU 利用率只有 40%,看起来"资源充足"
⑤ 于是开始怀疑代码、数据库、网络……查了几天
⑥ 真正的原因是:那 40% 是被掐出来的,不是真实需求

四个让人误判的因素:

因素 误导
平均 CPU 不高 看起来资源有余量
没有明显的错误 只是慢,不是挂
火焰图看起来"正常" 因为采样也受影响
现象是「抖动」而非「持续慢」 容易归因到 GC 或网络抖动

记住这句话:如果现象是「间隔性抖动」+「平均 CPU 不高」,先查容器节流,再查别的。


四、还有哪些容器相关的干扰

干扰 表现 怎么确认
内存 limit + OOMKill 进程被无声杀死 dmesg、K8s events、memory.max
JVM 未识别容器内存 堆设得过大 → OOMKilled jcmd <pid> VM.flags 看 MaxHeapSize,与容器 limit 对比
sidecar 抢占资源 容器内总资源被分走 kubectl describe pod 看容器列表与资源
探针高频调用 健康检查引入额外负载 看探针配置(periodSeconds、timeoutSeconds)
节点邻居 宿主机上其他 Pod 抢占 st(steal)指标、节点级监控
网络策略/CNI 开销 跨节点调用变慢 对比同节点与跨节点调用的延迟
存储卷类型 网络盘 vs 本地 SSD await 指标、卷类型

JVM 与容器内存

# 确认 JVM 实际用的堆上限
jcmd <pid> VM.flags | tr ' ' '\n' | grep -i maxheap

# 对比容器内存 limit
cat /sys/fs/cgroup/memory.max

# ⚠️ 堆上限必须明显小于容器 limit(留出堆外:元空间、直接内存、线程栈、代码缓存)
#   经验值:堆 = 容器 limit 的 50%~75%

为什么必须留余量:JVM 除了堆还有——

  • 元空间(Metaspace)
  • 直接内存(Netty、序列化缓冲)
  • 线程栈(每个线程约 512KB–1MB)
  • 代码缓存(JIT 编译后的代码)
  • GC 自身的数据结构

这些加起来可能是几百 MB 到 1 GB。如果堆设成容器 limit 的 100%,几乎必然 OOMKilled。


五、压测环境与生产必须一致(否则数据无意义)

这是第 1 章 1.7 节「容器与编排配置」这一项的具体落实:

配置项 压测环境 生产环境 是否必须一致
CPU limit ✅ 必须
CPU request ✅ 建议
内存 limit ✅ 必须
JVM 堆参数 ✅ 必须
副本数 ⚠️ 单实例压测可以不同,但要注明
sidecar ✅ 建议(会占资源)
节点规格 ✅ 建议
存储卷类型 ✅ 建议

自动化检查(放进实验元数据采集脚本):

#!/usr/bin/env bash
# tools/check-container-parity.sh <EXPECTED_CPU_LIMIT> <EXPECTED_MEM_LIMIT_MB>
EXPECTED_CPU="${1:-100000 100000}"
EXPECTED_MEM="${2:-2048}"

ACTUAL_CPU=$(cat /sys/fs/cgroup/cpu.max 2>/dev/null || echo "n/a")
ACTUAL_MEM_MB=$(( $(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo 0) / 1048576 ))

echo "CPU limit : 期望 [$EXPECTED_CPU]  实际 [$ACTUAL_CPU]"
echo "内存 limit: 期望 [${EXPECTED_MEM}MB]  实际 [${ACTUAL_MEM_MB}MB]"

[ "$ACTUAL_CPU" = "$EXPECTED_CPU" ] || echo "❌ CPU limit 与生产不一致 —— 本次数据不可迁移"
[ "$ACTUAL_MEM_MB" = "$EXPECTED_MEM" ] || echo "❌ 内存 limit 与生产不一致 —— 本次数据不可迁移"

# 节流检查
if [ -f /sys/fs/cgroup/cpu.stat ]; then
  awk '/nr_periods/{p=$2} /nr_throttled/{t=$2} END{
    if (p>0) printf "节流比例: %.1f%%%s\n", 100*t/p, (t/p>0.25 ? "  ⚠️ 严重" : "")
  }' /sys/fs/cgroup/cpu.stat
fi

六、发现节流后怎么办

方案 说明 代价
提高 CPU limit 最直接 成本(也可能只是把问题推迟)
提高 CPU request 影响调度与 QoS 等级 可能调度不上
降低 CPU 需求 优化代码、减少序列化、降日志 需要工程投入,但收益持久
调整 GOMAXPROCS / 线程数 让应用知道自己只有 1 核 治标
确认是否真的需要那么多 CPU 也可能是需求本身不合理 —

判断优先级:

① 先确认节流比例(> 25% 才值得处理)
② 再看 CPU 需求是否合理(用火焰图找热点,可能有明显浪费)
③ 如果需求合理且无法优化 → 提高 limit
④ 如果需求不合理(比如日志占 20%)→ 优化代码

一个反直觉的提醒:提高 CPU limit 不一定能解决问题。如果瓶颈是 GC 停顿被节流放大,加 CPU 会让 GC 更快完成,确实有帮助;但如果瓶颈是数据库慢查询,加 CPU 完全无用——只会让应用更快地打到数据库上。


七、本节小结

  1. CPU limit 造成的是「周期性掐停」,不是「CPU 不够」——所以平均利用率不高,但延迟抖动。
  2. 确认方法:看 cgroup 的 nr_throttled / nr_periods(节流比例 > 25% 即严重)。
  3. 这个陷阱隐蔽的原因:本地无配额、平均 CPU 不高、现象是"抖动"而非"慢"。
  4. 生产必须监控容器 CPU 节流,并设为告警(它是领先指标)。
  5. JVM 堆必须明显小于容器内存 limit(留出元空间、直接内存、线程栈等),否则 OOMKilled。
  6. 压测环境的 CPU/内存 limit 必须与生产一致,否则数据不可迁移。
  7. 发现节流后:先确认需求是否合理,再决定是优化代码还是提高 limit。

八、自测

  1. 容器 CPU 利用率 40%,但 P99 抖动很大。请给出确认「是否被节流」的具体命令,以及判定标准。
  2. 一个 Java 服务的容器内存 limit 是 2 GB,JVM 参数是 -Xmx2g。这会有什么问题?应该设成多少?
  3. 你把 CPU limit 从 1 核提高到 2 核,但 P99 没有改善。请说出至少三种可能的原因。
  1. 命令:① cat /sys/fs/cgroup/cpu.max 确认配额;② cat /sys/fs/cgroup/cpu.stat,重点看 nr_periods(总周期数)和 nr_throttled(被节流的周期数);③ 计算 节流比例 = nr_throttled / nr_periods。判定标准:> 25% 严重(几乎肯定是主因);5%–25% 值得关注;< 1% 基本无影响。也可以看 throttled_usec / (nr_periods × period) 得到"被节流的时间占比"。在 Prometheus 里用 rate(container_cpu_cfs_throttled_periods_total[5m]) / rate(container_cpu_cfs_periods_total[5m])(这个指标对节流最灵敏,因为它以"周期"为粒度,即使 CPU 使用率不高也能反映 CFS 配额被耗尽)。
  2. 问题是堆几乎占满了容器内存,而 JVM 除堆之外还需要:元空间(Metaspace,可能几百 MB)、直接内存(Netty、序列化缓冲)、线程栈(每个线程 512KB–1MB,100 个线程就是 50–100MB)、代码缓存(JIT)、GC 自身结构、以及各种 mmap。这些加起来轻易超过 200 MB。后果:容器 OOMKilled(进程被无声杀死,K8s 事件里显示 OOMKilled),而且日志里可能看不到任何错误。应该设成:堆 = 容器 limit 的 50%–75%(常见做法 60%–70%),例如 2 GB limit → -Xmx1200m ~ -Xmx1400m。更稳妥的做法是用 -XX:MaxRAMPercentage=70 让 JVM 按容器内存自动计算,并同时设置 -XX:MaxMetaspaceSize 和 -XX:MaxDirectMemorySize 限定堆外上限。
  3. 可能原因:① 瓶颈根本不在 CPU——比如是数据库慢查询、下游服务慢、或锁竞争,加 CPU 无济于事(应该用 wall 火焰图确认时间花在等待上);② 节流不是主因——如果节流比例本来就很低(< 5%),提高 limit 自然没效果(应该先确认节流比例再决定);③ 应用没有利用新增的 CPU——比如线程池/协程并行度写死在 1,或者 Dispatchers.Default 被阻塞调用占满(第 2 章 2.7 节),新增的 CPU 用不上;④ 也可能是内存/IO 成为新瓶颈——CPU 不再是限制后,瓶颈转移到了别处;⑤ 还有一种情况:提高 limit 后 GC 线程有了更多 CPU,反而让 GC 更频繁地并发运行,与业务线程争抢,抵消了收益。