ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker资源限制与分配:CPU、内存、IO与GPU实战指南

Docker资源限制与分配:CPU、内存、IO与GPU实战指南

上周半夜接到一个电话,对方说测试环境的一台服务器连 SSH 都敲不进去了,重启之后登录上去一看,/var/lib/docker所在的分区被写满,内存被一个跑飞的容器吃到只剩几百兆,负载飙到 300 多。排查下来原因很简单:那个容器是用docker run裸起的,没加任何 Docker 资源限制,里面的批处理任务内存泄漏加上疯狂写日志,把整台宿主机拖垮了。这类事故我见过太多次,本质上不是 Docker 的问题,而是很多人默认把容器当成了"自带隔离的虚拟机",忘了它只是宿主机上的一堆进程。这篇就围绕 Docker 资源限制与分配这件事,把 CPU、内存、磁盘 IO、GPU 四个维度的限制方式、参数换算、踩坑点和验证手段完整讲一遍,包含docker run参数、Compose 写法、以及编排层的对应配置思路。不管你是刚接触容器、只会docker run -d起个服务的同学,还是已经在管生产集群、需要给多租户做资源配额的老手,应该都能从里面拿到能直接抄的东西。

1. 先把"为什么"想清楚:容器资源限制到底在限制什么

1.1 容器不是沙箱,是共享食堂里划出来的一块餐位

很多人对容器的第一印象是"轻量级虚拟机",这个类比在理解资源限制时会带来严重误导。虚拟机会被 hypervisor 硬性切走一部分 CPU 核和内存条,容器不会——所有容器共享同一个 Linux 内核,共享同一份 CPU 调度器、同一个内存管理子系统、同一套块设备队列。内核提供的能力叫cgroups(control groups),它的作用是给一组进程打上标记,然后在调度和回收的时候按标记做区别对待。

打个比方,宿主机是一间大食堂,CPU 是打饭窗口,内存是餐桌,磁盘 IO 是泔水桶的处理速度。cgroups 干的事不是给每个容器单独建一间食堂,而是给每个容器发一张带编号的餐卡,规定"你最多只能同时占两张桌子""你打饭的优先级比别人低一半""你倒泔水的速度不能超过每秒多少升"。食堂还是那间食堂,只是有了规则。所以一旦某个容器的规则没设,它就等同于一个没有餐卡的人,理论上可以霸占所有餐桌。

这也解释了一个常见困惑:为什么容器里free -h看到的内存还是宿主机的总量?因为容器没有独立的内存视图,free读的是/proc/meminfo,那是内核全局数据。想看容器的真实限制,得去/sys/fs/cgroup/下面读对应文件,或者用docker stats。

1.2 四个维度对应的内核子系统,心里要有张表

做限制之前,先搞清楚每个维度落在哪个内核子系统上,这样出问题时你知道去哪看指标。下面这张表是我自己整理的一张对照,建议存下来:

资源维度内核子系统主要控制文件(cgroup v1 路径)Docker 常用参数
CPU 时间片权重cpucpu.shares--cpu-shares
CPU 使用时长上限cpucpu.cfs_quota_us/cpu.cfs_period_us--cpus/--cpu-quota
CPU 绑核cpusetcpuset.cpus/cpuset.mems--cpuset-cpus/--cpuset-mems
内存用量上限memorymemory.limit_in_bytes-m/--memory
内存+交换区上限memorymemory.memsw.limit_in_bytes--memory-swap
内存软限制memorymemory.soft_limit_in_bytes--memory-reservation
块设备带宽/IOPSblkioblkio.throttle.read_bps_device等--device-read-bps等
块设备权重blkioblkio.weight--blkio-weight
GPU 设备devices设备节点白名单--gpus/--runtime

需要提醒一点,cgroup v2 已经在新版发行版上成为默认(很多主流桌面与服务发行版近两年都切过去了),文件布局从"一个子系统一棵树"变成了"统一层级一棵树",路径变成/sys/fs/cgroup/<容器ID>/memory.max这种形式。参数名在 Docker 层没变,但你去手工查内核文件的时候路径会不一样。判断当前是 v1 还是 v2,执行stat -fc %T /sys/fs/cgroup/,输出cgroup2fs就是 v2。

