ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker磁盘告警?一键清理脚本设计与防复发实践

Docker磁盘告警?一键清理脚本设计与防复发实践

磁盘告警这件事,做过运维和开发的人大概都经历过:某天早上打开终端,敲个df -h,发现根分区已经飘红,使用率 98%,而业务代码一行没改、日志也没暴增。顺着du -sh一层层往下翻,最后大概率会停在/var/lib/docker这个目录上——镜像、容器层、构建缓存、卷、日志,全堆在里面。这篇就围绕“一键清理 Docker 磁盘文件”这个主题,把我这些年处理这类问题的完整思路、脚本设计、踩过的坑和验证方法讲透,从“为什么 Docker 会吃盘”一直讲到“怎么安全地一键清理并防止复发”。

适合谁看:日常要和 Docker 打交道的后端开发、测试环境维护者、个人服务器玩家,以及刚开始接触容器化部署、被磁盘告警搞得手忙脚乱的同学。全文不依赖任何特定云平台,命令和脚本都是通用的,照着改改就能用。

1. Docker 磁盘被吃掉的真实构成

很多人一看到磁盘满,第一反应是“删镜像”。但实测下来,镜像往往不是最大头。要设计一个靠谱的一键清理方案,第一步必须搞清楚 Docker 占用的磁盘到底分成了哪几块,每块的增长逻辑是什么,删掉之后会不会影响正在跑的业务。这一节把账算清楚。

1.1 五类占用:镜像、容器层、构建缓存、卷、日志

Docker 在磁盘上的数据基本可以归为五类,它们的增长方式和清理风险完全不同:

