前置阅读:建议先阅读 容器资源限制与 cgroup 深入:CPU/内存/IO 配额与 OOM 与 GPU 容器与 AI 推理部署:NVIDIA Container Toolkit 与 MIG 深度实践。本篇聚焦 CPU 配额、绑核、NUMA 亲和与实时性 QoS 保障。
1. 从 CFS 到 cgroup:容器 CPU 调度的底层模型
Linux 从 2.6.23 起默认使用 CFS(Completely Fair Scheduler)。CFS 用 vruntime 记录每个可调度实体的虚拟运行时间,每次总是挑选 vruntime 最小的任务运行,从而在时间维度上逼近公平。调度周期由 sched_latency(默认 6ms,随可运行任务数动态放大)与 sched_min_granularity(默认 0.75ms)共同决定。
真正关键的一点是:CFS 的公平发生在「调度实体」之间。cgroup 把一组进程聚合成一个调度实体,于是我们可以在三个层面干预 CPU 分配:
- 权重(weight/shares):决定相对份额,只在 CPU 发生争抢时生效。
- 带宽(quota/period):决定绝对上限,用超了就被节流。
- 亲和(cpuset):硬性限定可运行的核心与内存节点。
容器的本质是 namespace 做隔离、cgroup 做资源控制,CPU 相关的三个子控制器恰好对应上述三条腿。理解这一点,后面所有参数都会变得顺理成章。
# 确认宿主机的 cgroup 版本
stat -fc %T /sys/fs/cgroup
# cgroup2fs -> 统一层级(v2)
# tmpfs -> 传统层级(v1)
1.1 容器与 cgroup 的对应关系
Docker 默认用 systemd 驱动时,每个容器对应一个 scope 单元;用 cgroupfs 驱动时则直接在 /sys/fs/cgroup/ 下建目录。想找到某个容器真正的 cgroup 路径,看这两条:
docker inspect -f '{{.HostConfig.CgroupParent}}' web
cat /proc/$(docker inspect -f '{{.State.Pid}}' web)/cgroup
输出会直接告诉你该去哪个目录找 cpu.stat、cpu.max 与 cpuset.cpus,这是后续所有排查的起点。
一句话:shares/weight 管「抢多少」,quota 管「最多多少」,cpuset 管「能在哪跑」——三者正交,必须分别配置。
2. cgroup v1 与 v2 的 CPU 控制器对照
cgroup v1 把每个控制器挂到独立层级,路径形如 /sys/fs/cgroup/cpu/;v2 收敛为单层级,接口名也被精简,最典型的是 cfs_quota_us 与 cfs_period_us 被合并成一个 cpu.max。
| 能力 | cgroup v1 文件 | cgroup v2 文件 |
|---|---|---|
| 相对权重 | cpu.shares | cpu.weight |
| 带宽上限 | cpu.cfs_quota_us 与 cpu.cfs_period_us | cpu.max |
| 运行统计 | cpu.stat | cpu.stat |
| 核心亲和 | cpuset.cpus | cpuset.cpus |
| 内存节点亲和 | cpuset.mems | cpuset.mems |
| 实时带宽 | cpu.rt_runtime_us 与 cpu.rt_period_us | cpu.rt.max |
权重换算:v1 的 shares 默认 1024,v2 的 weight 默认 100。两者线性映射,可用公式 weight = 1 + (shares - 2) * 9999 / 262142 互转;shares 1024 大约对应 weight 39。
# v1:查看某容器权重
cat /sys/fs/cgroup/cpu/docker/<cid>/cpu.shares
# v2:systemd 驱动的 scope 路径
cat /sys/fs/cgroup/system.slice/docker-<cid>.scope/cpu.weight
一个容易踩的差异:v2 中 cpuset 的 cpus 与 mems 为空表示「无限制」,而 v1 中为空则可能继承父级。从 v1 迁到 v2 时,很多「亲和突然失效」的问题都出自这里。
3. Docker CPU 参数语义与换算
Docker 的所有 CPU 参数最终都落到 cgroup 文件上。理解换算关系,才能把「看起来的核数」翻译成内核能懂的数字。
| Docker 参数 | 落点(v2) | 语义与换算 |
|---|---|---|
| –cpus=2 | cpu.max 写成 200000 100000 | 等价 quota 除以 period,上限 2 核 |
| –cpu-period=100000 | cpu.max 第二列 | CFS 周期,单位微秒,默认 100000 |
| –cpu-quota=150000 | cpu.max 第一列 | 单周期可用时间,单位微秒 |
| –cpu-shares=512 | cpu.weight | 相对权重,默认 1024 |
| –cpuset-cpus=0-3 | cpuset.cpus | 硬绑定到物理核 0 到 3 |
| –cpuset-mems=0 | cpuset.mems | 硬绑定到 NUMA 节点 0 |
--cpus=2 的实现就是 quota = 2 × period = 200000,period 保持默认 100000;因此 --cpus=1.5 会得到 quota=150000。当 --cpu-quota 与 --cpus 同时出现时,后者优先。
docker run -d --name web \
--cpus=2 \
--cpu-shares=512 \
--cpuset-cpus="0-3" \
--cpuset-mems="0" \
--cpu-period=100000 \
--cpu-quota=200000 \
nginx:alpine
3.1 shares 与 quota 的配合
shares 只在争抢时生效,quota 则在任何时刻都是硬上限,两者可以叠加:给在线服务设 quota 防止它吃满整机,同时用 shares 保证它在争抢中不落下风。
# 在线服务:上限 4 核,争抢时权重高于批处理
docker run -d --name online --cpus=4 --cpu-shares=2048 api:latest
# 批处理:上限 8 核,权重较低,主动让出空闲算力
docker run -d --name batch --cpus=8 --cpu-shares=256 batch:latest
这样 batch 在 online 空闲时能用满 8 核,一旦 online 忙起来,batch 会自动退让——这就是「弹性共享、硬性兜底」的组合。
一句话:
--cpus是给人看的上限,--cpu-quota是给内核看的上限,二者最终写进同一处 cpu.max。
4. CPU 节流(throttling)根因与排查
当容器在一个 CFS 周期内用光 quota,该周期剩余时间会被强制「冻结」,直到下个周期开始——这就是节流。它不是内存 OOM 那种致命错误,却会让尾延迟(p99)突然抬高,典型症状是「平均 CPU 不高,但请求偶尔变慢」。
cpu.stat 里三个字段是诊断核心:
- nr_periods:经历过的周期总数。
- nr_throttled:被节流的周期数。
- throttled_time:累计被冻结的纳秒数。
cat /sys/fs/cgroup/cpu/docker/<cid>/cpu.stat
# nr_periods 120000
# nr_throttled 8300
# throttled_time 41200000000
# 节流周期占比 = nr_throttled / nr_periods
# 冻结时长占比 = throttled_time / (nr_periods * period)
排查顺序建议如下:
- docker stats 看 CPU% 是否长期贴住上限(2 核时接近 200%)。
- 读 cpu.stat 确认 nr_throttled 是否持续增长。
- mpstat -P ALL 1 看各核是否饱和,区分「配额不足」与「宿主争抢」。
- pidstat -t 定位线程级热点,perf top 找函数级热点。
docker stats --no-stream web
mpstat -P ALL 1 5
pidstat -t -p $(pgrep -f nginx) 1 5
perf top -p $(pgrep -f nginx)
调优方向有三条:提高 quota(放宽 --cpus)、增大 period(减少周期性抖动但放大突发容忍)、或改用 cpuset 独占核彻底消除节流。选择哪条取决于业务是「平均吞吐敏感」还是「尾延迟敏感」。
4.1 监控与验证工具箱
| 工具 | 观察对象 | 典型用法 |
|---|---|---|
| docker stats | 容器级 CPU 与内存 | docker stats –no-stream |
| cpu.stat | 节流周期与冻结时长 | 读 cgroup 目录下 cpu.stat |
| mpstat | 逐核利用率 | mpstat -P ALL 1 |
| pidstat | 进程与线程级 CPU | pidstat -t -p PID 1 |
| perf | 函数热点与调度延迟 | perf sched latency |
| numastat | 本地与远端内存命中 | numastat -p PID |
# 容器级快照
docker stats --no-stream --format "table {{.Name}} {{.CPUPerc}}"
# 逐核利用率,观察是否有核长期打满
mpstat -P ALL 1 5
# 调度延迟分布,能暴露唤醒延迟尖刺
perf sched record -- sleep 5
perf sched latency
验证是否绑核成功,最直接的办法是进容器看 CPU 亲和掩码:nproc 与 taskset -pc 1 的输出应当只剩你绑定的那几个核。
5. 实时调度:SCHED_FIFO 与 SCHED_RR 及 RT 带宽
CFS 面向吞吐与公平,实时策略面向确定性。SCHED_FIFO 与 SCHED_RR 的优先级范围是 1 到 99,高于所有 CFS 任务,一旦就绪就会抢占普通进程。SCHED_RR 在同等优先级之间按时间片轮转,SCHED_FIFO 则一直运行到阻塞或主动让出。
为防止 RT 任务饿死系统,内核用 RT 带宽做兜底:sched_rt_period_us 默认 1000000(1 秒),sched_rt_runtime_us 默认 950000,即 RT 任务最多占用 95% 的 CPU 时间。
sysctl kernel.sched_rt_period_us kernel.sched_rt_runtime_us
# kernel.sched_rt_period_us = 1000000
# kernel.sched_rt_runtime_us = 950000
容器内启用 RT 需要两件事:授予 SYS_NICE 能力,并显式给出 RT 运行时间预算。
docker run -d --name rt-app \
--cap-add SYS_NICE \
--cpu-rt-runtime=950000 \
--cpu-rt-period=1000000 \
rt-image:latest
# 容器内设置调度策略与优先级
chrt -f 50 ./realtime-worker
chrt -r 30 ./round-robin-worker
风险提示:RT 任务一旦进入死循环,会占满 95% 的核并显著抬高系统抖动;生产上建议配合 isolcpus 把这些核从通用调度中摘除。另外 --cpu-rt-runtime 需要与 --cpu-rt-period 成对设置,只给其一通常不会生效。
6. NUMA 拓扑:从 lstopo 到 cpuset-mems
现代多路服务器每个 CPU 插槽连着自己的内存控制器,形成 NUMA 节点。访问本地节点内存的延迟约 80 到 100ns,跨节点访问要经过 UPI 或 QPI 互联,延迟可能翻倍,带宽也会下降。容器若不绑内存节点,内核可能把页分配到任意节点,于是「跨节点访存」会拖慢一切。
正确姿势是先查拓扑,再谈绑定。
lscpu | grep -i -E "numa|socket|core|thread"
numactl --hardware
lstopo-no-graphics --of txt
numactl –hardware 会输出节点数、各节点 CPU 列表、各节点内存大小,以及一张 distance 矩阵(本地为 10,跨节点通常 21 左右,数值越大越远)。也可以用 sysfs 直接读。
cat /sys/devices/system/node/node0/cpulist
# 0-23,48-71
cat /sys/devices/system/node/node0/meminfo | head -3
cat /sys/devices/system/node/node0/distance
# 10 21
6.1 远端内存访问的代价
跨节点访存不只慢在延迟,还会占用互联带宽。当两个容器分别绑在不同节点、却共享同一块大页缓存时,一方写入会让另一方的缓存行失效,产生「伪共享式」的跨节点流量。用 numastat 观察 numa_hit 与 numa_foreign 的比例,就能判断是否发生了大量远端分配。
numastat
numastat -p $(pgrep -f app)
# numa_hit 与 numa_foreign 的比例是关键指标
7. 绑核与内存亲和实战
--cpuset-cpus 与 --cpuset-mems 必须成对使用:只绑核不绑内存,等于把计算放在 node0 却可能读 node1 的页,白白付出跨节点代价。Docker 会把这两个值写进 cpuset.cpus 与 cpuset.mems。
docker run -d --name db \
--cpuset-cpus="0-7" \
--cpuset-mems="0" \
postgres:16
容器内还可以用 numactl 二次收窄(前提是容器已获得对应 cpuset 权限)。
numactl --cpunodebind=0 --membind=0 -- ./app
numactl --physcpubind=0-3 --localalloc -- ./app
numastat -p $(pgrep -f app)
–membind 强制只从指定节点分配,内存不足时直接失败;–preferred 则优先但不强制,允许回退。低延迟场景建议用 –membind 换取确定性。
7.1 超线程 siblings 避让
同一物理核上的两个逻辑核共享执行单元与缓存,若把两个忙线程放在 siblings 上,性能反而下降。先查 siblings,再挑核。
cat /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
# 0,48 -> cpu0 与 cpu48 是同一物理核的两个超线程
cat /sys/devices/system/cpu/cpu0/topology/core_id
7.2 运行时动态调整
多数 cgroup 参数可以热更新,无需重启容器:
# 放宽 CPU 上限到 4 核
docker update --cpus=4 web
# 调整权重
docker update --cpu-shares=1024 web
# 直接写 cgroup 文件(需 root,v2 路径)
echo "400000 100000" > /sys/fs/cgroup/system.slice/docker-<cid>.scope/cpu.max
注意 cpuset 的热更新受限:v2 中只有当 cpuset.cpus 为空(尚未绑定)时才能首次写入,一旦绑定后想改需先置空,因此绑核最好在容器创建时就确定,别指望后期调整。
一句话:先 lstopo 看拓扑,再挑同一 NUMA 节点内、且互不互为 siblings 的核,最后把 cpuset.cpus 与 cpuset.mems 一起写死。
8. Docker 与 K8s CPU Manager 对比及低延迟隔离
Docker 靠运维手工计算并写死 cpuset;K8s 由 kubelet 的 CPU Manager 自动分配独占核。静态策略要求 Pod 为 Guaranteed QoS(requests 等于 limits 且为整数),此时容器获得整核独占,非 Guaranteed 容器共享剩余核。
| 维度 | Docker 手工绑定 | K8s CPU Manager static |
|---|---|---|
| 触发条件 | 显式传 cpuset 参数 | Pod 为 Guaranteed 且 CPU 为整数 |
| 分配粒度 | 任意核集合 | 整核独占 |
| 拓扑感知 | 需自行计算 | 配合 topology-manager-policy |
| 回收方式 | 手动 | kubelet 定期 reconcile |
Kubelet 侧的关键参数:
--cpu-manager-policy=static
--cpu-manager-reconcile-period=5s
--topology-manager-policy=single-numa-node
一个独占 4 核的低延迟 Pod 示例:
apiVersion: v1
kind: Pod
metadata:
name: low-latency
spec:
containers:
- name: app
image: app:latest
resources:
requests:
cpu: "4"
memory: "8Gi"
limits:
cpu: "4"
memory: "8Gi"
8.1 内核级隔离:把核从通用调度中摘除
对延迟极度敏感的业务(DPDK、音视频编解码、交易系统),应在启动参数上把核从通用调度中摘除,并关闭该核的调度时钟与 RCU 回调。
# /etc/default/grub 中追加内核参数
GRUB_CMDLINE_LINUX="isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7"
# 更新引导后重启
update-grub && reboot
- isolcpus:把这些核从 CFS 的通用负载均衡中排除,只留给显式绑定的任务。
- nohz_full:核上只有一个任务时停止周期性 tick,减少中断抖动。
- rcu_nocbs:把 RCU 回调卸载到其他核,避免本地延迟尖刺。
8.2 混合负载下的 QoS 分级
生产上很少所有服务都独占核,常见做法是按重要性分三级。
| 级别 | 手段 | 适用负载 |
|---|---|---|
| 独占级 | cpuset 独占核加 RT 预算 | 交易、DPDK、实时音视频 |
| 保障级 | cpu.max 设上限加 shares 加权 | 在线 API、数据库 |
| 共享级 | 仅设 shares,不设 quota | 批处理、离线任务 |
分级的关键是「不要让共享级和独占级抢同一批核」:用 cpuset 把独占核划出去,再让共享级在剩余核上用权重竞争。这样即便离线任务突然放量,也不会侵入实时业务的核。
8.3 生产踩坑速查
| 现象 | 根因 | 处置 |
|---|---|---|
| p99 抖动但平均 CPU 低 | CFS 节流 | 读 cpu.stat 调 quota 或改 cpuset |
| 跨节点访存慢 | 未绑内存节点 | 补上 –cpuset-mems |
| 两线程互相拖慢 | 落在同一超线程 | 按 siblings 避让挑核 |
| RT 进程卡死整机 | RT 带宽被耗尽 | 调 sched_rt_runtime_us 并 isolcpus |
| 迁 v2 后亲和失效 | cpus 与 mems 空值语义变化 | 显式写入并逐项校验 |
9. 总结
| 主题 | 关键结论 | 一句话记忆 |
|---|---|---|
| CFS 与 cgroup | 权重管争抢、带宽管上限、cpuset 管位置 | 三条腿正交,分开配 |
| v1 与 v2 | cpu.max 合并了 quota 与 period | 迁 v2 先验空值语义 |
| 参数换算 | –cpus=2 即 quota 200000 除以 period 100000 | 换算上限要盯 period |
| 节流排查 | nr_throttled 与 throttled_time 是关键 | 平均低不代表没节流 |
| 实时调度 | RT 受 95% 带宽约束 | RT 要配 SYS_NICE 与预算 |
| NUMA 绑定 | cpus 与 mems 必须成对 | 绑核不绑内存等于白绑 |
| 超线程 | siblings 共享执行单元 | 忙线程避开同一物理核 |
| K8s 对比 | static 策略自动独占整核 | Guaranteed 才给独占 |
| 低延迟隔离 | isolcpus 加 nohz_full 加 rcu_nocbs | 摘核、停 tick、卸 RCU |
容器 CPU 治理的核心,是把「相对权重、绝对上限、物理亲和」三件事分开处理:cpu.weight 决定争抢时的份额,cpu.max 决定单周期的硬上限,cpuset 决定任务究竟跑在哪几个核、用哪几个内存节点。绝大多数线上「偶发变慢」都源于两点——一是 quota 太小导致 CFS 节流,表现为平均 CPU 不高却尾延迟飙升,靠 cpu.stat 的 nr_throttled 与 throttled_time 一读便知;二是只绑核不绑内存,任务落在 node0 却读 node1 的页,跨节点访存把延迟翻倍。对延迟敏感的业务,进一步用 SCHED_FIFO 配合 RT 带宽获得确定性,再以 isolcpus、nohz_full、rcu_nocbs 把核从通用调度中摘除。落地顺序建议:先 lstopo 摸清拓扑,再按 NUMA 节点成对绑定 cpus 与 mems,最后用 K8s static 策略或 Docker 参数固化,并持续用 docker stats、cpu.stat、mpstat 验证效果。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。