我平时会用 docker save 在两台不共享镜像仓库的机器之间搬运镜像。但从来没想过,执行完 docker rmi 之后,那些层到底去了哪里。Docker Engine 29.9.1 在10月14日发布,修的就是这个问题——答案是:什么也没发生。层留在磁盘上,永远留着,而且没有任何命令能看到它们。
修复说明只有一行:当镜像重复包含同一层时,docker save 不再在镜像删除后把层留在磁盘上。为了搞清楚"重复包含同一层"是什么意思,我翻出了对应的 moby 合并请求(#53927),然后分别构建了有问题的和修复后的引擎,自己动手验证了一遍。
根因是一个永远不释放的引用计数
docker save 写出镜像配置和层的时候,会对每个层取一个引用,防止写入过程中被垃圾回收。代码在延迟调用里释放这个引用,但释放之前会先检查该层的 diff ID 是否在同一次运行中已经被保存过。如果镜像的 RootFS.Layers 里同一个 diff ID 出现了两次——也就是两个内容逐字节相同的层——第二次就会命中"已保存"分支,在延迟释放执行之前直接返回。于是有一个层带着一个没人释放的引用走掉了。
这个前提条件听起来很怪:镜像为什么会把同一层列两遍?如果 Dockerfile 在两个不同步骤里把同一个文件复制进镜像,就会发生。内容相同,产出的 diff tar 相同,层的摘要也就相同。我故意构建了一个:
- 基础镜像 busybox:1.36.1
- COPY f /f 执行两次
- docker inspect 显示第二层和第三层摘要完全一致
复现过程
我从 download.docker.com 下载了 29.8.2(修复前的版本)和 29.9.1 的静态二进制文件,各自用独立的 --data-root 运行,互不干扰。两者都使用经典的 overlay2 图驱动,发布说明里点名受影响的就是这个存储后端。为了用上它,我必须在 daemon.json 里设置 "features": {"containerd-snapshotter": false},因为新版 Docker 默认走 containerd 镜像存储。
先做对照:在 29.8.2 上完全不调用 docker save,直接删除镜像。layerdb 目录从3个条目变成0,干净利落。
然后同样的构建,中间加一次 docker save。删除两个镜像之后,layerdb 里还剩3个条目,overlay2 目录仍然是4.5MB——和 save 出来的 tar 包一样大。什么都没释放,三个层目录就那么杵着,不属于任何镜像。
在 29.9.1 上跑同样的构建、保存、删除序列:条目数为0,只剩8KB的空目录结构。修复生效了。moby 合并请求的作者曾提到,他们没有在打过补丁的守护进程上重跑端到端复现——据我所知,这次算是补上了那个确认。
更让人在意的是:没有任何东西能发现它
比泄漏本身更让我不舒服的,是它完全不露痕迹。在有问题的 29.8.2 上,泄漏发生后执行 docker system df -v:镜像零个、容器零个、构建缓存零字节。那4.5MB躺在 overlay2 里,不出现在任何一列。
两个清理命令我都试了。docker system prune -af 回收0字节,docker builder prune -af 也是0字节。它们都基于镜像和层的记录工作,而那些记录已经不再引用这些孤儿层。我重启了守护进程,排除内存缓存掩盖问题的可能——4.5MB和三个目录还在。在受影响的引擎版本上,没有任何有文档记载的命令能把空间拿回来。唯一的办法是升级。
只影响一种存储后端
发布说明称这只影响"图驱动镜像存储"。在当前 Docker 上,意味着你得是一台升级过、但仍在使用 overlay2 的主机,而不是自 Docker Engine 29.0 起全新安装默认使用的 containerd 镜像存储。我没有盲信这个说法,在 29.8.2 上启用 containerd 镜像存储后跑了同样的构建、保存、删除序列:删除后 docker system df -v 什么都不列,快照目录只剩56KB的目录开销,没有泄漏。
这个说法站得住:全新安装的 Docker,或者任何已经切换到 containerd 存储的主机,无论引擎版本如何,都从未暴露在这个问题下。受影响的 specifically 是升级后留在旧驱动上的主机——按照 Docker 自己的迁移说明,就地升级默认保留 overlay2,这样的生产机群数量仍然不少。
重复次数不会放大泄漏
我原本猜测,重复层更多的镜像会按比例泄漏更多。于是构建了一个把同一文件复制五次的 Dockerfile,在坏引擎上保存再删除:tar 包还是4.5MB,泄漏后的磁盘占用还是4.5MB。layerdb 条目从3个变成6个,因为 overlay2 会为层栈中每个位置记录不同的链 ID,即使底层内容相同;但实际的层字节只存一份,在这些位置之间共享。泄漏被限制在唯一数据的大小上,不会随重复次数翻倍。这是整件事里唯一一个给标题泼冷水的数字:bug 是真的,修复也有意义,但它不是那种越用力越严重的泄漏。一次意外的重复,代价是一层的空间,不是五层。
我中途搞错的地方
第一次尝试用经典图驱动运行 29.8.2 时,我只是在 dockerd 命令行加了 --storage-driver overlay2,结果立刻失败:配置的驱动不可用。我以为这个参数本身就能关掉 containerd 镜像存储,其实不行——Docker 29 无论 --storage-driver 怎么设,默认都走 containerd 快照器,必须在通过 --config-file 传入的 daemon.json 里显式设置 features 才能启动经典驱动。小事一桩,但意味着我第一次"复现"其实一直跑在 containerd 存储上,自然看不到泄漏。
我还以为三条 RUN true 指令会产生三个相同的空层,用更普通的 Dockerfile 触发同一个 bug。并没有——旧版构建器识别出空 diff,根本不创建层,docker inspect 只显示基础镜像的那一层。这条路复现不了,你需要一个真正非空的重复,比如重复的 COPY。
怎么处理
在实际执行 docker save 的那台主机上运行 docker info | grep "Storage Driver"。启用新存储后,我的输出是 overlayfs 加 driver-type: io.containerd.snapshotter.v1;旧驱动上则是朴素的 overlay2。这一个词,就是每个受影响镜像泄漏4.5MB和完全不泄漏之间的全部差别。
最可能仍显示 overlay2 的主机,是那些自 Docker Engine 29.0 之前就没人动过存储配置的机器,因为就地升级留下的就是这个。如果你发现是这种情况,而这台机器上的备份或镜像迁移脚本又调用了 docker save,那我测到的4.5MB不会出现在 docker system df 里,不会被 docker system prune -af 清掉,也不会自己消失。我是装了 29.9.1 才把它弄掉的。没有第二个选项,我找过了。
热门跟贴