Docker资源限制实战:从cgroups原理到CPU内存IO进程数配置

发布时间:2026/10/27 20:50:17
Docker资源限制实战:从cgroups原理到CPU内存IO进程数配置

线上那台跑了三十多个容器的宿主机,半夜告警把我叫醒。负载冲到 90 多,登录上去一看,CPU 全被一个做图片批量缩放的容器占满了,内存也快见底。那个容器上线时图省事,docker run后面什么限制参数都没加,一次批量任务就把整台机器拖垮,同宿主机上其他业务跟着抖了二十多分钟。这件事之后,我把所有容器的资源限制重新梳理了一遍,也踩了不少坑:Docker 的资源控制看着就是几个命令行参数,真要用对,得先搞清楚 cgroups 在底层怎么记账、CPU 的"权重"和"配额"到底差在哪、内存限制为什么总和 swap 绑在一起算、磁盘 IO 的权重为什么经常设了等于没设。

这篇就把 Docker 的资源控制从头拆一遍。内容覆盖 CPU、内存、磁盘 IO、进程数四个维度,讲清楚每个参数背后的机制、换算方法、验证手段,以及编排层(Compose)里那些容易写错的地方。不管你是在自己笔记本上跑几个容器玩玩,还是在生产环境管几十上百个容器,这些内容都能直接拿去用——前半部分帮你看懂原理,后半部分是可以抄的配置和排查表。

1. 资源控制到底在控什么:先认清容器的"共享"本质

1.1 容器不是一台独立的小机器

很多人对容器的第一印象是"轻量级虚拟机",这个类比在日常使用中问题不大,但一涉及资源控制就会误导人。虚拟机有独立的操作系统内核,CPU 和内存是硬件虚拟化层切出来的,虚拟机内部看到的核数和内存就是分给它的那些。容器不是这样,容器和宿主机共用同一个内核,容器里nproc看到的核数、free -h看到的内存,默认就是宿主机的那一份。

这个差别直接决定了资源控制的语义:Docker 的资源限制不是"分配",而是"约束"。你没设限制的时候,容器可以毫无顾忌地用满整台机器的 CPU 和内存;你设了--cpus=2,也不是说这台机器上有两个核专门归它,而是说它在任何一百毫秒的时间窗口里,最多只能用掉相当于两个核的计算时间。理解了"约束"而不是"分配"这层意思,后面所有参数的取舍逻辑就顺了。

我见过不少团队在宿主机上只跑一个容器,觉得没必要设限制,结果这个容器稍微有点风吹草动就把监控 agent、日志采集 agent 一起饿死,反倒让问题定位变得更难。资源限制的第一价值往往不是保护业务,而是保护那些跑在同一台机器上的基础组件。

1.2 cgroups 负责算账,namespace 负责隔离

Docker 的隔离能力由两套内核机制配合完成,职责划分很清楚。namespace 管的是"看得见什么":PID namespace 让容器只看到自己的进程树,network namespace 给容器独立的网卡和端口空间,mount namespace 让容器有自己的文件系统视图。这些解决的是可见性问题,不解决用量问题。

真正的用量约束全靠 cgroups(control groups)。cgroups 是内核提供的一套按层级组织的资源计量与限制框架,每一组进程可以挂上一个或多个控制器,常见的控制器有 cpu、memory、blkio(新内核里叫 io)、pids、cpuset、devices 等。Docker 每启动一个容器,就在 cgroup 树里给它建一个节点,把容器的初始进程 PID 写进去,这个进程 fork 出来的所有子孙进程都会自动落在这个节点下,统一受到同一套限制约束。

cgroups 有两个大版本,现在处于过渡期,这一点在排查问题时特别容易踩坑。cgroup v1 把每个控制器挂成独立的层级,你在/sys/fs/cgroup/memory/下看内存,在/sys/fs/cgroup/cpu/下看 CPU。cgroup v2 改成单一层级,所有控制器统一挂在/sys/fs/cgroup/下,用memory.max、cpu.max、io.max、pids.max这样的统一命名。较新的发行版(比如近几年发布的几个主流版本)默认已经切到 v2,而很多老运维文档和监控脚本还是按 v1 的路径写的,导致取不到数据。

