Docker 基础体系 · 第 11/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker 资源治理:CPU、内存、PID、I/O、cgroups 与 OOM

Docker 容器的“资源限制”不是容器运行时凭空实现的一组参数,而是 Linux 内核提供的 cgroups(control groups,控制组)、命名空间、调度器、内存回收器和块设备 I/O 控制器共同作用的结果。

本文讨论 Linux 容器边界内的资源治理,示例以现代 Docker Engine、BuildKit 和 Compose 规范为基础。Windows 容器使用另一套资源与隔离机制,不能直接套用本文对 Linux cgroups、内核 OOM 和 /sys/fs/cgroup 的描述。


一、先建立整体模型:容器资源限制在哪里生效

一个容器至少涉及三类边界:

  1. 命名空间(namespace):决定进程能看到什么,例如进程号、网络接口、挂载点。
  2. cgroups:决定这些进程最多能使用多少 CPU、内存、PID、块设备 I/O 等资源。
  3. Linux 内核资源管理器:真正执行调度、内存回收、I/O 排队和 OOM 处理。

Docker Engine 通常负责:

  1. 创建或复用一个 cgroup;
  2. 将容器主进程及其后代进程加入该 cgroup;
  3. 根据 docker run、Compose 或 API 参数写入 CPU、内存等控制器;
  4. 启动容器进程;
  5. 在容器退出后读取退出状态、OOM 标记和日志。

典型的数据流可以简化为:

flowchart LR
    CLI[Docker CLI / Compose] --> Engine[Docker Engine]
    Engine --> Runtime[runc 或其他 OCI Runtime]
    Runtime --> CG[cgroup 层级]
    Runtime --> NS[Linux namespaces]
    CG --> CPU[CPU controller]
    CG --> MEM[Memory controller]
    CG --> PID[PIDs controller]
    CG --> IO[I/O controller]
    CPU --> K[Linux kernel]
    MEM --> K
    PID --> K
    IO --> K
    NS --> K
    K --> P[容器内进程]

这里最重要的因果关系是:

Docker 参数只是配置入口;资源限制最终是否生效,取决于宿主机内核、cgroup 版本、控制器是否启用、运行时实现以及底层设备是否支持相应控制。

因此,“命令接受了参数”不等于“参数一定按预期限制了进程”。


二、cgroups:资源治理的内核边界

2.1 cgroup 是什么

cgroup 将一组进程放入一个内核管理的层级中,并对这一组进程应用资源控制策略。

例如,一个容器的进程树可能是:

容器 cgroup
├── PID 1:应用进程
├── 工作线程
├── 子进程
└── 应用启动的辅助进程

只要子进程仍然属于该 cgroup,CPU、内存、PID 等限制通常都会作用于它。资源限制不是只针对 Docker 记录的那个“主进程”。

cgroup 与进程命名空间解决的问题不同:

  • PID namespace 让容器内进程看到自己的 PID 视图;
  • pids controller 限制该 cgroup 能创建多少个进程或线程;
  • memory controller 限制该 cgroup 的内存使用;
  • CPU controller 控制调度时间;
  • I/O controller 控制或统计块设备 I/O。

2.2 cgroup v1 与 cgroup v2

Linux 目前存在两种主要 cgroup 层级:

  • cgroup v1:不同资源控制器可以挂载在不同层级;
  • cgroup v2:使用统一层级,控制器通过 cgroup.subtree_control 启用。

查看当前系统:

stat -fc %T /sys/fs/cgroup

常见结果:

cgroup2fs

表示当前主要使用 cgroup v2;若看到 tmpfs,还需要继续查看挂载信息:

mount | grep cgroup

查看当前 cgroup v2 的控制器:

cat /sys/fs/cgroup/cgroup.controllers
cat /sys/fs/cgroup/cgroup.subtree_control

前一个文件表示当前层级可用的控制器,后一个文件表示当前层级向子 cgroup 开放的控制器。Docker、systemd 和宿主机管理员可能共同管理这些层级,因此不应随意手工改写生产环境的 cgroup 树。

Docker 对 cgroup 版本的选择还受到以下因素影响:

  • Linux 发行版和内核;
  • Docker Engine 配置;
  • systemd 是否作为 cgroup 管理器;
  • rootless 模式;
  • 用户会话是否拥有对应控制器权限。

