Docker Compose 部署多服务时内存限制没生效,先别急着怀疑 Docker

实际使用中发现,很多人说 Docker Compose 的内存限制没生效,最后查下来并不是 Docker 完全不管内存,而是 Compose 文件写法、Compose 版本、运行模式、cgroup 环境混在一起了。

典型场景是这样:一台 8G 或 16G 云服务器上跑多个服务,里面有 nginx、api、worker、redis、mysql、elasticsearch,Compose 文件里明明写了 deploy.resources.limits.memory,但 docker stats 里容器还能继续涨,甚至把宿主机内存吃满,然后系统开始 swap、卡顿,严重时直接 OOM。

这类问题不能只看 Compose 文件,要看最终容器有没有拿到 Docker Engine 的内存限制。判断标准不是“文件里写了”,而是看 docker inspect 里的 HostConfig.Memory

最常见的坑:deploy.resources 写了,但不是 Swarm 模式

很多 Compose 配置会这么写:

services:
app:
image: my-app:latest
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M

这个写法本身没错,但要看你怎么启动。

deploy 这一段最早主要是给 Docker Swarm 用的,也就是 docker stack deploy。如果用的是传统的 docker-compose up -d,尤其是老版本 docker-compose 1.x,很多 deploy 配置会被忽略。结果就是配置文件看起来很规范,容器实际没有 memory limit。

这里补充一点,很多线上机器还在用 docker-compose 这个老命令,不是 docker compose。两者不是简单少一个横杠的问题,底层实现和 Compose Spec 支持程度都有差异。

可以直接看版本:

docker-compose version

docker compose version

如果是 docker-compose 1.251.261.29 这类 Python 版老 Compose,遇到 deploy.resources 不生效很常见。生产环境别靠猜,直接 inspect。

正确验证方式

容器启动后执行:

docker inspect 容器名 --format '{{.HostConfig.Memory}}'

如果返回 0,说明没有设置硬内存限制。

比如设置了 512M,正常应该看到类似:

536870912

这个值是 byte,536870912 byte 就是 512MiB。只看 docker stats 不够,它只能反映当前使用情况,不能证明限制已经下发。

非 Swarm 场景下,优先用 mem_limit

如果你的部署方式就是普通 Docker Compose,也就是:

docker compose up -d

那更稳的写法是使用 mem_limit

services:
app:
image: my-app:latest
mem_limit: 512m
memswap_limit: 512m

mem_limit 对应 Docker Engine 的硬限制,最终会写到 HostConfig.Memory。这个配置在普通 Compose 部署里更直观。

memswap_limit 也要注意。如果只设置 mem_limit,不同 Docker 版本和宿主机 swap 配置下,容器可能还能使用额外 swap。实际线上排查时,经常看到“容器内存限制 1G,但机器还是被拖慢”,一查发现进程在疯狂打 swap,CPU iowait 飙高。

如果希望容器最多只能使用 512M,连 swap 都不要额外放大,可以写:

mem_limit: 512m
memswap_limit: 512m

如果允许容器使用 512M 内存 + 512M swap,可以写:

mem_limit: 512m
memswap_limit: 1g

deploy 配合 compatibility 不是万能保险

有些环境会用:

docker compose --compatibility up -d

或者:

docker-compose --compatibility up -d

这个参数会尝试把 deploy.resources.limits 转换成普通容器参数,比如内存、CPU 限制。但实际项目里不建议长期依赖这个行为。原因很简单:不同 Compose 版本、不同字段支持情况会有差异,后续迁移机器时容易踩坑。

如果不是 Swarm,就直接写 mem_limit。如果是 Swarm,就写 deploy.resources,并用 docker stack deploy。部署方式和配置语义最好对齐。

Compose 文件 version 不是越新越好

以前很多模板喜欢写:

version: "3.8"

然后在里面写 deploy.resources。这个组合看起来很现代,但如果用普通 docker-compose up,内存限制可能就是空的。

早期 Compose v2 写法里常见:

version: "2.4"
services:
app:
image: my-app:latest
mem_limit: 512m

这类写法在非 Swarm 场景下反而更符合预期。现在 Compose Spec 已经弱化了 version 字段,很多新版本会提示 version is obsolete,但老项目里仍然能看到大量 version: "3" 配置。

多说一句,升级 Compose 文件不要只改 version。很多人从 2.4 改到 3.8,顺手把 mem_limit 改成 deploy.resources,然后普通 Compose 部署下限制就丢了。

多服务部署时,内存限制“看起来没生效”的几个现场表现

这里说的是实际排查里经常遇到的现象。

docker stats 显示超过预期

比如给 app 写了 512M,但 docker stats 里看到 700M。这个时候先不要下结论,要看 stats 里的 LIMIT 列到底是多少。

如果 LIMIT 是宿主机总内存,比如 15.6GiB,那就是容器没有限制。

如果 LIMIT 是 512MiB,但使用量看起来逼近或略有异常,要继续看是不是 page cache、共享内存、JVM 堆外内存、tmpfs 等因素。

