容器 CPU 调度与 NUMA:绑核、实时性与 QoS 保障

从 Linux CFS 调度器与 cgroup v1/v2 CPU 控制器出发,讲解 Docker 的 --cpus、--cpuset-cpus、--cpuset-mems 参数语义与换算,剖析 CPU 节流根因与排查,深入 SCHED_FIFO 实时策略与 RT 带宽,结合 NUMA 拓扑讲解绑核与绑内存节点的远端访问代价,并给出 K8s CPU Manager 对比与生产清单。

前置阅读:建议先阅读 容器资源限制与 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.sharescpu.weight
带宽上限cpu.cfs_quota_us 与 cpu.cfs_period_uscpu.max
运行统计cpu.statcpu.stat
核心亲和cpuset.cpuscpuset.cpus
内存节点亲和cpuset.memscpuset.mems
实时带宽cpu.rt_runtime_us 与 cpu.rt_period_uscpu.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=2cpu.max 写成 200000 100000等价 quota 除以 period,上限 2 核
–cpu-period=100000cpu.max 第二列CFS 周期,单位微秒,默认 100000
–cpu-quota=150000cpu.max 第一列单周期可用时间,单位微秒
–cpu-shares=512cpu.weight相对权重,默认 1024
–cpuset-cpus=0-3cpuset.cpus硬绑定到物理核 0 到 3
–cpuset-mems=0cpuset.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)

排查顺序建议如下:

  1. docker stats 看 CPU% 是否长期贴住上限(2 核时接近 200%)。
  2. 读 cpu.stat 确认 nr_throttled 是否持续增长。
  3. mpstat -P ALL 1 看各核是否饱和,区分「配额不足」与「宿主争抢」。
  4. 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进程与线程级 CPUpidstat -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 与 v2cpu.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 验证效果。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「docker」更多文章

  1. Docker Daemon 运维:systemd 集成、配置调优与日志治理
  2. OCI 镜像与工件规范:manifest、index 与 artifact 生态
  3. 构建缓存进阶:Buildx 远程缓存、CI 加速与缓存失效