☰
Docker容器与镜像自动化清理脚本实战:搞定磁盘空间膨胀
2026/10/1 19:21:17 网站建设 项目流程

Docker用得越久,越能体会一条朴素真理:容器好起,垃圾难清。跑上一阵子业务,/var/lib/docker就开始膨胀,docker system df一看全是<none>镜像、Exited 状态的死容器、没人用的匿名卷,还有动不动几个G的构建缓存。这时候如果还在手动一条命令一条命令地敲,过一次两次还能忍,时间长了纯属折磨。

所以我花了点时间整理了一套自动化Docker容器与镜像清理脚本,配合定时任务跑起来之后,基本实现了“躺着等空间自动释放”。这篇文章就把整个思路、脚本、调度方式和踩过的坑完整记录下来,适合正在被Docker磁盘空间问题困扰的运维、后端开发和自建服务器玩家参考。我不会只丢一份脚本就完事,还会把每个命令为什么要这么写、哪些参数有坑、哪些场景下会误删,全给你讲清楚。

1. 先搞清楚Docker空间到底被谁吃了

1.1 容器、镜像、数据卷和缓存的关系

很多人一上手就习惯直接敲docker system prune -a,结果要么发现空间没少多少,要么把不该删的镜像全删了。问题根源在于没搞明白Docker的数据组织方式。

镜像不是一个大文件,而是分层存储的。你拉取一个镜像,它是由一层一层只读层叠加出来的;你基于镜像启动容器,Docker会在镜像层之上再加一层可写层。删除容器时,可写层默认会被丢弃,但如果你用了匿名卷,docker rm不指定-v的话,卷还会留在宿主机上。镜像分层机制带来的直接后果是:构建新镜像时产生的大量中间层,因为没有任何tag指向它们,就会变成<none>悬挂镜像,肉眼在docker images里能看到但很难手动识别。

数据目录的占用大头通常有四个:

  • 悬空镜像和未使用镜像:旧版本镜像、构建中间层、被新tag覆盖的旧层全部算进去。
  • 停止状态的容器:容器虽然停了,但可写层如果没有被清理,相关文件依然占着磁盘。
  • 匿名卷和未使用卷:容器被删了,但卷没有被删,日积月累非常吓人。
  • 构建缓存:docker build每一次执行都会生成构建缓存,哪怕构建失败也一样占地方。

我见过一台只跑两三个服务的机器,du -sh /var/lib/docker能到40G,里面的缓存和悬挂镜像贡献了30G。所以清理脚本的核心不是“随便删一删”,而是“识别出哪些资源已经彻底没有价值,然后安全回收”。

1.2 手动清理为什么不可持续

手动执行清理命令最大的问题不是命令复杂,而是容易误删和漏删。docker system prune -a会把所有没有被运行中容器引用的镜像全部删掉,如果你刚拉了一个镜像准备明天测试用,它也会被当作废料回收。更麻烦的是,当你手头有十个项目、几十个容器的时候,单靠肉眼去判断“这个容器要不要留”“这个卷后面还会不会用”根本不现实。

还有一个很容易被忽略的点:手动清理没有记录。今天清理了多少空间、具体删了哪些镜像,过了一个月你自己都记不清。一旦线上出问题,想回溯是不是因为误删导致的,没有任何日志可查。所以说,自动化清理脚本的价值不只在“自动”这两个字,更在于它强制了一层可验证的流程:设定阈值、保留策略、排除名单、执行日志、清理前后对比。没有这套流程,单纯的自动清理就是定时炸弹。

2. 自动清理方案的设计与命令拆解

2.1 从手动命令到自动化脚本

写脚本之前,我先把需要用到的Docker原生命令梳理了一遍。搞清楚每一条命令的能力边界,才知道哪些能放进脚本、哪些必须避开。

docker ps -a配合--filter可以筛选出指定状态的容器。比如docker ps -a --filter status=exited --filter status=created --filter status=dead就能列出停止、创建但未启动、异常死亡三态容器。拿到容器ID列表之后,可以遍历执行docker rm -v。-v参数很重要,它会在删除容器时把关联的匿名卷也删掉,避免卷残留。

