在排查Docker容器的内存问题时,很多人会觉得无从下手:明明宿主机内存快被吃光了,docker stats 里却看不出哪个容器在作妖。其实,这一切的背后都绕不开 cgroup。你要搞懂这两个东西是怎么配合的,才能在关键时刻快速揪出那个“内存黑洞”。下面我们就从最熟悉的场景说起。
一、先认识一下cgroup和Docker的关系
cgroup 是 Linux 内核自带的一套资源隔离和限制机制,全名太长不重要,你只要记住:它能把一堆进程塞进一个“小组”,然后对小组内的设备统一做限额,比如 CPU、内存、磁盘 IO。Docker 只是把 cgroup 包装得更友好,你执行 docker run -m 512M,本质上就是让 Docker 在 cgroup 里建一个文件夹,然后往里面写入“内存最多 512M”这类数字。喜欢怎么理解呢?你可以把 cgroup 想象成一个包工头,Docker 是老板,老板下达命令,包工头具体执行。
1.1 Linux 内核的“小组长”cgroup
你可以直接在宿主机上翻看这些“小组”的账本。它们通常挂在 /sys/fs/cgroup 下面。一个容器跑起来,就会多出一个以容器 ID 命名的子目录。在旧些的系统里,路径可能长这样:
# 先随便跑一个带内存限制的容器
docker run -d --name web-demo -m 256M nginx
# 拿到容器ID(短ID前12位)
cid=$(docker ps -q --filter name=web-demo)
# 查看这个容器主进程在 cgroup 里的登记路径
cat /proc/$(docker inspect -f '{{.State.Pid}}' web-demo)/cgroup
你会发现输出里有一截 /docker/容器完整ID,这串东西就是容器在 cgroup 家族中的“户口本”。沿着这个路径,你就能找到它吃内存的全部凭证。
1.2 Docker 和 cgroup 是怎么配合的
Docker 在创建容器时,会在 cgroup 相关子系统中分别建目录,把容器的所有进程都写进任务的列表里。之后,你通过 -m、--cpus 这些参数限制多少,它就去改对应的 cgroup 文件。比如内存限制对应的是 memory.limit_in_bytes。这也解释了为什么你改了 Docker 参数重启容器,cgroup 里的数字也跟着变。
二、内存限制是怎样一层层写进 cgroup 的
你可能会好奇,Docker 到底往 cgroup 里写了什么?我们来做个简单实验,把整个过程看得清清楚楚。
2.1 一个最简单的限制实验
启动一个限制内存 100M 的容器,然后手动去读 cgroup 里的文件,看数字是不是正好对应:
# 启动容器
docker run -d --name mem-test -m 100M redis:alpine
# 拿到容器短ID
cid=$(docker ps -q --filter name=mem-test)
# 注意:这里直接拼接路径,如果目录不存在可以看下下方的兼容性补充
limit_file="/sys/fs/cgroup/docker/$cid/memory.limit_in_bytes"
echo "限制文件路径: $limit_file"
cat "$limit_file"
输出结果很可能是 104857600,换算一下正好是 100×1024×1024。这就说明 Docker 把 -m 翻译成了字节数,塞进了 cgroup 的“限流阀”。
2.2 解读 cgroup 里的几个关键文件
光知道限制还不够,你得知道它现在用了多少。下面这几个文件是排查时经常要看的:
memory.limit_in_bytes:当前限制的字节数。memory.usage_in_bytes:这个容器已经用掉的字节数。memory.oom_control:里面有个oom_kill_disable字段,0 表示开启了 OOM 杀进程,1 表示不杀。memory.stat:详细统计,比如rss(真实物理内存)、cache(页缓存)等。
我们一条命令看看用量:
docker run -it --rm -m 100M alpine sh -c \
'echo "看一眼当前用量:"; cat /sys/fs/cgroup/memory.current 2>/dev/null || cat /sys/fs/cgroup/memory.usage_in_bytes'
注意,新版系统可能是 cgroup v2,路径和文件略有不同(后面我们会单独说)。但思路完全一样。
三、容器内存泄漏时,你会看到什么样的异常
内存泄漏,俗话讲就是“只进不出”。程序不停申请内存,用完又不还,越攒越多。在容器里,如果这个容器有内存上限,它很快就会被 cgroup 的“限流阀”堵住,然后触发内核的 OOM Killer。
3.1 泄漏的本体和它的马甲
很多程序泄漏的是堆内存,也就是 malloc 或 new 出来的空间。这些空间在 cgroup 里算作 rss(驻留内存)。你如果用 top 看,会看到某个进程的 RES 栏数值一直往上涨,但你以为它是宿主机的进程,实际上它躲在容器里。
3.2 在容器里泄漏会发生什么
假设一个容器限制 200M,但进程慢慢吃到 200M 以后,再申请内存就不行了。内核有两种选择:一种是把进程杀掉(默认行为),另一种是让你阻塞直到有人释放内存(很少见)。一旦被杀,docker inspect 里会看到 OOMKilled 为 true。不过有时候容器不止一个进程,杀的是最胖的那个,容器可能还活着,但业务已经挂了。更闹心的是,有些内存泄漏发生在匿名映射里,docker stats 显示正常的数字,但宿主机内存却在减少。
四、快速锁定宿主机上的“内存黑洞”
当宿主机内存报警,你第一时间看到的其实是进程,而不是容器。所以我们的思路要反过来:从进程反查容器。核心依据就是 /proc/<PID>/cgroup 文件里记录了进程属于哪个 cgroup。
4.1 从进程反查容器的方法
操作流程分三步:找出进程号 → 看它的 cgroup 路径 → 用容器 ID 查名字。下面我们用命令走一遍:
# 第一步:用 ps 按内存排序,找到最胖的进程 PID
pid=$(ps -eo pid --sort=-rss | head -2 | tail -1 | tr -d ' ')
# 第二步:查看这个进程的 cgroup 归属
cat /proc/$pid/cgroup
# 第三步:如果路径里有 docker/ 字样,最后一段就是容器完整 ID
cgrp=$(cat /proc/$pid/cgroup | awk -F: '{print $3}')
container_id=$(basename "$cgrp")
echo "疑似容器ID: $container_id"
# 第四步:用 docker inspect 反查名字,注意如果 ID 前有 docker- 和 .scope 要处理
docker inspect --format='容器名:{{.Name}} 镜像:{{.Config.Image}}' "$container_id"
这个过程中,你可能遇到路径是 /docker-<完整ID>.scope 的情况,这时需要去掉前缀和 .scope。我们用字符串处理一下:
# 更通用地截取容器ID
container_id=$(echo "$cgrp" | sed 's/.*docker[-/]//; s/\.scope$//')
echo "容器ID: $container_id"
4.2 一个现成的排查脚本
光靠手敲太慢,可以直接写一个小脚本,把宿主机上内存占用前 20 的进程全部列出来,并自动识别出它们属于哪个容器,甚至顺手打上标记。脚本如下,统一使用 bash:
#!/bin/bash
# 找出宿主机上内存占用较高的进程,并识别它们是否属于某个容器
echo "PID RSS(kB) 容器名 进程名"
echo "---- ------- --------------------- --------"
# 按 RSS 排序取前 20 个进程
for pid in $(ps -eo pid --sort=-rss | head -21 | tail -20); do
# 跳过没有权限的进程,比如内核线程
[ ! -r /proc/$pid/status ] && continue
# 提取进程名和 RSS 值
name=$(awk '/Name:/{print $2}' /proc/$pid/status)
rss=$(awk '/VmRSS/{print $2}' /proc/$pid/status)
# 读取 cgroup 路径,如果失败则跳过
cgrp=$(cat /proc/$pid/cgroup 2>/dev/null || echo "")
if [[ "$cgrp" == *"docker"* ]]; then
# 从 cgroup 里提取容器 ID
cid=$(echo "$cgrp" | sed 's/.*docker[-/]//; s/\.scope$//')
# 用 docker inspect 获取容器名
cname=$(docker inspect --format '{{.Name}}' "$cid" 2>/dev/null || echo "unknown")
printf "%-6s %-9s %-22s %s\n" "$pid" "$rss" "$cname" "$name"
else
printf "%-6s %-9s %-22s %s\n" "$pid" "$rss" "[宿主机进程]" "$name"
fi
done
这个脚本虽然不短,但逻辑很直白:遍历 /proc,看每个进程的 cgroup 里有没有 docker 字样,有就是容器里的,没有就是宿主机的。你直接复制到宿主机上跑一下,就能看到类似 leak-app 这样的名字出现在最前面。
五、完整案例:一个模拟的内存泄漏现场
为了让你不迷路,我们模拟一个真实场景。假设宿主机上有三个容器,一个叫 normal-web,一个叫 cache-service,还有一个叫 bad-app。我们故意让 bad-app 疯狂吃内存。然后你看怎么在几秒内揪出它。
5.1 制造故障
我们用 stress 工具来模拟内存泄漏,一个进程慢慢吃,另一个进程快速吃。注意,下面的命令会申请 300M 内存,并且不释放,相当于泄漏:
# 先跑一个正常的 web 服务
docker run -d --name normal-web -m 200M nginx
# 再跑一个带缓存的应用,同样限制 200M
docker run -d --name cache-service -m 200M redis:alpine
# 最后启动“有毛病”的应用,限制 400M,却申请 500M,内核会怎样?
docker run -d --name bad-app -m 400M stress --vm 1 --vm-bytes 500M
这里 bad-app 会触发 OOM,因为限制 400M,但申请 500M,内核会把它杀掉。为了表现出“泄漏但还没死”的情况,我们可以把限制改成 1G,让它在 500M 左右耗着:
# 改成限制 1G,此时进程能申请到 500M,但一直占用不释放
docker run -d --name bad-app -m 1G stress --vm 1 --vm-bytes 500M --vm-hang 10
5.2 开始排查
此时宿主机内存被 bad-app 占了大头。我们先看 free -h,然后直接跑我们那个脚本:
# 马上显示内存排行榜
free -h
# 运行排查脚本
bash pid2docker.sh
你会看到类似输出:
PID RSS(kB) 容器名 进程名
---- ------- --------------------- --------
12345 512340 /bad-app stress
...
这就锁定了罪魁祸首是 bad-app。接下来你就能从容决定是重启它,还是用资源限制把它按下去。
5.3 如果脚本不可用怎么办
万一手边没有脚本,就用几个简单的命令现场拼装:
# 找到最耗内存的进程
top -b -n1 | head -12
# 假设看到 PID 是 9999,直接读它的 cgroup
cat /proc/9999/cgroup
# 如果里面有 docker/1234...,那就执行
docker inspect --format '{{.Name}}' 1234...
不管哪种办法,核心思路都是“进程 -> cgroup -> 容器 ID -> 容器名”。这条路走通了,排查速度会非常快。
六、注意事项和避坑指南
排查过程本身不难,但有几个坑你一定会踩,提前说清楚。
6.1 cgroup v1 和 v2 的路径差异
早期系统(比如 CentOS 7)通常用 v1,路径是 /sys/fs/cgroup/docker/容器ID;新版系统(比如 Ubuntu 22.04)用 v2,路径可能变成 /sys/fs/cgroup/system.slice/docker-容器ID.scope。所以上面脚本里用 sed 做了兼容。如果路径匹配不到 docker,你也可以看看 cgroup 文件里是不是直接写了一串 0::/system.slice/docker-...。
6.2 小心 OOM 已经杀掉了进程
如果内存泄漏太猛,进程已经被 OOM 杀掉,那么 /proc/PID 就看不到了。这时候你可以通过 docker inspect 查看容器的容器状态:
docker inspect -f '{{.Name}} 状态:{{.State.Status}} 是否OOM:{{.State.OOMKilled}}' bad-app
输出会告诉你它是否被 OOM 杀掉。
6.3 别漏了 Page Cache
cgroup 的 memory.stat 里,cache 也是算在“已用内存”里的。页缓存是内核用来加速读文件用的,它可能写不回磁盘,所以数量看起来很大,但能被回收。在判断泄漏时,重点看 rss 部分,而不是 usage 全部。你可以这样看:
cid=$(docker ps -q --filter name=bad-app)
cat /sys/fs/cgroup/docker/$cid/memory.stat | grep -E '^(rss|cache|swap)'
如果 rss 高而 cache 低,那才是真正的程序内存泄漏。
6.4 不要误杀宿主机进程
上面脚本里,我们特意把不属于容器的进程标记成 [宿主机进程]。你要记住,不是所有高内存进程都该被 Docker 管。比如 kubelet、mysqld 如果直接装在宿主机上,也占内存,别误伤。
七、日常预防和监控
与其等内存泄漏把系统搞挂,不如提前做点防御。这里分享几个简单的做法,全部基于 Shell。
7.1 给容器加上硬性限制
这是最要紧的。每个容器都要有 -m 限制,而且要预留足够冗余。比如你预估应用最多用 300M,那就给 512M。这样就算泄漏,也只是容器 OOM,不会拖垮宿主机。
7.2 写一个简单的内存监控脚本
你可以用 docker stats 定时记录,这样出问题时能翻旧账。脚本很简单:
#!/bin/bash
# 每分钟把容器内存占用写入日志
logfile="/var/log/docker-mem.log"
while true; do
echo "$(date '+%Y-%m-%d %H:%M:%S')" >> "$logfile"
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}' >> "$logfile"
sleep 60
done
把脚本挂到 systemd 或 crontab 里即可。这里为了演示方便,我们只写了日志,实际你可以加上告警。
7.3 利用 cgroup 的 memory.events 提前预警
cgroup v2 有一个 memory.events 文件,里面记录了 OOM 次数等。你也可以定期检查:
# 查看某个容器的 OOM 记录
cid=$(docker ps -q --filter name=web-demo)
cat /sys/fs/cgroup/docker/$cid/memory.events 2>/dev/null || \
cat /sys/fs/cgroup/system.slice/docker-$cid.scope/memory.events 2>/dev/null
如果 oom_kill 次数增长,说明这个容器经常被杀,需要重点观察。
八、总结
Docker 不是魔法,它的资源限制全靠 cgroup 落地。当容器发生内存泄漏,只要我们掌握“进程 -> cgroup -> 容器 ID -> 容器名”这条反查链,就能在几十秒内锁定目标。记住几个关键命令:cat /proc/PID/cgroup 和 docker inspect。前者告诉你进程被哪个 cgroup 管着,后者告诉你这个 cgroup 对应哪个容器。同时,平时一定要给容器加内存上限,并做好日志记录。有备无患,才能真正做到从容应对。
以上,就是这次想要分享的全部内容。希望能帮你下次排障时少走几步弯路。
评论
围绕“Docker资源限制与cgroup内在联系,容器内存泄漏时如何快速锁定宿主机”参与讨论