可以使用以下命令查看 Docker 侧信息:

docker info

重点关注:

Cgroup Driver: systemd
Cgroup Version: 2

Cgroup Driver 是 Docker 如何组织 cgroup 的方式;Cgroup Version 是内核采用的 cgroup 版本。二者不是同一个概念。


三、CPU 资源治理:时间配额、相对权重与 CPU 集合

CPU 限制至少有三种不同语义,不能混为一谈:

  1. 绝对时间配额:一个周期内最多使用多少 CPU 时间;
  2. 相对权重:竞争时谁获得更多调度时间;
  3. CPU 集合:允许在哪些逻辑 CPU 上运行。

3.1 --cpus 的绝对上限

最常见的参数是:

docker run -d \
  --name cpu-demo \
  --cpus=1.5 \
  alpine \
  sh -c 'while :; do :; done'

--cpus=1.5 的含义是:在足够长的观察窗口内,该容器最多获得约 1.5 个逻辑 CPU 的计算时间。

在 cgroup v1 中,它通常映射到:

  • cpu.cfs_quota_us
  • cpu.cfs_period_us

在 cgroup v2 中,通常映射到:

  • cpu.max

配额的基本关系是:

C=QPC = \frac{Q}{P}

其中:

  • QQ 是一个周期内允许使用的 CPU 时间;
  • PP 是调度周期;
  • CC 是允许使用的 CPU 数量上限。

例如:

P=100000 μs,Q=150000 μsP = 100000\ \mu s,\quad Q = 150000\ \mu s

则:

C=150000100000=1.5C = \frac{150000}{100000}=1.5

这并不表示进程必须绑定在某一个 CPU 上,也不表示任何时刻都只能运行 1.5 个线程。它表示在调度器累计的周期配额意义上,容器总体最多获得相当于 1.5 个 CPU 的时间。

当容器在某个周期内消耗完 QQ 时,仍然可运行的任务会被限流,等待下一个周期补充配额。其表现通常是:

  • CPU 使用率接近配置上限;
  • 请求延迟在高负载下增加;
  • 进程不一定退出;
  • docker stats 看到的 CPU 使用率可能持续接近限制,而不是宿主机总 CPU 的 100%。

一个常见误解是:--cpus=1 会让程序只拥有一个线程。它不会禁止程序创建多个线程,只会限制这些线程总体获得的 CPU 时间。

3.2 --cpu-quota--cpu-period

也可以显式指定:

docker run -d \
  --name cpu-demo-explicit \
  --cpu-period=100000 \
  --cpu-quota=50000 \
  alpine \
  sh -c 'while :; do :; done'

这里:

C=50000100000=0.5C=\frac{50000}{100000}=0.5

即最多获得约半个 CPU。

周期过短或配额过小可能增加调度抖动;周期过长则可能导致一次限流持续时间较长。除非有明确的延迟和调度测试依据,否则通常优先使用语义更直接的 --cpus

3.3 --cpu-shares / --cpu-weight 是相对权重

相对权重不是硬上限。以 Docker 的传统参数为例:

docker run -d --name low \
  --cpu-shares=512 alpine sh -c 'while :; do :; done'

docker run -d --name high \
  --cpu-shares=1024 alpine sh -c 'while :; do :; done'

当两个容器都持续有 CPU 需求、并且没有绝对 quota 限制时,high 获得的调度权重约为 low 的两倍。

若两个容器的权重分别为 w1w_1w2w_2,且都在竞争同一个 CPU 资源,则理想化的 CPU 分配比例为:

s1=w1w1+w2,s2=w2w1+w2s_1=\frac{w_1}{w_1+w_2},\qquad s_2=\frac{w_2}{w_1+w_2}

但这只在以下条件近似成立:

  • 两个任务都持续 runnable;
  • 处于相同的 CPU 资源域;
  • 没有 CPU quota;
  • 没有 cpuset 隔离;
  • 没有其他任务和调度层级影响。

当宿主机空闲时,低权重容器仍可能使用大量 CPU。因此下面的配置不能表达“最多使用半个 CPU”:

--cpu-shares=512

它只表达“发生竞争时优先级较低”。

在 cgroup v2 中,这类相对权重通常表现为 cpu.weight;不同 Docker 版本和 cgroup 版本会进行参数映射,不能简单把 v1 的数值直接与 v2 文件中的数值比较。

