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

Docker OOM 与性能诊断:cgroups、PSI、CPU Throttle、I/O 和内存

Docker 容器的“性能问题”通常不是一个单独的 Docker 问题,而是 Linux 内核对进程组进行资源控制后的结果。Docker 负责创建容器、设置 cgroups 限制并汇总状态;真正执行内存回收、CPU 调度、I/O 调度和 OOM 处置的是 Linux 内核。

本文讨论 Linux 容器边界内的现代 Docker Engine。Docker Desktop 中的容器实际运行在 Linux 虚拟机内,宿主机上看到的 CPU、内存和 I/O 指标,不能直接等同于容器内部 Linux 虚拟机的指标。


先建立一条完整的故障路径

一个请求从应用进入容器后,可能经历以下资源路径:

flowchart LR
    A[应用线程] --> B{CPU 调度}
    B -->|配额耗尽| C[CPU Throttle]
    B -->|获得时间片| D[执行系统调用]

    D --> E[内存分配]
    E -->|超过 memory.max| F[内存回收]
    F -->|无法回收且仍超限| G[cgroup OOM Kill]

    D --> H[文件系统/块设备 I/O]
    H -->|设备队列拥塞| I[I/O Stall]
    H -->|cgroup I/O 限速| J[I/O Throttle]

    C --> K[请求延迟上升]
    G --> L[进程退出,常见状态 137]
    I --> K
    J --> K

    K --> M[PSI 记录资源等待]

这里有几个容易混淆的事实:

  • CPU 使用率高不等于 CPU 被 throttle。进程可能确实获得了大量 CPU。
  • 内存使用量接近限制不等于已经 OOM。内核可能仍能回收页缓存。
  • I/O 使用量高不等于 I/O 饱和。真正影响延迟的是设备队列、服务时间和等待时间。
  • PSI 高表示任务因为资源不可用而等待,但 PSI 不直接告诉你是哪个进程导致的。
  • 容器退出码 137通常表示进程收到 SIGKILL,但仅凭 137 不能证明一定是 cgroup OOM;宿主机 OOM、人工 kill -9 等也可能产生相同退出码。

cgroups:Docker 资源限制的内核基础

cgroups 解决什么问题

Linux control groups,简称 cgroups,将一组进程放入一个层级结构,并对这组进程应用资源控制。Docker 容器中的主进程及其子进程通常位于同一个容器 cgroup 或其子 cgroup 中。

cgroups 主要提供三类能力:

  1. 限制:例如内存最多使用 512 MiB、CPU 每 100 ms 最多运行 50 ms。
  2. 计量:统计 CPU 时间、内存事件、I/O 字节数等。
  3. 分组:使内核能够按容器、服务或其他进程组执行控制。

现代 Linux 通常使用 cgroup v2,其统一层级中的典型文件包括:

memory.max
memory.current
memory.events
cpu.max
cpu.stat
io.max
io.stat
pids.max

较早的系统可能使用 cgroup v1。v1 将控制器拆分为多个独立层级,例如:

memory.limit_in_bytes
memory.usage_in_bytes
cpu.cfs_quota_us
cpu.cfs_period_us
blkio.throttle.read_bps_device

Docker 命令行参数保持相对稳定,但底层文件名和部分语义会随 cgroup 版本变化。因此诊断时不能只背某个路径,必须先确认版本:

docker info --format 'CgroupVersion={{.CgroupVersion}}'

典型输出:

CgroupVersion=2

这表示 Docker 当前使用 cgroup v2;它不表示宿主机一定启用了所有控制器,具体可用控制器还应检查:

cat /sys/fs/cgroup/cgroup.controllers

Docker 参数如何映射到 cgroups

下面的容器限制分别作用于不同控制器:

docker run -d --name demo \
  --memory=512m \
  --memory-swap=1g \
  --cpus=0.5 \
  --pids-limit=256 \
  nginx:alpine

