运维知识
悠悠
2026年9月8日

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被SIGKILLOOM 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.CmdConfig.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>

三个故障的连锁反应

回到开头那个场景。事后复盘,故障链条是这样的:

  1. 一个Java容器没设内存限制,内存泄漏跑了几天,吃掉了宿主机大量内存
  2. 内核OOM Killer开始杀进程,先杀了这个Java容器,顺便还杀了另一个内存占用较高的容器
  3. 被杀的容器设了restart: always,疯狂重启,每次启动失败都往日志里写堆栈
  4. 日志没有轮转配置,几十分钟就写了好几个G
  5. 根分区100%满了
  6. 新容器起不来——no space left on device
  7. 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:22docker命令卡住Daemon受磁盘满影响

修完之后我做了什么

  1. 所有容器加上内存限制,按实际需求的1.5倍设上限
  2. 全局日志轮转daemon.json里配好max-sizemax-file
  3. Docker数据目录迁移到单独的数据盘,不和根分区抢
  4. 加磁盘监控告警,80%就告警,90%就开始自动清理dangling镜像
  5. 重启策略改为on-failure:5,最多重启5次就停下来,别死循环
  6. 每天凌晨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不像本地开发,跑起来能用就行。内存限制、日志轮转、磁盘监控,这三样东西一个不能少。缺了哪一个,早晚都会在某个凌晨以最不体面的方式提醒你。


我是「耕云躬行录」,专注分享云计算和运维的生产实践经验。如果这篇文章对你有帮助,帮忙点个赞、转发一下。

关注公众号:耕云躬行录

个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码