判断方式很简单:

stat -fc %T /sys/fs/cgroup/

返回cgroup2fs就是 v2,返回tmpfs就是 v1。另一个入口是docker info,输出里会有Cgroup Version: 2和Cgroup Driver: systemd这两行。这两条信息建议在每台机器上都确认一遍并记录,出问题时能省掉大量猜测。

1.3 不设限的三种翻车现场

我把这些年遇到的资源失控事故归了三类,基本覆盖了绝大多数场景。

第一类是计算型任务把 CPU 吃满。典型的是数据导出、图片处理、日志压缩这类批处理容器没设 CPU 上限,任务一跑起来,宿主机所有核都被压满。Linux 的 CFS 调度器在有多个可运行进程时是公平分配的,但只要那个容器的线程数远多于其他容器,它拿到的总时间片就远多于别人。表现出来就是同宿主机上其他服务的接口延迟从几十毫秒飙到几秒。

第二类是内存缓慢爬升直到把宿主机拖进 OOM。这类最阴险,因为进程泄漏的速度可能一天才涨几百兆,监控不一定设了合适的告警阈值。等宿主机整体可用内存见底,内核 OOM killer 出手时,它挑的不一定是那个内存大户,而是按 oom_score 挑一个相对好杀的进程,经常误伤数据库或者网关。这类事故我见过两次,每次都要花很久解释为什么"内存最多的那个容器没事,被杀的却是旁边那个"。

第三类是进程数爆炸。一个写错的脚本在容器里死循环 fork,或者某个服务在异常情况下不断重启子进程,容器里的 PID 迅速涨到几万个,宿主机 PID 表被耗尽,连ssh都登不进去。这类问题在没有--pids-limit的情况下几乎没有任何天然防线。

提示:上面三类事故的共同点是"殃及邻居"。容器化部署密度越高,单容器失控的爆炸半径越大,资源限制的优先级就越高。

2. CPU 限制:权重、配额、绑核是三套完全不同的逻辑

2.1 --cpu-shares 只在"抢"的时候才生效

--cpu-shares是 Docker 最早提供的 CPU 相关参数,默认值 1024,取值范围 2 到 262144。它对应 cgroup 的cpu.shares(v1)或cpu.weight(v2,Docker 会自动做换算)。很多人误以为这是个百分比,设成 512 就代表只能用一半 CPU,这是最常见的误解。

它的真实语义是相对权重,且只在 CPU 资源发生争抢时才有意义。假设宿主机有 8 个核,容器 A 的 shares 是 1024,容器 B 是 512,当两个容器都在满负荷跑时,CFS 会按 2:1 的比例分配 CPU 时间,A 大约拿到 5.33 核的等效算力,B 拿到 2.67 核。但如果容器 B 此刻在闲着,A 完全可以独占 8 个核,权重不会对它形成任何上限。

这个特性决定了 shares 的适用场景:多个业务容器共存、希望按重要性分配算力、但不想给任何一个设硬上限。比如线上服务给 1024,离线批处理给 256,平时批处理能跑满,一旦线上流量上来,批处理自动被压到很低的比例,不影响主链路。这种"弹性让路"的效果是--cpus做不到的,因为硬上限不会因为别人闲着而放宽。

反过来,如果你的目的是"某个容器绝对不能超过 X 个核",shares 一点用都没有。要防的是失控,就得用配额。

2.2 --cpus 和 period/quota 的换算关系

--cpus是目前最推荐的 CPU 硬限参数,写法直观,比如--cpus=1.5。它背后对应的其实是两个参数:--cpu-period和--cpu-quota。

CFS 调度器把 CPU 时间切成一个个周期,默认周期长度是 100000 微秒,也就是 100 毫秒。在这个周期内,如果某个 cgroup 用掉的 CPU 时间超过了配额,它就会被强制停下,直到下一个周期开始才能继续运行。配额的单位也是微秒。

所以--cpus=1.5等价于--cpu-period=100000 --cpu-quota=150000,意思是每 100 毫秒内最多使用 150 毫秒的 CPU 时间,折算下来就是 1.5 个核。