其含义是:

  • --memory=512m:容器内存使用上限为 512 MiB 左右的字节限制。
  • --memory-swap=1g:在支持该语义的 Linux 配置中,容器内存与 swap 的总额度约为 1 GiB;因此可使用的 swap 额度约为 1 GiB - 512 MiB
  • --cpus=0.5:设置 CPU 带宽配额,等效于每个调度周期允许使用半个 CPU。
  • --pids-limit=256:限制 cgroup 中的进程/线程数量,防止 fork bomb 或线程失控。

实际限制应通过 docker inspect 查看,而不是只看启动命令:

docker inspect demo --format '
Memory={{.HostConfig.Memory}}
MemorySwap={{.HostConfig.MemorySwap}}
NanoCpus={{.HostConfig.NanoCpus}}
CpuQuota={{.HostConfig.CpuQuota}}
CpuPeriod={{.HostConfig.CpuPeriod}}
PidsLimit={{.HostConfig.PidsLimit}}
'

其中内存通常以字节表示,NanoCpus 以十亿分之一 CPU 表示。不同 Docker 或 Compose 版本对未设置字段的输出可能不同。


内存:使用量、回收和 OOM 不是同一件事

内存使用量包含什么

容器的内存消耗不只有应用堆内存,还可能包括:

  • 匿名页:语言运行时堆、线程栈、malloc 分配等;
  • 文件页缓存:读取文件后缓存的内容;
  • tmpfs:包括 /dev/shm 和显式挂载的 tmpfs;
  • 内核记账项目:部分 socket、内核数据结构等;
  • cgroup v2 下由内核计量的其他内存类型。

因此,一个进程在容器内报告的 RSS,与 cgroup 的 memory.current 并不总是相等。

在 cgroup v2 中,可检查:

CG=/sys/fs/cgroup/docker/<container-cgroup>
cat "$CG/memory.current"
cat "$CG/memory.max"
cat "$CG/memory.events"
cat "$CG/memory.stat"

实际路径依发行版和 Docker cgroup 驱动而变化。更可靠的定位方法是先取得容器主进程 PID:

PID=$(docker inspect -f '{{.State.Pid}}' demo)
cat /proc/"$PID"/cgroup

在 cgroup v2 中可能看到:

0::/system.slice/docker-<id>.scope

然后将该相对路径拼接到 /sys/fs/cgroup。如果 Docker 使用 systemd cgroup 驱动,路径通常位于 system.slice 下;不能假定所有机器都有 /sys/fs/cgroup/docker/<id>

memory.max 是硬限制

在 cgroup v2 中:

  • memory.max 是硬限制;
  • memory.current 是当前计量值;
  • memory.high 是高水位线,不是立即杀进程的硬上限;
  • memory.events 记录触发过的内存事件。

例如:

cat "$CG/memory.events"

可能得到:

low 0
high 12
max 3
oom 1
oom_kill 1

这些字段不能按同一个含义解释:

  • high 增加:任务越过 memory.high,内核对其施加回收和节流压力;
  • max 增加:尝试超过 memory.max
  • oom 增加:发生了 cgroup 内存不足处理;
  • oom_kill 增加:内核实际杀死了 cgroup 中的进程。

当内存接近 memory.max 时,内核首先尝试回收可回收页面,例如干净文件页缓存。只有在无法通过回收、内存申请仍然无法满足时,才进入 OOM 处理。

可以把过程简化为:

A>RA > R

其中:

  • AA 是当前 cgroup 需要满足的可计量内存;
  • RRmemory.max
  • AA 超过 RR 时,内核先尝试回收;
  • 若回收后仍有 A>RA' > R,则触发 OOM 路径。

这解释了一个常见反例:容器曾经达到 500 MiB,但限制是 512 MiB,并不意味着它必然 OOM。若其中 100 MiB 是可回收页缓存,内核可以先回收它。反过来,容器只显示 450 MiB,也可能因瞬时分配、不可回收匿名页或记账时序而触发失败。

一个可重复的内存实验

在测试机上运行:

docker run --rm --name memtest \
  --memory=64m \
  python:3.12-alpine \
  python -c '
a = []
while True:
    a.append(bytearray(1024 * 1024))
'

这个程序不断分配匿名内存。可能观察到:

docker wait memtest

输出:

137

随后检查事件:

docker events --since 1m --filter container=memtest

并查看内核日志:

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

该实验的风险是触发宿主机压力,因此应只在有足够余量的测试机运行。64 MiB 是容器硬限制,不是“保证程序只能分配 64 MiB”;运行时本身、线程栈和其他内存也需要计入。

--oom-kill-disable 不是增加内存

Docker 支持 --oom-kill-disable,但它并不会增加内存,也不会让超限分配成功。它只是影响 OOM killer 的行为。在没有同时设置内存限制、或宿主机整体内存不足时使用它,可能使问题扩散到宿主机。

一个容器内的子进程被杀死后,主进程可能仍然存在,容器也可能暂时保持运行。因此不能只通过“容器是否退出”判断是否发生过 OOM,应检查:

docker inspect demo --format '
OOMKilled={{.State.OOMKilled}}
ExitCode={{.State.ExitCode}}
Error={{.State.Error}}
'

OOMKilled=true 是 Docker 对容器状态的记录;内核层面还应结合 memory.events 和内核日志确认。

swap 的边界

--memory--memory-swap 的行为依赖 Linux swap 是否存在、Docker 是否能配置对应 cgroup 文件以及 cgroup 版本。常见规则是:

  • memory=512mmemory-swap=1g:内存与 swap 合计上限约 1 GiB;
  • memory=512mmemory-swap=512m:通常表示不允许额外 swap;
  • 不设置 memory-swap 时,不能简单断言所有环境都会有相同的 swap 行为。

swap 能缓解短时内存压力,但会把匿名页换出到块设备,导致 I/O 等待和尾延迟上升。对延迟敏感服务,必须把“避免 OOM”和“允许 swap”作为两个独立的取舍来评估。


OOM:谁杀了谁,必须区分层级

OOM 是“无法满足内存分配请求”的故障处理机制,但至少有三个层级:

  1. 进程级或 cgroup 级 OOM:容器内存限制被触发。
  2. 宿主机级 OOM:整台 Linux 主机可用内存不足。
  3. 虚拟机或云平台级处置:例如宿主机回收、实例重启。

cgroup OOM 只应选择受限 cgroup 内的进程;宿主机 OOM 则可能杀死任意进程,包括 Docker daemon、数据库或另一个容器。

因此,诊断顺序应当是:

docker inspect <container> --format '{{json .State}}'
docker events --since 30m
dmesg -T | grep -Ei 'oom|out of memory|killed process'
cat "$CG/memory.events"

如果 oom_kill 增加,且内核日志显示被杀进程属于该容器,证据链较完整。若只有退出码 137,没有 OOM 事件,就不能直接下结论。


CPU Throttle:CPU 不够用的两种完全不同的情况

CPU quota 的形式化含义

Linux CFS 带宽控制通常使用周期 PP 和配额 QQ

  • PP:调度周期,常见单位为微秒;
  • QQ:每个周期允许该 cgroup 使用的 CPU 时间;
  • 可用 CPU 比例近似为:

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

例如:

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

每 100 ms 周期最多使用 50 ms CPU,因此 C=0.5C=0.5。这相当于平均半个逻辑 CPU 的带宽,而不是固定绑定到某一颗 CPU。

--cpus=0.5 是更直观的写法,通常会转换为相近的 period/quota 配置。

CPU throttle 的状态变化

一个周期内的状态可以这样推导:

  1. 周期开始,cgroup 获得 QQ 的运行预算;
  2. 一个或多个线程运行,消耗预算;
  3. 当累计消耗达到 QQ 时,cgroup 被标记为 throttled;
  4. 该 cgroup 中仍然 runnable 的线程不能继续运行;
  5. 下一个周期开始,预算恢复。

