【Docker实战 02】防止单个应用拉垮整台 VPS:Docker 容器 CPU、内存配额(cgroups v2)硬限制设置
2026-08-14 · DevCraft Studio
在 VPS 上用 Docker 跑 MySQL 或 Elasticsearch,内存泄露就能让整台机器连不上 SSH?本文讲清 Docker 原生限额参数(--memory、--memory-swap、--cpus)、Compose 里 deploy.resources.limits 的写法与兼容性坑,以及 cgroups v2 底层原理,教你给系统和 SSH 留出 200MB+ 内存防崩溃。
低配 Docker 实战 · 共 2 篇
延伸阅读
更多相关攻略推荐:泛域名 SSL 证书自动化避坑:Acme.sh + DNS API 、BGP 是互联网的"高德地图":打开网页时,数据是这样找路的、【优化线路 02】BGP / AS4837 常规线路年付 10 美元、高级玩家玩出花:如何在 VPS 上广播自己的 IP 地址?BYOIP、计算机科学两大难题之一:缓存失效与 CDN 瞬时刷新奥秘。
一、惨剧现场:一个 Elasticsearch 把整台 VPS 拖下水
先讲个真实到肉疼的场景。你花几刀买了台 2GB 内存的 VPS,docker run 起一个 Elasticsearch 做日志检索,或者跑个 MySQL 当网站数据库。某天晚上程序有个内存泄露,ES 的内存占用从 800MB 一路涨到把 2GB 吃光。
接下来发生的事很经典:物理内存见底,系统被迫开始疯狂回收,可是已经来不及——内核 OOM Killer 启动,它不认识哪个是你的坏进程,为了保命把 sshd、dockerd 一起考虑,最后你的 SSH 连不上、网站 502、容器全躺。你只能去云平台控制台强制重启。
这种一个容器拉垮整台机器的事故,在 VPS 上比你想象的常见。根因就一句话:容器默认不限制资源,它能吃多少吃多少,吃光宿主机的,大家一起完蛋。
二、底层原理:Docker 的限额其实是 Linux cgroups 在干活
Docker 本身不发明资源隔离,它只是把 Linux 内核的 cgroups(控制组)能力包装成了好用的参数。cgroups 是内核从很早版本就有的功能,负责给一组进程限定 CPU、内存、IO、PID 等资源上限。
现在主流发行版默认用 cgroups v2,接口在 /sys/fs/cgroup 下,比老 v1 更统一、更精细。你用 docker 设的每一个 --memory、--cpus,最终都会被翻译成对某个 cgroup 子目录里文件的写入,比如 memory.max、cpu.max。所以限制生效的本质,是内核按这些文件里的值去卡进程。
三、docker run 的硬限制三件套:--memory、--memory-swap、--cpus
最常用、最该设的就是这三个:
- --memory 512m:内存硬上限。容器一旦超过,内核直接 OOM Kill 掉它里面最占内存的进程,容器跟着挂,但爆炸范围被关在容器里,不影响宿主机。
- --memory-swap 768m:这是内存加 Swap 的总预算。上面例子里 512MB 内存加 256MB Swap。把 --memory-swap 设成和 --memory 相等(都是 512m),等于禁止这个容器用 Swap;设成 -1 则是无限 Swap(危险,会写满磁盘)。
- --cpus 1.5:CPU 核数上限,1.5 表示最多用 1.5 个核心的算力。注意和内存不同,超 CPU 不会被杀,只是被内核限流变慢。
完整一条例子:
- docker run -d --name app --memory 512m --memory-swap 768m --cpus 1.5 --pids-limit 256 --restart unless-stopped myapp:latest
顺带提 --pids-limit 256,它限制容器里最多能开的进程或线程数,能防 fork 炸弹(无限建进程)把宿主机的 PID 表填爆。还有 --blkio-weight 500 控制磁盘 IO 的相对优先级,让数据库比批处理任务更优先。
四、--memory-swap 的坑:生产数据库建议直接禁用 Swap
这里有个常见误区。很多人以为给容器多配点 Swap 更稳,其实对数据库这类服务恰恰相反。数据库要的是稳定低延迟,一旦开始用 Swap(哪怕是 SSD 上的),读写延迟会暴涨,反而更容易出现假死。所以社区普遍建议:数据库容器把 --memory-swap 设成和 --memory 相等,直接关掉 Swap,宁可超了被 OOM Kill 重启,也别让它慢到拖垮业务。
回到文章开头那个 Elasticsearch 事故,如果你当初加了一行 --memory 1g --memory-swap 1g,它最多吃掉 1GB,剩下 1GB 留给系统、SSH 和其他容器,你压根不会失联。
五、怎么确认限制真的生效了:exit 137 与 OOM 排查
设了不等于生效,得验证。两个好用的检查:
- docker stats --no-stream:一眼看到每个容器的 CPU 百分比、内存用量和上限;
- docker inspect --format '{{.State.OOMKilled}}' 容器名:返回 true 就说明它曾被 OOM Kill 过。
容器因为内存超限被杀,退出码是 137(等于 128 加信号 9,也就是 SIGKILL)。所以你在 docker ps -a 里看到 STATUS 是 Exited (137),基本就是内存撞上限了。这时不是去加大内存了事,而是先查程序有没有内存泄露——否则加大也照样吃光。
六、Docker Compose 里怎么写:deploy.resources.limits
如果你用 docker-compose.yml 管理一整套服务,限制要写在 deploy.resources.limits 下:
- services 下某个服务,deploy 里 resources 里 limits:cpus 写成 "1.0",memory 写成 512M;还可以加 reservations(软保底,比如 memory: 128M)。
兼容性提醒很重要:老的 docker-compose(Python 版,命令是 docker-compose)在非 Swarm 模式下会直接忽略 deploy 配置,静悄悄不生效。新版 Docker Compose v2(Go 插件,命令是 docker compose,v2.20 之后)在单机 docker compose up 下已经支持 deploy.resources.limits。如果你不确定版本,或者想要最大兼容,直接用顶层短写法的老键也行:mem_limit 写 512m、memswap_limit 写 768m、cpus 写 "1.5"、cpuset 绑定核心。两种写法底层都是同一套 cgroups,docker inspect 都能查到。
七、cgroups v2 底层长啥样:memory.max、cpu.max 与保护机制
想真正搞懂,得看 cgroup v2 的文件。一个容器对应 /sys/fs/cgroup/system.slice/docker-容器ID.scope 这样的目录,里面:
- memory.max:内存硬上限(字节),对应 --memory;
- cpu.max:格式是配额加周期,比如 150000 100000 表示每 100ms 周期里最多用 150ms CPU,即 1.5 核;
- pids.max:进程数上限;
- memory.current:实时用量,和 memory.max 的差就是你还剩多少余量。
更妙的是 v2 还有保护类参数:memory.low(尽力保护,全局内存紧张时尽量不回收这部分)和 memory.min(硬保护,极端情况下宁可 OOM 别人也不动它)。Docker 的 --memory-reservation 会映射到 memory.high(软节流,超了就回收但不杀),reservations 则关联 memory.low。这套四档机制(max、high、low、min)让隔离既硬又柔。
八、给系统留 200MB+ 内存:别让容器吃光宿主机
用户最该记住的一点是:你给所有容器的内存上限之和,必须明显小于 VPS 总内存,留出给操作系统、SSH、Docker 守护进程的头空间(headroom)。经验值:在 8GB 机器上留约 1GB,在小机器上至少留 200MB-512MB。
比如一台 2GB VPS,你可以这么分:SSH 和系统约留 256MB-512MB,剩下 1.5GB 分给容器(Nginx 128MB、MySQL 1GB、应用 384MB 之类),总和压在 1.5GB 以内,系统永远有喘息空间,SSH 不会掉。
如果你真想给 sshd 上硬保险,可以把它所在的 systemd 服务 cgroup 设 memory.low 甚至 memory.min,但实战里容器总限额小于总内存减头空间这条最简单有效。
九、实战分配:给 1GB/2GB VPS 的容器预算
给一套常见模板(以 2GB VPS 为例):
- 反向代理 Nginx:--memory 128m,小进程,限额紧能立刻暴露配置失控;
- 数据库 MySQL/PG:--memory 1g --memory-swap 1g(禁 Swap),shared_buffers 配到约 256MB;
- 应用容器:--memory 384m --cpus 1.0;
- 后台任务:--memory 256m --cpus 0.5。
关键提醒:很多运行时读不到 cgroup 限制。比如旧版 JVM 会按宿主机总内存去算堆大小,得加 -XX:MaxRAMPercentage=75 让它按容器限额来;Node.js 要设 --max-old-space-size 小于容器上限;PostgreSQL 的 shared_buffers 也得手动配小。否则程序自己撑大了,cgroup 一刀切 SIGKILL,你还没反应过来就 137 了。
十、真实翻车实录:三个让我半夜爬起来的事故
理论说完了,来点真刀真枪的。第一起:有台机器只跑了 Nginx 做反代,我没给容器限额。某次上游配置出错,Nginx 疯狂 fork 子进程,瞬间把 2GB 内存吃光,连带宿主机 OOM,SSH 直接掉线,我只能去云平台控制台硬重启。加了 --pids-limit 之后,这种 fork 炸弹最多卡住自己,再也拖不垮主机,这就是进程数上限存在的意义。
第二起更典型:Java 应用在容器里没设 -XX:MaxRAMPercentage=75,老版本 JVM 读不到 cgroup 限制,直接按宿主机 4GB 去算堆,一启动就申请 2GB 堆加一堆非堆内存,加上其他容器,整机内存秒满。后来在启动参数里显式限制,再配合 --memory 1g,世界一下子清净了。记住一句话:运行时读不到限额时,必须在应用层手动设上限,别全指望 cgroup。
第三起最坑:我用老版 docker-compose(Python 那个)写了 deploy.resources.limits,本地 docker compose up 跑得好好的,上线后才发现那台机器装的是旧版,deploy 被静默忽略,等于没限制。一次内存泄露直接把数据库和 SSH 全带走了。教训很朴素:上线前用 docker inspect 确认 HostConfig 里 Limits 字段真的有值,别迷信配置文件写了就生效。
十一、命令行逐行讲解与日常排查清单
把第三条那条 docker run 拆开逐行看:docker run -d 是后台跑;--name app 给容器起名方便管理;--memory 512m 是内存硬上限,超了就杀;--memory-swap 768m 是内存加交换总预算,多出的 256m 允许用一点交换防抖动;--cpus 1.5 限制最多吃一个半核;--pids-limit 256 防进程数爆炸;--restart unless-stopped 是挂了自动拉起但不要无限重启(配合内存上限,崩了就重启,不会拖垮整机)。这条命令把内存、交换、CPU、进程数四道闸都关上了,才算真正兜底。
日常排查我习惯这么一套:先看 docker stats --no-stream 整体水位;再 docker inspect 容器名 翻到 HostConfig 和 State,确认 Memory 和 CpuQuota 不为 0;想看底层就直接 cat /sys/fs/cgroup/system.slice/docker-xxx.scope/memory.current 和 memory.max,实时用量和上限一目了然。建议加个监控告警:当某个容器内存超过上限八成,或者出现 OOMKilled=true,就发消息给你,别等 SSH 掉了才发现。
- 告警阈值:容器内存用量到上限 80% 就提醒,到 95% 就告警,留出你处理的时间窗;
- 日志留存:把 docker events 和 dmesg 的 OOM 记录收进日志系统,事后好复盘到底是哪个容器作妖;
- 上线演练:正式跑业务前故意用 stress-ng 压一下,确认限制真生效、宿主机 SSH 不掉,再放心交给我们自己的服务。