容器被杀,退出码 137

内存限制真正生效时,容器进程超过限制,常见结果是 OOMKilled,退出码 137。

查看方式:

docker inspect 容器名 --format '{{.State.OOMKilled}} {{.State.ExitCode}}'

如果看到:

true 137

说明限制不是没生效,而是生效后进程顶到了上限。这个时候该调应用参数,不是继续怀疑 Docker。

宿主机 OOM,但容器没显示 OOMKilled

这种更麻烦。常见原因是容器根本没限制,或者多个容器限制总和超过宿主机可用内存,再加上系统进程、page cache、日志、监控 agent,最后宿主机自己被打爆。

比如一台 8G 机器:

mysql 设 4G
redis 设 2G
api 设 2G
worker 设 2G
nginx 设 512M

单看每个服务都“有上限”,加起来已经超过 10G。再考虑 Docker daemon、系统服务、文件缓存,8G 机器不可能稳定撑住。内存限制不是资源扩容,它只是隔离边界。

cgroup 环境也会影响内存限制

Docker 的内存限制最终依赖 Linux cgroup。现在常见的是 cgroup v1 和 cgroup v2 两种环境。大部分主流发行版都没问题,但一些特殊内核、精简系统、容器套容器环境、rootless Docker,会出现限制能力不完整或者行为和预期不一致。

可以看 Docker 信息:

docker info | grep -i cgroup

常见输出类似:

Cgroup Driver: systemd
Cgroup Version: 2

如果是比较老的 CentOS 7、定制内核、OpenVZ 类环境,内存限制要重点验证。尤其是一些低价 VPS,宿主虚拟化层本身就有资源约束,Docker 内部再做限制时会出现统计不准、OOM 行为异常。

真正做多服务部署,建议选 KVM、标准内核、资源隔离明确的云服务器。比如业务里有 API、Redis、队列、任务服务混跑,又对线路稳定和防护有要求,可以看看 129云 的美国高防-E型,16C、16G DDR4 ECC、300G 防御,比较适合有 DDoS 风险的游戏、接口、海外业务入口。香港大宽带-B型适合轻量服务加高带宽下载、分发类场景,500Mbps 峰值但内存是 4G,跑太多容器就不合适。需要咨询配置可以直接打 400-9177118。

JVM、Node.js、Redis 这类服务要单独看应用内存参数

容器限制只是外层边界,应用自己不一定知道该收敛。

Java 服务

Java 在老版本 JDK 里对容器内存感知不完善,可能按宿主机内存计算堆大小。比如容器限制 1G,但 JVM 以为机器有 16G,然后默认堆参数开得很大,启动后很快 OOMKilled。

生产里更建议显式写 JVM 参数:

JAVA_TOOL_OPTIONS=-Xms512m -Xmx512m

如果容器限制 1G,堆不要直接给满 1G。还要留给 Metaspace、线程栈、Direct Buffer、JIT、native memory。一般 1G 容器给 -Xmx512m-Xmx600m 比较稳,具体看业务线程数和框架。

Node.js 服务

Node.js 的 V8 heap 也建议显式设置:

NODE_OPTIONS=--max-old-space-size=512

容器限制 768M 或 1G 时,别把 old space 设置得太满。Node 还有 native addon、Buffer、连接、日志缓冲等内存。

Redis 服务

Redis 更不能只靠 Docker 限制。Redis 超过容器限制后被 OOMKill,数据和可用性都会受影响。应该在 redis.conf 里设置:

maxmemory 512mb
maxmemory-policy allkeys-lru

如果 Redis 容器限制 768M,Redis 的 maxmemory 可以设 512M 或 600M,给 fork、AOF rewrite、连接和缓冲留空间。Redis 做 RDB/AOF 时内存瞬时上涨很常见。

一个更接近生产的 Compose 写法

普通 Docker Compose 部署可以参考这种写法:

services:
nginx:
image: nginx:1.25
mem_limit: 256m
memswap_limit: 256m
restart: always

api:
image: my-api:latest
mem_limit: 1g
memswap_limit: 1g
environment:
- JAVA_TOOL_OPTIONS=-Xms512m -Xmx512m
restart: always

worker:
image: my-worker:latest
mem_limit: 768m
memswap_limit: 768m
restart: always

redis:
image: redis:7
mem_limit: 768m
memswap_limit: 768m
command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
restart: always

这里的重点不是照抄数值,而是容器限制和应用限制要配套。外层 Docker 限制负责兜底,应用内部参数负责主动控制。

如果这套服务放在 4G 内存机器上,就已经比较紧。nginx 256M、api 1G、worker 768M、redis 768M,加起来 2.8G,看似还有 1.2G,但系统、Docker、日志、page cache、监控 agent 都要占内存。业务有峰值时,4G 很容易抖。