镜像清理分成两个层级:

  • docker image prune -f:只清理悬挂镜像(dangling images),也就是没有tag、没有被容器引用的镜像层。这个相对安全。
  • docker image prune -a -f:清理所有未被容器使用的镜像,不管有没有tag。这个杀伤力大,必须配合--filter使用。

数据卷的清理用docker volume prune -f,它会删除所有没有被任何容器使用的卷。这里要注意,如果你有“容器暂时停止但卷还要留”的场景,这个命令会误删。所以脚本里必须加白名单机制,不能无脑执行。

构建缓存的清理用docker builder prune -f,这是Docker 20.10之后专门提供的命令,比直接删/var/lib/docker目录下的缓存文件安全得多,也干净得多。

2.2 为什么我选择 Shell + cron

有人可能会问,清理任务为什么不用Python写、不用Go写,甚至不用Ansible去编?我的理由很直接:这个任务本质上就是一组Linux命令的有序组合,Shell脚本是最贴近底层、最容易被审查的形式。脚本要给运维同事看,要放到生产环境跑,Shell做逻辑分支和调用外部命令都特别自然。Python当然也能写,但多一层解释器依赖,在一些精简的服务器镜像里可能还得现装,平白增加复杂度。

用cron做定时调度,理由也很简单:cron是Linux自带的,稳定且透明。你可以用crontab -l随时查看调度计划,出了问题可以直接改,不会像某些容器化的调度方案一样还要先排查调度器本身。如果你的服务器用的是systemd,也可以用systemd timer,它的日志和依赖管理更统一,但对新手来说配置文件比cron难理解一些。我的建议是,个人服务器和中小团队用cron足够了,上了K8s或者大规模集群之后再去考虑更复杂的策略。

2.3 清理策略里的几个关键参数

自动化清理最怕的就是“一把梭”。我在脚本里设计了几个关键参数来控制清理力度:

  • 磁盘使用率阈值:只有当根分区或者Docker数据目录所在分区使用率超过这个阈值时,才执行真正的大清理;空间充足的情况下直接跳过,减少不必要的I/O和误删风险。
  • 保留天数:所有镜像清理都加上--filter until=xxx,给刚拉取、刚构建的镜像一个“缓冲期”。因为有些镜像可能只是暂时没被容器引用,不代表后续不需要。
  • 排除名单:关键业务的容器、数据卷、镜像名,必须能被脚本识别并跳过。比如数据库容器和它的数据卷,如果因为清理策略失误被删掉,那真的会出大事。
  • 执行上限:清理容器的数量不能无上限,一次清理太多会带来不可控的连带影响,所以脚本里设置了最多处理200个容器的限制。

3. 完整脚本实现与定时任务落地

3.1 一台服务器上直接能跑的清理脚本

下面这份脚本是我在实际服务器上跑过的版本,根据用途做了一些精简,但核心逻辑保持一致。使用前请务必先看脚本里的配置区,把排除名单改成你自己的业务容器名。