目标等价写法说明
0.5 核--cpus=0.5或--cpu-quota=50000 --cpu-period=100000每周期最多 50ms CPU 时间
1 核--cpus=1或--cpu-quota=100000整周期占满,等价于被绑到 1 核
2.5 核--cpus=2.5或--cpu-quota=250000需要至少 3 个物理核才有意义
4 核--cpus=4超过宿主机核数时,限制实际不生效

period 的可调范围是 1000 到 1000000 微秒。调小 period 会让限流更精细但上下文切换更多,调大则相反。除非有明确的延迟敏感需求,我一般不动 period,直接用--cpus让它取默认值。

这里有个必须知道的现象:CPU 配额限制是通过"掐断"实现的。容器用完配额后,内核会在本周期剩余的几十毫秒内直接不给它 CPU,表现出来的不是"变慢",而是"周期性停顿"。对于一个单线程、对延迟敏感的服务,--cpus=0.5带来的尾延迟可能比预期严重得多。我的经验是,延迟敏感的服务宁可给--cpus=1以上,再配合副本数横向扩,而不是把单实例压到零点几核。

2.3 --cpuset-cpus 绑核:收益明确,代价也明确

--cpuset-cpus是另一种思路:不限制用多少,而是限制用哪些。--cpuset-cpus="0-3"会把容器内所有进程的 CPU 亲和性限定在 0 到 3 号核上,也可以用--cpuset-cpus="0,2,4"这样指定离散的核。

它带来的收益主要有两个。一是减少缓存失效和跨核调度开销,对计算密集型、缓存敏感的服务(比如高频交易撮合、实时音视频编码)效果明显,我实测过一个做实时转码的服务,绑核后 P99 延迟下降了大概 15%。二是物理隔离,把关键业务和平凡业务绑到不同的核上,平凡业务即使失控也不会挤占关键业务的核。

代价同样清楚。第一,绑核之后容器能用的核数就是固定的,空闲的核它也用不了,资源利用率会下降。第二,如果多个容器绑到了重叠的核集合上,它们之间照样互相抢,--cpuset-cpus本身不提供任何配额保护。第三,多核物理机上要注意 NUMA 拓扑,把容器的核绑到跨 NUMA 节点的集合上,内存访问延迟反而会升高。

还有一点很关键:绑核之后,容器内程序的线程数应该和绑定的核数匹配。比如你给一个 Java 服务绑了 4 个核,但 JVM 认为有 32 个核,建了 32 个 GC 线程和一大堆工作线程,这些线程全挤在 4 个核上互相切换,效果反而比不绑还差。我在实际项目中遇到过这种情况,最后是用-XX:ActiveProcessorCount=4修掉的。

2.4 一套可以直接抄的 CPU 限制参数和验证方法

线上服务我一般这样组合:

docker run -d \ --name api-service \ --cpus=2 \ --cpu-shares=1024 \ --cpuset-cpus="0-3" \ --memory=4g \ nginx:stable

这里--cpus=2是硬上限,防止它失控;--cpu-shares=1024在与其他容器竞争时保证优先级;--cpuset-cpus把它限制在固定的核集合上,降低抖动。三个参数各司其职,并不冲突。

验证限制是否真的生效,跑一个压测容器最直接:

# 起一个带限制的容器,跑满是 CPU 的压测 docker run --rm -d --name cpu-test --cpus=1 progrium/stress \ --cpu 4 --timeout 60s # 另开一个终端看用量 docker stats --no-stream cpu-test

如果限制生效,CPUPerc 会稳定在 100% 附近,不会跑到 400%。注意docker stats里的 CPU 百分比是以单个核为 100% 计的,一个限制为 2 核的容器跑满会显示 200%,这是正常现象,不是 bug。

想看得更细,直接读 cgroup 文件,以 v2 为例:

CID=$(docker inspect -f '{{.Id}}' cpu-test) cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/cpu.max # 输出形如 "100000 100000",前者是配额,后者是周期 cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/cpu.stat # nr_throttled 和 throttled_usec 是限流次数与时长,这两个数字最能说明问题

cpu.stat里的nr_throttled是我最常看的指标。它不为零不代表有问题——任何设置了配额且负载跑满的容器都会有节流;但如果它增长极快,同时业务延迟也在涨,那就说明配额给紧了,该扩容或者调大--cpus了。