3.4 --cpuset-cpus 是 CPU 亲和性

docker run -d \
  --name pinned \
  --cpuset-cpus="2,3" \
  alpine \
  sh -c 'while :; do :; done'

这表示容器任务只能被调度到逻辑 CPU 2 和 3 上。

它与 --cpus=2 不等价:

  • --cpus=2:可以在多个 CPU 间迁移,但总体 CPU 时间上限约为两个 CPU;
  • --cpuset-cpus=2,3:只能在编号为 2、3 的 CPU 上运行,但如果没有 quota,理论上可以占满这两个 CPU。

CPU 编号还涉及宿主机的:

  • 超线程;
  • NUMA 节点;
  • CPU 离线状态;
  • 容器编排平台的 CPU 分配策略。

因此,设置 cpuset 前应先检查:

lscpu
nproc

3.5 CPU 诊断:区分“配额不足”和“程序没有工作”

查看容器统计:

docker stats cpu-demo

查看配置:

docker inspect cpu-demo \
  --format '{{json .HostConfig}}' | jq

在 cgroup v2 中,找到容器对应的 cgroup 后,可以查看:

cat cpu.max
cat cpu.weight
cat cpu.stat

cpu.stat 中常见的字段包括运行时间、被限流的时间和限流次数。不同内核版本字段可能略有差异,但 nr_throttledthrottled_usec 一类信息可用于判断 CPU quota 是否造成了限流。

如果 CPU 使用率很低但延迟很高,不能直接得出“CPU 限制太小”的结论,还要检查:

  • I/O 等待;
  • 锁竞争;
  • 下游服务;
  • GC;
  • CPU steal;
  • 线程池是否耗尽;
  • cgroup CPU throttling 统计。

四、内存治理:限制、回收、交换区与 OOM

内存是最容易产生误解的资源,因为“使用量”“可回收缓存”“交换区”“进程 RSS”和“容器限制”不是同一个量。

4.1 --memory 是 cgroup 内存上限

docker run -d \
  --name mem-demo \
  --memory=256m \
  alpine \
  sh -c 'sleep 3600'

--memory=256m 为容器 cgroup 设置内存上限。进程申请内存时,内核会把该 cgroup 的匿名页、文件页以及其他受控制的内存计入相应统计;具体计费细节受 cgroup 版本和内核实现影响。

在 cgroup v2 中,常见文件包括:

memory.max       硬上限
memory.current   当前使用量
memory.high      高水位,触发回收或限速
memory.events    事件计数
memory.stat      分类统计
memory.swap.max  swap 使用上限

memory.max 的值为 max 时表示不设置该层级的硬上限。

容器内的 /proc/meminfo 不能始终准确表达容器可用内存。应用应优先使用容器运行环境提供的 cgroup 信息,或者使用已经正确感知 cgroup 的运行时和监控代理。

4.2 memory.highmemory.max 的区别

在 cgroup v2 中:

  • memory.high 是压力控制线;
  • memory.max 是硬限制。

达到 memory.high 后,内核通常会增加回收和限制该 cgroup 的行为,使其变慢,但不必立即杀死进程。

达到 memory.max 后,内核尝试回收内存;如果仍无法满足分配请求,就可能在该 cgroup 内触发 OOM 处理。

Docker 的常用 --memory 主要对应硬上限,不应把它理解为“超过后先优雅降速”。

4.3 swap 的含义

--memory-swap 的含义依赖宿主机是否启用 swap,也受到 cgroup v1/v2 和 Docker 版本行为影响。常见关系可以这样理解:

  • --memory=256m --memory-swap=512m:内存和 swap 合计上限通常为 512 MiB,理论上允许约 256 MiB swap;
  • --memory=256m --memory-swap=256m:通常表示内存与 swap 合计上限相同,因此不额外允许 swap;
  • --memory-swap=-1:通常表示不限制总的 memory+swap,但仍受宿主机实际 swap 和其他限制影响。

不能把 swap 当作免费内存。它可以避免部分瞬时分配直接失败,但会引入明显的访问延迟;数据库、低延迟服务和高吞吐服务是否允许 swap,需要依据应用特性验证。

Docker 还提供:

--memory-swappiness=0

