凌晨 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 不知道的空间”:
三、动手:一层层往下钻
第 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.9GBdocker 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.xlsxSIZE 是可写层大小,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 写可能被阻塞——业务便跟着“卡”了。
这次事故有三个叠加因素:
- 应用在异常路径下疯狂重试并打 WARN(业务 bug);
- 没有日志限流(应用日志框架没做“同一错误限频”);
- 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 -fdocker 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,不要 rm | Docker 仍持有句柄,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。
参考资料
- Docker 官方文档:JSON File logging driver——json-file 默认不轮转,max-size / max-file / compress 选项与 daemon.json 写法(值必须为字符串)。
- Docker 官方文档:docker system prune——prune 清理范围,-a、--volumes、--filter、-f 的含义。
- Docker 官方文档:docker system df——df / df -v 输出列与 SHARED SIZE、UNIQUE SIZE、RECLAIMABLE 的定义。
- Docker 官方文档:docker builder prune——清理构建缓存及 -a、--filter、--keep-storage 选项。
- Docker 官方文档:OverlayFS storage driver——overlay2 的目录结构、可写层与 copy-up 机制。
- Netdata:Docker logs taking too much disk space——定位大日志的命令、truncate 原地清空,以及配置只对新建容器生效的提醒。
一起聊聊