CPU Cgroup:怎么限制容器 CPU,又怎么正确统计容器 CPU 开销?
实验环境:Ubuntu 24.04.4 LTS / 内核 6.8.0-106-generic / Cgroup v2(unified hierarchy)/ 华为云 FlexusX 实例 8 vCPU 16GB / Docker 29.1.3
引子:监控里 CPU 才 10%,服务却已经被打满?
某天值班,你收到一条告警:“容器 CPU 使用率超过阈值”。你kubectl exec进去top一看:
%Cpu(s): 10.5 us, 0.7 sy, 0.0 ni, 88.9 id, ...“才用了 10%,还有 88.9% 空闲,慌什么?”——然后业务方说接口还是大面积超时。你一脸懵。
问题出在哪?容器里的top在看"宿主机"的 CPU,而不是"这个容器"的 CPU。Linux 的/proc/stat是全局的、没有被 namespace 隔离,容器里读到的就是宿主机的全量数据。真正属于容器的 CPU 开销,被安静地记在它自己的 cgroup 里。
本文分两部分:上半部分讲怎么用 Cgroup v2 限制容器的 CPU,下半部分讲怎么正确地统计容器的 CPU 开销,并解释"容器里 top 为什么不准"。所有结论都用宿主机上的真实输出佐证。
一、Cgroup v2 下的 CPU 控制文件长什么样
Cgroup v2 是"单一层级"(unified hierarchy)。Docker 起的每个容器,在宿主机上都对应一个 scope:
/sys/fs/cgroup/system.slice/docker-<容器ID>.scope/里面和 CPU 相关的核心文件有三个:
| 文件 | 含义 |
|---|---|
cpu.max | CFS 带宽控制:<quota> <period>,单位是微秒。quota/period 即允许使用的 CPU 比例 |
cpu.weight | CFS 权重(相对值,范围 1–10000,默认 100),只在 CPU 争抢时生效 |
cpu.stat | 统计:累计 CPU 用时、被 throttle 的次数/时长等 |
我们起四个容器,分别用不同 Docker 参数,再看宿主机上真实的 cgroup 文件内容:
dockerrun-d--cpus=1--namec-cpus1 alpine...dockerrun-d--cpu-quota=50000--cpu-period=100000--namec-quota...dockerrun-d--cpu-shares=512--namec-shares...dockerrun-d--namec-default...宿主机上读取对应 cgroup(以c-cpus1为例,<ID>为容器 ID):
SC="/sys/fs/cgroup/system.slice/docker-<ID>.scope"cat$SC/cpu.max# 100000 100000cat$SC/cpu.weight# 100四个容器的真实对照:
### c-cpus1 (--cpus=1) cpu.max = 100000 100000 cpu.weight= 100 ### c-quota (--cpu-quota=50000 --cpu-period=100000) cpu.max = 50000 100000 cpu.weight= 100 ### c-shares (--cpu-shares=512) cpu.max = max 100000 cpu.weight= 59 ### c-default (无限制) cpu.max = max 100000 cpu.weight= 100三件事一目了然:
--cpus=1等价于cpu.max = 100000 100000,即每个period(默认 100000µs = 100ms)内最多用100000µs的 CPU → 1 个核。--cpu-quota=50000 --cpu-period=100000直接映射成cpu.max = 50000 100000→ 0.5 核。--cpu-shares=512不影响cpu.max(仍是max,即不限带宽),而是把cpu.weight设成59。Docker 把 v1 的cpu.shares(默认 1024)按公式折算成 v2 的cpu.weight(默认 100),512大约落到59。注意:cpu.weight只在多个 cgroup 抢同一段 CPU 时间时才起作用——它不限制上限,只决定"抢的时候你占多少比例"。
内核机制:CFS Bandwidth Control
cpu.max背后的机制叫CFS Bandwidth Control(完全公平调度器的带宽控制)。每个受控的 cgroup 在每个period内拿到一个quota的"CPU 时间额度"。调度器给该 cgroup 里的任务发时间,发到额度用尽,就把整个 cgroupthrottle(限流)挂起,直到下一个 period 额度刷新。这正是下一节实验里nr_throttled指标的来源,也是本系列第三篇"容器变慢"的主角。
二、限制真的生效了吗?用 stress-ng 实测
起一个限制 1 核的容器,里面用stress-ng --cpu 4起 4 个 CPU 压测进程(故意多开,制造争抢,看限额是否真把总量卡在 1 核):
dockerrun-d--cpus=1--namestress1 ubuntu:22.04...dockerexec-dstress1 /usr/bin/stress-ng--cpu4--timeout600sleep8先看 cgroup 文件与真实用量(从宿主机读cpu.stat,用两次采样算 3 秒增量):
SC="/sys/fs/cgroup/system.slice/docker-<ID>.scope"echo"cpu.max =$(cat$SC/cpu.max)"# 100000 100000# usage_usec 两次采样,间隔 3 秒cpu.max = 100000 100000 usage_usec 起=12910301 止=15925915 (3秒增量=3015614 usec, 理论1核=3000000 usec)3 秒里容器实际只用了3,015,614 µs的 CPU——几乎精确等于 1 核 × 3 秒 = 3,000,000 µs。限额生效了:虽然里面 4 个进程抢着要 CPU,但内核硬把总用量压在 1 核以内。
对照再起一个不限 CPU的容器、同样 4 个 worker:
stress-unlim cpu.max = max 100000 usage_usec 3秒增量=12011395 usec (理论4核=12000000 usec)不限核时 3 秒用了12,011,395 µs≈ 4 核,和开 4 个 worker 完全吻合。
从宿主机看:docker stats才是对的
CONTAINER ID NAME CPU % MEM USAGE / LIMIT a9ec3d50f6fa stress1 100.37% ... e015b4bccf62 stress-unlim 401.74% ...docker stats的 CPU%以"单核"为 100% 基准:限 1 核的容器跑满 ≈ 100%,不限核、4 worker 的容器 ≈ 400%(占满 4 核)。这正好和我们上面从cpu.stat算出的"1 核 vs 4 核"互相印证。
划重点:
docker stats背后读的就是容器 cgroup 的cpu.stat,是隔离的、正确的;而容器里top读的/proc/stat是宿主机的,会骗人。
三、为什么容器里的 top 显示 CPU 不准
证据 1:容器里的 /proc/stat 和宿主机是同一份
同一时刻,分别在容器内外读/proc/stat第一行:
[in-container] cpu 6766 160 5243 1730226 4112 0 34 0 0 0 [on-host] cpu 6768 160 5243 1730227 4112 0 34 0 0 0几乎一模一样(差的那 2 个 tick 只是采样时差)。/proc/stat没有被 PID/IPC 之外的任何 namespace 隔离,容器直接看到宿主机的全局 CPU 累加值。
证据 2:容器里的 top 把宿主机的 load 和 CPU 全抖出来了
top - 16:33:14 up 36 min, 0 users, load average: 0.10, 0.06, 0.07 Tasks: 10 total, 5 running, 5 sleeping, 0 stopped, 0 zombie %Cpu(s): 10.5 us, 0.7 sy, 0.0 ni, 88.9 id, 0.0 wa, ... PID USER %CPU COMMAND 324 root 33.3 stress-ng 322 root 26.7 stress-ng 321 root 20.0 stress-ng 323 root 20.0 stress-ng注意:
load average: 0.10 ...是宿主机的负载(读/proc/loadavg,全局未隔离),不是这个容器的;%Cpu(s): 10.5 us, 88.9 id是宿主机 8 核的全局占用(10.5% × 8 ≈ 84% 被本次压测吃掉),不是"容器还剩 88.9% 空闲"——事实上该容器已经被死死按在 1 核;- 倒是每行进程的
%CPU还算靠谱(来自/proc/<pid>/stat,是 per-process 的,4 个 worker 各约 25%,加起来约 100% = 1 核配额)。
所以"容器里看 88.9% idle 以为有空间"是错觉;这个容器此刻已经把它的 1 核配额吃满,再也不能多要。
解决方案:lxcfs(让 /proc 对容器"说真话")
LXCFS(Lex Cross Filesystem)的思路就是:在容器启动时,把一系列伪造的/proc文件挂进容器,这些文件的内容由 lxcfs 根据容器自己的 cgroup 实时生成。比如:
# 典型 lxcfs 挂载(以 Docker 为例,需 privileged 或相应挂载权限)dockerrun-d\-v/var/lib/lxcfs/proc/cpuinfo:/proc/cpuinfo:rw\-v/var/lib/lxcfs/proc/stat:/proc/stat:rw\-v/var/lib/lxcfs/proc/meminfo:/proc/meminfo:rw\-v/var/lib/lxcfs/proc/uptime:/proc/uptime:rw\--namemyapp myimage挂上之后,容器里读/proc/stat就只看到本容器的 CPU 累加(由 lxcfs 把 cgroup 的cpu.stat换算成cpuX行),top/htop显示的%Cpu(s)和 load 才真正反映容器自身。Kubernetes 上常见做法是用lxcfsDaemonSet + 相应的 volume 挂载,或较新内核配合cpu.stat直接暴露。
排查纪律:排障时一律以宿主机
docker stats/ 容器 cgroupcpu.stat为准,容器里的top、mpstat、free看到的系统级数字都是宿主机的,只能参考 per-process 的%CPU。
四、cpu.stat 与 docker stats 的关系(怎么正确统计)
回到限 1 核容器stress1的cpu.stat全量:
usage_usec 15929915 user_usec 15106759 system_usec 823156 core_sched.force_idle_usec 0 nr_periods 627 nr_throttled 121 throttled_usec 34889074 nr_bursts 0 burst_usec 0字段含义:
usage_usec:容器累计使用的 CPU 时间(微秒)。docker stats就是对两次采样的usage_usec做差分、除以真实时间窗,得到"这段时间占了多少核"。user_usec/system_usec:用户态 / 内核态各自耗时,二者相加 ≈usage_usec(本例 15106759 + 823156 = 15929915,完全对上)。这一项拆分在 Cgroup v1 里是没有的。nr_periods/nr_throttled/throttled_usec:CFS 带宽控制的关键指标——nr_throttled是被限流的次数,throttled_usec是累计被限流(拿不到 CPU)的时间。本例nr_throttled=121、throttled_usec≈34.9s,因为 4 个 worker 抢 1 核,几乎每个 period 都超额被挂起。这正是"容器被限流变慢"的直接证据,下一篇会专门展开。
所以,"正确统计容器 CPU 开销"的标准做法:
- 用
docker stats看实时占用(读的就是cpu.stat,隔离正确); - 要历史/精细化,直接读 cgroup
cpu.stat的usage_usec(微秒累计),自己差分算速率; - 想区分用户态/内核态,看
user_usec/system_usec; - 怀疑"被限流拖慢",看
nr_throttled/throttled_usec; - 容器里
top只能信 per-process 的%CPU,系统级数字一律忽略。
五、Cgroup v1 vs v2 的差异(老教程的坑)
你的系统如果是 CentOS 7、Ubuntu 18.04 等老系统,很可能还是Cgroup v1,CPU 子系统文件完全不同。对照一下:
| 能力 | Cgroup v1 | Cgroup v2(本文) |
|---|---|---|
| 累计 CPU 用时 | cpuacct/cpuacct.usage(纳秒) | cpu.stat的usage_usec(微秒) |
| 用户/内核拆分 | 无(只有总cpuacct.usage) | 有user_usec/system_usec |
| CPU 限额 | cpu/cpu.cfs_quota_us+cpu/cpu.cfs_period_us | cpu.max(一行quota period) |
| 相对权重 | cpu/cpu.shares(默认 1024) | cpu.weight(默认 100,范围 1–10000) |
| 限流统计 | cpu/cpu.stat里的nr_throttled/throttled_time | cpu.stat里的nr_throttled/throttled_usec |
几个容易踩的坑:
- v1 的
cpuacct.usage是纳秒,v2 的usage_usec是微秒,单位差 1000 倍,脚本换算时别搞反; - v1 没有
user/system拆分,想看"到底是算得多还是 syscall 多"只能上perf;v2 一行搞定; - v2 的
cpu.weight和 v1 的cpu.shares不是线性对应,Docker 内部做了一层折算(如本文--cpu-shares=512 → weight 59),别直接拿数字比; - v1 可以同时挂在
cpu和cpuacct两个子系统,v2 合并成统一层级,路径和文件都更干净。
判断自己系统用哪版,看/sys/fs/cgroup是否是cgroup2fs挂载即可:
stat-fc%T /sys/fs/cgroup# cgroup2fs => v2;tmpfs => v1;(有的系统 v1/v2 混合)六、小结与思考题
本文用两组对照实验讲清了容器 CPU 的"限"与"算":
--cpus=N落到 v2 的cpu.max = N*100000 100000;--cpu-shares落到cpu.weight(相对权重,不封顶);--cpu-quota/--cpu-period直接对应cpu.max的 quota/period。- 限制确实生效:限 1 核容器 3 秒只用 ~3.0M µs ≈ 1 核;不限核 4 worker 用 ~12M µs ≈ 4 核。
docker stats读的是隔离的cpu.stat,正确;容器里top读的/proc/stat是宿主机的,系统级数字会骗人,只有 per-process%CPU相对可信。- 根因是
/proc/stat和/proc/loadavg未被 namespace 隔离;lxcfs 通过挂载伪造/proc让容器内工具"说真话"。 - 正确统计:盯
usage_usec差分、user/system拆分、以及nr_throttled/throttled_usec判断是否被限流。 - v1 用
cpuacct.usage(纳秒)+cpu.cfs_quota_us+cpu.shares,v2 用cpu.stat(微秒,带 user/system 拆分)+cpu.max+cpu.weight,注意单位与映射差异。
思考题:
- 两个容器都设
--cpus=1,但一个--cpu-shares=512、一个默认 1024。在宿主机 CPU 完全空闲时,谁的cpu.max更高?当宿主机 CPU 被打满争抢时,权重会怎样影响它们实际拿到的 CPU? cpu.weight和cpu.max同时设置时,二者如何协作?权重会在"限额以内"还是"限额之外"生效?- 如果
nr_throttled很高但容器业务并不觉得慢,可能是什么原因?如果业务明显变慢且throttled_usec持续增长,第一反应该调哪个参数? - 为什么
docker stats的 CPU% 以"单核 100%“为基准,而不是"占宿主机总核数的百分比”?这在 8 核和 128 核机器上对告警阈值有什么影响?