1.3 什么该限、什么不必限,别一刀切

我见过一些团队为了"规范",给所有容器都套上--cpus=0.5 -m=256m,结果是把一个正常服务限成了响应极慢的半瘫状态。资源限制不是越严越好,它的本质是隔离爆炸半径,不是省资源。

我的判断标准大概是这么几条:第一,任何跑在共享宿主机上、且不是你自己完全掌控行为的容器,都要设内存上限,因为内存是唯一一种"用超了会直接把别人也拖下水"的资源;第二,CPU 上限要看业务类型,CPU 密集型任务(编译、转码、批处理)该设硬顶,IO 密集型或者突发型服务(网关、API)更适合用 shares 做软约束;第三,磁盘 IO 限制主要给写日志多、做数据落盘的容器,普通无状态服务限了收益不大;第四,GPU 只在真的要用的时候分配,不要图省事给个--gpus all,那等于把整张卡的所有算力和显存暴露给一个可能写错代码的进程。

还有一点容易被忽略:--cpus、-m这些参数在 Linux 宿主上才真正生效。如果你在桌面系统上跑 Docker Desktop,容器实际跑在一个虚拟机里,你设的限制是限制那个虚拟机内部的资源,而不是直接限制你本机的物理资源。这点在本地做压测验证时特别容易造成误判。

2. CPU 限制:软约束、硬顶、绑核,三套方案别用混

2.1 CPU shares:只在"抢饭"时才生效的相对权重

--cpu-shares(cgroup v2 里叫cpu.weight)是最容易被误解的参数。它不是CPU 百分比,也不是核数,而是一个相对权重值,默认 1024。它的生效条件是"CPU 处于争抢状态"——如果宿主机 CPU 空闲,一个 shares 只有 128 的容器照样能跑满所有核;只有当多个容器同时抢 CPU 时,内核才按权重比例分配时间片。

举个例子,容器 A 设--cpu-shares=1024,容器 B 设--cpu-shares=512,宿主机 4 核全忙的情况下,A 大概能拿到 4 核 × 2/3 ≈ 2.67 核的算力,B 拿到 1.33 核。但如果只有 B 在跑,B 能用到全部 4 核。

这个特性决定了 shares 的适用场景:你希望容器在有竞争时退让,但空闲时能尽量跑满。典型的比如后台异步任务、日志采集、定时清理,这类工作平时占用低,突发时也不该跟主业务抢资源,用 shares 比用--cpus更合适。

--cpu-shares的取值范围是 2 到 262144(0 会被当成默认值 1024),实际使用时没必要设得太精细,1024、512、256 这几档基本够用。

2.2 --cpus:硬顶配额,参数换算得算明白

需要"这个容器无论如何不能超过 X 核"这种硬性保证时,用--cpus。它的底层是 CFS(完全公平调度器)的带宽控制,由两个参数配合完成:cpu.cfs_period_us(周期,默认 100000 微秒,也就是 100ms)和cpu.cfs_quota_us(一个周期内允许使用的微秒数)。

换算公式很直观:

配额(quota) = 核数 × 周期(period)

比如--cpus=1.5,等价于--cpu-quota=150000 --cpu-period=100000,意思是每 100ms 内这个容器最多能用 150ms 的 CPU 时间(可以理解成 1.5 个核满载)。

--cpus=0.5就是--cpu-quota=50000,每 100ms 最多用 50ms。

还有一个冷门参数--cpu-period,可调范围是 1000 到 1000000 微秒。一般不建议改,因为周期越短,限流越精确但上下文切换开销越大;周期越长,突发能力越强但瞬时可能超出限制较多。默认 100ms 是经过长期验证的平衡点。

注意:--cpus和--cpu-shares可以同时设置,两者不冲突。shares 决定竞争时的比例,quota 决定绝对上限。我在生产上常见的组合是:主服务设--cpus硬顶防止跑飞,同时保留默认 shares;旁路服务只设 shares。