该参数控制匿名页被换出的倾向,但它不是“绝对禁止 swap”的统一保证,且在不同 cgroup 版本、内核和配置下可用性存在差异。生产环境必须结合宿主机 swap 设置和实际内核行为验证。

4.4 OOM 的完整故障路径

OOM(Out Of Memory)表示内存分配无法满足,不等于“容器退出码一定是 137”。

一个典型流程如下:

  1. 容器进程申请更多内存;
  2. cgroup 当前使用量接近 memory.max
  3. 内核尝试回收文件页、匿名页或使用允许的 swap;
  4. 仍无法满足分配;
  5. 触发 cgroup 内存 OOM;
  6. 内核选择一个或多个进程发送 SIGKILL
  7. 如果被杀的是容器 PID 1,容器退出;
  8. Docker 记录容器状态,并根据 --restart 策略决定是否重启。

如果没有设置特殊的退出处理,进程收到 SIGKILL 后通常不能执行清理代码。Docker 中常见的退出码 137 是:

128+9=137128+9=137

它通常表示进程因 SIGKILL 退出,但 137 本身不能证明一定是 OOM。还可能来自管理员执行 kill -9、运行时故障处理或其他外部操作。

判断是否为 OOM,应组合检查:

docker inspect container_name \
  --format 'Exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}}'

查看内核日志:

dmesg -T | grep -i -E 'out of memory|oom|killed process'

使用 systemd 的机器还可以:

journalctl -k -g 'oom|out of memory|killed process'

cgroup v2 中可以查看:

cat memory.events

常见字段包括:

  • high:触发 memory.high 的次数;
  • max:触及 memory.max 的次数;
  • oom:发生 OOM 条件的次数;
  • oom_kill:因 OOM 被杀死的次数。

这些计数是诊断“容器内存达到边界”的证据,比单独看退出码更可靠。

4.5 oom_score_adj--oom-kill-disable

Linux 在全局内存压力下需要选择被杀进程,进程的 oom_score_adj 会影响选择倾向。Docker 的:

--oom-score-adj=-500

可以调整容器进程的 OOM 选择分数,但这不是内存预留,也不是禁止 OOM。

--oom-kill-disable

会尝试禁止 OOM killer 杀死该容器内进程,但它不是“让内存无限增长”。如果容器设置了内存限制,达到限制后进程可能卡在分配、回收或内核等待路径;如果宿主机整体耗尽内存,其他进程可能承担更大风险。

除非已经理解宿主机 OOM 行为和恢复路径,否则不应为了避免看到 OOMKilled=true 而启用该选项。

4.6 完整内存测试示例

下面使用 Python 逐步分配匿名内存:

docker run --rm \
  --name mem-test \
  --memory=64m \
  python:3.12-alpine \
  python -c '
import time
blocks = []
for i in range(20):
    blocks.append(bytearray(8 * 1024 * 1024))
    print(f"allocated={(i + 1) * 8} MiB", flush=True)
    time.sleep(0.2)
'

预期现象:

  • 容器可能打印若干次 allocated=...
  • 在接近 64 MiB 限制后,进程可能被杀死;
  • 宿主机执行 docker inspect 时可能看到 OOMKilled=true
  • 实际可分配量不一定正好等于 64 MiB,因为解释器、动态库、页表和其他内存也要计入 cgroup。

这个示例不能用于测量精确 OOM 阈值,只能展示“应用分配、cgroup 计费、内核回收和 OOM”之间的关系。


五、PID 资源治理:限制进程和线程数量

PID controller 限制的是一个 cgroup 中可以创建的任务数量。Linux 中线程也属于 task,因此线程数过多同样会消耗 PID 配额。

docker run -d \
  --name pid-demo \
  --pids-limit=128 \
  alpine \
  sh -c 'sleep 3600'

--pids-limit=128 表示该容器及其子进程、线程最多只能拥有约 128 个 cgroup task。它不是限制“容器内可见 PID 最大值”,也不是 PID namespace 的 PID 编号上限。

当应用继续创建进程或线程时,常见表现是:

  • fork() 返回 EAGAIN
  • 创建线程失败;
  • 线程池扩容失败;
  • shell 执行新命令失败;
  • 应用日志出现 “resource temporarily unavailable”。

可以查看:

docker stats pid-demo
docker top pid-demo

如果需要在容器内观察进程和线程:

