← 全部笔记

Docker · 7 底层原理

cgroups:限制了什么

cgroups 把一组进程的资源合起来计账;子组的用量还要经过父组的上限。

约 15 分钟 · 截至 2026-11-03,Docker Engine 29.x · 实验未实测 · Linux 容器 · cgroup v2

  • /sys/fs/cgroup/v2:CPU、内存共用一棵树
    • system.slice/memory.max ← 256 MiB(假设)
      • docker-A.scope/memory.max ← 192 MiB
        容器 A 的主进程 + 子进程 → 合计用量计入 A,也计入父组
      • docker-B.scope/memory.max ← 192 MiB
        容器 B 的主进程 + 子进程 → 与 A 共受父组的 256 MiB 约束
图 1 两个子组各设 192 MiB,也不能同时用满:父组只允许合计 256 MiB。路径缩写、限额为示意;文件实际以字节记录。

01 / 从进程到组

上限沿树生效

控制组(control group,cgroup)是 Linux 内核组织进程、统计并控制资源的机制。控制器(controller)负责一种资源,例如 memory 管内存、cpu 管 CPU 时间。v2 把这些控制器放在同一棵目录树中。[1]

目录里的文件是内核接口,读它们是在读内核状态。cgroup.procs 列出本组进程的编号(PID);普通容器中新建的子进程继承所在组。一个程序开出多个子进程,仍然合用这组限额。

图 1 的父组统计整个子树。子组可以再收紧上限,不能放宽祖先的约束。限额也不等于预留:给 A 设 192 MiB,不会立刻为它划走这块内存。

假设父组里只有 A、B,A 从零开始,B 已用 160 MiB。A 的上限是 192 MiB,它还能直接再用 192 MiB 吗?写出父组这一关的剩余额度。

不能。父组剩余 256 − 160 = 96 MiB。即使 A 自己没用满,继续分配也会遇到父组的约束。这里固定 B 用量、忽略其他开销;真实系统还会尝试内存回收,96 MiB 不是实际分配成功的保证。

深入一点:目录名为什么和图里不同?

Docker 的 cgroup 驱动决定如何管理目录。v2 上默认的 systemd 驱动常见路径是 /sys/fs/cgroup/system.slice/docker-<完整容器 ID>.scope/;cgroupfs 驱动常见路径是 /sys/fs/cgroup/docker/<完整容器 ID>/。rootless(以普通用户运行 Docker)通常在用户子树中。不要把图中的 A、B 当成实际容器 ID。[2]

cgroup.controllers 列出当前组可向下分配的控制器;cgroup.subtree_control 决定对子组启用哪些控制器。文件是否存在还取决于内核支持和父级授权;本页只读,不手改这棵树。

02 / 参数落点

参数变成文件值

模块 3 用 --memory 和 --cpus 设置上限;这里追到它们在 cgroup v2 的落点。Docker 经容器运行时配置控制组,真正执行限制的是内核。下面是文档推导的对应关系,未实测。[3] [1]

Docker 参数v2 文件与预期值读法
--memory=64mmemory.max
67,108,864 字节
64 × 1024 × 1024,即 64 MiB;本组与子组的内存硬上限。
--cpus=0.5cpu.max
配额 50,000;周期 100,000
两列单位均为微秒(µs);每 100 ms 最多用 50 ms 的累计 CPU 时间。

cpu.max 的顺序是配额、周期。配额除以周期,就是可用的 CPU 时间份额。默认周期为 100,000 µs;--cpus=1.5 等价于配额 150,000 µs、周期 100,000 µs。多个线程可在多个核上同时消耗这份预算。

周期保持 100,000 µs,改成 --cpus=0.75。请先算,再拨动下面的滑块核对。

cpu.max 第一列应为 µs。

0.75 × 100,000 = 75,000 µs。它表示时间预算,不指定在哪个核执行;绑定 CPU 编号要用 --cpuset-cpus。

示意模型:一个持续忙碌的线程、一个可用 CPU、无其他竞争,周期 100 ms;假设一开始就连续运行。

实心段:运行 50 ms空白段:节流等待 50 ms全长:100 ms

cpu.max = 50000 100000

