首页 游戏 软件 排行 专题

Docker磁盘占用虚高3倍:overlay2分层与写时复制的坑

来源: 本站整理 日期:2026/11/4 13:36:55

如果你正在排查 Docker 镜像层和容器层的磁盘占用,或者需要一台 NVMe 大容量云服务器来跑容器,这几家可以先看看报价和技术说明:ethernetservers(全球云服务器,NVMe/HDD 存储盘与独享服务器)、serverfield(AI 云基础设施,NVMe 全闪存与多地数据中心)

那天下午我在收拾一台跑了大半年的构建机。docker images 挨个列出来,最大的一个镜像 210MB,加起来撑死不到 1G。可我一跑 du -sh /var/lib/docker,屏幕上跳出 31G。我第一反应是磁盘有坏道,或者是别的进程偷偷写了东西。最后查出来,什么坏道都没有,是我自己一直在犯的一个认知错误:Docker 的磁盘占用从来不是「镜像大小之和」,而是镜像层 + 容器可写层 + 日志 + 构建缓存四本账叠在一起算的。

Docker overlay2 联合文件系统分层结构示意图

overlay2 到底是个什么东西

要理解这笔账,先得知道镜像在磁盘上是怎么存的。现代 Docker 默认用的存储驱动是 overlay2,它本质上是 Linux 的联合文件系统(union filesystem)的一种实现:把每一层镜像目录叠在一起,挂载成一个看起来完整的目录树给你用。

关键在于「叠加」这两个字怎么实现。假设你的镜像有三层:最底层的 busybox 只有 2MB,中间层装了 python 占 180MB,最上层是你 COPY 进来的业务代码 30MB。overlay2 不会在挂载时把这 30MB 真的复制到最上层目录里去,它用的是写时复制(Copy-on-Write,CoW):读的时候,上层没这个文件就往下层找,一路找到根目录;只有当你真的去写这个文件的时候,才把文件从下层「拉」上来一份到你自己的层里,你改的是这份副本。

这套机制对只读运行容器的场景极省空间。但它带来两个几乎必然会踩的坑。

坑一:容器写过的文件,全都会在可写层留下一份完整副本

第一个坑是写入放大。假设你的镜像里有 500MB 的静态资源,容器启动后某个进程往其中某个目录写了日志写时复制不会只复制那一个文件,它复制的是整个目录树。也就是说,为了写进去 1KB 的日志,你的可写层会多出 500MB。

我的构建机就是这样炸的。跑完一轮 CI,十几条流水线各自起了容器做测试,测试脚本往 /usr/share/xxx 里写临时文件。结果 12 个容器加起来,可写层吃掉了 24G,而镜像加一起还不到 1G。这事儿的隐蔽之处在于,你用 docker ps -a 看容器列表,每个容器的 SIZE 字段可能就显示几十 MB,但它在磁盘上实际占着几个 G。

排查这事儿有个非常好用的命令组合,先看清楚钱花在哪了:

docker system df -v 会把 Images、Containers、Local Volumes、Build Cache 四本账分开列,其中 Images 那一栏会给出每个镜像的 SHARED SIZE 和 UNIQUE SIZE。SHARED SIZE 是各镜像之间共用同一层底层时的大小,这部分在删掉任何一个镜像时都不会被释放;真正能通过删镜像省下来的,只有 UNIQUE SIZE。这是我以前一直搞错的地方我总觉得删镜像能省很多空间,实际上多个镜像基于同一基础镜像时,基础层谁也带不走。

Containers 那一栏则会告诉你每个容器的 writable layer 到底多大,这一栏的数字往往最吓人。

Docker 磁盘占用分层堆叠示意图

坑二:删了容器不等于删了它的可写层

第二个坑是「我明明删了」。docker rm 掉容器之后,可写层的目录如果还有别的引用,就不会被立刻清理。同样地,docker rmi 删镜像,被其他容器引用着的层也留着。真正会变成「幽灵占用」的,是那些已经退出但从没被 rm 过的容器,以及被 tag 覆盖后失去名字但层还在的镜像。