3. 内存限制:参数配错一个,容器就会被 OOM 带走

3.1 --memory 和 --memory-swap 的配对规则

内存这块的坑比 CPU 多得多,核心原因在于--memory从来不是单独起作用的,它和--memory-swap是一对耦合参数,而且默认行为很反直觉。

先说 cgroup 里的记账方式:v1 中的memory.limit_in_bytes管的是物理内存上限,memory.memsw.limit_in_bytes管的是"物理内存 + swap"的总量上限。Docker 的--memory-swap对应的正是后者,也就是说它填的不是 swap 的大小,而是内存加 swap 的总额。这个设计让无数人算错过。

规则可以整理成这张表:

--memory--memory-swap实际效果
未设置未设置不做任何内存限制
512m未设置物理内存 512m,可用 swap 512m,合计上限 1g
512m1g物理内存 512m,可用 swap 512m,合计上限 1g
512m512m物理内存 512m,完全禁用 swap
512m-1物理内存 512m,swap 不限量
512m小于 512m非法配置,Docker 会直接报错

最容易出问题的是第二行:只设--memory=512m不设 swap 时,容器实际可以用到 1g 的内存总量。很多团队以为设了 512m 就到顶了,结果容器在压力下把 swap 吃掉一半,机器整体 swap 抖动,磁盘 IO 飙升,表现出一堆莫名其妙的问题。

我的做法是默认显式禁用 swap,把--memory和--memory-swap设成相等:

docker run -d --name cache-service \ --memory=2g --memory-swap=2g \ redis:7

理由很直接:容器化环境里,swap 带来的收益(避免 OOM)远小于它带来的风险(不可控的 IO 抖动和延迟毛刺)。对延迟敏感的服务,swap 一旦被触发基本等于服务不可用,还不如让它干脆点被 OOM 掉然后重启。

3.2 OOM 的判定逻辑:谁先被杀

内存超过--memory上限时,内核会先尝试回收页缓存和可回收的 slab。回收不动了,才轮到 OOM killer 出手,在容器的 cgroup 范围内挑一个进程杀掉。

挑谁?看oom_score。这个分值大体和进程占用的内存量正相关,但会被oom_score_adj这个可调参数修正,范围 -1000 到 1000。Docker 在这里做了一个很重要的默认行为:设置了内存限制的容器,其内部进程的oom_score_adj会被调高,而没设限制的容器会被调低。这意味着宿主机内存真正告急时,内核会优先杀那些"已经被限制住"的容器里的进程,保护宿主机自身的系统进程。

这个行为有它的合理性,但也解释了前面提到的怪现象:内存大户如果没设限制,它的oom_score_adj反而是偏低的,不容易被杀;旁边那个乖乖设了限制的小容器,反而成了首选目标。所以"给容器设了内存限制"这件事本身,在宿主机整体内存规划上也是必须的,不能只靠个别容器自觉。

另外两个参数值得知道:

--oom-kill-disable会禁止在这个容器内触发 OOM kill。听上去像是"保护容器",实际非常危险——内存超限后进程不会被杀,而是被内核挂起等待内存,如果它持有锁,整条调用链都会卡死。只有在--memory已经明确设置的前提下才考虑用它,否则容器可以把整台宿主机拖垮。

--oom-score-adj用来手动调整优先级,取值 -1000 到 1000,数值越低越不容易被杀。给关键服务设一个负值(比如 -500)是合理的,但别设成 -1000,那是内核里少数几个核心进程的待遇。

容器被 OOM 杀掉之后,docker inspect里能看到痕迹:

docker inspect -f '{{.State.OOMKilled}}' cache-service

输出true就说明上次退出是 OOM 导致的。配合docker events --filter event=oom可以实时抓 OOM 事件,做告警很合适。

3.3 JVM、Node 这类运行时的"看不见"陷阱

这是我踩得最深的一个坑。容器内存限制设了 2g,容器里的 Java 服务还是被 OOM 杀,查下来 JVM 堆设的是 4g——因为 JVM 读的是宿主机的物理内存,压根没看 cgroup 限制。

