Docker生产故障三连击:OOM Killed+磁盘撑爆+容器起不来,一篇讲透怎么救
容器被OOM Killed——Exit Code 137的真相
先说结论:容器退出码137 = 128 + 9,就是被SIGKILL了,99%是OOM Killed。
怎么确认是OOM?
第一步,看容器状态:
docker inspect <container_id> --format='{{.State.OOMKilled}}'
返回true就实锤了。
再看详细点:
docker inspect <container_id> --format='{{json .State}}' | python3 -m json.tool
输出里重点看这几个字段:
{
"Status": "exited",
"Running": false,
"OOMKilled": true,
"ExitCode": 137
}
还可以从宿主机的dmesg里找到更底层的信息:
dmesg -T | grep -i "oom\|killed process"
你会看到类似这样的日志:
[Tue Sep 3 02:15:33 2026] Memory cgroup out of memory: Killed process 28456 (java)
total-vm:4856320kB, anon-rss:2097152kB, file-rss:45312kB
这条日志告诉你:cgroup内存超限,内核OOM Killer直接干掉了容器里的java进程。注意,这是Linux内核层面的决策,不是Docker做的。Docker只是通过cgroup设了一个上限,真正动手的是内核。
为什么会被OOM?
三种常见情况:
1. 压根没设内存限制
这是最低级也最常见的错误。没有--memory参数,容器可以吃掉宿主机全部内存,最后触发宿主机级别的OOM Killer——这时候可能不只是你的容器被杀,Docker Daemon甚至别人的容器都可能被连坐。
# 查看容器的内存限制
docker stats --no-stream
如果MEM LIMIT那一列显示的是宿主机总内存,说明没设限制。
2. JVM堆内存和容器限制不匹配
这个坑踩过的人应该不少。容器限制了2G内存,JVM的-Xmx也设成2G——看起来合理对吧?但JVM的内存不止堆(Heap),还有Metaspace、线程栈、直接内存、JIT编译缓存这些堆外内存(Off-Heap)。加起来实际占用远超2G,直接被杀。
正确的做法是给堆外内存留出余量:
# 容器限制2G,JVM堆最多给1.2G左右
docker run -d --memory=2g --memory-swap=2g \
-e JAVA_OPTS="-Xms512m -Xmx1200m -XX:MaxMetaspaceSize=256m" \
your-java-app:latest
这里--memory-swap=2g和--memory设成一样,是为了禁用swap。容器里用swap,延迟会飘得很厉害,还不如让它直接被杀然后重启。
3. 应用本身有内存泄漏
这种情况会表现为"锯齿形"——内存一路涨,OOM被杀,重启,又一路涨,再被杀。
用docker stats持续观察:
# 每2秒刷新一次
docker stats --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}"
如果内存百分比一直在稳步上升,那就是泄漏了,得查应用代码。
防OOM的配置模板
分享一个我现在在用的compose片段:
services:
your-app:
image: your-app:latest
deploy:
resources:
limits:
memory: 2g
reservations:
memory: 512m
# 如果用docker run就是:
# --memory=2g --memory-reservation=512m --memory-swap=2g
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
memory-reservation是软限制,低于这个值时内核会优先回收别的容器的内存,给你保底。logging那段下面磁盘部分会讲,先留个眼。
磁盘撑爆——overlay2把根分区吃完了
OOM的问题刚处理完,发现另一台机器上docker pull拉镜像失败,docker exec也进不去容器,报错:
Error response from daemon: failed to create task for container:
write /var/lib/docker/overlay2/.../diff: no space left on device
df -h一看:
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 100G 99G 120M 100% /
根分区100%满了。
磁盘被谁吃了?
先别急着删东西。定位一下到底是什么在占空间:
# Docker自己的统计
docker system df
输出长这样:
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 45 6 12.8GB 9.2GB (71%)
Containers 23 6 2.1GB 1.8GB (85%)
Local Volumes 15 4 8.5GB 6.2GB (72%)
Build Cache 0 0 15.3GB 15.3GB
一眼就能看出谁是大户。上面这个例子里Build Cache占了15.3G,镜像占了12.8G其中9.2G可回收——这两个就是最常见的罪魁祸首。
再看看具体哪些目录占得多:
du -sh /var/lib/docker/*
16G /var/lib/docker/overlay2
8.5G /var/lib/docker/volumes
2.1G /var/lib/docker/containers
15.3G /var/lib/docker/buildkit
三大磁盘杀手
1. 容器日志——最容易被忽略的定时炸弹
Docker默认用json-file日志驱动,不限大小,不限数量。一个疯狂输出日志的容器,几天就能把几十G的磁盘写满。
找出哪个容器日志最大:
find /var/lib/docker/containers/ -name "*.log" -exec ls -lh {} \; | sort -k5 -h | tail -10
或者更直接:
for c in $(docker ps -aq); do
log_path=$(docker inspect --format='{{.LogPath}}' $c)
size=$(du -sh "$log_path" 2>/dev/null | cut -f1)
name=$(docker inspect --format='{{.Name}}' $c)
echo "$size $name $log_path"
done | sort -h
找到大日志后,不要直接rm日志文件。rm只是删除了文件的目录项,Docker进程还持有文件句柄,空间不会释放。正确做法是截断:
# 清空日志但不删文件
truncate -s 0 /var/lib/docker/containers/<container_id>/<container_id>-json.log
然后,赶紧加上日志轮转配置。在/etc/docker/daemon.json里加:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}
systemctl restart docker
这个配置对已有容器不生效,只对新创建的容器生效。所以最好在compose文件里也写一份(前面那个模板里写了)。
2. 废弃镜像和停止的容器
长期运行的机器上,CI/CD反复构建会留下大量<none>镜像(dangling images),停掉的容器也不会自动删除。
# 看有多少dangling镜像
docker images -f dangling=true
# 看停止的容器
docker ps -a -f status=exited
清理的命令:
# 删除所有停止的容器、悬空镜像、未使用的网络
docker system prune -f
# 更激进:连没被任何容器引用的镜像一起删
docker system prune -a -f
# 还要清理未被使用的volume(注意!volume里可能有数据)
docker system prune -a --volumes -f
⚠️ --volumes那个一定要慎用,先确认volume里没有要保留的数据。我见过有人一把prune --volumes把数据库的持久化volume删了,然后数据没了。
3. Build Cache——CI机器的隐形杀手
如果机器上跑过docker build,build cache可以悄悄涨到几十个G:
docker builder prune -f
# 只保留最近24小时的cache
docker builder prune --filter "until=24h" -f
长期方案:防止磁盘再次撑爆
除了日志轮转,在/etc/docker/daemon.json里设一个存储上限也很有用:
{
"storage-driver": "overlay2",
"storage-opts": [
"overlay2.size=40G"
],
"data-root": "/data/docker",
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}
data-root把Docker数据目录迁到一个单独的分区或大磁盘上,不要让它和根分区抢空间。
另外搞个cron定期清理:
# /etc/cron.daily/docker-cleanup
#!/bin/bash
docker system prune -f --filter "until=168h"
docker builder prune -f --filter "until=48h"
chmod +x /etc/cron.daily/docker-cleanup
容器起不来——Restarting循环和各种退出码
磁盘清理完,以为可以交差了。结果有两个容器还是起不来,一个疯狂Restarting,一个Exited (1)安静躺尸。
排查三板斧
第一步:看退出码
docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
常见退出码速查:
| 退出码 | 含义 | 常见原因 |
|---|---|---|
| 0 | 正常退出 | 应用主进程结束了(可能CMD写错了) |
| 1 | 应用错误 | 代码异常、配置错误、依赖缺失 |
| 126 | 命令不可执行 | 权限问题或二进制格式不对 |
| 127 | 命令找不到 | CMD/ENTRYPOINT里的命令不存在 |
| 137 | 被SIGKILL | OOM Killed或者被docker kill |
| 139 | 段错误 | 二进制兼容性问题(常见于多架构镜像) |
| 143 | 被SIGTERM | 正常停止(docker stop) |
第二步:看日志
# 看最后100行
docker logs --tail 100 <container_name>
# 如果容器在疯狂重启,看上一次的日志
docker logs --tail 100 --since 5m <container_name>
日志看不到东西的话,可能是应用直接崩了来不及输出,或者日志写到了文件里。这时候:
# 进入容器(如果能进去的话)
docker exec -it <container_name> /bin/sh
# 进不去就用docker cp把日志文件拷出来
docker cp <container_name>:/app/logs/error.log ./error.log
第三步:看inspect
docker inspect <container_name> | python3 -m json.tool | less
重点看:
State部分:退出码、OOM状态、错误信息HostConfig.Binds:挂载点是否正确Config.Cmd和Config.Entrypoint:启动命令对不对NetworkSettings:网络配置、端口映射
几个高频翻车场景
场景1:端口被占
Error response from daemon: Ports are not available:
exposing port TCP 0.0.0.0:8080 -> 0.0.0.0:0: listen tcp 0.0.0.0:8080:
bind: address already in use
# 找到谁占了这个端口
ss -tlnp | grep 8080
# 或
lsof -i :8080
场景2:volume权限问题
容器里进程用的是非root用户(比如uid=1000),但挂载的宿主机目录是root所有。
# 日志里通常会报Permission denied
docker logs <container_name> 2>&1 | grep -i "permission"
# 解决:调整宿主机目录权限
chown -R 1000:1000 /data/app-data/
# 或者在Dockerfile里用同样的uid
场景3:依赖服务没起来
MySQL还没ready,应用容器就启动了,连不上数据库直接退出。
在compose里加depends_on配合健康检查:
services:
db:
image: mysql:8.0
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
app:
image: your-app:latest
depends_on:
db:
condition: service_healthy
场景4:Entrypoint脚本有问题
这个比较隐蔽。有时候Entrypoint脚本在本地跑得好好的,上了生产就出问题。通常是换行符导致的——Windows上编辑的脚本带了\r\n。
# 检查
docker run --rm --entrypoint cat <image_name> /entrypoint.sh | cat -v
# 如果看到行尾有^M,就是\r\n的问题
# 修复
sed -i 's/\r$//' entrypoint.sh
还有一种:entrypoint.sh没有执行权限。
# Dockerfile里确保
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
场景5:容器重启策略导致的死循环
如果设了restart: always,容器启动失败后会一直重启。每次重启可能还会写日志、创建新的layer diff,加速磁盘消耗。
# 临时停止重启循环
docker update --restart=no <container_name>
docker stop <container_name>
# 然后慢慢排查
docker logs <container_name>
三个故障的连锁反应
回到开头那个场景。事后复盘,故障链条是这样的:
- 一个Java容器没设内存限制,内存泄漏跑了几天,吃掉了宿主机大量内存
- 内核OOM Killer开始杀进程,先杀了这个Java容器,顺便还杀了另一个内存占用较高的容器
- 被杀的容器设了
restart: always,疯狂重启,每次启动失败都往日志里写堆栈 - 日志没有轮转配置,几十分钟就写了好几个G
- 根分区100%满了
- 新容器起不来——
no space left on device - Docker Daemon本身也开始报错,
docker ps都卡住了
一个内存泄漏,引发了一连串的雪崩。
时间线总结
| 时间 | 事件 | 根因 |
|---|---|---|
| 02:00 | 告警:Java容器退出(137) | 内存泄漏 → OOM Killed |
| 02:01 | 告警:另一容器退出(137) | 宿主机内存紧张,被连坐 |
| 02:01~ | 容器反复重启,日志暴涨 | restart: always + 无日志轮转 |
| 02:20 | 告警:磁盘使用率100% | 日志写满根分区 |
| 02:21 | 新容器启动失败 | no space left on device |
| 02:22 | docker命令卡住 | Daemon受磁盘满影响 |
修完之后我做了什么
- 所有容器加上内存限制,按实际需求的1.5倍设上限
- 全局日志轮转,
daemon.json里配好max-size和max-file - Docker数据目录迁移到单独的数据盘,不和根分区抢
- 加磁盘监控告警,80%就告警,90%就开始自动清理dangling镜像
- 重启策略改为
on-failure:5,最多重启5次就停下来,别死循环 - 每天凌晨cron清理停止的容器和废弃镜像
一个排查清单
最后整理一个Docker生产故障快速排查的命令清单,收藏备用:
# === 全局状态 ===
docker ps -a # 所有容器状态
docker system df # Docker磁盘占用概览
docker stats --no-stream # 所有容器的CPU/内存/IO
# === OOM排查 ===
docker inspect <cid> --format='{{.State.OOMKilled}}' # 是否OOM
dmesg -T | grep -i "oom\|killed process" # 内核OOM日志
docker inspect <cid> --format='{{json .State}}' # 容器状态详情
# === 磁盘排查 ===
df -h # 文件系统使用率
du -sh /var/lib/docker/* # Docker各目录大小
find /var/lib/docker/containers/ -name "*.log" -exec ls -lh {} \; | sort -k5 -h # 容器日志大小
# === 容器启动失败 ===
docker logs --tail 100 <cid> # 容器日志
docker inspect <cid> --format='{{.State.ExitCode}}' # 退出码
docker inspect <cid> --format='{{json .HostConfig.Binds}}' # 挂载点
# === 清理 ===
truncate -s 0 /var/lib/docker/containers/<cid>/<cid>-json.log # 清空日志
docker system prune -f # 清理停止容器+悬空镜像
docker builder prune -f # 清理构建缓存
生产环境的Docker不像本地开发,跑起来能用就行。内存限制、日志轮转、磁盘监控,这三样东西一个不能少。缺了哪一个,早晚都会在某个凌晨以最不体面的方式提醒你。
我是「耕云躬行录」,专注分享云计算和运维的生产实践经验。如果这篇文章对你有帮助,帮忙点个赞、转发一下。
关注公众号:耕云躬行录
个人博客:躬行笔记