docker exec pid-demo sh -c 'ps -eLf | wc -l'

但 Alpine 镜像中的 ps 能显示的信息取决于安装的 procps 工具。宿主机侧还可以查看容器进程的 cgroup 归属:

docker inspect -f '{{.State.Pid}}' pid-demo

再根据该 PID 检查:

cat /proc/<host-pid>/status | grep -E 'Threads|NSpid'

设置 PID 上限时必须保留余量。应用本身可能需要:

  • 工作线程;
  • DNS 解析线程;
  • JIT 编译线程;
  • 日志线程;
  • 子进程;
  • 监控和探针进程。

过小的 pids-limit 会把正常扩容变成间歇性故障;不设置上限则 fork bomb 或错误的线程泄漏可能拖垮宿主机。PID 限制不是应用线程池配置的替代品,应用仍应有自己的并发上限。


六、I/O 资源治理:设备、队列与可观察性边界

I/O 限制比 CPU 和内存更依赖底层设备。Docker 只能把策略传给内核,实际效果还取决于:

  • 宿主机使用的块设备;
  • device-mapper、overlayfs、LVM、NVMe 等存储栈;
  • cgroup v1 blkio 或 cgroup v2 io 控制器;
  • 是否使用绑定挂载或命名卷;
  • 底层设备是否支持按 cgroup 限流;
  • 实际 I/O 是直接发生在块设备,还是被页缓存吸收。

6.1 按吞吐量限制

示例:

docker run -d \
  --name io-demo \
  --device-read-bps /dev/sda:10mb \
  --device-write-bps /dev/sda:5mb \
  alpine \
  sh -c 'sleep 3600'

语义是对指定块设备设置每秒读写字节数上限。单位和设备路径必须以当前 Docker Engine 支持的格式为准;设备名也必须是宿主机真实存在并且 Docker 可识别的块设备。

6.2 按 IOPS 限制

docker run -d \
  --name io-iops-demo \
  --device-read-iops /dev/sda:100 \
  --device-write-iops /dev/sda:50 \
  alpine \
  sh -c 'sleep 3600'

IOPS 是每秒 I/O 操作次数,不等于吞吐量。一个 4 KiB 请求和一个 1 MiB 请求都可能各计为一次操作,因此:

吞吐量IOPS×单次请求大小吞吐量 \approx IOPS \times 单次请求大小

这只是理想化关系。实际吞吐还受队列深度、设备并发、合并请求、缓存和文件系统影响。

6.3 相对 I/O 权重

docker run -d \
  --name io-low \
  --blkio-weight=300 \
  alpine sh -c 'sleep 3600'

docker run -d \
  --name io-high \
  --blkio-weight=900 \
  alpine sh -c 'sleep 3600'

相对权重只在多个任务竞争同一个受控 I/O 资源时产生明显效果。宿主机空闲时,低权重容器仍可能获得较高吞吐。

在 cgroup v2 中,相关接口通常是 io.maxio.weightio.stat;在 cgroup v1 中则常见 blkio.* 文件。Docker 参数到内核控制器的映射不是所有版本、存储驱动和设备类型都完全一致。

6.4 I/O 限制的常见反例:页缓存

下面的测试不一定立即体现磁盘限速:

dd if=/dev/zero of=/tmp/testfile bs=1M count=1024

原因是写入可能先进入页缓存,dd 很快返回,而真正的块设备写回在之后发生。更可靠的测试需要明确同步语义,例如:

dd if=/dev/zero of=/tmp/testfile bs=1M count=1024 conv=fdatasync

但这仍然不能保证结果只反映 Docker 限制,因为 /tmp 可能位于容器可写层,overlayfs 还会引入额外行为。

生产测试应明确:

  • 测试文件所在卷;
  • 读写是否绕过页缓存;
  • 是否使用宿主机相同的设备;
  • 是否存在其他工作负载;
  • 观察的是应用延迟、设备吞吐还是 cgroup 统计。

查看容器的实际 I/O 统计:

docker stats container_name

也可以在对应 cgroup 中查看 io.stat。如果 docker stats 与应用侧指标不一致,应确认统计口径:一个可能统计块设备 I/O,另一个可能统计文件系统调用完成时间或页缓存行为。


七、Docker 参数、Compose 配置与 BuildKit 的边界

7.1 docker run 是运行时限制