这个问题的修复分版本。较早的 JDK 8 需要手动加-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap才能感知 cgroup;JDK 8u191 之后以及 JDK 10 以后,-XX:+UseContainerSupport默认开启,JVM 会自动读取 cgroup 限制,并按MaxRAMPercentage(默认 25%)来设堆大小。但 25% 这个默认值对内存型服务往往偏小,我一般显式指定:

java -XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=70 -jar app.jar

留 30% 给堆外内存是有讲究的。JVM 除了堆,还有 Metaspace、线程栈、直接内存、CodeCache、GC 自身的辅助结构,加起来在生产环境里占到几百兆到一两 G 都正常。堆设到 90% 的容器,几乎必然因为堆外内存超限被 OOM 杀掉,而且死的时候堆 dump 里看不出任何异常,极难排查。

Node.js 有类似的问题。较老版本的 V8 按物理内存推算堆上限,在容器里会算得过大;新版本对 cgroup 有一定感知,但为了稳妥,还是建议显式指定--max-old-space-size。Go 的情况稍好,从较新版本开始运行时会对 cgroup 内存限制有一定识别,但GOMAXPROCS长期不感知 CPU 配额,容器限制 1 核而 GOMAXPROCS 是 32 的情况非常普遍,导致大量线程争抢一个核的配额,调度开销显著。这种场景下,显式设置GOMAXPROCS环境变量是必要的。

注意:容器里任何"根据系统信息自动推算资源用量"的运行时,都要单独确认它读的是 cgroup 限制还是宿主机总量。这一条适用于 JVM、Node、Go、.NET,也适用于 Erlang、Rust 生态里的部分运行时。

3.4 内存压测实操:把限制值逼出来看

验证内存限制,用stress-ng最方便:

docker run --rm -it --memory=256m --memory-swap=256m \ polinux/stress-ng --vm 1 --vm-bytes 512M --vm-hang 0

预期结果是容器在申请到 256m 左右时被 OOM 杀掉,docker ps -a里能看到退出码 137。如果它顺利吃到了 512m,说明限制没生效,回头检查 cgroup 版本和 driver 配置。

想看得更精确,进容器读 cgroup 文件:

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

一个细节:memory.limit_in_bytes读出来的数值通常比--memory设的稍大一点点,这是因为内核按内存页大小做了对齐。看到 268435456(256m)变成 268439552 之类的数字不用紧张,这是正常现象。

memory.max_usage_in_bytes(v2 里是memory.peak)是我最看重的指标之一,它记录了容器生命周期内的内存峰值。这个数字比docker stats的实时值有用得多,因为 OOM 往往发生在两次采样之间,实时值可能刚好错过峰值。根据峰值来定--memory,比拍脑袋靠谱。

4. 磁盘 IO 和进程数:最容易被整体忽略的两个维度

4.1 blkio 权重经常设了等于没设,硬限才可靠

磁盘 IO 的限制参数分两类:权重和硬限。

--blkio-weight默认 500,范围 10 到 1000,语义和 CPU 的 shares 类似,只在多个容器竞争同一块设备时按比例分配。听起来很合理,问题出在它依赖块设备的 IO 调度器。传统的内核 IO 调度器 CFQ 支持按 cgroup 权重分配,但现代内核在大多数场景下默认用的是mq-deadline或者none(尤其 SSD 和 NVMe 设备),这些调度器对 blkio 权重的支持很有限,甚至完全不支持。

我实测过好几次,在 NVMe 盘上给两个容器分别设 100 和 900 的权重,同时跑fio,两边的吞吐几乎一样。所以如果你在 NVMe 环境里指望--blkio-weight控制 IO,大概率是白设。

真正可靠的是硬限参数:

docker run -d --name log-writer \ --device-read-bps /dev/sda:50mb \ --device-write-bps /dev/sda:20mb \ --device-read-iops /dev/sda:1000 \ --device-write-iops /dev/sda:500 \ my-log-app

-bps系列限的是字节吞吐,-iops系列限的是每秒 IO 次数,可以只设其中一组。这几个参数在 cgroup v1 里由 blkio 控制器实现,在 v2 里由 io 控制器实现,都是硬性截断,不依赖调度器,可靠性高得多。