占用类型典型路径增长原因清理风险
镜像(Image)/var/lib/docker/overlay2频繁拉取、构建新版本低,但删了要重新拉
容器可写层/var/lib/docker/overlay2容器内写文件、临时数据中,删容器即丢数据
构建缓存/var/lib/docker/buildkit反复docker build低,重建会慢一点
数据卷(Volume)/var/lib/docker/volumes数据库、上传文件等持久化数据高,删了数据就没了
容器日志/var/lib/docker/containers/*/*-json.log应用疯狂打日志低,但要注意别删正在写的

这里有个反直觉的点:构建缓存经常是隐形大户。现在默认用 BuildKit 构建,每次docker build都会留下大量中间层,日积月累几十 GB 很常见,而docker images里根本看不到它们。我见过一台构建机,镜像列表加起来不到 5GB,但/var/lib/docker占了 60GB,一查全是 buildkit 缓存。

另一个容易被忽略的是容器日志。Docker 默认的 json-file 日志驱动不做轮转,一个疯狂打日志的应用,几天就能写出几十 GB 的-json.log。这类文件在docker system df里甚至不完全体现,得单独去containers目录下看。

1.2 为什么docker system df的数字和du对不上

这是新手最容易困惑的地方。docker system df显示总占用 20GB,但du -sh /var/lib/docker却是 45GB,差了 25GB 去哪了?

原因主要有两个。第一,docker system df统计的是 Docker 自己管理的对象,而du统计的是文件系统实际占用,两者口径不同。第二,已删除但被进程占用的文件——某个容器删了,但它打开的文件句柄还被残留进程持有,文件在du里还算着,实际空间没释放。这种情况用lsof +L1能查出来。

提示:判断磁盘到底被谁占着,别只信docker system df。先du -sh /var/lib/docker/*看大头在哪,再针对性处理,比盲目prune高效得多。

1.3 一键清理到底该“清”什么、不该“清”什么

设计清理脚本前,先划清边界。我的原则是:默认只清可再生的东西,持久化数据一律不碰。

可安全清理的:停止的容器、悬空镜像(dangling image,即<none>标签的)、未被任何容器引用的构建缓存、已退出的容器日志。

需要谨慎的:未被使用的镜像(可能下次部署要用)、未被引用的卷(可能是备份数据)。

绝对不能自动清的:正在运行的容器、正在被挂载的卷、业务数据库的数据目录。

这个边界决定了脚本的默认行为。一键清理不等于“一键删光”,而是“一键清掉那些删了也不心疼的东西”。后面第 3 节的脚本就是按这个原则设计的。

2. 清理命令的取舍:哪些能进脚本,哪些必须手动

Docker 自带的清理命令就那么几个,但每个的行为差异很大,能不能放进“一键”脚本里,得逐个掂量。这一节把常用命令的适用场景和坑讲清楚,避免你写出来的脚本把生产环境搞挂。

2.1docker system prune的默认行为与-a的杀伤力

docker system prune是最常被提起的命令,但它默认只清理:停止的容器、悬空镜像、未被使用的网络、悬空构建缓存。注意,默认不删未被容器使用的镜像。

加上-a之后,行为就变了:所有未被任何容器使用的镜像都会被删。这意味着你本地存着准备回滚的旧版本镜像,一执行就没了。我在测试环境用-a没问题,但生产构建机上从来不敢直接加。

# 保守版:只清停止的容器、悬空镜像、悬空缓存 docker system prune -f # 激进版:连未被使用的镜像一起清(慎用) docker system prune -a -f

还有一个--volumes参数,加上会连未被引用的卷一起删。这个参数我建议永远不要放进自动脚本,卷里可能是数据库数据、上传文件,删了就是事故。

2.2docker builder prune单独处理构建缓存

既然构建缓存是大头,就该单独给它一条命令,而不是混在system prune里。docker builder prune专门清 BuildKit 缓存,默认清悬空的,加-a清全部。

# 清理悬空构建缓存 docker builder prune -f # 清理全部构建缓存(下次构建会全量重建,慢) docker builder prune -a -f

我的做法是脚本里默认用不带-a的版本,把“全清”作为一个可选参数暴露出来。因为全清之后下一次 CI 构建会明显变慢,这个代价要让使用者自己决定。

2.3 容器日志的清理:truncate而不是rm

容器日志不能直接rm。原因很简单:日志文件正被容器进程以追加模式打开着,你rm掉,进程还持有句柄,空间不释放,而且新日志会写到一个“已删除”的 inode 上,越写越乱。

正确做法是用truncate把文件截断为 0:

# 找到所有容器日志并截断 find /var/lib/docker/containers -name "*-json.log" -exec truncate -s 0 {} \;

truncate -s 0把文件大小设为 0,但保留 inode,进程继续写也没问题。这是清理日志的标准姿势,比rm安全得多。

注意:截断日志前最好确认对应容器不是正在排查问题的对象,日志清了就找不回来了。生产环境建议先配置日志轮转,而不是靠事后截断。

2.4 命令组合的推荐顺序

清理顺序也有讲究。我的经验是先清构建缓存和日志(这俩最安全、收益最大),再清停止的容器,最后才考虑镜像。因为镜像清理可能触发重新拉取,放在最后能减少不必要的网络开销。

推荐顺序:

  1. 截断容器日志(释放最快,风险最低)
  2. docker builder prune -f(清构建缓存)
  3. docker container prune -f(清停止的容器)
  4. docker image prune -f(清悬空镜像)
  5. 视情况docker image prune -a -f(清未使用镜像,需人工确认)

这个顺序写进脚本,配合每步前后的磁盘占用对比,就能清楚看到每一步释放了多少空间。

3. 写一个真正能用的清理脚本

网上很多“一键清理”脚本就是一行docker system prune -a -f --volumes,看着爽,用起来险。我要的是一个默认安全、可观测、可回滚的脚本。这一节把脚本拆开讲,每一段为什么这么写都说清楚。

3.1 脚本的整体结构与安全开关

先看整体框架。核心设计有三个:默认不删卷、默认不删未使用镜像、执行前打印将要清理的内容并支持--dry-run。

#!/usr/bin/env bash set -euo pipefail # 默认配置 DRY_RUN=false CLEAN_IMAGES=false CLEAN_VOLUMES=false LOG_TRUNCATE=true # 参数解析 while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true; shift ;; --images) CLEAN_IMAGES=true; shift ;; --volumes) CLEAN_VOLUMES=true; shift ;; --no-log) LOG_TRUNCATE=false; shift ;; *) echo "未知参数: $1"; exit 1 ;; esac done

set -euo pipefail是脚本健壮性的基础:遇错退出、未定义变量报错、管道错误不吞掉。这三个选项能挡掉一大半“脚本跑一半出错还继续执行”的坑。

--dry-run是我强烈建议每个清理脚本都有的开关。清理是不可逆操作,先让用户看一眼“将要删什么”,比事后追悔强太多。

3.2 每一步的磁盘占用对比与输出

光清理不够,还得让用户看到效果。脚本里封装一个函数,在每步前后记录/var/lib/docker的大小:

docker_size() { du -sh /var/lib/docker 2>/dev/null | awk '{print $1}' } step() { local desc="$1"; shift local before after before=$(docker_size) if [[ "$DRY_RUN" == "true" ]]; then echo "[DRY-RUN] 将执行: $desc" else echo ">>> 执行: $desc" "$@" fi after=$(docker_size) echo " 清理前: $before 清理后: $after" }

这样每执行一步,终端上就能看到“清理前 45G,清理后 32G”,直观且有说服力。du在大目录上会慢一点,但相比清理本身耗时可忽略。

3.3 日志截断、缓存清理、容器清理的具体实现

把前面讲的命令按顺序串起来:

# 1. 截断容器日志 if [[ "$LOG_TRUNCATE" == "true" ]]; then step "截断容器日志" bash -c \ 'find /var/lib/docker/containers -name "*-json.log" -exec truncate -s 0 {} \;' fi # 2. 清理构建缓存 step "清理悬空构建缓存" docker builder prune -f # 3. 清理停止的容器 step "清理停止的容器" docker container prune -f # 4. 清理悬空镜像 step "清理悬空镜像" docker image prune -f # 5. 可选:清理未使用镜像 if [[ "$CLEAN_IMAGES" == "true" ]]; then step "清理未使用镜像" docker image prune -a -f fi # 6. 可选:清理未引用卷(默认关闭) if [[ "$CLEAN_VOLUMES" == "true" ]]; then step "清理未引用卷" docker volume prune -f fi

注意第 1 步用bash -c包了一层,因为find ... -exec里有分号,直接作为参数传给step函数会被 shell 提前解析。这种细节不处理,脚本跑起来就会报语法错误。

3.4 权限、路径与跨平台兼容的注意事项

脚本默认假设/var/lib/docker是 Docker 数据目录,但这不是绝对的。有人改过>DOCKER_ROOT=$(docker info -f '{{.DockerRootDir}}' 2>/dev/null || echo "/var/lib/docker")

用docker info拿真实路径,拿不到再回退到默认值。这样脚本在改过配置的机器上也能正常工作。

权限方面,清理命令基本都需要 root 或 docker 组权限。脚本开头可以加个检查:

if ! docker info >/dev/null 2>&1; then echo "无法访问 Docker,请用 sudo 或加入 docker 组" exit 1 fi

至于跨平台,macOS 上 Docker Desktop 的数据在虚拟机里,/var/lib/docker路径不存在,日志截断那步会失败。所以脚本里对find那步加了容错,路径不存在就跳过,不中断整体流程。

4. 清理之后:那些脚本救不了你的场景

脚本能解决 80% 的磁盘问题,但剩下 20% 往往更棘手。这一节讲几个我真实踩过的坑,都是“跑完清理脚本磁盘还是满”的情况,排查思路比命令本身更值钱。

4.1 已删除文件仍被进程占用:lsof定位法

最典型的现象:清理脚本跑完,docker system df显示占用已经很小,但df -h还是 95%。这时候八成是已删除文件被进程持有。

排查方法:

# 找出被删除但仍被占用的文件 lsof +L1 2>/dev/null | grep -i docker | head -20

输出里会显示进程 PID、文件大小和(deleted)标记。找到之后,重启对应进程(通常是某个容器或 dockerd 本身)就能释放空间。我遇到过一次,某个容器被docker rm了,但它 fork 的子进程还在跑,持有几十 GB 的日志文件句柄,du看不到,df却实实在在占着。

提示:lsof +L1里的+L1表示“链接数小于 1”,也就是已删除但还开着的文件。这个技巧不只对 Docker 有用,任何磁盘“账对不上”的场景都适用。

4.2 overlay2 目录膨胀但镜像不多:容器可写层在作怪

有时候docker images没几个,docker system df的镜像占用也不大,但/var/lib/docker/overlay2就是很大。这通常是容器的可写层在膨胀——容器里跑了写文件的应用,比如临时缓存、未清理的临时文件。

定位方法:

# 查看各容器可写层大小 docker ps -s --format "table {{.Names}}\t{{.Size}}"

Size列就是可写层大小。如果某个容器显示2GB (virtual 5GB),说明它写了 2GB 数据到可写层。这类数据在容器删除前不会释放,清理脚本也清不掉,只能进容器里删文件,或者重建容器。

4.3 卷里的数据:最不该被“一键”的部分

卷是持久化数据的载体,数据库、上传文件、配置都在里面。docker volume prune删的是“未被任何容器引用”的卷,听起来安全,但有个陷阱:容器停了但没删,卷仍被引用,prune 不会删;容器删了,卷就变成未引用,prune 会删。

也就是说,你为了“清理”删掉一个停止的容器,它的卷就暴露在 prune 的删除范围内了。如果那个卷里是数据库数据,而你又恰好跑了带--volumes的清理,数据就没了。

我的做法是:卷永远手动处理。先docker volume ls看列表,再docker volume inspect <name>确认用途,确认无用才docker volume rm。这一步绝不放进自动脚本。

4.4 清理后磁盘没降:文件系统层面的延迟释放

还有一种情况:清理命令执行成功,docker system df数字也降了,但df -h迟迟不降。这可能是文件系统层面的延迟,尤其是某些文件系统对已删除空间的回收不是实时的。通常等几分钟或触发一次 sync 就会恢复:

sync

如果等了很久还不降,再回到 4.1 的lsof排查。别急着重启机器,先确认是不是真的没释放。

5. 从“一键清理”到“不再需要清理”

清理是治标,配置才是治本。一个健康的 Docker 环境,不该频繁依赖清理脚本。这一节讲几个从源头控制磁盘增长的配置,做完之后清理脚本从“每周必跑”变成“偶尔跑一次”。

5.1 给容器日志加上轮转上限

这是收益最大的一项配置。Docker 默认的 json-file 驱动不轮转,必须显式配置。在/etc/docker/daemon.json里加:

{ "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "3" } }

含义是单个日志文件最大 50MB,最多保留 3 个,也就是单容器日志上限 150MB。改完systemctl restart docker生效,但只对新建的容器生效,已有容器需要重建。

这个配置我建议所有环境都加上。50m/3 对大多数应用够用,日志量大的可以调到 100m/5。关键是有了上限,就不会出现单个日志文件几十 GB 的情况。

5.2 构建缓存的容量限制

BuildKit 支持给缓存设上限,避免无限增长。在daemon.json里配置:

{ "builder": { "gc": { "enabled": true, "defaultKeepStorage": "10GB" } } }

这样构建缓存超过 10GB 会自动回收,不用等手动清理。defaultKeepStorage的值按机器磁盘大小调整,构建机可以给大一点,个人机器 5-10GB 足够。

5.3 定期任务的编排思路

即使有了上面的配置,定期清理仍有必要,作为兜底。用 cron 或 systemd timer 都行,关键是频率别太高、日志要留。

# 每周日凌晨 3 点执行清理,输出到日志 0 3 * * 0 /usr/local/bin/docker-clean.sh >> /var/log/docker-clean.log 2>&1

频率上我倾向每周一次,太频繁没意义(磁盘不会一周涨满),太稀疏又可能来不及。日志一定要留,出问题时能回溯“上次清理删了什么、释放了多少”。

5.4 监控告警比事后清理更重要

最后一点,也是很多人忽略的:与其等磁盘满了再清理,不如提前告警。一个简单的监控脚本,磁盘使用率超过 80% 就发通知,比事后救火从容得多。

#!/usr/bin/env bash THRESHOLD=80 USAGE=$(df / | awk 'NR==2 {gsub("%",""); print $5}') if [[ "$USAGE" -ge "$THRESHOLD" ]]; then echo "磁盘使用率 ${USAGE}%,超过阈值 ${THRESHOLD}%" # 这里接通知渠道 fi

这个脚本配合 cron 每小时跑一次,成本极低,但能让你在磁盘真正告急前介入。清理脚本是消防员,监控才是防火墙。

我个人在实际操作中的体会是,Docker 磁盘问题从来不是“删一下就完事”的技术活,而是“搞清楚什么能删、什么不能删、怎么让它别再涨”的系统活。一开始我也图省事,直接docker system prune -a -f --volumes一把梭,结果有次把测试环境的数据库卷清了,重建数据花了大半天。从那以后,我的清理脚本就再也没碰过卷,也加上了--dry-run。真正省心的状态,是日志轮转配好、缓存上限设好、监控告警挂好,然后清理脚本安安静静躺在 cron 里,一周跑一次,日志里记着它释放了多少空间——你几乎忘了它的存在,但它一直在兜底。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进