资讯动态

Docker清理脚本实战:回收容器镜像与构建缓存磁盘空间

发布时间:2026/10/1 20:07:01 来源:尧图企业网站定制
1. 为什么我非要写一套清理脚本而不是继续用docker system prune我的服务器三年前还是512GB磁盘现在只剩不到80GB。起初我以为是日志写太多一查才发现磁盘被Docker吃掉了大半。做运维这几年几乎每台跑着Docker的主机都会经历同样的事镜像越积越多容器堆积成山磁盘告警一封接一封最后不得不半夜爬起来清数据。Docker的磁盘占用来源其实很固定拉取过的历史镜像、构建过程中产生的悬空镜像dangling images、停止后没有及时删除的容器、被容器持有但容器已删除的匿名卷、以及BuildKit构建缓存。这些东西平时隐藏在/var/lib/docker里用du一查才发现某个overlay2目录动辄几十GB。手动清理不是不行docker system prune -a一把梭确实能腾出不少空间但问题也很明显——它会把所有未运行的容器、未使用的网络、悬空镜像全部干掉而不区分业务优先级。我曾经在生产环境连续清理完不到一小时有一个业务容器需要回滚到旧版本结果旧镜像已经被prune连坐删掉了重新拉取还遇到镜像仓库网络抖动直接耽误了半小时上线。从那次之后我就意识到清理动作不能指望人肉手工更不能一把梭无差别删除必须有一套可控制、可灰度、可排障的自动化脚本。这篇文章我会完整展开我自己在用的这套Docker容器与镜像清理脚本。它不是什么神秘工具核心就是docker命令行加Shell脚本逻辑再加一层cron或者外部触发机制。适合的场景包括个人开发机的磁盘维护、CI跑批节点的镜像回收、公司内部Docker宿主机的定期巡检。如果你对容器清理的理解还停留在手动敲docker rm和docker rmi这篇可以帮你把整套思路捋顺顺便避掉几个纯踩坑才能发现的雷区。2. 明确清理对象哪些东西能删哪些东西必须留在保护区很多人在动手写清理脚本之前习惯先打开搜索引擎找现成代码贴到服务器上跑完发现业务容器被删了或者共享镜像没了。我不建议这么干。清理这件事第一步不是写命令而是盘清自己的Docker对象生命周期。2.1 容器的清理分级容器的状态在Docker里分为运行、已退出、创建未启动、重启中几种。清理的时候要把它们按能不能随便删分成几个等级运行中的容器一级保护区绝对不删。哪怕脚本逻辑写得再严谨也不能在清理逻辑里对Running状态的容器做任何操作。退出但存在时间较短的容器很可能是一次失败尝试比如docker run参数错误容器秒退后留在那里。这类可以设置一个宽限期比如退出超过24小时才纳入清理。退出且存在时间很长的容器往往是以前临时跑测试、执行一次性任务、或者容器启动后崩溃没处理的遗留品。这些是清理脚本的主要目标。处于Restarting的容器要格外小心可能代表业务正在蒸腾。虽然处于这个状态的容器有健康问题但直接删除会让服务失去自动恢复的机会。建议这类容器只告警不删除。我在脚本里给容器清理配置了两个白名单机制一个是保留最近N小时退出的容器一个是必须排除的容器名列表。后者给数据库迁移、定时任务这类退出但千万不能删的容器留了口子。2.2 镜像的清理策略要区分两种形态镜像清理比容器复杂得多因为镜像存在父子层级关系和标签引用关系。直接一点说镜像分两类看待悬空镜像REPOSITORY和TAG都显示none这类镜像是构建或者pull过程中被新版本替换掉的残留层没有任何标签引用也不能被后续复用理论上想怎么删就怎么删。有标签的历史镜像比如你持续用myapp:latest这个Tag构建每次构建都会把旧的myapp:latest变成悬空镜像但之前手动打的myapp:1.2.0这个Tag可能还在。这类带Tag的镜像不一定安全如果业务已经切到1.3.0而1.2.0是半年前的老版本那它占着几个GB的空间也没什么价值。所以我的脚本里会把悬空镜像和超过N天未被使用且Tag不在保留列表的镜像分别设置开关默认只开悬空镜像清理带Tag的旧版本镜像清理必须人工显式开启并配置保留白名单。毕竟镜像删除不像容器删除删除后无法从容器状态里恢复只能重新pull或者重新build。2.3 匿名卷、网络、BuildKit缓存不要一锅端卷和网络的清理非常容易被忽略也很容易出事故。我见过有人一个docker volume prune把所有未被容器引用的卷全干掉了其中包括一个跑着MySQL的容器正在用的数据卷——因为那个容器是docker compose启动但compose文件的volume定义有问题导致数据卷实际没有被正确挂载到容器里容器显示在运行卷却被判定为未使用。删完那一刻整个库直接没法启动。所以我设计脚本时卷清理这个动作默认是关闭的。只有在极确定某些卷已经彻底不用的前提下才手动开启指名道姓的卷清理。网络清理相对安全因为Docker的自定义网络没有绑定数据重建成本低。BuildKit缓存docker builder prune也是比较安全的空间回收来源尤其是在CI节点上构建缓存经常几百GB往上走。3. 脚本骨架拆解把清理逻辑拆成独立模块而不是一个prune命令拼到底上面那些都盘清楚了才轮到写代码。我个人习惯构建一个类似下面这样结构的Shell脚本而不是把所有逻辑塞进一个函数里#!/usr/bin/env bash # Docker 容器与镜像清理脚本2024-12版 # 分成三个区域配置区 / 核心函数区 / 执行区 set -Eeuo pipefail ################################## # 配置区每次部署只需改这里 ################################## CLEAN_CONTAINER1 CLEAN_IMAGE1 CLEAN_BUILDER0 CLEAN_VOLUME0 CONTAINER_EXITED_AFTER_HOURS24 IMAGE_DANGLING1 IMAGE_UNTAG_AFTER_DAYS7 KEEP_IMAGESmysql:8.0.36 redis:7.2.4 nginx:1.26.2 EXCLUDE_CONTAINERSmy-db-migrate my-scheduler KEEP_CONTAINER_NUM5 LOG_FILE/var/log/docker-clean.log配置区单独分离出来是为了让不同环境的部署成本降到最低。开发机上可能想把CLEAN_IMAGE关掉因为总是要来回切换镜像版本CI节点上需要把CLEAN_BUILDER打开因为构建缓存是真正的空间刺客。3.1 容器清理函数给容器一个缓刑期再决定是否删除容器清理函数这层核心就是先拿到一段时间内的退出容器列表再过滤掉白名单里的名字然后执行删除。但这里有个绕不开的坑过滤白名单不能只比对容器名还要比对镜像名。因为有些容器退出后名字可能带随机后缀比如migrate_x3f9你只匹配migrate-*根本防不住。我这里给出一个稳定版本的做法clean_containers() { local threshold_date threshold_date$(date -d -${CONTAINER_EXITED_AFTER_HOURS} hours %s) docker ps -a --filter statusexited --format {{.ID}}|{{.Names}}|{{.Image}}|{{.FinishedAt}} \ | while IFS| read -r cid cname cimage cfat; do local finish_epoch finish_epoch$(date -d $(echo $cfat | cut -d -f1-2) %s) # 跳过白名单 if echo $EXCLUDE_CONTAINERS | grep -wq $cname 2/dev/null; then continue fi if echo $EXCLUDE_CONTAINERS | grep -wq $cimage 2/dev/null; then continue fi if [ $finish_epoch -lt $threshold_date ]; then docker rm ${cid} fi done }有几个细节值得注意FinishedAt这个字段在部分时区设置下获取到的时间格式不是标准的YYYY-MM-DD开头而是2024-12-01 10:30:00 0800 0800所以截取前两个字段用cut -d -f1-2是必要操作。还有如果一个容器退出很久但它用的镜像刚好是另一个运行中容器正在引用的镜像删容器本身没有任何影响不要因为这个而不敢删。3.2 镜像清理函数先从悬空下手再处理过期Tag悬空镜像的清理是性价比最高的回收动作逻辑本身也非常简单clean_dangling_images() { local dangling_ids dangling_ids$(docker images -f danglingtrue -q) if [ -n $dangling_ids ]; then docker rmi ${dangling_ids} 2/dev/null || true fi }有没有必要写两行判断有。因为docker images -q在没有结果时输出空字符串直接丢给docker rmi会提示requires at least 1 argument虽然不影响继续执行但没有加引号的变量在循环里容易触发其他问题。有Tag的过期镜像清理不再基于docker images过滤因为Docker原生不提供镜像最后使用时间这个元数据。因此我改用了一种折中方式遍历所有镜像取出Tag里的日期前缀如果编译日期超过设定天数就进入候选删除列表。clean_untagged_images() { local cutoff_date cutoff_date$(date -d -${IMAGE_UNTAG_AFTER_DAYS} days %s) docker images --format {{.ID}}|{{.Repository}}:{{.Tag}}|{{.CreatedSince}} \ | while IFS| read -r iid itag icreated; do # 保留白名单内的镜像 local skip skip0 for keep in $KEEP_IMAGES; do if [ $itag $keep ]; then skip1 break fi done if [ $skip -eq 1 ]; then continue; fi # 判断镜像创建是否早于截止时间 local created_years created_days created_years$(echo $icreated | grep -oP ^\d || echo 0) created_days$(echo $icreated | grep -oP \d || echo 0) if [[ $icreated *years* ]]; then local n_year n_year${created_days% *} if [ $n_year -gt 1 ]; then docker rmi $iid 2/dev/null; fi elif [[ $icreated *months* ]]; then local n_month n_month${icreated%% *} if [ $n_month -ge 3 ]; then docker rmi $iid 2/dev/null; fi fi done }这段逻辑不是完美的因为CreatedSince本身给的只是粗略时间。但实际使用中三个月以上和一年以上这两个档位已经能覆盖绝大多数清理需求。如果你对精度有更高要求改用docker image inspect去取Created字段再转成时间戳对比即可代价是inspect一张镜像一个HTTP请求几百张镜像会让脚本变慢。4. 构建缓存与日志的回收很多人清理完容器和镜像磁盘依然没降下来我遇到过一个很典型的案例有台CI节点docker system df显示镜像只占40GB但整个Docker目录有180GB。一开始我以为是overlay2文件系统统计口径的问题后来逐目录排查才发现真正吃空间的是/var/lib/docker/tmp和新版的BuildKit缓存目录。镜像再大也就是几十上百个镜像的总和但构建缓存可以轻松达到这个量级的数倍。4.1 BuildKit缓存的清理原则BuildKit缓存分两层一层是tmp目录的中间构建产物另一层是containerd里保留的缓存快照。docker builder prune可以一次性清空也可以加过滤条件clean_builder_cache() { # 只清理超过48小时的构建缓存 docker builder prune --filter until48h --force }我在生产环境是不会用docker system prune -a这种命令的但docker builder prune --filter until48h相对安全因为BuildKit缓存只影响构建速度不影响运行中的容器和镜像。清理完构建缓存构建速度确实会轻微下降需要重新拉取基础层和解压但在磁盘紧张的情况下这个代价可以接受。实际效果上在我那台节点上执行完docker builder prune --filter until48h之后瞬间释放了大概110GB空间。这也是我建议每个Docker宿主机把缓存清理纳入周期任务的原因。4.2 容器日志的截断与轮转清理日志不能直接用rm删掉容器日志文件因为Docker的JSON日志文件一旦被外部删除容器进程的文件句柄仍然指向这个已经被删除的inode磁盘空间并不会释放。正确的做法是用truncate -s 0将文件大小置零或者让Docker的json-file日志驱动启用轮转。这两种方式在脚本里的体现不同。轮转需要改/etc/docker/daemon.json并重启Docker服务而truncate是即时生效的。我一般推荐优先配置日志驱动轮转脚本只留一个兜底clean_container_logs() { local log_path for cid in $(docker ps -q); do log_path$(docker inspect --format{{.LogPath}} $cid) if [ -f $log_path ]; then local log_size log_size$(stat -c%s $log_path) if [ $log_size -gt $((128*1024*1024)) ]; then truncate -s 0 $log_path echo TRUNCATED $cid log from $log_size bytes to 0 fi fi done }阈值可以按主机磁盘状况调整。容器日志达到几百MB却不做轮转是运维事故的高发原因。而且清理日志无法通过prune完成必须单独处理。5. 落盘与调度让脚本按cron执行之前先想清楚判定逻辑和幂等性脚本写完了接下来就是调度和判定。这里我踩过的坑比较典型值得展开说一下。5.1 磁盘阈值触发与定时清理双模式单纯的cron定时任务在每天固定时间跑一遍其实不能应对突发情况。比如凌晨3点磁盘告警但你cron设置在凌晨2点等下次跑已经晚了。所以我的方案是双保险脚本本身支持传入一个阈值参数当磁盘使用率超过设定值时强制执行同时cron里的定时任务主要用于常规巡检。# 磁盘使用率超过80%时触发全量清理 disk_usage$(df / | awk NR2 {print $5} | sed s/%//) if [ $disk_usage -gt ${DISK_THRESHOLD:-80} ]; then CLEAN_CONTAINER1 CLEAN_IMAGE1 CLEAN_BUILDER1 fi5.2 防止脚本并发执行定时任务和阈值触发如果同时出现脚本可能跑重。清理类的操作重复执行本身不是致命的但docker rmi和docker rm同时跑会导致莫名其妙的资源竞争报错甚至让某些镜像的引用计数短暂混乱。解决方式很简单脚本开头加一个文件锁。exec 9/var/lock/docker-clean.lock if ! flock -n 9; then echo Another instance is running, exit. exit 0 fi5.3 清理后的验证与告警清理结束后脚本要自行验证结果。验证分两层第一层是执行完docker system df重新统计空间看释放量是否达到预期第二层是确认没有误删关键容器比对当前运行的容器列表和清理前记录的快照。告警我使用的是钉钉或者飞书机器人的Webhook最简单的文本通知即可send_notify() { curl -s -X POST $WEBHOOK_URL \ -H Content-Type: application/json \ -d {\msgtype\:\text\,\text\:{\content\:\Docker清理完成释放 $(echo $before $after | awk {print ($1 - $2)/1024/1024})GB\}} \ /dev/null 21 || true }告警信息不要只报清理完成四个字至少要把每个分类删了多少个对象、释放了多少空间写清楚方便事后回看。6. 这套脚本在跑了一年的宿主机上实际碰到了什么幺蛾子脚本运行一年下来遇到的坑比想象中多。趁这个机会全部列出来你可能也会碰上。6.1 悬空镜像删不掉提示Image is referenced in multiple repositories这个报错的根因是同一个镜像ID对应了多个Tag其中一部分Tag已经变成了none另一部分还挂着有效的Repository和Tag。docker rmi image_id时Docker会拒绝删除因为它不想让一个仍然有名称引用的镜像直接被移除。正确做法是删除时带:tag而不是image_id或者干脆先删掉引用它的有效Tag再删镜像。脚本里我加了这样一层兜底if ! docker rmi $iid 2/dev/null; then for tag in $(docker inspect --format{{range .RepoTags}}{{println .}}{{end}} $iid); do docker rmi $tag 2/dev/null || true done fi6.2 Deleted container still holds mount point容器删除后偶尔会有/var/lib/docker/containers/id/mounts残留目录导致overlay2挂载点残留。这种情况一般出现在Docker版本升级的中途清理脚本遇到这类问题不会立刻解决但至少不要因为残留mount点报错而中断整个清理。我在脚本里对docker rm的返回值做了宽松处理docker rm $cid $LOG_FILE 21 || true后面再用mountpoint -q检查残留挂载点能卸载就强制卸载。6.3 千万别在Docker Desktop上直接跑这套脚本热搜里也出现了不少关于Docker Desktop的词。要提个醒Windows和macOS上的Docker Desktop本质是虚拟机里的一个Linux环境脚本里的stat -c%s、df这些指令虽然可以进入VM执行但在Docker Desktop环境下做镜像和容器清理时Docker Desktop自带的管理界面和资源回收机制优先级更高。直接在宿主机上跑针对Linux的Shell脚本容易碰到路径对不上和权限问题。桌面版的清理建议直接用Docker Dashboard的Prune按钮或者使用Docker Desktop设置里的资源回收功能。6.4 Docker系统目录仍不见缩小检查overlay2和迁移空间有时候数据已经清掉了但磁盘占用还是居高不下。用du -sh /var/lib/docker/overlay2排查的时候需要确认当前Docker版本使用哪些存储驱动。尤其注意如果Docker.service配置里>docker info --format {{.DockerRootDir}} - {{.Driver}}一个宿主机上如果存在多个data-root路径下的镜像缓存清理策略要相应调整。老旧的Docker安装尤其CentOS 7下的centos7镜像下载场景有时数据残留分布在/var/lib/docker、/var/lib/containerd等不同目录单纯清理镜像和容器不够还得清理containerd命名空间下的内容。但这部分需要特别谨慎不要贸然删除先确认是什么空间再决定。6.5 清理过度导致镜像仓库压力有一年我在内网环境里为了省磁盘把IMAGE_UNTAG_AFTER_DAYS调到3天结果镜像仓库那边频繁出现被pull的旧版本镜像因为本地删得太勤每次回滚和重新调度都要走镜像仓库拉取内网带宽倒是够但镜像仓库的存储碎片化和拉取压力都上来了。后来调整为7天情况明显缓解。清理脚本里的每个阈值都应当结合镜像仓库的可用性和网络带宽来定不是越激进越好。7. 对生产环境的最终建议先模拟再灰度最后全量落地如果你的生产环境还没有任何一套自动清理机制我不建议直接把脚本挂到cron里。先在一个非核心节点上跑两到三周确认日志里没有异常删除记录再复制到核心节点最后再挂自动触发。中间至少保证一件事每周人工查看一次清理日志不要彻底甩手不看。自动化是为了减去重复劳动不是让异常排查更困难。另外脚本里的所有配置区参数建议用文件版本管理不要直接改服务器上的生产脚本。我自己的版本是存在Git仓库里每次改动走commit记录避免哪天误改了一个保留列表把重要容器全删了结果查半天不知道哪一次改动的锅。这里有一个我目前觉得非常有价值的小细节在EXCLUDE_CONTAINERS白名单里写注释说明为什么这个容器要排除一年后回来看你未必记得当初为什么跳过某个迁移容器。注释比命令本身更值钱。磁盘清理这件事最怕的不是清理不干净而是你以为已经清干净了。希望这篇脚本能帮你把Docker宿主机上那些隐藏的磁盘杀手逐个识别出来并且用可控、可观测的方式送它们走。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