2.3 cpuset 绑核:追求确定性延迟时才动用

--cpuset-cpus直接把容器绑定到指定的物理核上,比如--cpuset-cpus="0,1"表示这个容器只会在 0 号和 1 号核上运行。它和--cpus=2有本质区别:后者是"总共只能用相当于 2 个核的时间片,但可以在任意核上漂移",前者是"物理上锁死在这两个核"。

绑核的价值在于消除 CPU 迁移带来的缓存失效和调度抖动,对延迟敏感型应用(高频交易、实时音视频处理、某些数据库)有实际收益。但代价也很明显:一旦绑死,容器就无法借用其他空闲核,高峰期可能出现明明机器有富余算力、容器却在排队的情况。

实际使用中还有两个细节要留意。一是绑核要考虑 NUMA 拓扑,如果绑的核和分配的内存不在同一个 NUMA 节点上,跨节点访问内存的延迟反而更高,这时候要配合--cpuset-mems一起用。二是别把系统进程和管理容器绑到同一批核上,否则监控、日志这些基础组件会跟业务抢同一批物理核。

查看宿主机拓扑的命令是lscpu和numactl --hardware,分配前先看一眼,别凭感觉写核号。

2.4 CPU 限制的实操验证:不压测等于没设

参数写完不验证,等于没配。我这边的标准动作是用压测工具把容器压到饱和,然后用docker stats观察实际占用是否被卡在设定值上。

先起一个受限容器:

docker run -d --name cpu-test \ --cpus=1.0 \ --cpu-shares=512 \ --cpuset-cpus="0,1" \ alpine:3.19 \ sh -c "apk add --no-cache stress-ng && stress-ng --cpu 4 --timeout 300s"

然后另开一个终端看:

docker stats cpu-test --no-stream

预期结果是 CPU% 稳定在 100% 附近(docker stats里 100% 等于 1 个核),而不是 400%。如果看到 380%、390% 这种数字,说明--cpus没生效,先检查是不是跑在 Docker Desktop 的虚拟机里,或者 cgroup 版本与参数不匹配。

想看内核层面的真实配额,可以在宿主机上执行:

cat /sys/fs/cgroup/cpu.max # cgroup v2 的输出格式为 "quota period"

输出类似100000 100000,就说明配额是 1 核。如果是max 100000,说明没有限制。

3. 内存限制:OOM Killer 的触发逻辑与高频踩坑

3.1 -m 与 --memory-swap 的联动规则,必须记住

-m(等同于--memory)设定容器可用的物理内存上限,单位可以是b/k/m/g。超过这个值,内核的 OOM Killer 会挑一个进程干掉,容器随之退出,退出码通常是 137。

--memory-swap是物理内存加交换区的总和上限,注意是总和不是单独的 swap 大小。两者组合的规则容易绕晕,我整理成表:

-m设置--memory-swap设置实际效果
512m未设置(默认)可用 512m 内存 + 512m swap,总上限 1g
512m512m可用 512m 内存,swap 为 0,禁用交换
512m1g可用 512m 内存 + 512m swap
512m-1可用 512m 内存,swap 不限制(不推荐)
未设置任意值参数无效,会被忽略

这里有个很多人不知道的坑:不设--memory-swap时,默认 swap 大小等于内存上限,也就是说你以为限了 512m,实际上容器可以用到 1g。如果宿主机没开 swap,那这个默认值形同虚设;但如果宿主机开了 swap(尤其是一些默认配置的云主机),容器可能大量使用 swap 导致性能雪崩,而你完全不知道。

我的习惯是明确写--memory-swap等于-m的值,等于主动禁用 swap,让内存超限时干脆利落地 OOM,而不是拖着一台半死的机器慢慢卡。

还有一个参数--memory-reservation(软限制),它不会阻止容器短暂超用,只在系统内存紧张时触发回收。适合做"平时允许突发,压力大时优先被回收"的场景,和--memory配合用。