#!/usr/bin/env bash # ============================================= # docker-daily-clean.sh # Docker容器与镜像自动化清理脚本 # 适用:Linux + Docker Engine 20.10+ # ============================================= set -uo pipefail # ---------- 配置区 ---------- LOG_FILE="/var/log/docker-daily-clean.log" DATA_DIR="/var/lib/docker" THRESHOLD=80 # Docker数据目录所在分区使用率阈值(%) CLEAN_DAYS=3 # 未被引用的镜像保留天数(h) MAX_CONTAINER_NUM=200 # 单次清理容器数量上限 declare -a EXCLUDE_CONTAINERS=("mysql" "redis" "nexus") # ---------- 工具函数 ---------- now() { date '+%Y-%m-%d %H:%M:%S'; } log() { echo "[$(now)] $*" >> "$LOG_FILE"; } # ---------- 前置检查 ---------- USED_PERCENT=$(df -P "$DATA_DIR" | awk 'NR==2 {print $5}' | tr -d '%') log "Docker数据目录使用率: ${USED_PERCENT}%" if [ "$USED_PERCENT" -lt "$THRESHOLD" ]; then log "使用率低于阈值 ${THRESHOLD}%,本次不执行清理" exit 0 fi log "使用率超过阈值,开始执行清理" # ---------- 1. 清理停止状态的容器 ---------- log "开始清理停止状态的容器..." while IFS= read -r cid; do name=$(docker inspect -f '{{.Name}}' "$cid" 2>/dev/null | tr -d '/') if [ -z "$name" ]; then continue fi # 排除名单检查 for excluded in "${EXCLUDE_CONTAINERS[@]}"; do if [ "$name" == "$excluded" ]; then log "跳过受保护容器: $name" continue 2 fi done docker rm -v "$cid" && log "已删除容器: $name ($cid)" done < <(docker ps -a --filter status=exited --filter status=created --filter status=dead --format '{{.ID}}' | head -n "$MAX_CONTAINER_NUM") # ---------- 2. 清理悬空镜像 ---------- log "开始清理悬空镜像..." docker image prune -f --filter "until=${CLEAN_DAYS}h" >> "$LOG_FILE" 2>&1 # ---------- 3. 清理未被容器引用的旧镜像 ---------- log "开始清理超过${CLEAN_DAYS}天且未被引用的镜像..." docker image prune -a -f --filter "until=${CLEAN_DAYS}h" >> "$LOG_FILE" 2>&1 # ---------- 4. 清理未被使用的数据卷 ---------- log "开始清理未使用数据卷..." docker volume prune -f >> "$LOG_FILE" 2>&1 # ---------- 5. 清理构建缓存 ---------- log "开始清理构建缓存..." docker builder prune -f >> "$LOG_FILE" 2>&1 # ---------- 6. 输出清理结果 ---------- log "清理执行完毕,当前Docker空间使用情况:" docker system df >> "$LOG_FILE" 2>&1

先说几个重要的设计点:

df -P是POSIX兼容输出格式,比普通df的输出更稳,用awk 'NR==2 {print $5}'取使用率百分比并去掉百分号,这个写法在CentOS 7、Ubuntu 20.04、Debian 11上我都验证过。

容器清理这一步,我用head -n "$MAX_CONTAINER_NUM"限制单次处理数量,避免一次删除上千个容器导致Docker daemon压力过大。容器名不是用docker ps --format直接拿,而是通过docker inspect再取一次,因为ID转名称的过程中如果容器刚好被其他进程删除,inspect会返回空,脚本也能安全跳过。

镜像清理分为两步是有意的:先清理悬空镜像,这个操作基本不会误伤;再清理未被引用的镜像,这是一个风险更高的操作,但加上--filter until=${CLEAN_DAYS}h之后已经给了3天缓冲。如果你有那种“拉下来但暂时不运行”的镜像,需要保留超过3天,那就把CLEAN_DAYS调大,比如改成168h,刚好一周。

数据卷清理是风险点,docker volume prune -f会删除所有没有被容器使用的卷。我脚本里的默认逻辑是直接清,但如果你有“容器即将重建、卷要保留”的情况,要么在清理前给卷加上容器引用,要么干脆把这一步注释掉,改成手动执行。

日志方面,我不追求花哨的告警系统,把所有输出重定向到同一个日志文件,并调用docker system df把清理后的状态完整记录下来。这个日志文件非常重要,出问题的时候能帮你定位到具体删了什么。

3.2 定时任务配置:cron 和 systemd timer

脚本写完之后,调度就是关键了。我在生产环境用的是cron,配置如下:

# 每天凌晨3点15分执行 15 3 * * * /opt/scripts/docker-daily-clean.sh >> /var/log/docker-daily-clean-cron.log 2>&1

这里有一个非常容易踩的坑:cron执行时默认PATH很窄,可能不包含/usr/bin和/usr/local/bin。如果你在脚本里直接写docker,cron环境下可能提示找不到命令。解决方式有两种,一是在脚本开头加上:

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