需要注意设备名要写宿主机上的块设备路径,不是容器内的。容器里看到的/dev/sda和宿主机上的/dev/sda未必是同一个东西,写错了限制不会生效,而且不会报错——这是最坑的地方。上线前务必用lsblk在宿主机上确认设备名,并且注意云环境下设备名可能变化。

4.2 --pids-limit 是挡住 fork 炸弹的最后一道防线

--pids-limit限制的是容器内可以存在的进程和线程总数,对应 cgroup 的 pids 控制器。不设的话默认是 -1,也就是不限制。

这个参数平时存在感很低,但它的价值在出事那一刻体现得淋漓尽致。我在一个项目里遇到过:某个服务的健康检查脚本写错了逻辑,在依赖不可用时不停地 fork 子进程去重试,又没有正确回收,十几分钟内容器里的进程数涨到了三万多个。宿主机 PID 表被打满,同机器上其他容器连线程都建不出来,ssh也登不进去,最后只能硬重启。

如果当时设了--pids-limit=512,那个容器在触到上限后fork会直接返回EAGAIN,服务会自己报错退出重启,爆炸半径就限制在一个容器内。

docker run -d --name worker \ --pids-limit=512 \ my-worker

设多少合适?看服务形态。一个 Go 或者 Java 服务,线程数通常是几十到几百,512 到 1024 是常见的安全值。Python 或 Node 的多进程 worker,要按 worker 数量乘以单 worker 线程数再留一倍余量。拿不准的时候先设一个大一点的数(比如 2048),观察一段时间docker stats或者 cgroup 里的pids.current,再往下收。

4.3 --ulimit nofile 和连接数上限的关系

--ulimit限制的是容器内进程能打开的文件描述符等资源。最常调整的是nofile:

docker run -d --ulimit nofile=65536:65536 my-gateway

前面的值是软限制,后面是硬限制。软限制是进程实际生效的上限,硬限制是软限制能调到的天花板,普通进程不能突破硬限制。

这里有个常见误区:把nofile调到很大就以为能扛住更多连接。实际上,单进程能扛多少连接受三重约束——nofile只是第一重,第二重是内核的fs.file-max(全局文件句柄上限),第三重是net.ipv4.ip_local_port_range和连接跟踪表的大小。容器内看到的nofile上限还受 dockerd 自身 systemd 单元LimitNOFILE的限制,如果 dockerd 只被允许开 65536 个句柄,你给容器设 100 万也没有意义。

排查连接数问题时,我习惯按这个顺序看:容器内ulimit -n、cat /proc/sys/fs/file-max、cat /proc/sys/fs/file-nr(当前已分配/已用/上限)、以及ss -s看连接状态分布。很多时候瓶颈根本不在句柄数,而在 TIME_WAIT 堆积或者后端处理慢导致的连接积压。

4.4 这两个维度的验证方式

磁盘 IO 的验证用fio最直接:

docker run --rm -it --device-write-bps /dev/sda:10mb \ --mount type=bind,source=/tmp/iotest,target=/data \ xridge/fio --name=w --rw=write --size=200M --filename=/data/test.bin

如果限制生效,写入速度会稳定在 10MB/s 附近,不会明显超出。测的时候要注意目标目录确实落在你限定的那块设备上,跨设备测试结果没有参考价值。

进程数的验证更简单,在容器里跑一个死循环 fork 的脚本:

docker run --rm -it --pids-limit=64 alpine sh -c \ 'while true; do sleep 1 & done'

跑到六十几的时候会开始大量输出can't fork之类的错误,docker inspect里的 PidsStats 能看到上限值。这个测试建议在测试环境做,生产环境跑容易引出别的问题。

5. 编排层怎么落地:Compose 里的资源声明有不少版本坑

5.1 deploy.resources 在老版本 docker-compose 里会被静默忽略

单机跑docker run加参数很直接,一旦上了 Compose,问题就来了。同一份资源限制,在 Compose 文件里有至少三种写法,而且生效情况取决于文件版本和 Compose 实现。

第一种是 v2 语法的顶层字段:

services: api: image: my-api:1.0 mem_limit: 2g memswap_limit: 2g cpus: 1.5 cpu_shares: 1024 pids_limit: 512

第二种是 v3 语法的deploy.resources:

services: api: image: my-api:1.0 deploy: resources: limits: cpus: '1.5' memory: 2g reservations: cpus: '0.5' memory: 512m