因此,进程可能看到:

  • 宿主机 CPU 仍然很空闲;
  • 容器 CPU 使用率只有 50%;
  • 但请求延迟显著上升。

这是因为容器的额度耗尽,并不意味着整台机器没有空闲 CPU。

在 cgroup v2 中检查:

cat "$CG/cpu.max"
cat "$CG/cpu.stat"

可能得到:

50000 100000
usage_usec 8123456
user_usec 7000000
system_usec 1123456
nr_periods 1200
nr_throttled 800
throttled_usec 45000000

关键变量是:

  • nr_periods:经过的调度周期数;
  • nr_throttled:发生过节流的周期数;
  • throttled_usec:累计被节流的时间。

一个有意义的比例是:

T=Δthrottled_usecΔperiodT = \frac{\Delta throttled\_usec}{\Delta period}

但不能只看 nr_throttled / nr_periods。如果每次只节流很短时间,比例高不一定严重;如果节流总时长很大,即使周期比例不高,也可能影响尾延迟。

CPU 高使用率与 throttle 的反例

设容器限制为 0.5 CPU:

  • 容器实际使用 0.49 CPU;
  • 没有 throttle;
  • 宿主机还有很多空闲 CPU。

此时容器已经接近自身配额,但不能说它被 throttle。

另一个场景:

  • 容器实际使用 0.45 CPU;
  • throttled_usec 持续增加;
  • 容器内有 8 个 runnable 线程;
  • 宿主机还有 4 个空闲 CPU。

这可能是突发并发在每个周期前半段耗尽额度,后半段所有线程等待下一个周期。平均使用率不高,但延迟和 PSI CPU 会变差。

cpu.weight--cpu-shares 不是硬配额

CPU weight 是竞争时的相对权重。只有当多个 cgroup 同时争用同一 CPU 资源时,权重才决定相对分配;它不会保证某个容器一定获得某个 CPU 百分比,也不会单独产生 quota 式硬节流。

因此:

  • --cpus--cpu-quota 更接近硬上限;
  • --cpu-shares 或 cgroup v2 的 cpu.weight 是竞争优先级;
  • --cpuset-cpus 是 CPU 亲和性约束,不等于 CPU 配额。

诊断 CPU 时至少同时观察:

docker stats <container>
cat "$CG/cpu.stat"
cat /proc/pressure/cpu

docker stats 适合快速查看,cgroup 文件适合判断是否真正发生 quota throttle,PSI 适合判断系统或该 cgroup 是否有任务因 CPU 不可运行而等待。


PSI:从“资源使用了多少”转向“任务等了多久”

PSI 的定义

Pressure Stall Information,简称 PSI,记录任务因为某类资源不足而无法继续推进的时间比例。它关注的是 stall,不是资源占用率。

Linux 常见全局文件:

cat /proc/pressure/cpu
cat /proc/pressure/memory
cat /proc/pressure/io

cgroup v2 中,若内核和挂载配置支持,还可以在 cgroup 目录看到:

cat "$CG/cpu.pressure"
cat "$CG/memory.pressure"
cat "$CG/io.pressure"

典型输出:

some avg10=12.50 avg60=4.10 avg300=1.20 total=987654
full avg10=1.50 avg60=0.20 avg300=0.05 total=12345

含义是:

  • some:至少有一个任务因该资源等待;
  • full:该 cgroup 中所有可运行任务同时因该资源等待;
  • avg10avg60avg300:最近 10、60、300 秒内的平均 stall 百分比;
  • total:累计 stall 时间,通常以微秒表示。

例如 cpu some avg10=20 不是“CPU 使用率 20%”,而是观察窗口内累计有约 20% 的时间,至少一个任务因 CPU 不可得而停顿。

PSI 与 CPU throttle 的关系

CPU quota throttle 会让任务不能运行,因此可能增加 CPU PSI;但二者不是同一个指标:

  • CPU throttle:该 cgroup 的配额是否耗尽;
  • CPU PSI:任务是否因 CPU 不可得而等待;
  • 系统 CPU PSI:整个系统是否存在 CPU 运行队列压力。

