Docker容器资源控制实战:cgroups、CPU内存限制与OOM排查
1. 先搞清楚:容器为什么"默认不吃饱"也不"默认不越界"
真正在生产环境里跑过容器的人,半夜被叫起来处理告警的原因,十有八九不是镜像拉不下来,也不是端口冲突,而是某个容器悄悄把宿主机内存吃光,然后整台机器上的服务集体假死。Docker 的资源控制这件事,说白了就是给容器套一根缰绳——CPU 用多少、内存能涨到哪、磁盘写多快、能开多少进程,全都写死在启动参数里,越界就掐断。它看起来只是几个--memory、--cpus参数,但真正落地时,参数背后的 cgroups 语义、swap 的记账方式、OOM 的触发顺序,每一条都能让人栽跟头。
这篇内容适合三类人看:刚把单体应用搬进容器、还没配过任何限额的新手;已经配了限额但发现"好像没生效"的中级使用者;以及要给团队定容器规范、需要一套可复用参数模板的负责人。我不会只罗列参数表——那种东西官方文档里就有——而是把每个参数为什么这么设计、什么时候会失效、失效之后怎么定位,一条一条拆开讲。
1.1 容器不是虚拟机,资源隔离靠的是内核的两套机制
很多人对容器的第一层误解,是把它当成"轻量级虚拟机"。虚拟机有独立的 Guest OS,有 hypervisor 在中间做硬件虚拟化,CPU 和内存是"切出来"的;容器没有这一层,它和宿主机共享同一个内核,进程直接跑在宿主机上,只是被两套内核机制约束住了。
第一套叫namespace,负责"看不见"。PID namespace 让容器里的进程看不到宿主机的其他进程,NET namespace 给它独立的网卡和路由表,MNT namespace 给它独立的文件系统视图。namespace 解决的是隔离感,但它管不了"用多少"——一个容器里的进程照样能把宿主机的 CPU 全部占满,namespace 对此无能为力。
第二套叫cgroups(control groups),负责"用多少"。它把一组进程归到一个控制组里,然后对这个组统一施加 CPU、内存、块设备 IO、进程数等维度的限制和统计。Docker 的资源控制本质上就是一层对 cgroups 的封装:你敲的--memory=512m,Docker 守护进程会翻译成对/sys/fs/cgroup/memory.max的写入;你敲的--cpus=1.5,翻译成cpu.max里的 quota 和 period。理解了这一层,后面所有的"参数没生效"问题,都能顺着 cgroup 文件往下查。
提示:宿主机内核版本直接决定你能用哪些限制项。cgroups v1 和 v2 的挂载结构和文件命名差异很大,排查前先确认自己在哪一套上。
1.2 默认不限:一个真实压测场景带来的教训
Docker 在设计上是默认不给任何资源上限的。你不写--memory,容器就能吃到宿主机全部可用内存;你不写--cpus,容器就能在所有核上跑满。这个设计是有意为之——为了让容器行为尽量接近普通进程,也为了不和上层的调度器(比如编排系统)抢职责。
但这个默认值放到生产上就是个雷。我印象很深的一次:一个内部服务做批量图片处理,镜像里跑的是一个多进程 pool,池大小按os.cpu_count()自动算。测试机上只有 4 核,跑得好好的;上了 32 核的宿主机,池子直接开到 32 个进程,每个进程还要加载一份几百 MB 的模型,内存瞬间冲到 20 多个 G。宿主机上还跑着数据库和网关,内核 OOM Killer 一动手,先把数据库进程给杀了——因为它的 oom_score 更高。整个链路雪崩,而始作俑者那个容器,因为启动时压根没写--memory,反而活得挺好。
这件事之后我给自己定了个规矩:凡是上生产的容器,--memory和--cpus必须有值,一个都不能省。哪怕给得宽松一点,也比完全不设强。宽松的上限至少能保证故障被圈在一个容器里,而不是扩散到整台机器。
另外一个容易忽视的点是:容器内应用看到的 CPU 数,默认是宿主机的核数。这就是上面那个 pool 开到 32 的根因。后面讲--cpuset-cpus的时候会说到,绑核能顺带修正这个问题,但绑核本身有副作用,得权衡。
1.3 cgroups v1 与 v2:排查问题前先认清自己在哪套上
这个差异值得单独拎出来说,因为它直接影响你查问题时该看哪个文件。cgroups v1 是每个子系统一棵独立的树,CPU 一棵、内存一棵、IO 一棵,挂在/sys/fs/cgroup/cpu/、/sys/fs/cgroup/memory/下面,各自管理。v2 改成了统一层级,所有控制器挂在同一棵树上,路径形如/sys/fs/cgroup/system.slice/docker-<容器ID>.scope/,控制器通过cgroup.controllers文件声明。
快速判断方法:
# 存在这个文件基本就是 v2 ls /sys/fs/cgroup/cgroup.controllers两套的差异体现在几个具体地方。内存限制在 v1 里是memory.limit_in_bytes,v2 里是memory.max;swap 限制 v1 是memory.memsw.limit_in_bytes,v2 是memory.swap.max,而且 v2 的 swap 值是单独计的,不再和内存限制绑在一起算总量,这个语义变化很容易让人算错。CPU 限制 v1 用cpu.cfs_quota_us和cpu.cfs_period_us两个文件,v2 合并成cpu.max,一行写quota period。
还有个实际影响:早期版本的docker stats在 cgroups v2 环境下,某些字段(尤其是内存相关的)读数不准,会显示成宿主机总量。如果你的监控面板上容器内存一直是宿主机总量,先别怀疑监控系统,去看看内核用的是哪套 cgroups,以及 Docker 版本是否够新。
2. CPU 资源控制:分清"上限"和"权重"是入门第一课
CPU 这块是参数最容易被误用的地方。--cpus和--cpu-shares这两个参数,名字看起来都在说"用多少 CPU",实际语义差了十万八千里。用错的结果是:你以为给容器设了天花板,实际上它该跑满还是跑满,只是排队的时候稍微靠后一点。
2.1--cpus是硬顶,--cpu-shares只是抢饭时的优先级
--cpus=1.5对应的是 CFS(完全公平调度器)的带宽控制。内核把 CPU 时间切成固定长度的周期(默认 100ms),然后规定这个 cgroup 在每个周期内最多只能用 150ms 的 CPU 时间——折合下来就是 1.5 个核的算力。写进 cgroup 就是cpu.max = 150000 100000(单位是微秒)。
这个限制是硬的。容器里的进程一旦用完这个配额,本周期内剩下的时间就再也拿不到 CPU,只能等到下一个周期。表现出来就是延迟毛刺:一个请求正好卡在配额耗尽的那一刻进来,得等到下个 100ms 周期才能被处理。
--cpu-shares=512则完全是另一回事,它对应的是相对权重。这个值只在多个 cgroup 争抢同一批 CPU 时才起作用。宿主机 CPU 空闲的时候,哪怕你只给了 2 的权重,容器照样能跑满所有核;只有当一个权重 512 的容器和一个权重 1024 的容器同时抢 CPU,才会按 1:2 的比例分。默认值是 1024。
用一个生活类比:--cpus是给这个人每天只发 1.5 小时的饭票,吃完就没了;--cpu-shares是食堂打饭时的排队顺序,人多的时候权重高的先打、打得多,人少的时候谁来了都能随便吃。
| 参数 | 对应 cgroup 文件 | 语义 | 空闲时是否限制 | 典型场景 |
|---|---|---|---|---|
--cpus=1.5 | cpu.max | 绝对算力上限 | 是 | 多租户、按量计费、防止单容器打满 |
--cpu-shares=512 | cpu.weight | 争抢时的相对权重 | 否 | 同机多服务,希望繁忙时按重要性分配 |
--cpuset-cpus=0-3 | cpuset.cpus | 绑定到指定物理核 | 是 | 低延迟、NUMA 敏感、避免跨核缓存失效 |
--cpu-quota/--cpu-period | cpu.max | 手动指定配额与周期 | 是 | --cpus的底层等价写法 |
实操中我的建议是:两套一起用。--cpus兜住最坏情况,防止一个容器把整机打满;--cpu-shares处理正常争抢,让核心服务在资源紧张时优先拿到算力。只配--cpu-shares在单容器场景下等于没配,因为它压根没有上限。
2.2 绑核的收益与代价:--cpuset-cpus不是万能药
--cpuset-cpus=0-3把容器内的进程限定在 0 到 3 号逻辑核上。它的直接收益有两个:一是彻底避免和其他关键服务争抢同一批核,二是减少跨核调度带来的缓存失效,对延迟敏感的服务(比如实时音视频处理)效果明显。
但代价同样实在。第一,绑核之后宿主机上的 CPU 会被物理切分,被绑定的核即使空闲,其他没绑的容器也用不了,整体的 CPU 利用率会下降。第二点更隐蔽:容器内nproc和/proc/cpuinfo反映的是绑定的核数,某些运行时(比如刚才提到的多进程 pool、某些线程池实现)会据此自动决定并发度。你以为给它 4 个核它就开 4 个线程,但如果这个服务本身是 IO 密集型的,4 个线程可能根本喂不饱——这种情况下绑核反而成了性能瓶颈。
还有个坑:绑核和--cpus混用时,两者是取交集的关系。你绑了 4 个核,又写了--cpus=8,实际效果是 4 个核的算力上限。这种自相矛盾的配置不会报错,只会让你在压测时对着数据发懵。所以我一般会建议团队:要么用--cpus做软性总量控制,要么用--cpuset-cpus做硬性物理隔离,别同时上。
2.3 一份能直接抄的算力分配方案
假设一台 8 核 32G 的宿主机,要跑三个服务:一个对外网关、一个业务主服务、一个离线任务处理器。怎么分?
网关是 IO 密集型,CPU 用得不多但延迟敏感;业务主服务是核心,需要稳定算力;离线任务可以忍受被抢占,但不能影响前两者。我的分配思路是这样的:
- 网关:
--cpus=2 --cpu-shares=1024,不绑核,让它能灵活调度到任意空闲核,保证延迟 - 业务主服务:
--cpus=4 --cpu-shares=1024 - 离线任务:
--cpus=1.5 --cpu-shares=256
三个服务的--cpus加起来是 7.5,略小于 8,故意留出一点余量给系统进程和 Docker 守护进程本身。总权重 1024+1024+256=2304,网关和主服务各占约 44%,离线任务占 11%,争抢时离线任务会自动退让。
这里有个细节值得说:--cpus之和可以超过物理核数,内核允许超卖,因为不是所有容器都会同时跑满。但我个人倾向于不要超卖,宁可留余量。超卖在流量突增时会集体触发 CPU throttling,表现出来就是所有服务一起变慢,而且很难定位是哪个容器导致的——因为大家的配额都被吃满了。
3. 内存控制:OOM 这件事,早发现比晚发现值钱一百倍
内存是容器里最容易出事、也最难排查的一项。CPU 用超了只是变慢,内存用超了是直接被杀,而且容器被杀之后你只能看到一个 exit code 137,具体是哪个进程、为什么涨上去,全得靠事后的日志和监控去还原。
3.1--memory与--memory-swap的记账关系,别再算错了
-m或--memory=512m限制的是这个 cgroup 能用的物理内存总量,包括匿名内存(堆、栈)、page cache、内核态内存。超过这个值,内核会先尝试回收 page cache,回收不动就触发 cgroup 级别的 OOM Killer,杀掉容器内的进程。
--memory-swap的语义经常被误解。它限制的是内存加 swap 的总和,而不是"swap 单独多少"。几个典型组合:
| 配置 | 实际效果 |
|---|---|
-m 512m,不写 swap | swap 部分默认等于-m,即最多可用 512m 内存 + 512m swap |
-m 512m --memory-swap 512m | swap 部分为 0,等于禁用 swap |
-m 512m --memory-swap 1g | 总上限 1g,其中内存 512m,swap 512m |
-m 512m --memory-swap -1 | swap 不限,只要宿主机有 swap 就能一直涨 |
看到了吧,不写--memory-swap的时候,Docker 会自动给你配一份等量的 swap。这个默认行为在很多场景下是坑:你以为给了 512m 上限,实际它能吃到 1g,只不过超出的部分在 swap 上,性能会掉得很惨,但不会立刻 OOM——等到 swap 也满了才被杀,中间那段时间服务的响应时间可能已经慢到不可用了。
生产环境的常规做法是--memory-swap显式等于--memory,把 swap 关掉。让内存超限立刻暴露成 OOM,而不是拖成一场慢性的性能雪崩。当然,这要求你对应用的真实内存峰值有把握,不然就是频繁重启。
3.2 应用层的堆内存必须比容器限额小一截
这是最容易出问题的地方。容器限额是 512m,你把 JVM 的-Xmx也设成 512m,结果必然是 OOM。为什么?因为 JVM 占的内存远不止堆:还有元空间(Metaspace)、线程栈、直接内存(DirectByteBuffer,Netty 这类框架大量使用)、代码缓存、GC 自身的结构开销。堆设成 512m,实际 RSS 可能稳定在 600m 以上。
经验值是堆内存占容器限额的 60% 到 75%。512m 的容器,-Xmx给 320m 到 380m 比较稳妥。如果是堆外内存用得多框架,还得再往下压。
更省心的做法是让 JVM 自己和 cgroup 联动。JDK 8u191 之后的版本默认开启容器感知(-XX:+UseContainerSupport),-XX:MaxRAMPercentage=70.0就能让它按 cgroup 限额的 70% 自动算堆大小。这样容器限额改了,堆也跟着变,不用手工同步两份配置。
Node.js 就没这么智能了。V8 的默认老生代上限和宿主机内存挂钩,容器里跑起来可能直接按宿主机内存算,很容易超出容器限额。必须显式传--max-old-space-size=384(单位 MB)。Python 的进程更麻烦,因为它没有一个统一的内存池概念,只能靠--memory硬限加应用层的分批处理来控制。
注意:容器内执行
free -h看到的往往是宿主机的内存信息,不是容器的限额。这是历史遗留问题,因为/proc/meminfo没有被 namespace 化。想在自己的程序里读真实限额,得去读/sys/fs/cgroup/memory.max或环境变量,别信free。
3.3 OOM 排查实录:从 exit code 137 倒推现场
容器被杀之后,第一步看退出码。137等于 128 + 9,基本就是被 SIGKILL 了。但 SIGKILL 的来源有两种:一种是 cgroup 内存超限触发的 OOM Killer,另一种是外部(比如编排系统健康检查失败)主动 kill。要区分,得看容器的OOMKilled标记:
docker inspect --format '{{.State.OOMKilled}}' <容器名> # 输出 true 就是被内存限制杀的如果是true,下一步是确认它到底涨到了多少、涨在哪个部分。宿主机侧的内核日志会有记录:
dmesg -T | grep -i "oom\|killed process" | tail -20这条命令能看到被杀进程的 PID、RSS 大小、当时的 cgroup 路径。拿到 PID 之后如果想复盘,可以在容器的 cgroup 目录下翻memory.events(v2)里的oom_kill计数,或者memory.peak(较新内核支持)看历史峰值。
如果OOMKilled是false但容器还是 137 退出,那大概率是外部 kill,去查编排系统的日志或者有没有人手动执行了docker kill。
我遇到过最迷惑的一次:容器反复重启,OOMKilled一直是 false,内存监控也显示只用了 200 多兆。查了半天才发现是--pids-limit设得太小,应用在高峰期创建的线程数超了限制,创建线程失败抛异常,进程自己退出了。这个案例说明:资源限制的几项之间会互相影响,排查时不能只盯着内存看。
4. 磁盘 IO 与进程数:两块最常被忽略的限制
CPU 和内存配完了,很多人就收工了。但真实的资源事故里,磁盘 IO 和进程数惹的祸一点不少——前者会让整台机器的数据库卡住,后者能直接让宿主机失去响应。
4.1 blkio 限制的生效边界,比想象中窄
块设备限制有三组参数:--device-read-bps、--device-write-bps限带宽(比如--device-write-bps /dev/sda:10mb),--device-read-iops、--device-write-iops限每秒 IO 次数,--blkio-weight设相对权重(10 到 1000)。
最大的坑在于它只对直接 IO 生效,对 page cache 的写不生效。也就是说,一个应用往文件里连续写小数据,这些写先落进内核的 page cache 就直接返回了,根本不经过限速,等内核在后台批量刷盘的时候,早就绕过了 blkio 的限流点。表现出来就是:你明明限了 10MB/s,iostat一看写入峰值还是几十兆。
所以对写敏感的容器,光靠 blkio 限速不够。更可靠的做法是在应用层控制写入节奏,或者用--storage-opt限制容器可写层的空间大小(只对特定存储驱动有效),再或者把数据卷挂到单独的分区上做隔离。另外,新版 Docker 在 cgroups v2 下走的是io.max接口,语义和 v1 有细微差别,参数写法和 v1 并不能完全对应,升级内核前最好先验证一遍限制是否真的生效。
相对温和一点的--blkio-weight更好用一些。它不设硬顶,只在多个容器同时打 IO 时按比例分配,适合"不能限死但也不能让某个容器独吞"的场景。默认值 500,给核心服务 800、备份任务 100 是个常见的配法。
4.2--pids-limit:几块钱的参数,挡的是整机宕机
fork 炸弹这个古老的东西,在容器时代依然有效。一个死循环里while(true) fork(),几秒钟就能把宿主机的 PID 表耗尽,然后整台机器上所有进程都创建不了,ssh 都登不进去。
--pids-limit=200这一行参数就能把它挡住:容器内的进程数达到 200,再 fork 就直接返回失败,错误被限制在容器内部。这个参数的成本几乎为零,收益却是防灾难级的,我强烈建议所有容器都加上。
数值怎么定?先看应用正常运行时用了多少线程。Tomcat 默认线程池 200,加上 JVM 自身的线程,一个中型 Java 服务轻松上百。可以先用一个宽松值跑几天,然后看 cgroup 里的pids.current峰值,再乘个 1.5 倍作为上限。别拍脑袋给 50,那样应用自己就把自己卡死了。
4.3--ulimit:文件描述符这个隐性天花板
文件描述符限制和 pids 限制是两回事。--ulimit nofile=65536:65536控制的是容器内进程能打开多少文件句柄。默认值继承自 Docker 守护进程,通常是 1024 或者 65536 的软限制。
高并发网络服务特别容易撞这个墙。每个 TCP 连接占一个 fd,几千并发就是几千个 fd 打底。撞上之后的症状很典型:日志里突然刷屏Too many open files,新连接全部建不上,但 CPU 和内存看起来都很正常。这类问题第一次遇到能查半天,知道是 ulimit 之后一分钟解决。
配的时候注意格式,软限制:硬限制,两个值都得写,只写一个的话另一个会取默认值,容易出意外。
5. 落地实操:一份可直接复用的启动配置
前面讲的都是拆开的原理,这一节把它们拼成能直接跑的东西。我给出的配置是给一个中等规模 Web 服务用的,参数含义在注释里写清楚,你按自己的实际情况调整数值就行。
5.1docker run完整参数模板
docker run -d \ --name app-service \ --memory=768m \ --memory-swap=768m \ --memory-reservation=512m \ --cpus=2 \ --cpu-shares=1024 \ --pids-limit=300 \ --ulimit nofile=65536:65536 \ --device-write-bps /dev/sda:20mb \ --restart=unless-stopped \ -p 8080:8080 \ my-app:latest几个参数值得单独说明。--memory-reservation是软限制,内核在内存紧张时会优先回收超过软限制的部分,但不会因为超过软限制就杀进程。它比硬限制更"温和",适合那些平时内存用得多、但可以临时压缩的应用。不写这个参数的话,软限制默认等于硬限制,等于没有缓冲。
--memory-swap显式设成和--memory一样,就是我前面说的关 swap 的做法。如果你确实需要 swap 作为缓冲(比如应用有明显的冷启动峰值),可以设成--memory的 1.5 倍,但要接受性能上的波动。
--cpus=2和--cpu-shares=1024一起用,前者兜底后者调味。这个组合在单机多容器的场景下表现最稳。
5.2 Compose 里的等价写法与常见笔误
Compose 的字段名和命令行不一样,而且有一个非常容易写错的地方:
services: app-service: image: my-app:latest deploy: resources: limits: cpus: "2.0" memory: 768M reservations: cpus: "0.5" memory: 512M pids_limit: 300 ulimits: nofile: soft: 65536 hard: 65536 ports: - "8080:8080"第一个坑:deploy.resources.limits在非 Swarm 模式下的docker compose up里,早期版本是不生效的。较新版本的 Compose V2 已经支持了,但如果你用的是老版本,稳妥写法是改用mem_limit和cpus这类顶层字段。写完一定要用docker inspect确认一遍限额真的落到了 cgroup 上,别以为写了就生效。
第二个坑:cpus: 2和cpus: "2.0"。YAML 里不加引号的2会被解析成整数,某些版本的 Compose 对整数类型的 CPU 值处理有 bug,加引号写成字符串最保险。
第三个坑:memory: 768M里的大写 M。Docker 认m、M、mb、M这些写法,但768MB这种带 B 的有时不被识别,会静默退化成默认值。统一写成768m或768M最省心。
5.3 验证与压测:写完必须自己验一遍
配置写完不算完,必须验证限额真的生效了。三个层次的验证方法,从轻到重:
第一层,看容器状态里的限额:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.HostConfig.PidsLimit}}' app-service输出应该是805306368 2000000000 300这样的数字(内存单位是字节,CPU 是纳核)。如果内存那块是 0,说明你的限制压根没传进去。
第二层,直接进 cgroup 文件确认。cgroups v2 环境下:
# 找到容器对应的 cgroup 路径 docker inspect --format '{{.HostConfig.CgroupParent}}' app-service # 查看实际写入的限制值 cat /sys/fs/cgroup/<路径>/memory.max cat /sys/fs/cgroup/<路径>/cpu.max cat /sys/fs/cgroup/<路径>/pids.max这里的值才是内核真正认的,比docker inspect更可信。
第三层,压测验证。装个stress-ng在容器里跑:
# CPU 压测:起 8 个 worker,看实际能用多少 docker exec app-service stress-ng --cpu 8 --timeout 30s # 期间另开一个终端观察 docker stats app-servicedocker stats里的 CPU% 应该稳定在 200% 左右(对应--cpus=2),而不是冲到 800%。如果冲上去了,说明限制没生效,回去查参数拼写或者 cgroup 版本兼容性。
内存压测更直接:
# 尝试分配 1G,容器限额 768M,预期会被 OOM kill docker exec app-service stress-ng --vm 1 --vm-bytes 1G --timeout 30s预期结果是容器退出,docker inspect里OOMKilled为 true。如果它安安稳稳跑完了,说明内存限制没起作用,这个必须查清楚——否则上生产就是个定时炸弹。
6. 常见问题与排查速查表
这一节是我这些年踩过的坑的集合,按症状归类,方便出问题时直接对号入座。
6.1 症状速查:从现象倒推原因
| 症状 | 最可能的原因 | 快速确认方式 |
|---|---|---|
容器内free显示宿主机总量 | 正常现象,/proc/meminfo未做隔离 | 读/sys/fs/cgroup/memory.max才是真限额 |
docker stats内存读数异常偏大 | cgroups v2 + Docker 版本较老 | 升级 Docker,或直接用 cgroup 文件核对 |
| CPU 明明限了 2 核,压测还是跑到 800% | --cpus拼写错误或写在 Compose 的错误层级 | docker inspect看NanoCpus是否为 0 |
| 容器延迟周期性毛刺 | CPU 配额被周期性耗尽,触发 throttling | 读cpu.stat里的nr_throttled是否持续增长 |
容器反复重启,OOMKilled为 false | 可能是 pids 限制或 ulimit 撞墙 | 看容器日志有没有Resource temporarily unavailable |
| IO 限速看起来完全没效果 | page cache 写绕过了 blkio 限流 | 用dd oflag=direct做直接 IO 测试对比 |
| Java 服务稳定 OOM,堆明明没满 | 堆外内存(Metaspace、DirectBuffer)超限 | 加-XX:NativeMemoryTracking=summary看明细 |
关于 CPU throttling 那一行,值得多说两句。nr_throttled是累计被限流的次数,throttled_time是累计被限流的时间。如果这个值在持续增长,说明容器长期处于"配额不够用"的状态,表现出来就是 P99 延迟飙高但平均延迟正常——因为大部分请求都很快,只有撞上配额耗尽那一刻的请求会等一整个周期。这种问题用平均指标完全看不出来,必须看分位数和 throttling 计数。
6.2 三个反直觉的细节,记下来能省很多时间
第一个:容器的--memory统计里包含 page cache。一个容器大量读写文件之后,内存使用量会涨上去,但那部分是缓存,内核在内存紧张时会自动回收,不算真正的泄漏。看到内存曲线缓慢上涨不要慌,先看memory.stat里的file字段占了多少。真正要担心的是anon(匿名内存)持续上涨。
第二个:限制改小不会自动触发 OOM。你在线把一个容器的内存限制从 1G 调到 512M,而它当前已经用了 800M,内核不会立刻杀它,而是允许它暂时超出,等它自己降下来或者下次申请内存时才处理。所以在调整限制之后的一段时间里,实际用量可能超过新限额,这是正常的,别当成 bug。
第三个:--cpus支持小数,--cpu-shares不支持。--cpus=0.5是合法的,代表半个核;--cpu-shares=0.5不合法,因为权重是整数,范围 2 到 262144。写小数会被静默截断或者报错,取决于版本。
我个人在实际操作中的体会是,资源控制这件事最难的不是记住参数,而是建立"先量化再限制"的习惯。很多人上来就拍一个--memory=512m,结果应用正常运行时就需要 600M,容器一直在重启,然后开始怀疑 Docker 有问题。正确的顺序应该是:先不设限跑一段时间,用docker stats和 cgroup 的memory.peak把真实峰值摸清楚,再在此基础上留 20% 到 30% 的余量设限。参数是结果,不是起点。
最后分享一个小技巧:把上面那套验证命令写成一个脚本,每次改完资源配置就跑一遍,输出三行——限额是否写入、压测能否突破、OOM 是否按预期触发。这三行全绿了再上线,比任何人工检查都可靠。