这里有个著名的坑:deploy段最初是为 Swarm 模式设计的,老版本的docker-compose(Python 实现的那一版)在非 Swarm 模式下up的时候会完全忽略deploy段,而且不给任何提示。无数人写了deploy.resources.limits.memory: 2g,以为限制生效了,实际上容器跑起来毫无约束。

新版的 Docker Compose(用 Go 重写的那一版,命令是docker compose而不是docker-compose)已经会应用deploy.resources.limits。如果还在用老版本,有两个办法:一是升级到新版本,二是加--compatibility参数让老版本按兼容模式解析deploy段。

我的做法是两个都写,mem_limit系列和deploy.resources同时存在,前者保证老工具链下生效,后者保证新工具链和集群模式下生效,两边不冲突:

services: api: image: my-api:1.0 mem_limit: 2g memswap_limit: 2g cpus: 1.5 pids_limit: 512 deploy: resources: limits: cpus: '1.5' memory: 2g

看起来啰嗦,但这几行配置是花过学费换来的,值得。

5.2 limits 和 reservations 的语义差别

这两个词在 Compose 和 Kubernetes 里都有,语义基本一致但细节不同,值得单独说清楚。

limits是上限,超过就受限或者被杀。CPU 超了会被节流,内存超了会被 OOM。

reservations是"预留"或者说"保障",语义在两种环境下不完全一样。在 Swarm 里,它表示调度时给这个服务预留的资源量,集群调度器会确保节点上有这么多资源可用才把任务放上去。在 Compose 单机模式下这个字段基本不起作用,而在 Kubernetes 里对应的requests则直接影响调度和 QoS 分级。

实际配置时,我的原则是:limits按业务的峰值需求给,留 20%-30% 余量;reservations(或 K8s 的 requests)按业务的稳态需求给,大约是 limits 的 50%-70%。这个比例让调度器有足够信息做合理放置,又不至于因为预留过多导致节点利用率低下。

5.3 一份可以直接抄的 Compose 配置

把前面这些参数攒在一起,这是我日常用的一份模板:

services: api: image: my-api:1.0 restart: unless-stopped mem_limit: 2g memswap_limit: 2g cpus: 1.5 cpu_shares: 1024 pids_limit: 512 ulimits: nofile: soft: 65536 hard: 65536 deploy: resources: limits: cpus: '1.5' memory: 2g reservations: cpus: '0.5' memory: 1g healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"] interval: 30s timeout: 3s retries: 3

几个说明:mem_limit和memswap_limit设成相等是为了禁用 swap;cpus加cpu_shares组合起来既有硬上限又有竞争优先级;pids_limit防 fork 炸弹;ulimits处理连接数。整套配下来,单个容器失控基本不会把宿主机拖垮。

6. 观测和排查:让限制值真正看得见

6.1 docker stats 的读法与三个常见误读

docker stats是最常用的观测入口,但它有几个地方特别容易看错。

第一个是 CPU 百分比的基准。它以单个核为 100%,所以限制 4 核且跑满的容器会显示 400%。看到 400% 不用慌,看一眼--cpus的设置就明白了。另外在 cgroup v1 环境下,docker stats的 CPU 计算偶尔会出现明显偏差,尤其在容器刚启动或者长时间空闲后突然加压的瞬间,v2 下会准确一些。

第二个是 MEM LIMIT 这一列。如果容器没设内存限制,这一列显示的可能是宿主机的总内存(cgroup v1)或者一个非常大的数值(v2)。看到 64G 之类数字的时候,先确认是不是"没设限制"而不是"限制是 64G"。

第三个是 MEM USAGE 的口径。它统计的是 cgroup 里的memory.usage_in_bytes,包含 page cache。也就是说,一个容器大量读写文件之后,MEM USAGE 会涨得很高,但其中大部分是可回收的缓存,不代表真的内存泄漏。想看真实占用,要减去 cache 部分:

# cgroup v2 cat /sys/fs/cgroup/memory.stat | grep -E '^(anon|file|slab) '

anon是匿名页(真正的进程内存),file是文件缓存。判断是否有内存问题时,看anon的增长趋势比看docker stats的 MEM USAGE 靠谱得多。