3.2 为什么 Java 容器总是被 OOM Kill

这是我在实际支持中被问得最多的一类问题。现象是:明明给容器限了 2g,-Xmx也设成了 1.5g,怎么还是被杀?

原因通常有三个层面。

第一层是老版本 JDK 不识别 cgroup 限制。JDK 8 早期版本(8u191之前)读取的是宿主机物理内存来决定默认堆大小,如果宿主机 64g,JVM 会默认把堆开到 16g,容器一启动就直接超限。从8u191开始有了-XX:+UseContainerSupport(后来默认开启),才会正确读取 cgroup 限制。

第二层是 JVM 自身的内存构成。-Xmx只控制堆,除此之外还有元空间(Metaspace)、线程栈、直接内存(Direct Buffer)、Code Cache、GC 自身的数据结构。这部分加起来少则两三百兆,多则上 G。堆设 1.5g 配 2g 的容器,余量太小,稍有波动就撞墙。

第三层,也是最反直觉的一层:页缓存(page cache)也计入 cgroup 内存。容器往磁盘写文件时,内核会把这部分数据缓存起来,这部分内存属于该 cgroup。虽然内存压力大时页缓存可以被回收,但如果你的容器在短时间内大量写文件,瞬时可能先撞上硬限制。这就是为什么"明明代码里没泄漏,还是被 OOM"——问题出在 IO 上。

对应的实操建议是:Java 容器的内存上限建议是堆大小的 1.5 到 2 倍;优先用-XX:MaxRAMPercentage=70让 JVM 按容器限制自动算堆,而不是写死-Xmx;大量写文件的容器要额外预留 20% 左右的余量。

3.3 内存限制的配置与监控实操

一份我常用的、比较稳妥的启动参数:

docker run -d --name app \ -m 2g \ --memory-swap 2g \ --memory-reservation 1500m \ --oom-kill-disable=false \ -e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70 -XX:+ExitOnOutOfMemoryError" \ myapp:latest

--oom-kill-disable=false是默认值,明确写出来是为了防止有人随手设成 true。这个参数一旦设成 true,容器超内存时不会被杀,而是让宿主机整体进入 OOM 边缘,是妥妥的事故放大器,我建议任何场景都不要开。

-XX:+ExitOnOutOfMemoryError让 JVM 遇到 OOM 时主动退出,这比被内核干掉更容易留下清晰的日志和退出原因。

监控层面,容器内的内存使用可以从 cgroup 文件直接读,比docker stats更精确:

# cgroup v2 cat /sys/fs/cgroup/memory.current cat /sys/fs/cgroup/memory.max # cgroup v1 cat /sys/fs/cgroup/memory/memory.usage_in_bytes cat /sys/fs/cgroup/memory/memory.limit_in_bytes

memory.current里包含了页缓存,想只看匿名内存(真正被进程占用的部分)需要额外计算。做告警时我一般按memory.current / memory.max > 0.85触发预警,比 0.95 提前一点,留出应对时间。

还有一个诊断利器是docker inspect里的State.OOMKilled字段:

docker inspect app --format '{{.State.OOMKilled}}'

返回true就是被 OOM Killer 干掉的,可以立刻排除掉"进程自己崩了"的可能。

4. 磁盘 IO 限制:最容易配了个寂寞的一环

4.1 blkio 限速家族:bps 与 iops 两套指标

Docker 提供了四个限速参数,都是针对具体块设备的:

  • --device-read-bps:读带宽上限
  • --device-write-bps:写带宽上限
  • --device-read-iops:读 IOPS 上限(每秒读次数)
  • --device-write-iops:写 IOPS 上限

用法是--device-write-bps /dev/sda:10mb这种形式,冒号前面是宿主机上的设备路径,后面是限制值。注意这里写的是宿主机设备路径,不是容器里的。查询设备路径用lsblk或df -h,比如根分区在/dev/vda1,那限制就要写--dev/vda1。

还有权重类参数--blkio-weight(范围 10 到 1000,默认 500),和 CPU shares 类似,是相对权重,只在多个容器同时争抢同一块设备的 IO 时按比例分配。