常用运行时参数包括:

docker run -d \
  --name app \
  --cpus=2 \
  --memory=1g \
  --pids-limit=512 \
  --cpuset-cpus=0-3 \
  --restart=unless-stopped \
  image:tag

这些参数作用于该容器运行期创建的 cgroup。它们不会修改镜像,也不会自动限制同一镜像启动的其他容器。

--restart=unless-stopped 只决定退出后的启动策略,不能修复 OOM。若应用每次启动都在相同阶段达到 memory.max,结果可能是:

启动 → OOM → 重启 → OOM → 重启

这种循环需要结合 docker events 和监控告警识别,而不是简单增加重启次数:

docker events \
  --filter container=app \
  --filter event=die \
  --filter event=restart

7.2 Compose 配置

一个运行时资源配置示例:

services:
  api:
    image: example/api:1.0
    cpus: 1.5
    mem_limit: 512m
    pids_limit: 256
    restart: unless-stopped
    mem_swappiness: 0

Compose 还支持 deploy.resources 形式,例如:

services:
  worker:
    image: example/worker:1.0
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 256M
        reservations:
          cpus: "0.25"
          memory: 128M

这里必须区分规范和实现:

  • deploy.resources 最初与 Swarm 服务部署语义关系密切;
  • 现代 Compose 实现对其中部分资源字段可以应用到本地容器;
  • 不同 Compose 版本、部署目标和字段的支持情况可能不同;
  • reservations 通常表达调度或资源预留意图,不等于容器已经获得一个独立的内核硬配额。

在目标环境执行:

docker compose config
docker compose up -d
docker inspect <container>

通过最终容器的 HostConfig 和实际 cgroup 文件确认,而不要只依据 YAML 文件推断限制已生效。

7.3 BuildKit 构建资源与运行时资源不是一回事

docker builddocker buildx build 使用 BuildKit 时,构建步骤可能运行在:

  • Docker Engine 管理的 BuildKit worker;
  • docker-container 驱动创建的 BuildKit 容器;
  • Kubernetes 或远程 BuildKit worker。

因此:

docker run --memory=512m ...

不会限制后续 docker build 的构建过程。构建资源边界必须配置在 BuildKit worker 或构建命令支持的资源参数上,并根据所用 builder 驱动验证。

查看 builder:

docker buildx ls
docker buildx inspect --bootstrap

构建阶段的 CPU 和内存消耗还可能来自:

  • 编译器;
  • 链接器;
  • 包管理器;
  • 并行构建任务;
  • BuildKit worker 自身。

镜像构建资源和最终容器资源应分别设计:前者防止 CI 节点被编译任务拖垮,后者控制服务运行期行为。


八、资源限制与容器生命周期、日志、监控的连接

资源治理不能只看“当前配置”,还必须覆盖退出、重启、日志和告警。

8.1 退出与信号

OOM killer 发送的是 SIGKILL,应用无法捕获,也不会执行优雅退出逻辑。普通停止通常是 Docker 先发送 SIGTERM,等待超时后再发送 SIGKILL。因此:

  • 优雅停止失败不等于 OOM;
  • OOM 后没有应用清理日志是正常的;
  • 关键状态不能只依赖进程退出前写入;
  • 应用应定期持久化必要状态。

8.2 日志可能放大磁盘压力

容器资源限制不自动限制 Docker 日志文件的磁盘容量。应用持续输出日志时,可能出现:

  • 容器本身 CPU 被日志格式化和写入消耗;
  • I/O 竞争加剧;
  • /var/lib/docker 所在文件系统被写满;
  • Docker daemon 或其他容器受影响。

查看日志驱动:

docker inspect app \
  --format '{{json .HostConfig.LogConfig}}'

创建容器时可以设置日志轮转,例如:

docker run -d \
  --name app \
  --log-opt max-size=10m \
  --log-opt max-file=5 \
  image:tag

日志轮转只控制 Docker 日志驱动的文件行为,不能替代应用日志采样、级别控制和集中式日志容量治理。

8.3 docker stats 不是完整监控系统

docker stats --no-stream

适合快速观察,但有明确边界:

  • CPU 百分比需要结合宿主机逻辑 CPU 数和 Docker 统计口径理解;
  • 内存统计可能受缓存和 cgroup 版本影响;
  • 网络和块 I/O 是累计值或采样值;
  • 短时 OOM、瞬时 PID 峰值可能被采样遗漏;
  • 容器删除后,历史数据可能无法仅靠 Docker CLI 恢复。