docker stats常用的几个参数我列一下:

参数作用
--no-stream只输出一次,适合脚本采集和写监控
--all/-a包含已停止的容器
--format自定义输出格式,比如table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}

做监控采集一定要加--no-stream,默认的流式输出会一直挂在终端上,在脚本里会卡死。

6.2 直接读 cgroup 文件拿原始数据

docker stats给的是加工过的数据,真要定位问题,原始 cgroup 文件更可靠。cgroup v2 下这几个路径值得记住(systemd 作为 driver 时,容器 scope 在/sys/fs/cgroup/system.slice/下;cgroupfs driver 则直接在/sys/fs/cgroup/下):

CID=$(docker inspect -f '{{.Id}}' <容器名>) BASE=/sys/fs/cgroup/system.slice/docker-${CID}.scope cat $BASE/cpu.max # CPU 配额与周期 cat $BASE/cpu.stat # 含 nr_throttled、throttled_usec cat $BASE/memory.max # 内存上限 cat $BASE/memory.current # 当前用量 cat $BASE/memory.peak # 历史峰值 cat $BASE/memory.events # 含 oom、oom_kill 计数 cat $BASE/pids.current # 当前进程数 cat $BASE/pids.max # 进程数上限 cat $BASE/io.stat # 块设备读写字节与次数

memory.events里的oom_kill计数特别有用。它是个累计值,只要不为零就说明这个容器被 OOM 杀过,配合oom计数(触发了多少次 OOM 条件)能区分"内存紧张但回收住了"和"真的杀进程了"两种情况。

cgroup v1 的路径和方法类似,只是文件名不一样:memory.limit_in_bytes、memory.usage_in_bytes、memory.failcnt、pids.current等。写监控脚本时最好做个版本判断,别写死一种路径。

6.3 常见问题速查表

下面这张表是我这些年排查资源问题时整理的高频场景,基本覆盖了八成以上的疑问:

现象可能原因排查动作
容器 CPU 用量远低于--cpus设置但服务很慢程序自认为核数多,线程过度创建检查nproc、GOMAXPROCS、JVM 的 ActiveProcessorCount
docker stats显示 CPU 400% 但不卡百分比以单核计,属正常对照--cpus值确认
设了--memory=2g容器却用到近 4g未设--memory-swap,默认允许等量 swap把两者设成相等以禁用 swap
容器反复重启,日志无异常被 OOM 杀docker inspect看OOMKilled字段
JVM 服务被 OOM 但堆 dump 正常堆外内存超限(元空间、直接内存、线程栈)用MaxRAMPercentage留出 30% 余量
--blkio-weight设了没效果磁盘调度器不支持权重改用--device-write-bps等硬限
内存限制改了但容器内看到的没变改的是容器运行时配置,需重建docker update只能改部分,内存通常需重建
容器里nproc显示宿主机核数未做 CPU 亲和性限制用--cpuset-cpus或运行时参数修正
cgroup 文件路径找不到v1/v2 版本或 driver 不同stat -fc %T /sys/fs/cgroup/确认版本
宿主机内存告急,先死的是小容器oom_score_adj被调整,受限容器优先被杀关键服务设--oom-score-adj负值

还有一个经验:docker update支持在运行中调整部分资源限制,比如--cpus、--memory、--pids-limit,但--memory调小时如果当前用量已经超过目标值,可能会失败或者触发 OOM。对--cpuset-cpus这类涉及调度拓扑的调整,动态修改的行为在不同内核版本上不一致,我一般直接重建容器,省得排查那些玄学问题。

最后分享一个我在实际运维里形成的习惯:每个容器上线前,先问三个问题——它的 CPU 峰值大概多少、内存稳态和峰值差多少、最坏情况下它会 fork 多少进程。这三个问题答不上来,说明你对这个服务的资源画像还没有把握,那就先给一组宽松但明确的上限(比如按压测峰值的 1.5 倍给),跑一到两周,用memory.peak、cpu.stat里的节流数据、pids.current的历史曲线反过来校准,再收紧。比起一次性拍一个精确数字,这个"先框住、再校准"的路径在实践中可靠得多,也不容易因为限制过紧引发新的故障。