
服务器磁盘告警那段时间我几乎每天都要登录上去跑一遍df -h看着 Use% 一点点往上爬心里越来越没底。当时机器上跑了一堆 Docker 服务docker images刷出来一大片我的第一反应很直接镜像太占地方了删可真动起手来才发现Docker 删除镜像这件事远远不是执行一条docker rmi那么简单。同名的镜像为什么删完还在为什么明明没有容器运行却提示“被使用”为什么镜像删光了du -sh /var/lib/docker还是几十个 G这篇文章我就把从那次磁盘告警到现在积累的整套镜像清理思路完整记录下来包括镜像删除的基本原则、删不掉时的根因分析、prune 家族的具体用法以及比镜像更隐蔽的磁盘占用到底藏在哪里希望帮你少走我走过的那些弯路。1. 先搞清楚 Docker 镜像占什么空间、删掉的到底是什么1.1 镜像的分层结构决定了删除方式和直觉不一样Docker 镜像不是一个大文件它是由一组只读层叠加出来的每一层对应 Dockerfile 里的一条指令。拉取镜像时Docker 会按层下载构建镜像时每一层都会被缓存。这个设计带来的好处是多个镜像可以共享底层。比如你本地拉了 nginx、redis、mysql它们很可能都基于同一个debian或alpine基础镜像那这一层在磁盘上只有一份被多个镜像共同引用。也正是因为这种共享机制删除一个镜像时Docker 不会立刻把它的所有层都从磁盘抹掉。它只是把该镜像对应的层引用计数减一只有当某个层没有任何镜像、没有任何容器继续引用时这个层才真正成为可回收的孤儿层。很多人删完镜像后习惯用du -sh /var/lib/docker查看发现空间并没有少太多原因就在这里——你以为删掉了一个镜像实际可能只是删掉了最顶上的业务层底下那一大块基础层还被其他容器或镜像拽着。所以正确理解镜像占用的第一步是接受一个反直觉事实docker images列表里 SIZE 列加起来的总和并不等于 Docker 实际占用的磁盘空间。列表里每个镜像显示的是它逻辑上的完整大小会重复计算公共层实际磁盘上的唯一层只会存一份。查看真实占用要依赖docker system df。1.2 docker system df一切清理动作开始前先看这张表在动手清理之前我会先执行这条命令docker system df输出大概是这样的TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 12 3 8.925GB 7.262GB (81%) Containers 18 4 2.431GB 1.893GB (77%) Local Volumes 7 2 5.763GB 4.976GB (86%) Build Cache 0 0 0B 0B这张表会把镜像、容器、卷、构建缓存四类对象的占用和可回收量一次性列出来。我个人的习惯是任何清理操作之前先跑一遍这个命令确认“可回收的到底是谁”而不是凭感觉上来就docker rmi -f。其中 Images 行的 RECLAIMABLE 指的是未被任何容器使用的镜像Containers 行看的是停止容器遗留的可写层Local Volumes 对应未挂载到容器里的数据卷Build Cache 则是镜像构建过程中的中间缓存。需要额外说明的是如果你在 Docker Desktop 上操作镜像和容器数据在 Linux 版默认位于/var/lib/docker在 macOS 和 Windows 上则被封装在虚拟磁盘里。删除一部分镜像后Docker Desktop 会自动回收虚拟磁盘内的空间但宿主机上的.raw或vhdx虚拟磁盘文件本身不一定立刻缩小你可能会遇到“Docker 里显示空间释放了但宿主机磁盘还是那么大”的错觉。这个问题我们后面专门展开。2. 删除单个镜像image rm 完整用法与删不掉的常见原因2.1 从基础命令开始docker rmi 的常规操作删除单个镜像最直接的命令是docker rmi nginx:latest等价写法是docker image rm nginx:latest两种写法用的是同一套接口docker rmi是旧版留下来的简写。你可以按仓库名:标签删除也可以按镜像 ID 删除docker rmi 8c5c6f9c9e2d按 ID 删除时不需要写全取前几位能唯一区分即可。同时删除多个镜像可以这样docker rmi image1:tag1 image2:tag2配合命令替换可以把当前未使用的镜像一次性列出来例如把所有悬空镜像的 ID 交给 rmidocker rmi $(docker images -f danglingtrue -q)这条命令在清理构建过程产生的废弃镜像时非常常用但直接执行前最好先只输出列表确认一次我把确认这一步放在后面 prune 部分细说。2.2 按名称删除和按 ID 删除行为完全不一样这是很多人踩坑的地方。同一个镜像 ID 可以同时拥有多个标签比如你给某个自定义镜像同时打了myapp:1.0.0和myapp:latest两个标签它们指向同一个 ID。当你执行docker rmi myapp:1.0.0实际发生的是移除这一个标签引用。因为myapp:latest还指着同一份镜像数据docker images里仍然看得到这个镜像只是少了一个 tag。很多人误以为“删失败了”其实只是“引用的名字少了一个”。反过来当你执行docker rmi image_idDocker 发现这个 ID 有多个标签会直接报错Error response from daemon: conflict: unable to delete 8c5c6f9c9e2d (must be forced) - image is referenced in multiple repositories此时不能直接删需要先把关联的标签全部移除或者用-f强制。我强烈不建议在这种场景下用-f因为强制删除并不会把标签也清掉后面容易出现一些很奇怪的残留状态。正确的做法是把该 ID 下所有标签都删掉再执行镜像删除。2.3 最常见的“删不掉”根因容器还在引用镜像Docker 里有一条很容易被忽略的规则只要存在一个容器无论它是运行中、已停止、还是崩溃退出状态它都会锁定创建时所基于的那个镜像。你执行docker rmi时看到这类错误Error response from daemon: conflict: unable to remove repository reference nginx:latest (must force) - container abc123def456 is using its referenced image abc123def456解决方法不是加-f而是先去处理容器# 查看所有容器找到引用该镜像的容器 docker ps -a # 删除指定容器 docker rm abc123def456 # 或者删除所有已退出的容器 docker rm $(docker ps -a -q -f statusexited)我曾经在测试环境遇到过一台机器上有几十个已经退出、压根不用的容器每个容器还保留了它的可写层每个几十上百 MB。镜像删不掉只是一方面空间也被这些无谓的容器层白白占着。所以“先清容器、再清镜像”应该成为你的固定顺序。如果容器是运行中且确实停不掉的那说明这个镜像本来就该留着更不要强删。3. 批量清理悬空镜像与未使用对象prune 家族的正确用法3.1 悬空镜像到底是什么它从哪里冒出来的你执行docker images时偶尔会看到一堆none:none的镜像它们就是悬空镜像官方叫 dangling images。产生的原因主要是反复用同一个名字构建镜像。新镜像构建成功后旧镜像失去了标签引用但数据层还在于是变成none:none。反复执行docker pull并更新到新版本旧标签被新版本替代后旧镜像悬空了。构建过程中某一层生成后没有被后续层引用也会留下悬空层。悬空镜像本身没有标签不能直接通过docker rmi 名字删除但可以通过 ID 删。查看它们最省事的方法是过滤docker images -f danglingtrue输出里 REPOSITORY 和 TAG 都是none只有 IMAGE ID 和 SIZE 有值。这类镜像是清理动作里优先级最高的目标因为它们没有任何标签引用也几乎不可能再被使用删掉后不会影响任何打包好的版本回滚。3.2 docker image prune 与 -a 的天壤之别docker image prune是清理悬空镜像的专用命令docker image prune执行后 Docker 会列出准备删除的悬空镜像并让你确认。加上-f可以跳过确认docker image prune -f如果想直接清理所有未被容器使用的镜像而不仅仅是悬空镜像需要加上-adocker image prune -a注意这里藏着一个很大的风险点。-a会把所有没有被任何容器引用的镜像全部列为删除候选哪怕它带正常标签、是你上个星期还辛辛苦苦构建出来留着备用回滚的版本。一旦确认Docker 会一次性把标签带数据全部抹掉。所以在执行-a前我向来建议先跑一遍不带删除的查看命令docker images或者用--filter缩小范围比如只删除 24 小时前拉取且未被使用的镜像docker image prune -a --filter until24h这种方式相对安全一些至少保留最近还需要用的版本。如果你团队有明确的镜像命名或标签规范也可以按标签过滤比如只清理没有prod-前缀的镜像。把筛选条件命令写准确比事后从镜像仓库重新拉取好几 G 的基础镜像要省事得多。3.3 docker system prune 全家桶一次清理该注意什么如果你想一次性清理未被使用的容器、网络、悬空镜像和构建缓存可以用docker system prune这个命令默认会删除所有已停止的容器、所有未被容器使用的网络、所有悬空镜像以及构建缓存。注意它的范围比image prune宽很多执行前 Docker 会明确提示你哪些对象会被回收并要求输入y确认。生产环境里我一般会谨慎使用因为已停止的容器里如果还留着错误现场清掉之后排查问题就没法查看了。更激进的全量清理是docker system prune -a --volumes-a会把所有未被使用的镜像也清掉--volumes会顺带清理所有未被容器挂载的匿名卷。匿名卷可能包含容器写入但没做持久化的数据清理后不可恢复。所以我加粗建议任何情况下都不要轻易在生产环境执行这套带--volumes的命令除非你非常确定所有需要的数据都已经用命名卷或宿主机目录持久化了。我把几个 prune 命令的适用范围整理成了表格方便按需选择命令默认清理对象主要风险docker image prune悬空镜像低docker image prune -a全部未被容器使用的镜像可能删掉备用回滚版本docker system prune停止的容器、网络、悬空镜像、构建缓存停止容器里的现场丢失docker system prune -a --volumes全部未使用镜像、匿名卷、缓存等数据卷不可恢复慎用4. 比镜像更占磁盘的隐藏空间构建缓存、容器日志与卷镜像删干净了磁盘空间不一定就富余。这也是我最初非常困惑的地方。后来用du -sh /var/lib/docker/*一层一层看下去才发现真正吃磁盘的往往是另外三个大家伙构建缓存、容器日志和卷。4.1 Docker 的数据到底放在哪里Linux 环境默认存储目录是/var/lib/docker可以用docker info查看docker info | grep Docker Root Dir输出会显示类似Docker Root Dir: /var/lib/docker。进入这个目录可以看到containers/、image/、volumes/等子目录。排查空间占用时我一般会先看整体分布sudo du -sh /var/lib/docker/*这一条命令基本能定位大头。通常是containers目录里的 json 日志文件、overlay2目录里的镜像与容器层、volumes目录里的数据卷。这些目录之间也会交叉引用所以最直观的方式还是回到docker system df配合du一起看。4.2 构建缓存删了镜像也换不回来的隐形空间Docker 构建镜像时会缓存每一层同一个环境下多次执行docker build即使最终镜像不产生变化缓存的中间层也会越积越多。很多 CI 机上 Build Cache 占到十多个 G 是很常见的事情。清理构建缓存用docker builder prune和前面一样加-f跳过确认docker builder prune -f如果只清理某个特定构建器产生的缓存可以带上--builder参数。还有一点容易被忽略BuildKit 的缓存和旧版 builder 的缓存是分开管理的。如果你还在用DOCKER_BUILDKIT0 docker build这种方式构建清理时要把两者都考虑进去。我的做法是定期执行docker builder prune -a -f因为构建缓存本身就是不可重复依赖的删掉后无非是下次构建慢一点比它白白占着磁盘要划算得多。4.3 容器日志文件无限增长但几乎没人去删容器日志是隐藏空间消耗的大户。默认的日志驱动是json-file每个容器的标准输出和标准错误会被源源不断地写入宿主机上的 json 日志文件。路径一般是/var/lib/docker/containers/container_id/container_id-json.log有些服务一天写几个 G 日志非常轻松。因为日志文件属于容器而非镜像所以清理镜像对它没有任何作用。排查时可以直接在宿主机上看这些文件的大小或者一条命令找出最大的几个日志文件用ls -lh或du都行。我个人的处理方式是先找出 log 文件路径确认目标容器仍然存活或至少日志不再需要用truncate把它置空而不是直接rm因为直接删除可能影响 Docker 对日志文件句柄的管理最稳妥的是truncate -s 0。sudo truncate -s 0 /var/lib/docker/containers/container_id/container_id-json.log更根本的解法是在启动容器时限制日志大小。给容器加日志轮转参数docker run -d \ --log-opt max-size10m \ --log-opt max-file3 \ nginx:latest这套参数表示单个日志文件最大 10MB最多保留 3 个文件超过后自动轮转并删除旧文件。你还可以在 Docker daemon 的daemon.json里设置全局默认值这样后续启动的容器都自动继承{ log-driver: json-file, log-opts: { max-size: 10m, max-file: 3 } }修改daemon.json后需要重启 Docker 生效。如果容器已经在跑且当初没配置日志轮转也可以动态改但 Docker 不会自动重建容器所以一般还是配合重启或重新创建容器来生效。4.4 匿名卷与命名卷清理前先确认里面有没有数据-v指定挂载数据卷时如果用的是匿名卷没有给卷起名字容器删除后卷还会继续保留久而久之就会形成一堆没有任何容器引用、但依然存在于磁盘上的数据卷。清理它们需要谨慎因为卷里存储的是容器写入的真实数据不像镜像和构建缓存那样可以随时重建。我的判断标准很简单先看还有哪些卷被使用# 查看卷列表并判断是否还存在引用 docker volume ls docker ps -a没有容器挂载的卷就是可清理候选但清之前一定要确认里面没有数据库目录、没有业务导出文件、没有你需要留档的日志。确认无误后再清理docker volume prune这条命令同样会要求确认。如果真的不确定卷里是什么我建议先把卷挂载到一个临时容器里查看内容。删卷这件事属于“宁可留着吃灰也不要去赌”。毕竟数据卷的体量往往比镜像还大而且一旦删除没有任何回滚手段。5. 从一次真实磁盘告警看镜像清理的完整排查链路5.1 现场信息磁盘使用率 92%从哪里开始查那台测试机同时部署了 gitlab、mysql、redis、nginx还有几个 Java 服务同时我还在上面频繁构建测试镜像。收到磁盘告警后我第一件事不是急着删镜像而是按这个顺序确认df -h docker system df du -sh /var/lib/docker/* | sort -rh | headdf -h确认总磁盘和已用空间docker system df直接看 Docker 管理范围内谁占得多最后从目录层面定位具体元凶。那次的输出我记得很清楚Images 约 65GContainers 约 8GLocal Volumes 约 5GBuild Cache 约 12G。也就是说真正的问题不是“镜像太多”一句话能概括的而是镜像、容器层、缓存三块同时膨胀。5.2 按优先级逐步回收容器层、镜像、构建缓存、日志我当时的清理顺序是这样的清退停掉的容器。用docker ps -a看到大量Exited状态的容器多半是测试失败后随手留下的。先删除已退出容器容器对应的可写层和日志文件句柄会被释放。处理日志大文件。定位到几个超过 5G 的 json.log用truncate -s 0立即释放空间。这一步不依赖任何镜像命令但效果立竿见影。删除不再需要的镜像。因为机器上有些镜像是我刚构建完、还没投入使用但又在几个小时内可能用到的版本我没有直接docker image prune -a而是用时间过滤删掉 7 天前拉取且未被使用的镜像。清理构建缓存。测试机上的 Build Cache 基本没用直接docker builder prune -f。最后检查卷。确认没有容器引用的卷里没有数据库文件后用docker volume prune回收。上面的每一步我都会在执行前后分别跑一次docker system df记录 RECLAIMABLE 数字的变化这样可以验证清理真实生效了也方便判断是否还有遗漏。那次处理完之后磁盘使用率从 92% 降到了 40% 左右其中日志文件和构建缓存的贡献比镜像本身还要大。5.3 这条链路里最容易犯的三个错误第一个是清理顺序倒置。先删镜像时大量报冲突然后再回头找容器折腾半天。先删容器再删镜像会顺畅很多。第二个是盲目prune -a。我见过有人为了省事直接对测试机执行docker system prune -a --volumes结果把还在保留的数据库卷一起清了最后只能从备份恢复。教训就是--volumes这个参数不能当默认答案它必须配合“你完全清楚卷里是啥”这个前提。第三个是只看镜像不看日志。镜像删了几个 G但日志文件分分钟又重新占满。解决日志问题的关键是把轮转写进容器启动参数和daemon.json否则今天的清理只是明天的问题而且很快就会重复。6. 日常预防让镜像和磁盘占用不再失控6.1 把清理动作定时化而不是等告警清理一次只能解一时之急真正有效的还是让它在日常自动发生。我推荐至少建立两层定时任务每天执行一次基础清理每周执行一次大面积回收具体根据你的部署习惯来定。例如在 Linux 上用 crontab 每天凌晨清理悬空镜像0 2 * * * /usr/bin/docker image prune -f每周清理未使用的镜像与构建缓存保留 48 小时内创建的那些0 3 * * 0 /usr/bin/docker image prune -a -f --filter until48h 0 4 * * 0 /usr/bin/docker builder prune -f注意如果在容器内或者某些受限环境里跑 Docker 命令可能需要加上sudocrontab 的环境变量也要确保能找到docker可执行文件。定时脚本执行前最好先在命令行手动验证一遍避免权限问题导致清理一直静默失败。6.2 从源头减少垃圾镜像的产生定时清理是治标从源头减少才是治本。我在新项目里开始刻意遵守几条规则效果很明显优先使用体积小的基础镜像。同样一个服务基于alpine构建出的镜像往往只有基于完整debian镜像的四分之一。镜像体积越小将来清理时的空间回收效率也越高拉取和部署都更快。不在本机反复打重复 tag。如果每次构建都给同一个镜像打一个新版本号本机上的镜像数量会指数增长。规范化 tag 之后旧版本只在需要时可以重新构建而不是全堆在本地。用.dockerignore减少不必要的构建上下文。很多镜像包里被打进了一堆日志和依赖目录构建缓存和最终镜像都跟着膨胀。下线一个项目时顺手清理对应镜像和卷。项目如果已经不用了留着镜像不但占空间还会影响后续排查时对“哪些镜像还重要”的判断。6.3 监控 Docker 磁盘占用几条命令形成习惯我个人的习惯是每隔几天执行一下docker system df如果看到某个类型 RECLAIMABLE 比例超过 70%就说明积累的垃圾已经有点多了该动手。也可以写一个简单的脚本把磁盘占用情况输出到日志或监控系统#!/bin/bash # 简易 Docker 磁盘占用巡检脚本 df -h / | awk NR2 {print Host disk used: $5} docker system df更进一步的话可以在监控工具里采集/var/lib/docker的目录大小超过阈值就告警。这样不用等到磁盘满才发现问题。日志轮转配置也建议在每次新环境搭建时直接放进daemon.json里所有后续启动的容器都能继承不用一个个去加参数。这套东西搭好之后说实话我已经很久没有因为 Docker 磁盘不够用而半夜爬起来清理了。最后再分享一点个人的体会Docker 的镜像清理本质上区分的是“能重建的东西”和“不能重建的东西”。镜像、构建缓存、停止容器的可写层都属于前者大胆删数据卷、日志里有价值的部分属于后者动手前多确认一遍。把这两类东西分清楚清理操作就不再靠运气而是有一套稳定的决策依据这也是我踩过很多次坑之后最想提醒你的经验。