4.2 只对直接 IO 生效:这是最经典的坑

--device-write-bps这套参数底层是 cgroup 的 blkio throttle 控制器。它的作用点在块设备层,也就是说只有真正下发到块设备的 IO 请求才会被限速。

问题在于,Linux 的写操作默认是写进页缓存就返回,由内核异步刷盘。一个容器用dd或者普通文件写入方式狂写,数据先进页缓存,这时候写请求根本还没到块设备层,blkio throttle 完全拦不住。你会看到限制设了 10mb/s,实际写速跑到几百兆。

验证的时候必须加oflag=direct,绕过页缓存走直接 IO:

docker run --rm -it \ --device-write-bps /dev/vda1:10mb \ --device-read-bps /dev/vda1:10mb \ alpine:3.19 \ sh -c "dd if=/dev/zero of=/tmp/test bs=1M count=200 oflag=direct"

不带oflag=direct的话,前几百兆会瞬间写完(因为进的是缓存),只有缓存回刷那一小段才受限制,测试结果毫无参考价值。

这个特性决定了 blkio 限速的实际防护范围:它对数据库、消息队列这类用 O_DIRECT 方式写文件的应用有效;对日志狂写、普通文件读写这类走页缓存的行为基本无效。所以不要指望用 blkio 限速来防止日志写爆磁盘,那件事得靠日志轮转和存储配额。

4.3 存储层配额:从 overlay2 和卷层面兜底

真要防止容器把磁盘写满,靠谱的做法是在存储层做限制,有两种思路。

第一种是给 Docker 的数据目录单独挂一个分区或者逻辑卷,这样最坏情况下写满的是这个分区,不会影响系统盘上的其他关键服务。这是成本最低、收益最高的做法,我的建议是所有生产宿主机都这样规划。

第二种是用存储驱动的配额能力。overlay2配合xfs文件系统开启pquota挂载选项后,可以给每个容器设根文件系统大小上限:

# 挂载参数示例:prjquota # 启动容器时指定 docker run -d --storage-opt size=10G --name limited-io alpine:3.19 sleep infinity

这个方案的限制是只支持xfs且必须是prjquota方式挂载,ext4不支持;另外它限的是容器可写层,不包括任何 volume 或者 bind mount。

顺带说一个容易忽视的点:容器的可写层数据在容器删除时会一起消失,真正占磁盘的是 volume、bind mount 和镜像层。做磁盘配额时,如果只限可写层而不考虑 bind mount 出去的目录(比如日志目录),等于把最大的口子留着。日志目录的配额得靠文件系统本身或者日志轮转来做,logrotate配maxsize和rotate参数,比什么都实在。

5. GPU 资源分配:从设备透传到显存软限制

5.1 工具链准备:宿主驱动 + 容器运行时

GPU 容器跑起来的必要条件有三个:宿主机装了匹配的驱动、装了 GPU 容器运行时工具链、Docker 配置里注册了这个运行时。三样缺一样,容器里就看不到设备。

先确认宿主机驱动正常:

nvidia-smi

能看到显卡列表和驱动版本就说明驱动没问题。然后安装容器工具链(各发行版包管理器的包名不同,以官方文档为准),接着配置 Docker 运行时,编辑/etc/docker/daemon.json:

{ "runtimes": { "nvidia": { "path": "nvidia-container-runtime", "runtimeArgs": [] } } }

改完重启 Docker 服务。验证方式很简单:

docker run --rm --gpus all ubuntu:22.04 nvidia-smi

这条命令能正常输出显卡信息,说明整条链路通了。如果报could not select device driver之类的错误,八成是工具链没装或者 daemon.json 没写对。

5.2 --gpus 参数详解:别图省事给 all

--gpus参数的写法比较特殊,它接受的是键值对形式,常见用法有这几种:

写法含义
--gpus all暴露所有 GPU
--gpus '"device=0,1"'只暴露 0 号和 1 号卡
--gpus 2暴露 2 张卡(不指定具体编号)
--gpus 'all,capabilities=utility'只暴露工具能力,不含计算能力