如果是 16G 机器,可以把核心服务分配得宽一点,同时保留 2G 到 4G 给系统和缓存。像 129云 马来西亚-E型是 16C、16G DDR4 ECC、120G SSD、70Mbps 峰值、三网优化,适合东南亚业务、多容器服务、后台任务和接口服务混部;如果业务主要面向国内访问香港节点,香港大宽带-B型的 500Mbps 峰值更偏带宽型,但 4G 内存不建议塞太多服务。

检查路径不要跳步

排查内存限制不生效,可以按现场命令直接看。

看 Compose 实际渲染结果

docker compose config

这个命令能看到 Compose 最终解析后的配置。有些变量没替换、缩进写错、服务名写错,用这个命令能很快发现。

看容器 HostConfig

docker inspect 容器名 --format 'Memory={{.HostConfig.Memory}} MemorySwap={{.HostConfig.MemorySwap}} MemoryReservation={{.HostConfig.MemoryReservation}}'

如果 Memory=0,硬限制就是没下发。

如果 MemorySwap=-1,通常表示 swap 没有限制,具体还要看宿主机是否启用 swap。

看容器是否 OOMKilled

docker inspect 容器名 --format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} Error={{.State.Error}}'

如果服务重启过,但业务日志里没有异常,Docker 这里能看到被系统杀掉的痕迹。

看宿主机内核日志

dmesg -T | grep -i -E 'oom|killed process'

或者:

journalctl -k | grep -i -E 'oom|killed process'

这里能看到到底是哪个进程触发了 OOM。很多时候业务方只看到“服务挂了”,但内核日志会直接写出被杀的进程名、PID、内存占用。

memory reservation 不是硬限制

Compose 里有些人会写:

deploy:
resources:
reservations:
memory: 512M

或者在 Docker 参数里看到 MemoryReservation。这个不是硬上限,它更像软保证或调度参考。容器超过 reservation 并不会立刻被杀。

真正的硬限制要看 limits.memory 或普通 Compose 里的 mem_limit。线上如果要防止某个服务把整台机器拖死,不能只写 reservation。

容器里的 free 命令可能误导判断

还有一个常见误判:进入容器执行 free -m,看到总内存还是宿主机 16G,于是认为限制没生效。

这不一定对。容器内很多系统工具读的是 /proc/meminfo,看到的是宿主机视角,不代表 cgroup 没有限制。判断容器限制要看 cgroup 文件或 Docker inspect。

在 cgroup v2 环境里可以看:

cat /sys/fs/cgroup/memory.max

如果输出类似:

1073741824

就是 1G。若输出 max,表示没有限制。

在 cgroup v1 环境里可以看:

cat /sys/fs/cgroup/memory/memory.limit_in_bytes

不过不同发行版、不同 Docker 配置下路径可能不一样,所以 docker inspect 仍然是最直接的验证方式。

多服务混部时,别把内存算得太满

实际线上机器最怕“纸面配置刚刚好”。比如 8G 机器,容器限制总和设到 7.5G,看起来很精细,实际很危险。

需要额外留出来的内存包括:

系统进程、Docker daemon、containerd、journald、日志采集 agent、监控 agent、SSH、安全组件、文件系统 page cache、网络连接缓冲、iptables/nftables 相关开销。

数据库类服务还会用文件缓存。即使 MySQL buffer pool 设置 2G,系统 page cache 也会继续参与 IO。Elasticsearch、ClickHouse、MongoDB 这类更明显,不建议和太多业务容器挤在小内存机器上。

一个比较保守的经验是:4G 机器容器限制总和控制在 2.5G 到 3G;8G 机器控制在 5.5G 到 6.5G;16G 机器控制在 11G 到 13G。剩下的空间留给系统和突发。业务越依赖磁盘 IO,越要给 page cache 留余量。

Swarm 模式下才按 Swarm 的方式写

如果使用 Docker Swarm,配置可以写:

services:
api:
image: my-api:latest
deploy:
replicas: 2
resources:
limits:
memory: 1G
reservations:
memory: 512M

启动方式是:

docker stack deploy -c docker-compose.yml my-stack

这时 deploy.resources 才是它原本的使用场景。Swarm 会根据 reservations 做调度参考,limits 会下发给任务容器。

如果同一个文件既想给 Swarm 用,又想给普通 Compose 用,就要非常小心。建议拆成两个文件,或者至少在普通 Compose 文件里显式写 mem_limit,不要让维护人员靠启动命令去猜。

最后一个容易忽略的点:改完 Compose 后要重建容器

修改内存限制后,只执行某些应用 reload 是没用的。内存限制属于容器创建时的 HostConfig,通常需要重新创建容器。

常用操作:

docker compose up -d --force-recreate

或者针对单个服务:

docker compose up -d --force-recreate api

执行完再 inspect:

docker inspect api容器名 --format '{{.HostConfig.Memory}}'

如果还是 0,就回到 Compose 写法、Compose 版本、启动命令、cgroup 环境继续查。不要只看 YAML 文件,Docker 最终收到什么配置才算数。