Docker 与容器排查 列表全部文章
Docker 与容器排查

根分区 100%:一个没配 max-size 的 json.log,把 200G 磁盘“吃”成了 0 字节

凌晨磁盘告警,df 显示根分区 100%,罪魁是 /var/lib/docker。用 docker system df -v、du 逐层下钻,定位到 json-file 日志无限增长,讲清 max-size/max-file 配置、docker system prune 各选项的风险(--volumes 会删数据)与构建缓存清理。

更多

凌晨 2 点 17 分,手机疯狂震动。不是女朋友(我没有),是监控:“app-node-03 磁盘使用率 100%”。我迷迷糊糊摸到电脑,ssh 上去敲了一个 df -h,敲完一个字都没憋出来,只在心里说了句:“又是你,docker。”

这篇是我的“磁盘被 Docker 吃光”备忘录:怎么不靠猜,一层一层下钻找到是谁吃的;怎么不删错东西地清理(docker system prune --volumes 那个参数,我见过有人真的一键抹掉了数据库数据);最后怎么让它以后吃不了。

一、现象:根分区满了,服务开始“连锁反应”

$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1       197G  197G     0 100% /
tmpfs           7.8G     0  7.8G   0% /dev/shm
/dev/vdb1       500G  120G  380G  24% /data

根分区 / 197G 全部用完。磁盘满了会引发一串很有意思的连锁反应:MySQL 写不进去、日志组件报 No space left on device、Docker 自己拉不了镜像、甚至 ssh 登录都偶尔卡顿。别慌,第一件事和往常一样:别乱删,先弄清是谁占的。

有一个小坑要先排除:df 说满了,但 du 又加不出这么多——大概率是已删除但仍被进程打开的文件。用 lsof +L1 看一眼:

$ lsof +L1 | head -5
COMMAND   PID USER   FD   TYPE DEVICE   SIZE/OFF NLINK NODE NAME
java     2231 app    12w   REG  253,1 3221225472     0 1024 /app/logs/app.log (deleted)

这次输出为空——没有“幽灵文件”,说明空间真的是被实实在在的文件占了。继续往下钻。

二、排查路线图

我固定用“从大到小、逐层下钻”的方式,同时区分“Docker 认账的空间”和“Docker 不知道的空间”:

① df -h哪个挂载点满了 ② du -xh --max-depth=1 /哪个一级目录大 ③ /var/lib/docker再看 containers / overlay2 ④ docker system df -v镜像/容器/卷/缓存 containers/*/*-json.log日志文件巨大 → 限流+轮转(本次元凶) overlay2 容器可写层大容器里在写临时文件→ 挂卷 / 清理 / 换路径 镜像 / 悬空镜像 / 构建缓存CI 机器常见→ image / builder prune 原则 先“认清谁占的”,再“选最小的清理动作”;volume 里可能是数据库,最后才碰,且永远先确认有备份

三、动手:一层层往下钻

第 1 步:找出最大的一级目录

$ du -xh --max-depth=1 / 2>/dev/null | sort -rh | head -6
197G    /
176G    /var
9.1G    /usr
6.4G    /home
3.2G    /opt
$ du -xh --max-depth=2 /var 2>/dev/null | sort -rh | head -4
176G    /var
172G    /var/lib
171G    /var/lib/docker
1.8G    /var/log

-x 表示不跨文件系统(不会把 /data 挂载点算进来),sort -rh 是按“人类可读大小”倒序。171G 的 /var/lib/docker——嫌疑人确定。

第 2 步:看 docker 目录里是谁

$ du -sh /var/lib/docker/* 2>/dev/null | sort -rh
132G    /var/lib/docker/containers
34G     /var/lib/docker/overlay2
3.1G    /var/lib/docker/image
1.2G    /var/lib/docker/volumes
...

注意:不是常见印象里的 overlay2(镜像和容器可写层),而是 containers 目录占了 132G。这个目录放的是每个容器的元数据和日志,本来应该只有几 MB。

第 3 步:找出最大的日志文件

$ find /var/lib/docker/containers/ -name '*-json.log' -exec ls -lhS {} + | head -4
-rw-r----- 1 root root 118G Sep 26 02:16 /var/lib/docker/containers/8c1f...a90e/8c1f...a90e-json.log
-rw-r----- 1 root root 9.4G Sep 26 02:16 /var/lib/docker/containers/5d7b...3e2f/5d7b...3e2f-json.log
-rw-r----- 1 root root 1.1G Sep 25 23:40 /var/lib/docker/containers/a02e...77c1/a02e...77c1-json.log

一个文件 118G。它属于哪个容器?用前几位 ID 匹配:

$ docker ps -a --no-trunc --format '{{.ID}}  {{.Names}}  {{.Image}}' | grep 8c1f
8c1f...a90e  order-consumer  demo/order-consumer:2.3.0
$ docker inspect --format '{{json .HostConfig.LogConfig}}' order-consumer
{"Type":"json-file","Config":{}}

结论清楚了:LogConfig.Config 是空的 {}——用的是默认的 json-file 驱动,没有任何 max-size / max-file。再看内容,随手看几行尾部:

$ tail -n 3 /var/lib/docker/containers/8c1f...a90e/8c1f...a90e-json.log
{"log":"2026-09-26 02:16:41.203 WARN  [consumer-3] c.d.o.MqListener - retry consume msgId=... error=timeout\n","stream":"stdout","time":"2026-09-26T02:16:41.203881Z"}
{"log":"2026-09-26 02:16:41.204 WARN  [consumer-3] c.d.o.MqListener - retry consume msgId=... error=timeout\n","stream":"stdout","time":"2026-09-26T02:16:41.204103Z"}
{"log":"2026-09-26 02:16:41.204 WARN  [consumer-5] c.d.o.MqListener - retry consume msgId=... error=timeout\n","stream":"stdout","time":"2026-09-26T02:16:41.204377Z"}

下游超时,消费者在疯狂重试并每次都打 WARN,一秒钟几千行。业务 bug 是引信,没设日志上限是炸药。每行日志还被 json-file 加了一层 JSON 封装(log、stream、time),实际占用比原始文本更大。

第 4 步:让 Docker 自己汇报一遍

$ docker system df
TYPE            TOTAL   ACTIVE   SIZE      RECLAIMABLE
Images          38      12       11.4GB    6.2GB (54%)
Containers      15      12       33.8GB    33.7GB (99%)
Local Volumes   9       6        1.19GB    412MB (34%)
Build Cache     146     0        7.9GB     7.9GB

docker system df 分四行:镜像、容器、本地卷、构建缓存。RECLAIMABLE 是可以回收的量。要看每个对象各占多少,加 -v:

$ docker system df -v
Images space usage:
REPOSITORY              TAG     IMAGE ID       CREATED        SIZE     SHARED SIZE   UNIQUE SIZE   CONTAINERS
demo/order-consumer     2.3.0   4b1a...        3 weeks ago    412MB    186MB         226MB         1
<none>                  <none>  9e0c...        2 months ago   398MB    186MB         212MB         0
...
Containers space usage:
CONTAINER ID   IMAGE                       COMMAND   LOCAL VOLUMES   SIZE      CREATED       STATUS         NAMES
8c1f...        demo/order-consumer:2.3.0   ...       0               28.3MB    3 weeks ago   Up 3 weeks     order-consumer
...
Local Volumes space usage:
VOLUME NAME        LINKS   SIZE
mysql-data         1       842MB
...

这里有两个易错点,值得记:

  • Containers 部分的 SIZE 是容器可写层的大小(写入层),不包含 json-log 日志文件——所以 order-consumer 显示才 28.3MB,但它的日志有 118G。docker system df 对“日志吃盘”这件事基本是盲的,必须回到 du;
  • 镜像表里的 <none> 就是悬空镜像(dangling image):旧版本被新 tag 顶掉以后留下的孤儿层,CONTAINERS 列为 0。

第 5 步(可选):容器写入层膨胀怎么查

如果是 overlay2 大,那是容器可写层(upperdir)里写了太多东西——比如应用把导出文件、临时缓存写在容器文件系统里,而不是挂载的卷。用 docker ps -s 快速对比:

$ docker ps -s --format 'table {{.Names}}\t{{.Size}}'
NAMES             SIZE
report-export     14.2GB (virtual 14.9GB)
order-consumer    28.3MB (virtual 440MB)
$ docker diff report-export | head
C /tmp
A /tmp/export_20260925.xlsx
A /tmp/export_20260926.xlsx

SIZE 是可写层大小,virtual 是可写层加上只读镜像层。docker diff 用 A(新增)、C(变更)、D(删除)标出文件系统相对镜像的改动。根据官方文档,overlay2 的层都在 /var/lib/docker/overlay2/<hash>/diff 里,写入时发生 copy-up:改一个大文件的一个字节,也会把整个文件复制到可写层。所以往容器文件系统里写大文件又反复修改,比想象中更浪费。

四、根因:json-file 默认“不轮转”

Docker 默认的日志驱动是 json-file,它把容器 stdout/stderr 每行包装成一个 JSON 对象,写到 /var/lib/docker/containers/<容器ID>/<容器ID>-json.log。按官方文档:默认情况下 json-file 驱动不做日志轮转;max-size 默认 -1(无限),max-file 默认 1,且 max-file 只有在设了 max-size 时才有效。

换句话说,只要这个容器不死、应用持续往 stdout 打日志,这个文件就会永远增长,直到磁盘写满。而磁盘满了之后,Docker 自己也写不了日志,应用往 stdout 写可能被阻塞——业务便跟着“卡”了。

这次事故有三个叠加因素:

  1. 应用在异常路径下疯狂重试并打 WARN(业务 bug);
  2. 没有日志限流(应用日志框架没做“同一错误限频”);
  3. Docker 层没有 max-size(这是让小 bug 变成大事故的关键)。

顺便解释一个高频疑问:“容器里的应用日志已经用 logback 写到文件、也做了滚动,为什么还有 json.log?”——因为如果同时把日志打到控制台(stdout),Docker 也会再存一份。Spring Boot 默认的 console appender 就是这么来的。

五、止血与修复

止血:先把盘腾出来(而且别删错)

不要对那个巨大的 json.log 直接 rm:Docker 守护进程仍然持有这个文件的句柄,删除后空间并不会释放(就是前面说的“已删除但被打开”),你还会失去排查线索。正确做法是原地截断,保持 inode 和句柄不变:

# 先备份最后 2 万行做证据,再截断
$ tail -n 20000 /var/lib/docker/containers/8c1f...a90e/8c1f...a90e-json.log > /data/tmp/order-consumer-tail.log
$ truncate -s 0 /var/lib/docker/containers/8c1f...a90e/8c1f...a90e-json.log
$ df -h /
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1       197G   79G  110G  42% /

截断后 118G 立即释放,磁盘回到 42%。这是应急手段,不是修复。接着清理明显没用的东西,从最安全的开始:

# 1) 构建缓存(最安全,只是下次构建会慢一点)
$ docker builder prune --filter until=72h -f
Total:  6.4GB

# 2) 悬空镜像(<none>:<none>)
$ docker image prune -f
Total reclaimed space: 2.8GB

# 3) 已停止的容器(确认没人要看它的日志再清)
$ docker container prune -f

docker system prune 的各选项与风险

docker system prune 是“一把梭”,用之前必须知道它的每个开关会删什么:

命令会删什么风险
docker system prune已停止的容器、未被使用的网络、悬空镜像、未使用的构建缓存低。但“已停止的容器”可能是你想留着查日志的那个
docker system prune -a在上面基础上,删除所有未被任何容器使用的镜像(不只悬空)中。下次部署要重新拉镜像;私有仓库离线时可能拉不下来,回滚版本镜像也会被删
docker system prune --volumes额外删除未被容器使用的匿名卷高,可能丢数据。如果数据库容器刚好停了,它的匿名卷就是“未使用”,会被清掉
docker system prune -a --volumes -f以上全部,且不再确认生产机器上不要手滑。-f 会跳过 [y/N] 确认
--filter until=24h只清理 24 小时之前创建的对象推荐:给“一把梭”加上时间约束,减少误伤

官方文档提示:--volumes 只清理匿名卷,命名卷默认不受影响(较新的版本行为如此;老版本行为不同,别赌运气)。我的规矩是:生产机器永远不带 --volumes。要清卷,先 docker volume ls -f dangling=true 看清楚名字,人工确认每一个:

$ docker volume ls -f dangling=true
DRIVER    VOLUME NAME
local     3f9a0c...
local     mysql-data-bak
$ docker volume inspect mysql-data-bak --format '{{.Mountpoint}} {{.CreatedAt}}'
/var/lib/docker/volumes/mysql-data-bak/_data 2026-03-02T10:11:03+08:00

看到 mysql-data-bak 这种名字,你就该庆幸没手滑敲 --volumes。

根治:给日志加上限,全局 + 单容器双保险

方式一:daemon.json 全局配置(所有新建容器生效):

// /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "5"
  }
}
$ sudo systemctl restart docker      # 生效
# 注意:只对“重启后新建”的容器有效,已有容器要 docker rm 后重建(compose 里 up -d --force-recreate)
$ docker inspect -f '{{json .HostConfig.LogConfig}}' order-consumer
{"Type":"json-file","Config":{"max-file":"5","max-size":"100m"}}

两个易错点:log-opts 里的值必须是字符串("5",不能写数字 5),否则守护进程起不来;而且 systemctl restart docker 会重启守护进程,如果没开 live-restore,所有容器会跟着重启,要选低峰期。daemon.json 不能带注释,上面示例的注释只是为了讲解,实际文件里要去掉。

方式二:docker-compose 里单服务配置:

services:
  order-consumer:
    image: demo/order-consumer:2.3.1
    logging:
      driver: json-file
      options:
        max-size: "100m"
        max-file: "5"
        compress: "true"    # 轮转后的旧文件压缩

方式三:docker run 时指定:

$ docker run -d --name demo --log-driver json-file --log-opt max-size=100m --log-opt max-file=5 demo/app:1.0

这样每个容器最多占用 100MB × 5 = 500MB(还有压缩的话更少)。如果已经有集中日志(ELK、Loki),还可以把驱动换成 local(默认自带轮转,且格式更紧凑)或 fluentd / gelf 等转发驱动。注意:换成非 json-file/local 驱动后,docker logs 在旧版本可能读不到日志,先确认你的排查习惯不受影响。

再补上应用层与磁盘层的两道保险:

  • 应用:异常重试要有退避,同一条 WARN 做限频(如每分钟一条并附带次数);生产不要同时把日志既写文件又打 stdout;
  • 磁盘:把 Docker 数据目录迁到独立数据盘(daemon.json 里的 data-root),这样 Docker 出问题最多把数据盘撑满,不会拖死整个根分区;
  • 监控:磁盘使用率 > 80% 预警,并对“增长速度”做告警(1 小时涨 10G 比 90% 使用率更早暴露问题)。

一段用来巡检的小脚本,我丢在了 cron 里:

#!/bin/bash
# 找出大于 500MB 的容器日志,并反查容器名
find /var/lib/docker/containers -name '*-json.log' -size +500M | while read f; do
  id=$(basename "$f" -json.log)
  name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null)
  echo "$(du -h "$f" | cut -f1)  $name  $id"
done

六、复盘

做法为什么
磁盘满 → 先 df,再 du -x --max-depth=1 下钻,别上来就 prune不知道谁占的就清理,等于盲人拆弹;docker system df 看不到日志,必须配合 du
大日志文件用 truncate -s 0,不要 rmDocker 仍持有句柄,rm 后空间不释放,还丢现场
daemon.json 统一配 max-size / max-file默认 json-file 不轮转,一个刷屏容器就能吃光整盘
已有容器要重建才能应用新日志配置日志配置在容器创建时写入,改 daemon.json 不影响存量容器
生产机器不带 --volumes;-a 前想清楚回滚镜像还在不在匿名卷里可能就是数据;-a 会删掉可回滚的旧版本镜像
CI 机器定时 docker builder prune --filter until=72h构建缓存只增不减,几十 GB 很常见
Docker 数据目录放独立数据盘 + 磁盘增长速率告警隔离爆炸半径,早发现比早清理更重要

一句话备忘:磁盘满 → df → du -x 下钻 → /var/lib/docker/containers 里找 *-json.log → truncate 应急 → daemon.json 设 max-size/max-file 根治;prune 千万别带 --volumes。

事后复盘会上,同事问我:“那个容器打了 118G 日志,有人读过吗?”我说:“有——它自己读过。”整个会议室沉默了三秒,然后大家默契地开始往 daemon.json 里加 max-size。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人