
在多容器共存的宿主机上,没有受到任何限制的容器就如同没有熔断机制的线程一样——内存泄漏、死循环和fork风暴会在几分钟之内使整个系统崩溃掉。OOM Killer会首先杀死的是肇事者本身,而不是SSH、监控Agent或者数据库进程等正常运行的服务。正确的做法并不是等到出现问题才去重启,而是在cgroup层面上给每个容器划定好资源边界。
一、cgroup 才是真正起作用的
Docker 的 --memory 和 --cpus 参数是声明式的,它们都会被转换成 Linux 内核中的控制组:
- cgroup v1中,CPU、内存和IO分别有不同的层次,《memory.limit_in_bytes》和《cpu.cfs_quota_us》用不同的文件来管理
- cgroup v2:采用统一的层次结构,
memory.max、memory.high、cpu.max、pids.max等参数一起进行管理,包括页面缓存和套接字缓冲区在内的所有资源都被算作内存
v2有一个隐藏的问题:读取大文件时生成的page cache会被计入到容器的内存中,从而造成“堆内存很小但是被OOM”的情况。在排查的时候不能只看应用层的RSS,还要查看memory.current以及memory.events。
确认宿主机版本:
stat -fc %T /sys/fs/cgroup
# cgroup2fs = v2,tmpfs = v1
docker info | grep -i "cgroup version"
二、内存:先固定好顶部,然后再讨论Swap策略
1. 基本的硬性约束
docker run -d \
--name api \
--memory=1.5g \
--memory-swap=1.5g \
--memory-reservation=1g \
myapp:latest
--memory:硬顶,超过直接 OOM Kill(退出码 137)--memory-swap=同值:不允许使用Swap来代替内存,一旦出现错误就会马上被发现--memory-reservation:软限制,在内存不足的时候会被回收掉,但是不会杀死进程
数据库和缓存服务应该关闭swap,而批处理和CI构建可以保留一些swap作为缓冲区。
2. JVM/Node要明码标价
老的JVM没有读取cgroupv2的功能,在计算堆大小时使用的是宿主机的物理内存:
# Java 11+
java -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -jar app.jar
# Node
NODE_OPTIONS="--max-old-space-size=1024" node server.js
如果设置了1.5G的内存限制的话,那么JVM仍然会请求8G的内存,这样就会导致内存溢出。
三、CPU:硬配额和权重分别使用
docker run -d \
--name worker \
--cpus=1.5 \
--cpu-shares=512 \
--cpuset-cpus=0,1 \
myworker:latest
--cpus=1.5:CFS硬配额,相当于v2中的cpu.max(即150000 100000=每100ms最多使用1.5个CPU)--cpu-shares=512:只有存在竞争的情况下才有效,默认值为1024,并不是一个限制--cpuset-cpus:绑定核心,适用于对延迟要求较高的服务,但是会降低调度的灵活性
常见的错误就是把 --cpu-shares 当作上限来使用,这样容器仍然可以占用全部4个CPU核心。
查看节流情况:
cat /sys/fs/cgroup/cpu.stat
# nr_throttled 持续增长 = CPU 配额给少了
四、进程数、文件句柄和I/O:防止“隐形耗尽”
1. PID 上限(防止fork炸弹)
--pids-limit=200
如果不设置的话,一个容器fork出去之后就会把宿主机的PID表全部填满,所有的服务都会失去连接。
2. 文件描述符
--ulimit nofile=65535:65535
--ulimit nproc=4096
Nginx、网关和WebSocket的服务应该提高一些,不然的话就会遇到EMFILE的问题了。
3. 磁盘I/O和临时空间
--blkio-weight=500
--tmpfs /tmp:size=100m
--shm-size=256m
blkio-weight:相对IO优先级(100-1000),数据库为高,日志收集为低- /dev/shm 默认只有64MB,在Chrome、Postgres和Redis哨兵等情况下需要扩大
五、Compose 中使用的声明式基线
services:
web:
image: myapp:latest
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
pids: 100
reservations:
memory: 128M
ulimits:
nofile:
soft: 65535
hard: 65535
tmpfs:
- /tmp:size=100m
必须在创建的时候说明一下,不能依靠运行时的修改来改变它——一旦重新启动就会丢失。
六、检验限制是否真的起作用
# 容器视角
docker stats --no-stream
# cgroup v2 直接读文件
CG=$(docker inspect -f '{{.Id}}' api)
cat /sys/fs/cgroup/system.slice/docker-${CG}.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-${CG}.scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-${CG}.scope/pids.max
# 确认是否 OOM 致死
docker inspect -f '{{.State.OOMKilled}} {{.State.ExitCode}}' api
容器中的 free 显示的是宿主机的总内存量,应该用 cgroup 文件里的数据来判断。
七、生产环境默认基线(2核4G宿主机示例)
| 维度 | 推荐值 | 目的 |
|---|---|---|
| 内存 | 单容器 ≤ 1.2G,预留 800M 给系统 | 防系统级 OOM |
| CPU | 单容器 ≤ 1.5 核 | 留 0.5 核给内核与 Dockerd |
| PID | 100–256 | 防 fork 风暴 |
| Swap | 等同内存或全禁 | 故障快速暴露 |
| 日志 | 配 json-file max-size 10m max-file 3 | 防磁盘被日志吃满 |
| 关键服务 | oom-score-adj 调低 | 降低被误杀概率 |
daemon 层全局兜底(/etc/docker/daemon.json):
{
"default-ulimits": {
"nofile": { "Name": "nofile", "Hard": 65535, "Soft": 65535 }
},
"oom-score-adjust": -500
}
八、典型事故复盘
现象:Java 服务半夜退出,Exit Code 137,同机 MySQL 也连不上。
根因:没设 --memory,JVM 按宿主机 16G 初始化堆,实际占用 12G,触发宿主机全局 OOM,先杀监控再杀数据库。
修复:
--memory=2g --memory-swap=2g \
-e JAVA_OPTS="-XX:MaxRAMPercentage=70"
上线之后没有发生类似的事故。
总结
容器资源调优并不是简单地分配多少资源的问题,而是要画出更严格的界限来。内存采用硬性上限+应用层堆对齐的方式,CPU使用硬性配额而不是权重,PID、fd等隐藏泄露被堵住,在cgroup v2中用memory.max和cpu.max的实际值作为标准。把限制写成声明、验证写入磁盘、监控节流以及OOM事件,单个容器失控不会导致整个宿主机崩溃。