二是cron命令里用绝对路径:

15 3 * * * /usr/bin/docker ...

不过更推荐在脚本内部设置PATH,因为脚本可能在多个不同环境下被手动执行,设置好PATH能保证一致性。另外,如果你的服务器装了宝塔面板或者其他运维面板,直接在面板里配置定时任务也可以,本质上还是生成一条cron记录。

如果你更习惯systemd timer,可以按下面的方式配。先创建service单元:

# /etc/systemd/system/docker-clean.service [Unit] Description=Docker Container and Image Cleanup [Service] Type=oneshot ExecStart=/opt/scripts/docker-daily-clean.sh

再创建timer单元:

# /etc/systemd/system/docker-clean.timer [Unit] Description=Run docker-clean daily at 03:15 [Timer] OnCalendar=*-*-* 03:15:00 Persistent=true [Install] WantedBy=timers.target

启用命令:

systemctl daemon-reload systemctl enable docker-clean.timer --now systemctl list-timers | grep docker-clean

systemd timer的优势是日志由journald统一管理,查看最近一次执行结果用journalctl -u docker-clean.service就可以了。而且Persistent=true能保证如果服务器在凌晨关机了,开机后它会补执行一次错过的任务。这个特性在个人服务器上很实用,cron虽然也有anacron可以处理,但配置起来比systemd timer麻烦。

3.3 清理效果怎么看才算真的干净

清理完成不是只看日志说“清理完毕”就行了,要验证空间是否真的释放。我的标准验证流程有三步:

第一步,查看分区使用率:

df -h /var/lib/docker

确认使用率从之前的90%以上降回了正常区间。第二步,执行docker system df查看Docker自己统计的资源占用情况,关注IMAGE、CONTAINERS、LOCAL VOLUMES、BUILD CACHE这几项的RECLAIMABLE(可回收)数值。如果可回收部分变得很小,说明清理策略确实生效了。

第三步,对比清理前后日志。我脚本里已经记录了每次清理完成后的docker system df,所以直接查日志就能看到趋势:

grep "当前Docker空间使用情况" -A 8 /var/log/docker-daily-clean.log

如果发现某次清理后空间反而涨了,那就要检查是不是业务方在那段时间大量拉取镜像或者创建了容器,未必是脚本的问题。

整个清理流程跑下来,我实测过一台数据量中等的服务器,一次清理释放了约9.6G空间,其中构建缓存占大头,其次是悬空镜像。如果你的服务器上主要是静态服务、没有频繁构建,那大头可能会变成旧镜像和停止容器的可写层,策略细节需要根据实际情况微调。

4. 常见问题与避坑经验实录

4.1 高频问题速查表

我整理了实际使用过程中团队同事问我最多的几类问题,直接做成一个速查表:

现象可能原因解决方案
清理后磁盘空间没变化有容器正在使用这些镜像或卷,清理命令无法删除它们用docker system df看可回收部分,再检查是否有大量日志文件占空间
脚本提示docker: command not foundcron的PATH缺少docker所在目录在脚本内显式export PATH
提示permission denied当前用户不在docker组,或者脚本用了sudo但日志文件属主不对将当前用户加入docker组,或统一用root执行cron
删除镜像时提示image is being used by container某个停止的容器还在引用该镜像先清理容器,再清理镜像
数据卷被误删没有配置排除名单,直接执行了docker volume prune脚本中增加保护名单,或先备份卷数据
空间释放之后很快又满了有服务在疯狂写日志,容器内日志未做轮转给Docker配置日志驱动上限,重点检查json-file单个容器日志大小
清理执行时间太长镜像和容器数量过多给清理命令加超时,或分批执行

4.2 我踩过的三次“翻车”记录

第一次翻车,是对着docker image prune -a -f直接执行,没加until过滤。当时一台测试机上有一批刚拉下来的业务镜像,还没来得及跑起来就被清掉了。同事过来问镜像哪去了,我查了history才知道是被自己清理掉的。从那之后,我的所有清理脚本一律加保留期参数,没有例外。

