开篇 — CPU 只有 40%,p99 却要 3 秒
容量会议上出现频率最高的一句话是这样的。"CPU 使用率才 40%,所以不是 CPU 的问题。"这句话在三种情况下是错的。而这三种情况就是本文的全部内容。
top -bn1 | head -4
# top - 14:22:31 up 9 days, 4:12, 1 user, load average: 3.21, 3.04, 2.88
# Tasks: 214 total, 2 running, 212 sleeping, 0 stopped, 0 zombie
# %Cpu(s): 31.2 us, 4.1 sy, 0.0 ni, 42.6 id, 0.4 wa, 0.0 hi, 0.5 si, 21.2 st
最后一列的 21.2 st 是本文的起点。空闲看上去有 42.6%,但 21.2% 是已经被别人拿走的时间,应用本来能用的 CPU 就少了这么多。而且还有一个在这里根本不出现的第三个原因。容器限流(节流)在这个画面的任何位置都不会显示。
steal time 的定义 — Hypervisor 没有给出的时间
st 是 vCPU 已经处于可运行状态,却因为 Hypervisor 没有分配物理 CPU 而被迫等待的时间占比。它不是发生在客户机内核内部的事,而是发生在客户机外部的事,客户机只是事后被告知而已。
工作原理是半虚拟化接口。在 KVM 上,客户机内核通过 MSR_KVM_STEAL_TIME 注册一块共享内存结构体,Hypervisor 把自己将 vCPU 调度出去的累计纳秒数写进去。客户机内核读取该值,并反映到 /proc/stat 的第 8 个字段上。Xen 也用 runstate 信息做同样的事。
head -2 /proc/stat
# cpu 9284712 1832 1204831 88213904 42193 0 18922 1928374 0 0
# cpu0 1160589 229 150604 11026738 5274 0 2365 241046 0 0
# user nice system idle iowait irq softirq steal guest guest_nice
第八个数字就是 steal 的累计 tick。top 和 mpstat 只是把这个值的差分展示出来而已。
mpstat -P ALL 1 3 | tail -5
# 14:23:03 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
# 14:23:03 all 30.51 0.00 4.02 0.38 0.00 0.51 21.34 0.00 0.00 43.24
# 14:23:03 0 29.90 0.00 3.96 0.50 0.00 0.99 22.28 0.00 0.00 42.37
# 14:23:03 1 31.14 0.00 4.08 0.26 0.00 0.03 20.40 0.00 0.00 44.09
如果每个 CPU 都均匀地在 21% 附近,说明整台宿主机过载;如果只集中在某个特定 vCPU 上,说明该 vCPU 所在的物理核上有竞争者。后者往往只要重新启动实例、迁移到另一台宿主机就能解决。
这里要点出最常见的误解。st 高并不代表自己的进程占用了很多 CPU。恰恰相反。那是想用却没能用上的时间,原因在实例之外。无论怎么优化应用代码,这个数值都不会降下来。
第二个误解同样重要。st 为 0 并不等于没有 Hypervisor 竞争。steal 的记账只有在 Hypervisor 把这个信息暴露给客户机时才会生效。VMware 环境下的客户机大多看到 st 是 0,真正的竞争只能在宿主机侧的 %RDY(CPU ready)指标里确认。在本地虚拟化环境中断定"st 是 0,所以没问题",是典型的误诊。
# 确认半虚拟化时钟和 steal 记账是否处于启用状态
grep -o 'kvm\|xen\|vmware\|hyperv' /sys/hypervisor/type 2>/dev/null
systemd-detect-virt
# kvm
grep -E 'CONFIG_PARAVIRT_TIME_ACCOUNTING' /boot/config-$(uname -r)
# CONFIG_PARAVIRT_TIME_ACCOUNTING=y
st 从百分之几开始算问题,以及能做些什么
没有绝对标准,但运维上的经验法则相对一致。
- 低于 1%:正常。在共享基础设施上,0 反而更少见。
- 1~5%:观察对象。对延迟敏感的服务来说,p99 可能已经受到影响。
- 持续 5~10%:处理对象。吞吐量真的会被削掉这么多。
- 持续超过 10%:立刻迁走。在那台宿主机上做任何调优都没有效果。
关键不是瞬时值,而是持续性。因为备份窗口或邻居的批处理任务而跳几分钟是常有的事。告警按 5 分钟或 15 分钟平均值来配置,误报才会减少。
应对方式有四种。
# 1) 重新调度实例 — 先停止再启动,会被放到另一台物理宿主机上
# (重启会留在同一台宿主机上,所以没有效果)
aws ec2 stop-instances --instance-ids i-0abc123def4567890
aws ec2 start-instances --instance-ids i-0abc123def4567890
重启和停止再启动的区别是关键。在客户机内部执行 reboot 不会更换物理宿主机。只有通过云 API 停止再启动,放置才会重新发生。
其余三种是结构性措施。升级到能独占整个物理核的实例规格,邻居本身就消失了。专用宿主机或裸金属实例彻底消除了 Hypervisor 共享,代价是成本很高。另外,把对延迟敏感的工作负载分离部署到 st 较低的实例系列上,也是一个实用的选择。
有一个陷阱。st 高的时候增加 vCPU 数量往往适得其反。如果宿主机本来就过载,vCPU 增加多少,就要多等多少调度机会,而且在客户机内部连自旋锁竞争也会增加。提高实例规格,也就是提高对宿主机的占用比例,比提高核心数更正确。
可突发型实例的积分耗尽 — 相同症状,不同原因
这是第二个原因。AWS 的 T 系列以及其他云的同类档位,是按 基准性能(baseline)和 CPU 积分 这两个概念运作的。用得低于基准就积累积分,超过基准使用就消耗积分。
| 实例 | vCPU | 每个 vCPU 的基准性能 | 每小时获得的积分 |
|---|---|---|---|
| t3.nano | 2 | 5% | 6 |
| t3.micro | 2 | 10% | 12 |
| t3.small | 2 | 20% | 24 |
| t3.medium | 2 | 20% | 24 |
| t3.large | 2 | 30% | 36 |
| t3.xlarge | 4 | 40% | 96 |
| t3.2xlarge | 8 | 40% | 192 |
1 个积分相当于让一个 vCPU 以 100% 性能运行 1 分钟。t3.medium 每小时赚 24 个,而且有 2 个 vCPU,所以可持续的使用量是 2 乘以 20%,也就是相当于单个 vCPU 的 40%。继续用得比这更多,余额必然会走向 0。
症状很有特征。最初几个小时一切正常,从某一刻起性能阶梯式下跌并且不再恢复。负载没变,只有响应时间翻了几倍。在 standard 模式下,积分见底后 Hypervisor 会把你强制限制到基准性能,客户机把这个识别成 steal time。也就是说 st 会上升,但原因不是邻居,而是自己的预算。
区分方法很简单。如果 steal 随时间无规律地上下波动,那是邻居;如果从积分余额触及 0 的时刻起 steal 像台阶一样上升并固定住,那就是积分。
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 --metric-name CPUCreditBalance \
--dimensions Name=InstanceId,Value=i-0abc123def4567890 \
--start-time 2026-07-25T00:00:00Z --end-time 2026-07-26T00:00:00Z \
--period 300 --statistics Average \
--query 'sort_by(Datapoints, &Timestamp)[].[Timestamp,Average]' --output text | tail -6
# 2026-07-25T09:00:00Z 142.3
# 2026-07-25T10:00:00Z 88.1
# 2026-07-25T11:00:00Z 31.4
# 2026-07-25T12:00:00Z 0.0
# 2026-07-25T13:00:00Z 0.0
# 2026-07-25T14:00:00Z 0.0
从 12 点开始就是 0。如果延迟曲线正是从那一刻开始变差,诊断就结束了。
选择有三个。切换到 unlimited 模式后,即使没有积分也能继续突发,超出部分会额外计费(可以用 CPUSurplusCreditsCharged 指标确认实际费用)。如果是持续地超过基准在用,迁移到 M 或 C 系列的固定性能实例最终更便宜。如果突发确实是间歇性的,那么保留 T 系列,同时把积分余额挂上告警才是正解。
# 切换到 unlimited
aws ec2 modify-instance-credit-specification \
--instance-credit-specifications \
'InstanceId=i-0abc123def4567890,CpuCredits=unlimited'
成本判断标准是这样的。如果 unlimited 模式的超额计费常态化发生,就把这笔金额和高一档固定性能实例的差价做对比。在常态突发的状态下,大多是后者更便宜,而且性能可预测。
容器的 CFS 配额限流 — top 里完全看不到的第三个原因
这是最重要的一节。前面两个原因至少还留下 st 这个痕迹。限流则什么痕迹都不留。
工作方式是这样的。当 cgroup 上设置了 CPU 上限,内核会在每个周期(默认 100ms)分配配额。如果该组的任务在周期内把配额用完,内核就在剩余时间里把整个组从运行队列上摘下来。直到下一个周期开始为止,什么都不会运行。
来看看这种状态下会发生什么。一个 CPU limit 为 1 核的 Pod 里有 8 个线程。8 个同时跑起来,100ms 的配额在 12.5ms 内就用光了。剩下的 87.5ms 里,这个 Pod 完全停止。如果某个请求偏偏落在那个区间,响应上就多出 87.5ms。而平均 CPU 使用率甚至不会显示成 100%(1 核中的 1 核)。它看起来在 12.5% 附近。
于是就出现了这样的曲线。平均 CPU 使用率很低,steal 也是 0,平均负载也很安静,唯独 p99 有规律地跳。被限流的任务会从运行队列上摘掉,所以平均负载里也统计不到。
确认只需要 cpu.stat 这一个文件。
# cgroup v2(在容器内部,如果 cgroup 命名空间是 private 就能直接看到)
cat /sys/fs/cgroup/cpu.max
# 100000 100000
cat /sys/fs/cgroup/cpu.stat
# usage_usec 4128374621
# user_usec 3719283746
# system_usec 409090875
# nr_periods 86400
# nr_throttled 41287
# throttled_usec 2914837261
cpu.max 的两个数字是配额和周期,单位是微秒。100000 100000 表示 100ms 周期配 100ms 配额,也就是 1 核。没有限制时第一个值是 max。
需要读的比率只有一个。
限流比率 = nr_throttled / nr_periods = 41287 / 86400 = 47.8%
每个周期的平均停顿时间 = throttled_usec / nr_throttled = 2914837261 / 41287 = 70.6ms
全部周期中有一半发生了限流,而且每次发生平均停了 70ms。p99 延迟的真身就在这里。按实务标准,比率超过 5% 就是调查对象,超过 10% 就是处理对象。
如果是 cgroup v1,文件就不一样了。
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
# 100000
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
# 100000
cat /sys/fs/cgroup/cpu/cpu.stat
# nr_periods 86400
# nr_throttled 41287
# throttled_time 2914837261000
v1 的 throttled_time 是纳秒,v2 的 throttled_usec 是微秒。差了 1000 倍,所以这是迁移仪表盘时最常出错的地方。
要在宿主机上按 Pod 粒度扫一遍,可以这样做。
for f in /sys/fs/cgroup/kubepods.slice/*/*/cpu.stat; do
awk -v p="${f%/cpu.stat}" '
/^nr_periods/ {np=$2}
/^nr_throttled/{nt=$2}
END {if (np > 0 && nt/np > 0.05) printf "%5.1f%% %s\n", nt*100/np, p}
' "$f"
done | sort -rn | head -5
# 47.8% /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice
# 12.3% /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod4d22b71a.slice
在 Prometheus 里用 cAdvisor 的指标。
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total[5m]))
/ sum by (pod) (rate(container_cpu_cfs_periods_total[5m]))
经常见到只看 container_cpu_cfs_throttled_seconds_total 的仪表盘,但那个值回答的是"停了多久",而真正需要的是"多少个周期里停了多少次"。两个必须一起挂上。
内核版本也是要确认的值。5.4 之前的内核在 CPU 时间片过期处理上有 bug,配额还没用完就会发生限流。5.4 里移除了这套过期逻辑,症状大幅减少。如果在老节点上看到无法解释的限流,请先确认内核。
uname -r
# 6.8.0-45-generic
从 5.14 开始新增了 cpu.max.burst,可以把没用完的配额在一定额度内攒起来,然后瞬间超额使用。对短促的尖峰型工作负载效果很大。
# 允许最多攒到 50ms
echo 50000 | sudo tee /sys/fs/cgroup/kubepods.slice/.../cpu.max.burst
区分三个原因的表格与 Kubernetes CPU limit 之争
把前面这三个整理成一张表。症状看着相似,但诊断点和应对完全不同。
| 原因 | 实际发生的事 | top 的 st | cpu.stat 变化 | 平均负载 | 应对 |
|---|---|---|---|---|---|
| Hypervisor 竞争 | 邻居虚拟机占住物理 CPU | 5% 以上 | 没有变化 | 影响很小 | 停止后启动、更大的实例、专用宿主机 |
| 积分耗尽 | 自己的突发预算被用尽 | 阶梯式上升 | 没有变化 | 影响很小 | 切换到 unlimited、迁到 M/C 系列 |
| CFS 配额限流 | 因 cgroup 上限被强制停止 | 0 | nr_throttled 增加 | 不会上升 | 上调或移除 limit、调整并行度、burst |
第三行的"平均负载不会上升"尤其反直觉。被限流的任务会从运行队列中移除,所以不会被统计进等待队列。负载、CPU 使用率、steal 全都很安静,唯独服务很慢。出现这个组合时,请去看 cpu.stat。
接下来是争论。有一种主张是在 Kubernetes 里干脆移除 CPU limit。双方的依据都需要准确地看清楚。
支持移除的论据。CPU 是可压缩资源。和内存不同,不够也不会死,只是变慢而已。而且 request 已经被转换成 cpu.weight(v1 的 cpu.shares),在竞争时保证按比例分配。也就是说节点忙的时候按 request 比例分,节点空闲的时候如果没有 limit,剩余的 CPU 就能直接拿来用。有 limit 的话,即使节点上 CPU 还有富余也会被强制停下。这是纯粹的浪费。
支持保留的论据。没有 limit 的话,一个 Pod 的失控真的会让同一节点上的邻居变慢。weight 只保证竞争时的最低份额,并不设上限,所以创建几百个线程的 Pod 会通过上下文切换和缓存污染拉低邻居的实际性能。另外,没有 limit 的话性能特性会随节点的空闲程度而变,容量规划和压测结果的可复现性就崩了。如果是多租户集群,隔离不是可以谈判的东西。而且要通过 CPU 管理器的 static 策略拿到专用核心,就需要 Guaranteed QoS,也就是 limit 必须等于 request。
平衡点可以这样整理。
- request 一定要设置。这不是争论的对象。调度放置和竞争时的份额都出自这里。
- 适合移除 limit 的候选,是对延迟敏感且可信的自研服务。移除的同时,必须一起观察整个节点的 CPU 使用率和邻居 Pod 的延迟。
- 需要保留 limit 的对象,是批处理任务、可信度低的工作负载和多租户命名空间。在这里可预测性比效率更重要。
- 如果保留 limit,就要按并行度来设定。limit 1 核配 8 个线程,从结构上就会制造限流。
- 把上限告诉运行时。作为单项措施,这个的效果最大。
补充一下第 5 点。JVM 和 Go 运行时默认看到的是宿主机的全部核心数。在 64 核节点上,CPU limit 为 1 的容器里的 Go 程序会把 GOMAXPROCS 设成 64,64 个线程分享 1 核的配额,立刻就会撞上限流。
# Go:使用自动调整库,或者直接用环境变量指定
GOMAXPROCS=1 ./myapp
# JVM:容器感知默认是开启的,但需要确认
java -XX:+PrintFlagsFinal -version | grep -E 'ActiveProcessorCount|UseContainerSupport'
# intx ActiveProcessorCount = -1
# bool UseContainerSupport = true
# 显式指定
java -XX:ActiveProcessorCount=2 -jar app.jar
JVM 的容器感知在把 CPU limit 换算成核心数时使用向上取整。limit 是 1.5 核就当作 2。如果用了带小数的 limit,就要确认这个取整是不是在制造限流。
节点层面的选择也有两个。kubelet 的 --cpu-manager-policy=static 会给请求整数 CPU 的 Guaranteed Pod 独占分配专用核心,从而完全绕开 CFS 配额。把 --cpu-cfs-quota-period 从 100ms 缩短到 10ms 左右,单次停顿的时间会变短,延迟尖峰的振幅也随之减小。不过周期变短会增加调度开销,而且这个标志目前还不是稳定功能,所以要验证之后再应用。
防止复发 — 仪表盘上该挂什么
三个原因分别由不同的指标捕捉,所以三个都要挂上。少挂任何一个,那个原因就永远看不见。
# 1) steal — 节点粒度,5 分钟平均
avg by (instance) (rate(node_cpu_seconds_total{mode="steal"}[5m])) > 0.05
# 2) 限流比率 — Pod 粒度
sum by (namespace, pod) (rate(container_cpu_cfs_throttled_periods_total[5m]))
/ sum by (namespace, pod) (rate(container_cpu_cfs_periods_total[5m])) > 0.10
# 3) CPU 压力 — PSI。限流和竞争都能捕捉到
avg by (instance) (rate(node_pressure_cpu_waiting_seconds_total[5m])) > 0.20
一起挂上 PSI 是有理由的。cpu.pressure 直接测量的是"本来可以运行却没能运行的时间",所以无论原因是 steal、限流还是运行队列竞争,它都能抓到结果。它也存在于 cgroup 粒度。
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/cpu.pressure
# some avg10=68.42 avg60=61.03 avg300=44.18 total=182937461283
# full avg10=52.11 avg60=47.88 avg300=33.02 total=91827364512
some 是 68%,意思是最近 10 秒里有 6.8 秒这个 Pod 的某个任务在等 CPU。和 47.8% 的限流比率放在一起看,等于是从两个角度确认同一个事实。
把它留成定期巡检脚本,故障时就不用凭记忆去摸索了。
#!/usr/bin/env bash
# cpu-triage.sh — 一次性确认 CPU 延迟的三个原因
set -u
echo "== 1. steal time(5 秒平均) =="
mpstat 5 1 | awk '/Average/ {printf " %%steal = %s\n", $(NF-3)}'
echo "== 2. 虚拟化环境 =="
printf ' '; systemd-detect-virt
echo "== 3. cgroup 限流(v2) =="
if [ -r /sys/fs/cgroup/cpu.stat ]; then
awk '/^nr_periods/{np=$2} /^nr_throttled/{nt=$2} /^throttled_usec/{tu=$2}
END {
if (np > 0)
printf " 比率 %.1f%% (%d/%d),发生时平均 %.1fms\n",
nt*100/np, nt, np, (nt>0 ? tu/nt/1000 : 0)
}' /sys/fs/cgroup/cpu.stat
printf ' cpu.max = %s\n' "$(cat /sys/fs/cgroup/cpu.max 2>/dev/null)"
fi
echo "== 4. CPU 压力(PSI) =="
[ -r /proc/pressure/cpu ] && sed 's/^/ /' /proc/pressure/cpu
结语 — 使用率低并不等于 CPU 很闲
如果只记住一件事,就是这个。CPU 使用率衡量的是已经用了多少,而不是本来能用多少。想用却没能用上的时间会以 steal、积分耗尽、限流这三种形态出现,其中限流在使用率曲线的任何地方都不留痕迹。
把判断顺序压缩一下,就是这样。
- 先用
mpstat -P ALL 1看%steal。5 分钟平均超过 5% 就是实例的问题,在客户机内部无事可做。 - 如果 steal 从某个时刻起阶梯式上升并固定住,就确认积分余额。这是预算问题,不是邻居问题。
- 如果 steal 是 0 但延迟在跳,就去看
cpu.stat的nr_throttled。使用率和平均负载没法展示这个问题。 - 确认是限流之后,先调整运行时的并行度,再考虑上调 limit。调整
GOMAXPROCS或ActiveProcessorCount通常效果更大。 - 移除 CPU limit 不是万能药。先把 request 设置准确才是第一步,移除要限定在可信的工作负载上,并且和观测一起推进。
- 告警要把 steal、限流比率、PSI 三个全部挂上。三个原因并不会互相遮掩。
현재 단락 (1/140)
容量会议上出现频率最高的一句话是这样的。"CPU 使用率才 40%,所以不是 CPU 的问题。"这句话在三种情况下是错的。而这三种情况就是本文的全部内容。