预算耗尽后等到下个周期;0.5 不会把 CPU 的时钟频率减半。

图 2 份额改变每周期的时间预算;实际运行可能分散,多线程并行时也可能更早用完预算。

容器读到 cpu.max 是 150000 100000。哪种解释成立?

这两个值都是时间。CPU 编号由另一类约束管理。

预算属于整个组;多开子进程不会复制配额。

150,000 / 100,000 = 1.5。多核并行能在 100 ms 墙上时间内累计用到 150 ms CPU 时间;实际还受可用核数、竞争与父组限制。预算是上限,不保证一定拿得到。

03 / 动手核对

在容器里只读验证

你已在容器与虚拟机中见过共享内核。现在用同一内核暴露的文件验证上限。命令在你有 Docker 权限的终端执行,使用 Linux 容器;Docker Desktop 的内核位于其 Linux 虚拟机中。

未实测

本页编写环境没有 Docker,以下命令未在本机运行。数值预期依据官方参数含义计算,不是终端实录,待维护者补实测。实验只读 cgroup 文件,不需要 --privileged、宿主挂载或手改网络 / iptables;会创建临时容器,首次运行可能下载镜像。

1. 先确认服务端条件

docker version --format '{{.Server.Version}}'
docker info --format 'cgroup={{.CgroupVersion}} driver={{.CgroupDriver}} os={{.OSType}}'

本页面向 Engine 29.x;继续实验需服务端报告 cgroup=2、os=linux,且支持 CPU、内存控制器。若是 v1,请停在这里:本页的文件名不适用。不要为了实验修改宿主启动参数。[2]

2. 读取两项限额

显式选 --cgroupns=private,让容器的 cgroup 视图以自身组为根。--memory-swap=64m 与内存上限相同,用于禁用该容器的交换空间(swap:把内存页换出到磁盘)。[4] [3]

docker run --rm --network none --cgroupns=private \
  --memory=64m --memory-swap=64m --cpus=0.5 \
  alpine:3.22 sh -eu -c '
    test -f /sys/fs/cgroup/cgroup.controllers || {
      echo "需要 cgroup v2" >&2; exit 1;
    }
    for f in memory.max cpu.max; do
      printf "%s=" "$f"
      cat "/sys/fs/cgroup/$f"
    done
  '

预期值(文档推导,未实测):memory.max 为 67108864;cpu.max 为 50000 100000。前者是字节,后者两列是微秒。对照上一节的换算,不要用 free 或 CPU 核数代替限额文件。

若文件缺失、值为 max 或命令报错,先检查控制器支持、Docker 警告和 rootless 授权。不能仅凭命令没有报错就认定限制生效。

3. 改一个参数,再读一次

把上条命令改为 --memory=128m --memory-swap=128m --cpus=0.75。先写出两个文件的预期值,再运行核对。

文档推导:memory.max 应为 134217728,cpu.max 应为 75000 100000。内存与 swap 参数一起改,保持禁用交换空间。请记录你自己的服务端版本、驱动与实际读数,便于复核两组参数的差异。

深入一点:为什么容器里只看到自己的根?

cgroup 命名空间改变进程看到的路径。Docker 在 v2 上默认使用私有 cgroup 命名空间;本实验又显式指定一次。因此容器内的 /sys/fs/cgroup/memory.max 是自身组的文件,不是宿主机全局根组。路径视图的隔离不会解除父组的资源约束。[2]

在运行 Docker 守护进程的原生 Linux 主机上,可结合 docker inspect 的 State.Pid 与 /proc/<PID>/cgroup 查实际路径。v2 成员记录的格式是 0::路径。远程 Docker 或 Desktop 不能直接拿本地终端的 /proc 拼这个路径。

04 / 达限之后

内存回收,CPU 等待

内存是占着的容量,CPU 配额是周期内花掉的时间。两者达限后的行为不同;排障时要看对应证据。[1]

内存 / 容量上限

  1. ① 继续申请内存,触及 memory.max
  2. ② 内核尝试回收可释放的内存
  3. ③ 无法回收满足需求时,可能发生 OOM,杀死组内进程

CPU / 时间预算

  1. ① 忙碌线程消耗 cpu.max 配额
  2. ② 预算耗尽,组内任务被节流,暂停获得 CPU 时间
  3. ③ 进入下一周期后,预算补充,继续运行