我这台机器上最后清出来的 22G,几乎全是这两种:8 个 Exited 状态的构建容器,每个可写层 2G 出头;还有一个 none:none 的悬空镜像,是某次 build 中断留下的中间层。这两种东西你用 docker images 和 docker ps 都看不见,只有 docker system df -v 能把它们揪出来。

而在 overlay2 的目录层面,这些东西的实体全在 /var/lib/docker/overlay2/ 下,是一个以 hash 命名的目录。你去 ls 那个目录,每一层对应一个带 -diff 和 -merged 后缀的目录,-diff 就是这一层真正占磁盘的地方。

Docker 可写层空间回收示意图

安全的清理顺序,别一上来就 prune -a

说到清理,很多人第一反应是 docker system prune -a --volumes。这个命令我不建议在生产机上随手执行,它会删掉所有「没在运行的容器、所有没被容器使用的镜像、所有网络、构建缓存」,卷都可能给你干掉。有些卷虽然没挂载,但里面是别人手动 docker run 起来跑的一次性任务的数据。

我的顺序是这样:

第一步,看账。docker system df -v,把四本账看清楚。哪一类占比最大,先解决哪一类,别盲删。

第二步,清构建缓存。CI 机器上构建缓存常常是最大的一块。docker builder prune 会清掉所有悬空的构建缓存层,不影响正在跑的容器和镜像。这是最安全的一刀。

第三步,清退出容器。docker container prune 只删已停止的容器。这里有个坑要注意:如果你用了 --restart=always,正在运行的容器不会被删;但如果是手工启动后异常退出的容器,它会被当成「已退出」而清掉可写层,容器里没挂载出来的数据就没了。所以执行前先 docker ps -a 过一遍。

第四步,才考虑 docker image prune。注意不带 -a 的话只删悬空镜像(就是那些 none:none 的),安全得多。带 -a 会把所有没被运行的镜像都删掉,如果你本地依赖某个镜像做离线构建,那就得重新 pull。

第五步,检查 overlay2 目录是否还有残留。如果 docker system df 显示的空间和 du -sh /var/lib/docker 差得依然离谱,那就是有层没被回收。这种情况最省事的做法是停掉 docker 服务,把 overlay2 下确认没有引用的残留目录清掉再启动。不过这一步有风险,动手前务必 docker inspect 确认那个目录确实没有任何容器或镜像引用它。

从源头少占空间的办法

清理只是治标,真正省钱的是在构建阶段就不把这些账欠上:

一是把日志交给日志驱动,别让 json-file 驱动把 stdout 全存在容器可写层里。Docker 默认的 json-file 驱动是不限制大小的,容器打印几行日志就可能写出几个 G 文件。--log-opt max-size=100m --log-opt max-file=3 这种参数配上,比任何事后清理都管用。

二是容器里跑完就删,用 docker run --rm,别让测试容器挂在 docker ps -a 里攒着。

三是构建阶段用多阶段构建,把编译期的中间产物留在 builder 阶段,别把 gcc、node_modules 这些几 G 的东西 COPY 进最终镜像。

四是给容器加只读根文件系统,--read-only 加上临时目录用 tmpfs 挂载,容器根本没法往可写层里写东西,写时复制的那笔账自然就不会发生。

运维工程师检查服务器硬盘存储阵列

写在最后

回过头看,那台机器的 31G 里,镜像只占 1G,容器可写层占 24G,构建缓存占 5G,真正「应该存在」的数据不到 2G。Docker 的磁盘账本是多层的,你得知道每一层是谁在记谁的钱,才谈得上省空间。盲目 prune 一次可能省下几个 G,也可能顺手删掉你下周还要用的那个镜像这两件事,在生产机上的代价完全不是一个量级。

玩家留言 跟帖评论
查看更多评论
相关文章
猜你喜欢
同类下载