Skip to content
Published on

CPU steal time 与限流 — 如何区分 top 的 st、可突发型积分和 CFS 配额

分享
Authors

开篇 — 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 没有给出的时间

stvCPU 已经处于可运行状态,却因为 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。topmpstat 只是把这个值的差分展示出来而已。

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.nano25%6
t3.micro210%12
t3.small220%24
t3.medium220%24
t3.large230%36
t3.xlarge440%96
t3.2xlarge840%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 的 stcpu.stat 变化平均负载应对
Hypervisor 竞争邻居虚拟机占住物理 CPU5% 以上没有变化影响很小停止后启动、更大的实例、专用宿主机
积分耗尽自己的突发预算被用尽阶梯式上升没有变化影响很小切换到 unlimited、迁到 M/C 系列
CFS 配额限流因 cgroup 上限被强制停止0nr_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。

平衡点可以这样整理。

  1. request 一定要设置。这不是争论的对象。调度放置和竞争时的份额都出自这里。
  2. 适合移除 limit 的候选,是对延迟敏感且可信的自研服务。移除的同时,必须一起观察整个节点的 CPU 使用率和邻居 Pod 的延迟。
  3. 需要保留 limit 的对象,是批处理任务、可信度低的工作负载和多租户命名空间。在这里可预测性比效率更重要。
  4. 如果保留 limit,就要按并行度来设定。limit 1 核配 8 个线程,从结构上就会制造限流。
  5. 把上限告诉运行时。作为单项措施,这个的效果最大。

补充一下第 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、积分耗尽、限流这三种形态出现,其中限流在使用率曲线的任何地方都不留痕迹。

把判断顺序压缩一下,就是这样。

  1. 先用 mpstat -P ALL 1%steal。5 分钟平均超过 5% 就是实例的问题,在客户机内部无事可做。
  2. 如果 steal 从某个时刻起阶梯式上升并固定住,就确认积分余额。这是预算问题,不是邻居问题。
  3. 如果 steal 是 0 但延迟在跳,就去看 cpu.statnr_throttled。使用率和平均负载没法展示这个问题。
  4. 确认是限流之后,先调整运行时的并行度,再考虑上调 limit。调整 GOMAXPROCSActiveProcessorCount 通常效果更大。
  5. 移除 CPU limit 不是万能药。先把 request 设置准确才是第一步,移除要限定在可信的工作负载上,并且和观测一起推进。
  6. 告警要把 steal、限流比率、PSI 三个全部挂上。三个原因并不会互相遮掩。