图 3 OOM 可能终止进程;CPU 节流让任务等待,通常表现为更慢或响应延迟增加。

内存不足(Out of Memory,OOM)时,内核可能选择进程终止。memory.max 管组内合计,包括应用内存、文件页缓存及部分内核用量;也可能短暂超限。它既不是单个进程的配额,也不是磁盘文件大小上限。

检查 memory.current 看当前用量,memory.events 看事件计数;oom_kill 增加说明本组进程曾被 OOM 杀死。CPU 则看 cpu.stat 中 nr_throttled 的变化,判断本组配额是否发生节流。单次非零值只是历史,比较前后才知道这次是否增加。

先按“内存容量 / CPU 时间”分类,再用一句话解释它们为什么不能互相代替。

  • memory.max:内存硬上限
  • cpu.max:每周期的配额
  • memory.events:oom_kill
  • cpu.stat:nr_throttled
内存容量
CPU 时间

内存文件描述容量与内存事件;CPU 文件描述时间预算与节流。内存达限可能回收或终止进程;CPU 配额耗尽会等待下个周期。只看“程序慢了”不足以判断哪项达限。

深入一点:哪些结论不能从这两个文件推出?
  • max 表示本级没有该项上限;祖先的约束、宿主容量与竞争仍存在。
  • --memory 不单独说明 swap 总额。v2 用 memory.swap.max 管交换空间;Docker 的 --memory-swap 表示内存加交换空间的总额,两者不能照抄数值。
  • cpu.stat 的本级节流计数不能完整反映祖先的节流;怀疑父组限制时,还要在 Docker 主机检查父级。也不能把 --cpus 当成独占或预留 CPU。
  • PID、网络、挂载等命名空间管理可见范围;cgroup 的资源控制器管理用量。CPU、内存上限不能代替文件访问权限或网络隔离。

其他控制器还可管理任务数量、块设备 I/O 等资源。本页只追踪内存与 CPU,实验没有制造内存耗尽。

深入一点:rootless 为什么可能不生效?

Docker 官方说明:rootless 的 cgroup 资源限制需要 cgroup v2 与 systemd。若 docker info 中 Cgroup Driver 为 none,相关参数会被忽略。即使驱动是 systemd,也需父级委派相应控制器,尤其是 CPU。这里的“委派”就是允许普通用户管理该子树的资源控制。请先核对官方排障说明,不在本实验里改宿主配置。[5]

05 / 检索

读值还要看父级

A 的 memory.max 是 192 MiB;父组上限 256 MiB,同级 B 固定用了 160 MiB。忽略其他开销,A 从零开始最多还能占多少父组额度?

A 的 192 MiB 只是其中一关;父组还在统计 A 与 B 的合计。

父组的额度由子树共享,B 已占去 160 MiB。

256 − 160 = 96 MiB,小于 A 自身的 192 MiB 上限。这是固定条件下的额度计算;实际分配还受回收、其他用量和宿主可用内存影响。

有人说:“CPU 核数没变,内存工具也还能看到宿主总量,所以 Docker 没有限制。”请用本页的实验方法回应。

可见的硬件数量不能证明资源预算。先确认 cgroup v2,再读本组限额并考虑父组。

  • 读 memory.max,按字节核对 --memory。
  • 读 cpu.max,用配额 / 周期核对 --cpus。
  • 核数与时间预算不同,且父级可能进一步收紧上限。

来源

核对日期:2026-11-03。命令以 Docker 官方文档为准,文件语义以 Linux 内核文档为准;本页实验未实测。

  1. Linux Kernel — Control Group v2:统一层级、父级约束、memory.max / memory.events、cpu.max / cpu.stat。
  2. Docker — Runtime metrics:识别 v2、驱动与路径、私有 cgroup 命名空间。文档后半的 v1 指标例子不用于本页。
  3. Docker — Resource constraints:内存、swap、CPU 配额与周期、CPU 绑定。
  4. Docker — docker container run:--rm、--cgroupns、资源参数。
  5. Docker — Rootless tips / Limiting resources:v2 + systemd 条件与控制器委派。