第二次翻车,是做数据卷清理时没有设置排除名单。那台服务器上用Nexus做制品仓库,它的容器是长期运行的,但数据卷是通过docker-compose管理的命名卷。正常来说命名卷不会被匿名卷清理影响,但我当时把docker compose down跑到一半停了,容器没起来,卷处于“未被引用”状态,一分钟之内就被脚本的docker volume prune -f给删了。那个仓库里有大量内部制品,补数据花了整整一天。现在我的脚本配置区里永远维护一个黑白名单,凡是跟存储、数据库、制品库相关的容器,宁可不清,也不可错删。

第三次翻车,是日志文件把磁盘撑爆,但清理脚本一点忙没帮上。Docker容器默认的日志驱动是json-file,如果不限制单个日志文件大小,容器长时间运行产生的日志会持续膨胀,而这些日志文件都在/var/lib/docker/containers/<id>/目录下,它们跟容器是关联的,任何镜像清理命令都碰不到。当时服务器Docker数据目录直接到了100%,我一看清理脚本日志,每天只在阈值以下跳过执行。后来手动找到那个几十G的json日志文件,然后给Docker daemon加了如下配置:

{ "log-driver": "json-file", "log-opts": { "max-size": "20m", "max-file": "5" } }

重启docker服务后,单个容器日志上限变成了20M,最多保留5个文件,磁盘空间一下子稳了。这个经验其实比清理脚本本身还重要——清理脚本是治疗,日志限制才是预防。

4.3 脚本加固与告警思路

如果你想把这个脚本用到更正式的线上环境,我建议再做三件事。

第一,给脚本加dry-run模式。在正式删除之前先执行一个“只统计不删除”的运行,把将要删除的容器和镜像列出来。虽然cron里通常不会用dry-run,但每次调整策略之后,先人工跑一次dry-run确认列表,能避免90%的误删事故。

第二,把日志同步到远端日志平台,或者至少发一份告警邮件。最简单的发邮件方式是用mail命令,配合cron跑完之后发一份清理报告。如果公司内部有堡垒机或者日志平台,也可以直接追加写日志。关键是出了问题你能快速知道是哪一次清理、删了什么东西。

第三,使用Docker标签来做精细保护。比如给重要容器打上keep=true标签,脚本在清理容器时检查这个标签,存在则跳过。镜像也可以打标签,比如需要长期保留的镜像标记为keep=true,脚本的docker image prune -a命令配合--filter label!=keep就能实现精细化排除。标签方案比维护容器名黑名单更灵活,因为容器名可能在不同环境里重名,但标签是业务方自己打的,语义更清晰。

我脚本里目前用的是容器名黑名单,对于固定环境够用了,但如果你管理的集群规模更大,强烈建议升级成标签机制。

另外还有一点关于执行时间的建议:清理任务最好放在业务低峰期。凌晨三点是绝大多数业务系统的低谷,cron里默认的15 3 * * *就是一个安全的低频时间。

最后再说两句

整套方案跑下来,我最想强调的不是脚本本身,而是“清理策略比清理命令更重要”。docker system prune -a -f这种命令谁都会敲,但真正决定脚本是否安全的是阈值、保留期、排除名单和日志这四件套。我自己经历过误删镜像、误删数据卷、磁盘依然爆满的三次翻车之后,才意识到:自动化清理不是跑得越勤越好,而是要在“及时释放空间”和“不误伤业务数据”之间找到平衡点。

这份脚本我放在服务器上已经稳定跑了几个月,每天凌晨自动执行,日志记录清楚,空间使用率一直保持在可控区间。你可以先从复制脚本开始,根据自己的业务容器和镜像情况调整配置区,然后手动执行一次看看日志,再配置cron或systemd timer。如果后续业务规模变大,可以考虑在脚本里接入通知机器人,把每次清理结果推送到团队群里,这样整个自动化闭环就更完整了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询