以下情况可能出现:

现象 可能原因
throttle 高,系统 CPU PSI 低 容器自身配额太小,宿主机仍空闲
throttle 低,系统 CPU PSI 高 多个任务在宿主机上竞争 CPU,但该容器未被 quota 限制
CPU 使用率低,CPU PSI 高 线程频繁等待运行,或配额周期造成间歇性停顿
CPU 使用率高,CPU PSI 低 容器持续获得 CPU,计算密集但不排队

PSI 的 full 对服务尤其有价值:如果所有任务都在等待,通常意味着该 cgroup 在某些时间点完全无法推进;但它仍然不能替代应用级指标和线程分析。


I/O:限速、设备拥塞和文件系统缓存要分开看

I/O 的数据流

容器中的路径通常经过多层:

应用
  -> 进程文件描述符
  -> overlay2 上层目录或 bind mount
  -> page cache / writeback
  -> 块设备队列
  -> 虚拟磁盘或物理磁盘

容器隔离并不意味着容器拥有独立磁盘。多个容器可能共享同一宿主机块设备,Docker 的 I/O 限制也必须作用于实际承载数据的设备。

写入时,write() 成功可能只表示数据进入 page cache,并不表示已经落盘。真正的设备拥塞可能在后续 writeback 阶段出现。因此“应用写系统调用很快”与“磁盘没有压力”并不矛盾。

Docker I/O 限制

常见运行参数包括:

docker run -d --name iotest \
  --read-bps=/dev/sdb:10mb \
  --write-iops=/dev/sdb:100 \
  nginx:alpine

参数作用于指定设备:

  • --read-bps:限制读取吞吐;
  • --write-bps:限制写入吞吐;
  • --read-iops:限制读取操作次数;
  • --write-iops:限制写入操作次数。

设备名必须是容器所在 Linux 环境能够识别、且承载目标数据的块设备。对 bind mount,不能想当然地把设备写成容器内路径;应在宿主机确认:

findmnt -T /var/lib/docker
findmnt -T /path/to/bind-mounted-data
lsblk -o NAME,TYPE,FSTYPE,MOUNTPOINTS

cgroup v2 中可以查看:

cat "$CG/io.max"
cat "$CG/io.stat"

io.stat 常见字段包括读写字节数和 I/O 次数;具体字段由内核版本和设备支持情况决定。它说明该 cgroup 产生了多少 I/O,不直接说明这些 I/O 花费了多少时间。

I/O PSI 与设备指标

I/O 压力应结合多层指标:

cat /proc/pressure/io
iostat -xz 1
pidstat -d -p <pid> 1
vmstat 1

重点观察:

  • iostat 中设备的 await:I/O 平均等待时间;
  • %util:设备忙碌程度,但在现代多队列设备上不能孤立解释;
  • r_awaitw_await:读写等待;
  • vmstat 中的 wa:CPU 等待 I/O 的时间比例;
  • PSI I/O:任务因 I/O 无法推进的时间;
  • cgroup io.stat:容器是否真的发起了大量 I/O。

一个典型故障推导是:

  1. 容器 io.stat 的写入字节持续增加;
  2. iostat 显示宿主机设备 await 上升;
  3. /proc/pressure/iosome 上升;
  4. 应用线程在 write()fsync() 或后台 flush 上等待;
  5. 请求延迟上升。

如果只有 io.stat 数字高、设备 await 很低、PSI 也接近零,那么这是“使用量高”而不是“当前 I/O 瓶颈”。

overlay2 的写放大和日志

容器可写层通常使用 overlay2。频繁修改大文件可能触发 copy-up:文件第一次在上层修改时,需要从只读层复制到可写层。这会造成额外 I/O 和磁盘空间消耗。

应用日志写到 stdout/stderr 后,Docker logging driver 还可能将其写入宿主机。默认 json-file 日志若没有轮转,日志增长会:

  • 消耗宿主机磁盘;
  • 增加写 I/O;
  • 在磁盘满时导致应用写入失败或 Docker 行为异常。