生产监控至少应保留:

  • cgroup CPU usage、throttling;
  • memory.currentmemory.events、working set;
  • PID current 与 limit;
  • I/O bytes、IOPS、等待时间;
  • 容器退出码、OOMKilled 状态;
  • Docker events;
  • 宿主机内存、swap、磁盘和 inode。

九、一个可复现的综合诊断流程

启动一个同时设置 CPU、内存和 PID 限制的测试容器:

docker run -d \
  --name governed \
  --cpus=0.5 \
  --memory=128m \
  --pids-limit=64 \
  --restart=no \
  alpine \
  sh -c 'while :; do :; done'

检查配置:

docker inspect governed \
  --format 'Pid={{.State.Pid}} Exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'

实时观察:

docker stats governed

检查容器是否仍在运行:

docker ps -a --filter name=governed

如果它被手工停止,可能看到退出码 137,但 OOMKilled 不一定为 true。如果它因内存压力被内核杀死,通常可以在 docker inspect 和内核日志中找到 OOM 证据。

清理测试资源:

docker rm -f governed

docker rm -f 会强制停止并删除容器,但不会自动删除命名卷,也不会解决宿主机上已经产生的日志、临时文件或监控数据。资源清理应区分:

  • 容器元数据;
  • 可写层;
  • 命名卷;
  • bind mount 目录;
  • Docker 日志;
  • 外部监控数据。

十、常见错误推理

错误一:退出码 137 就是 OOM

正确判断需要同时检查:

docker inspect <container> \
  --format '{{.State.ExitCode}} {{.State.OOMKilled}}'

并结合内核日志、cgroup memory.events。137 只说明常见情况下进程因 SIGKILL 终止。

错误二:cpu-shares 是 CPU 百分比上限

不是。它是竞争时的相对权重。要表达硬上限,应使用 --cpus 或 quota;要表达 CPU 绑定,应使用 --cpuset-cpus

错误三:内存限制只计算应用 RSS

cgroup 的内存计费范围比单个进程 RSS 更复杂,通常还包括文件页、页表、内核相关对象以及其他受控制的内存类别。不能简单用宿主机 ps 的 RSS 加总替代 cgroup 统计。

错误四:限制了容器 I/O,就限制了所有磁盘活动

I/O 控制通常针对特定块设备和 cgroup 路径。页缓存、overlayfs、挂载卷、宿主机其他进程和底层存储队列都可能改变观测结果。

错误五:PID 限制只限制进程,不限制线程

Linux 线程也消耗 task/PID 资源。高线程数应用必须将线程池、运行时线程和辅助进程一并纳入容量计算。

错误六:Docker 接受了参数,所以限制一定生效

必须验证:

  1. Docker 使用 cgroup v1 还是 v2;
  2. 目标控制器是否启用;
  3. 当前运行模式是否有权限;
  4. 最终容器配置是否包含该参数;
  5. cgroup 文件是否呈现预期值;
  6. 压测时是否出现对应的 throttling、回收、OOM 或 I/O 统计。

十一、资源治理的核心取舍

资源上限的目标不是让每个容器都尽可能小,而是把故障边界从“拖垮宿主机”变成“单个工作负载受控失败”。

一个完整的配置通常需要同时回答:

  • CPU 是需要硬上限、相对权重,还是 CPU 绑定?
  • 内存达到边界时允许 swap 吗?
  • 应用是否能在被 SIGKILL 前保存状态?
  • PID 上限是否覆盖线程和子进程峰值?
  • I/O 限制作用于哪个真实块设备?
  • 资源不足后容器是否自动重启?
  • 重启循环如何告警?
  • 日志和监控本身是否会消耗相同的 CPU、内存和磁盘资源?
  • 构建阶段和运行阶段是否使用了不同的资源边界?

cgroups 提供的是内核级资源边界,不会自动解决容量规划、线程泄漏、内存泄漏、日志失控或错误的重启策略。只有把 cgroup 配置、应用并发模型、容器生命周期、日志容量和监控证据放在同一条故障链上,CPU、内存、PID、I/O 与 OOM 才能真正形成可验证的资源治理体系。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。