注意那个双层引号,因为参数值里包含逗号和等号,shell 解析容易出问题,写成'"device=0,1"'是最稳的。这个坑我踩过一次,当时在 CI 脚本里怎么调都报参数非法。

一个实践建议:用--gpus all之前先想清楚这个容器是不是真需要全部显卡。多卡机器上常见的场景是四张卡分给四个容器跑不同任务,一个容器all下去,其他三个容器直接没卡可用。用--gpus '"device=0"'明确指定编号,配合CUDA_VISIBLE_DEVICES一起用,可控性强得多。

5.3 显存与算力的软限制:Docker 层给不了硬隔离

这点必须说清楚:--gpus限的是设备可见性,不是算力和显存的硬上限。容器拿到卡之后,显存可以用满,算力可以跑满,Docker 层面没有类似于--cpus那样的硬性配额机制。

想做到接近硬隔离,目前主要有两种路径。

路径一是MIG(Multi-Instance GPU)切分,把一张物理卡拆成若干个互相隔离的实例,每个实例有独立的显存和计算单元,然后通过--gpus '"device=MIG-xxx"'分配给不同容器。这个方案隔离性最好,但需要特定型号的卡支持,且切分规则不灵活,改配置要重启。

路径二是框架层的显存软限制。比如深度学习框架通常提供显存增长模式或上限设置,让进程按需申请显存而不是一次性占满。这类配置属于应用侧,不是 Docker 侧的。它防不住恶意代码,但能防住自己的程序无脑占满显存。

还有一个常见误区是把CUDA_VISIBLE_DEVICES当成隔离手段。它只是个环境变量,控制进程能看到哪几张卡,容器里的进程如果知道卡的真实编号,理论上仍可以绕过。要真隔离还得靠设备白名单或者 MIG。

提示:GPU 容器启动失败时,先看宿主机nvidia-smi里卡是否正常,再看容器内nvidia-smi,最后看docker inspect里的DeviceRequests字段。三步定位,比到处翻日志快。

6. 组合配置实战:三种场景模板与一套排查清单

6.1 docker run 完整模板:一次把参数写全

下面这份是我给一个中等负载 Web 服务准备的启动模板,把四类限制都覆盖到了,可以直接改改就用:

docker run -d \ --name web-api \ --restart unless-stopped \ --cpus=2.0 \ --cpu-shares=1024 \ --memory=4g \ --memory-swap=4g \ --memory-reservation=3g \ --device-write-bps /dev/vda1:50mb \ --device-read-iops /dev/vda1:2000 \ --gpus '"device=0"' \ --pids-limit=512 \ --ulimit nofile=65535:65535 \ -v /data/logs/web-api:/app/logs \ -p 8080:8080 \ web-api:1.4.2

几个参数值得单独说明。--pids-limit=512限制容器内最大进程/线程数,这是防 fork 炸弹的关键,成本极低,强烈建议所有容器都加上。--ulimit nofile设置文件描述符上限,不设的话容器内默认值可能很小,高并发下会大量报 "too many open files"。--restart unless-stopped配合资源限制使用效果更好,因为限制保证了重启不会引发雪崩。

6.2 Compose 与编排层写法:语义对应关系

Compose 的写法是在deploy.resources下面配置,注意deploy段在早期版本只对 Swarm 生效,较新版本的 Compose 已经支持本地模式:

services: web-api: image: web-api:1.4.2 deploy: resources: limits: cpus: "2.0" memory: 4G reservations: cpus: "0.5" memory: 1G blkio_config: device_write_bps: - path: /dev/vda1 rate: 52428800 device_read_iops: - path: /dev/vda1 rate: 2000 pids_limit: 512 ulimits: nofile: soft: 65535 hard: 65535

注意blkio_config里的带宽单位是字节每秒,不是mb这种带单位的字符串,50mb 要写成 52428800。这是抄配置时最容易漏掉单位换算的地方。