因此 I/O 诊断要区分:

  • 数据目录;
  • overlay2 可写层;
  • 容器 stdout/stderr 日志;
  • bind mount 或 volume;
  • swap 和内核 writeback。

一次完整的诊断流程

第一步:确认容器实际状态

docker ps -a --no-trunc
docker inspect app --format '
Status={{.State.Status}}
Running={{.State.Running}}
ExitCode={{.State.ExitCode}}
OOMKilled={{.State.OOMKilled}}
Error={{.State.Error}}
Pid={{.State.Pid}}
'

先确认是:

  • 进程退出;
  • 容器被重启;
  • 健康检查失败;
  • OOMKilled;
  • Docker daemon 或宿主机异常。

不要先看到 137 就直接修改内存上限。

第二步:确认限制和实际 cgroup

docker inspect app --format '
Memory={{.HostConfig.Memory}}
MemorySwap={{.HostConfig.MemorySwap}}
NanoCpus={{.HostConfig.NanoCpus}}
CpuQuota={{.HostConfig.CpuQuota}}
CpuPeriod={{.HostConfig.CpuPeriod}}
BlkioWeight={{.HostConfig.BlkioWeight}}
PidsLimit={{.HostConfig.PidsLimit}}
'

取得 PID 和 cgroup:

PID=$(docker inspect -f '{{.State.Pid}}' app)
test "$PID" -gt 0
cat /proc/"$PID"/cgroup

PID 为 0 通常表示容器当前未运行,此时不能再从进程路径读取实时 cgroup 文件。

第三步:采集一段时间的增量,而非只看单点

CPU throttle、I/O 计数和 PSI 都是时间相关指标。单次 cat 很难说明问题:

while sleep 1; do
  date
  cat "$CG/cpu.stat"
  cat "$CG/memory.events"
  cat "$CG/memory.current"
  cat "$CG/cpu.pressure" 2>/dev/null || true
  cat "$CG/io.pressure" 2>/dev/null || true
done

生产环境不应无限制地打印高频采样结果;应交给 node exporter、cAdvisor、OpenTelemetry 或已有监控系统采集,并保存时间序列。

第四步:对齐应用指标

系统指标必须与应用时间线对齐:

  • 请求 P95/P99 延迟;
  • 活跃请求数;
  • GC 暂停;
  • 线程数;
  • 队列长度;
  • 数据库连接等待;
  • 日志写入量;
  • 重启时间。

例如 P99 在固定的 100 ms 周期附近出现尖峰,同时 throttled_usec 增长,往往比“CPU 使用率 50%”更能证明 quota throttle 影响了延迟。


使用 docker stats 时不要误读

docker stats --no-stream app

它适合快速查看:

  • 内存使用和限制;
  • CPU 使用率;
  • 网络流量;
  • 块设备读写;
  • PIDs 数量。

但它存在重要边界:

  1. CPU 百分比是采样窗口内的使用率,不直接显示 throttled_usec
  2. 内存数字受 Docker 版本、cgroup 版本和统计口径影响,不应替代 memory.currentmemory.stat
  3. BLOCK I/O 是累计读写量,不是设备延迟。
  4. 网络 I/O 只表示容器网络命名空间可见的流量,不等于应用端到端吞吐。
  5. 单次 --no-stream 只提供一个瞬时采样,不能判断趋势和增量。

docker stats 的正确定位是“快速筛查”,不是 OOM、节流和 I/O 瓶颈的最终证据。


Compose 中的限制与验证

Compose 可以表达常见资源限制,例如:

services:
  api:
    image: nginx:alpine
    mem_limit: 512m
    memswap_limit: 1g
    cpus: 0.5
    pids_limit: 256
    blkio_config:
      device_read_bps:
        - path: /dev/sdb
          rate: 10mb
      device_write_iops:
        - path: /dev/sdb
          rate: 100

启动后应验证实际生效配置:

docker compose config
docker compose up -d
docker compose ps
docker inspect "$(docker compose ps -q api)" \
  --format 'Memory={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}}'

需要区分 Compose 规范和部署环境:

  • mem_limitcpus 等由本地 Docker Engine 创建容器时执行;
  • 某些 deploy.resources 字段主要面向编排器,不能假设在所有非 Swarm 场景中都以相同方式生效;
  • cgroup v1/v2、Docker rootless、宿主机内核能力和设备路径都会影响最终效果;
  • 配置文件声明成功,不等于宿主机实际提供了对应控制器。

常见错误判断

“容器内存还有余量,所以不是内存问题”

错误原因:单点内存使用可能遗漏瞬时峰值、页缓存回收、多个进程总和或宿主机级压力。

验证方式:

watch -n 0.5 '
cat /sys/fs/cgroup/.../memory.current
cat /sys/fs/cgroup/.../memory.events
'

同时查看应用进程 RSS、运行时堆和宿主机内存。

“CPU 使用率只有 40%,所以 CPU 不可能是瓶颈”

错误原因:40% 可能是受限于一个很小的 quota;也可能是线程在周期性等待。

验证:

cat "$CG/cpu.stat"
cat "$CG/cpu.max"
cat "$CG/cpu.pressure"

“磁盘吞吐没有跑满,所以不是 I/O 问题”

错误原因:随机 I/O、同步写、低队列深度或单线程串行写入可能在低吞吐下产生高延迟。

验证:

iostat -xz 1
pidstat -d -p "$PID" 1
cat /proc/pressure/io

“PSI 高就说明某个容器被限流”

错误原因:全局 PSI 反映整台机器,可能由其他容器、内核回写或宿主机任务造成。

应优先查看容器 cgroup 的 *.pressure;若没有 cgroup PSI,再将全局 PSI、容器计数器和应用时间线结合分析。

“增加 --memory 就能修复所有 OOM”

错误原因:可能真正的问题是:

  • 内存泄漏;
  • 缓存没有上限;
  • 子进程或线程数量失控;
  • swap 引发 I/O 压力;
  • 宿主机总体内存不足;
  • 日志或数据目录导致系统级资源耗尽。

扩大限制只是改变触发点,不能替代确认分配来源和生命周期。


生产取舍:限制应与故障隔离一起设计

内存限制过小,会增加 cgroup OOM;限制过大,则可能让单个容器挤压其他服务并诱发宿主机 OOM。CPU quota 过小会产生周期性 throttle;只设置权重又无法阻止单个服务在低竞争时使用大量 CPU。I/O 限速可以保护共享设备,但过低的限速会把业务等待隐藏成应用延迟。

因此资源治理至少需要同时记录:

  • 配置的限制:memory.maxcpu.maxio.max
  • 实际使用:memory.current、CPU usage、io.stat
  • 失败计数:memory.events、进程退出和重启;
  • 等待程度:CPU、内存、I/O PSI;
  • 业务结果:延迟、错误率、队列和吞吐。

容量告警不应只设置“内存使用率超过 80%”。更有诊断价值的组合是:

  • memory.current / memory.max 持续升高;
  • memory.eventshighoom_kill 增量出现;
  • CPU throttled_usec 在业务高延迟窗口持续增加;
  • I/O PSI 与设备 await 同时升高;
  • 容器重启、退出码和应用错误在同一时间线上出现。

最终要回答的不是“哪个数字变红了”,而是:

  1. 哪个 cgroup 受到了哪种限制;
  2. 内核采取了回收、节流还是杀进程;
  3. 任务实际等待了多久;
  4. 等待是否与业务延迟或错误同时发生;
  5. 调高限制、降低并发、修复泄漏或迁移 I/O 后,计数器和业务指标是否恢复。

只有把 Docker 配置、cgroup 状态、PSI、内核日志、设备指标和应用时间线连成一条证据链,OOM 与性能诊断才不会停留在猜测层面。


系列导航与关联阅读

官方资料

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