如果最终要落到 K8s,语义对应的关系是:CPU 的 limit 对应--cpus,request 对应 shares 的分配依据;内存的 limit 对应-m,request 影响调度;GPU 通过resources.limits里的厂商资源名申请。K8s 没有直接对应--device-write-bps的字段,磁盘 IO 限速一般靠存储层的配额或者节点级别的 cgroup 实现。

6.3 压测验证:这三条命令能解决大部分疑问

配置写完后一定要验证,我常用三条命令组合,覆盖 CPU、内存、IO 三个维度。

CPU 验证压到 100% 看是否被卡住:

docker run --rm --cpus=0.5 alpine:3.19 sh -c \ "apk add --no-cache stress-ng >/dev/null && stress-ng --cpu 4 --timeout 30s"

同时开另一个终端docker stats观察,CPU% 应该稳定在 50% 左右。

内存验证用一个逐步吃内存的脚本,观察容器被打死的时机和退出码:

docker run --rm -m 256m --memory-swap 256m alpine:3.19 sh -c \ "apk add --no-cache stress-ng >/dev/null && stress-ng --vm 1 --vm-bytes 512M --timeout 20s" echo "exit code: $?"

退出码 137 说明被 OOM Killer 干掉,符合预期。

IO 验证必须带 direct 标志,前面已经讲过原因:

docker run --rm --device-write-bps /dev/vda1:10mb alpine:3.19 \ sh -c "dd if=/dev/zero of=/tmp/t bs=1M count=100 oflag=direct"

100MB 的数据在 10mb/s 的限制下应该跑 10 秒左右,如果一两秒就完事了,说明限制没生效,回头检查设备路径写对没有。

6.4 常见问题速查表:我踩过的和帮别人排查过的

下面这张表是我自己整理的,列的都是一线最常遇到的症状:

症状大概率原因排查动作
容器退出码 137内存超限被 OOM Killdocker inspect --format '{{.State.OOMKilled}}'
--cpus设了没用跑在 Docker Desktop 虚拟机上,或 cgroup 模式不匹配stat -fc %T /sys/fs/cgroup/,看是否cgroup2fs
--device-write-bps无效写入走页缓存,未走块设备层测试加oflag=direct
日志把磁盘写满blkio 限速管不住页缓存写入上logrotate,把日志目录单独挂分区
GPU 容器报 no device运行时未注册或工具链未装检查/etc/docker/daemon.json与docker info里的 Runtimes
--gpus参数报错引号嵌套被 shell 吃掉写成'"device=0"'双层引号
容器里 free 看到宿主机内存这是正常现象,容器无独立内存视图改看docker stats或 cgroup 文件
高并发下报 too many open filesnofileulimit 太小加--ulimit nofile=65535:65535
服务偶尔卡顿但指标不高CPU 被限得太死,突发时排队检查--cpus是否低于实际峰值需求

除了表里的,还有两个我印象比较深的坑,值得单独说。

一个是内存限制设了但监控显示没到上限就被杀。这种情况多半是--memory-swap没显式设置,走了默认值,实际可用内存加 swap 是-m的两倍,而监控看的是物理内存部分,两者对不上。统一显式写--memory-swap就等于排除掉这个变量。

另一个是多个容器同时启动时的"启动风暴"。所有容器都设了限制,但启动瞬间资源需求集中爆发,加上镜像拉取、依赖初始化一起上,导致整体启动很慢。这种情况不是限制没设好,而是要给编排层加启动节奏控制,或者把初始化阶段需要的资源预留出来。

我个人在实际操作中的体会是,资源限制这件事真正的难点不在参数怎么写,而在于你得先知道这个容器到底需要多少。我习惯的做法是先把容器裸跑一段时间,用监控数据摸清 CPU 峰值、内存稳态值、IO 峰值三个数字,再按峰值上浮 30% 到 50% 设限制。凭直觉拍脑袋设的数字,要么浪费资源,要么限死业务,两头都不讨好。还有一条经验:所有限制参数都写进版本管理的配置文件里,别用命令行现敲,出事故的时候你才知道可追溯有多重要。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进