容器 11:改一个文件为何可能增加整层
镜像内容与联合挂载
一个 OCI 层记录相对于下层的增加、修改、删除。顺序应用变化才能得到最终文件树;压缩层的 digest 与解压后 DiffID 属于制品层证据。OverlayFS 则是 Linux 内核的具体叠加文件系统:只读的 lowerdir、可写的 upperdir 和工作目录一起提供 merged 视图。应用读 /app/config 时读的是合并后当前可见的文件;首次写 lower 中的文件可能 copy-up 到 upper,随后修改 upper 副本。删除 lower 中的文件会在 upper 中留下 whiteout 语义以隐藏它,但不会从已经分发的旧 OCI blob 删除字节。不能把 union mount 当作容器镜像唯一可用的 snapshotter 实现。
两种“层”分别在什么时候出现
OCI image-spec 的 layer 是供构建、分发和解包的变更集:manifest 保存 layer 描述符,blob 的 digest 对存储字节取摘要,config 中的 DiffID 对解压后的层字节取摘要。构建器把“新增、修改、删除”编码为有顺序的层后,即使后一层覆盖文件,前一层的 blob 仍按自己的摘要存在,除非后续垃圾回收在确认没有引用后删除。OverlayFS 是运行某个进程时在特定 Linux 内核里给路径访问提供合并视图的一种方式;它的 lower/upper/merged 名字与磁盘目录、挂载参数和写入发生时的 copy-up 有关,而不是 OCI manifest 中能直接找到三个字段。二者可以在实现中配合,但不存在“每个 OCI tar 都必须恰好映射成一个 lowerdir 目录”的规范保证。
已有 image layout 生成器只创建一层包含 probe_app.py 的 tar;它能校验该层的字节摘要,却根本没有执行 overlay 挂载。用 sha256sum -c 核对源码包只证明独立获取的附件未被这次复制篡改,用 oci_layout.py verify 校验 blob 是另一种静态检查;要证明“写 lower 中的文件造成 copy-up”,必须拿到实际运行时 mountinfo 与 lower/upper 文件视图。当前云端 --mount-proc 返回 EPERM,尚无足够权限安全地完成私有 OverlayFS 挂载,这一项不能因为前两个静态检查通过而转为 LAB_VERIFIED。
同名文件被修改时先看哪一份
在 VM 的私有实验目录中放 lower/config=old,上层原本没有 config,合并挂载后第一次读应从 lower 看见 old。从 merged 将这个文件改为 new 时,内核需要准备可写副本,可能把 lower 中的文件 copy-up 到 upper,再将改动作用于 upper;之后 merged/config 显示 new,原来的 lower/config 仍是 old。比对时保留文件内容与 inode/stat、操作前后的 upper 目录和 mountinfo,而不是只保留 cat merged/config 一行;若脚本实际上直接改写了 lower 文件,就根本不是所要验证的 copy-up 分支。copy-up 也有元数据驱动与完整数据复制等内核条件差异,不能用某个较小文件一次写入造成的磁盘增量预测所有版本的开销。
这里还要分辨“复制现有文件供运行中修改”和“构建时产出新 OCI 层”。应用在 merged 中运行期间写配置,通常涉及容器可写层的 upper;是否将这段变化 commit 成新的 image manifest 是独立动作,原来镜像的 digest 不会因为容器刚写过文件而自动变化。如果之后删除当前容器的可写层,未写入 volume 的这段修改可能消失,但 lower 镜像 blob 不受这次删除影响。反过来,如果路径被 bind mount 或 volume 覆盖,它的实际写入位置根本不在这里讨论的 upper,必须先读实际 mountinfo 再归因,16 篇进一步拆分数据位置。
删除后的“不存在”属于哪一个视图
删掉 merged 中原本只位于 lower 的文件,内核不能通过普通删除把只读 lower 中已存在的文件字节擦掉;OverlayFS 使用 upper whiteout 遮住对应 lower 名字,使 merged 查不到它。具体内核文档列出 char device 0/0 或带 trusted.overlay.whiteout 属性的零长度普通文件等表示形式;实验不能要求每台 VM 的 upper 一定出现同名 .wh.* 文件。OCI layer 归档里的 whiteout 是另一套变更集表达,解包器在构建文件树时解释该 marker,不应在 OverlayFS upper 目录里按 tar 文件名的表现找证据。若只拍摄 merged 里“文件已经不见”,无法证明 lower 已删除,也不能证明历史镜像不含原内容。
目录的删除还可能涉及 opaque directory 语义:upper 的某个目录若被标记 opaque,lower 同名目录中的子项不会再合并可见。这解释了为什么只检查一个同名文件的 whiteout 不足以覆盖“整个目录被替换”的边界。实验需控制目录树,先在 lower 中放两个不同文件,再从 merged 对目录执行受控修改,查看 upper 的目录元数据和合并后剩余子项。若缺少访问扩展属性的权限,可记录目录内容与已知挂载前提,不能编造 trusted.overlay.opaque 的值。上述目标都留给 VM,不从现有 OCI tar 输出倒推内核实际 whiteout 形式。
三个挂载目录有什么前置条件
Linux 文档要求上层 upperdir 与 workdir 在同一个底层文件系统中,workdir 还要供 overlay 内部使用;lowerdir 是本次只读输入而不是可以任意清理的缓存。准备四个自建目录 lower、upper、work、merged 后,先检查底层文件系统、权限与挂载传播,再在确认属于专用 VM 的私有 mount namespace 执行挂载。若 mount 返回 EINVAL,排查 upper/work 位置与 backing FS、已有占用、挂载参数和内核支持;若返回 EPERM,则排查当前 namespace 中的能力和宿主限制。两种错误都不能简写成“镜像层损坏”。
运行后对照 cat /proc/self/mountinfo,应确认 merged 真正被 overlay 挂载,而不是误把尚未挂载的空目录当成结果。如果实验程序进入了另一个 mount namespace,必须采它自己的 /proc/<pid>/mountinfo,在宿主 shell 看不到 merged 挂载不能说明程序也看不到。测试完成先停止使用 merged 的实验进程、关闭打开的 FD,再卸载本次创建的挂载;退出私有 namespace 后在外侧检查同一实验目录及其他任务不受影响。权限不足时保存失败原始命令、退出码和清理“未创建挂载”的事实,不尝试在共享宿主修改根挂载传播。
正常案例:下层有 config=old,在上层写入 config=new,merged 视图看到新内容,下层 blob 的原始内容仍是旧值;失败/边界案例:新增一层“删除敏感文件”的变更,即使 merged 看不到,仍无法保证旧层在 registry 已消失。因此不要把秘密复制到早期构建层再在后续层删除;需重构构建输入与仓库保留策略。
这个反例可以从字节层追踪:先用只含合成字符串 demo-secret 的第一层生成镜像,然后在第二层按 OCI 变更集规则删除该文件。运行时合并树中读取失败只是“当前路径不可见”;若第一层 blob 仍被该镜像引用且能够按 digest 获取,下载并检查第一层 tar 就可能找回合成字符串。真实凭据绝不放进教学层;如果历史构建中已经发生泄露,删除后来层不足以撤销旧 blob 的读取能力,还需按实际分发范围轮换密钥并处理仓库中的历史引用。此案例在本仓库只给出可复现设计,没有专用 registry 或 overlay VM 的实际下载日志,不应写成“已从本机 registry 找回密钥”。
要在专用 VM 实测,先在自有临时目录建立 lower、upper、work、merged,确认 backing filesystem 支持后进入私有 mount namespace 再 mount;分别新增、修改、删除文件,对照 upper 目录、merged 内容、mountinfo、OCI 解包后的变更集,卸载后核对无残留。当前共享云端不能完成 --mount-proc 挂载,也没有明确委派 mount 能力;OverlayFS 实验 NOT_RUN。附件保留入口、失败检查和清理;最小源码包包含 10 的同一镜像生成器,但不把静态生成当成 OverlayFS 挂载实测。
将 VM 的实验记录按“内核挂载”“OCI 归档”“运行服务”三栏填写:第一栏必须有实际挂载、copy-up、whiteout/删除与卸载;第二栏必须有层 blob 及配置摘要核验;第三栏若希望证明容器确实读取该 merged 路径,还需要关联运行时对象、宿主 PID 与进程内文件读取结果。只完成第一栏不能证明这个目录一定来自某个 OCI image,只完成第二栏不能证明 Linux 内核采用 OverlayFS,第三栏单独出现一个 cat config=new 更无法解释旧字节在哪里。逐层保留未知项,是后文选择 snapshotter 与查数据持久位置时的起点。
一个仍持有文件 FD 的程序会看到什么
如果程序在 copy-up 前打开了 lower 文件并保留 FD,之后另一个进程经 merged 修改同一路径,两个文件描述符对路径名与文件对象的引用时机不同;“后来从路径打开看见 new”和“旧 FD 上之前已取得的对象是什么”不应被简单合并成一个必然相同的观察。要在 VM 验证,先记录两条进程各自打开路径和 FD 的时间,改写 merged 后分别通过新 open 和旧 FD 读取,再查 upper/lower 的状态。某些元数据更新也可能触发不同形式的 copy-up,不能只以“写过完整文件内容”作为触发条件。当前 VM 实测缺失,不在博客里填任何假想的 FD 读数。
遇到“容器删掉文件,宿主层大小没降”的工程问题,先判定删的是什么:若删的是 lower 提供的只读基础文件,白化后 merged 不见而旧 blob 仍按内容地址保留;若删的是本容器 upper 新建且没有其他引用的文件,后续空间释放还要看文件是否仍被进程 FD 打开以及底层文件系统如何回收;若路径实际上是 volume,就要去卷的真实挂载源看数据。这三条路径的资源归属、清理人和重启后的持久性各不相同。直接运行 du merged 只能看当前可见目录的大小,不能测量整个镜像历史层、底层共享快照和已被删除但仍打开文件的占用。
另一个误区是“每修改一次文件就立即增加一个 OCI layer”。运行中的 upper 可能不断变化,而 OCI 层通常是构建时生成的不可变变更集;构建缓存命中与否、Dockerfile 步骤边界、压缩编码以及构建器的合并行为会影响最终 manifest 中的层数。标题中的“可能”正是要保留这一区分:文件运行时 copy-up 是内核视图,产出新层是制品构建,逻辑上的修改次数不能直接等同于归档数量。定位空间问题时先问所指的是镜像 blob、当前可写快照、容器挂载,还是外部卷,之后再选择对应计数器或文件列表。
由此判断清理动作的可验证条件也不同:卸载 merged 只移除这次挂载视图,不会删除由其他镜像继续引用的 OCI blob;删除镜像引用也不能自动清掉仍在运行的进程打开的卷文件。把实际对象身份、引用者和删除动作留在记录里,才不会误把页面构建产物的消失当成宿主磁盘已经回收。
| 现象 | 要检查 | 不能直接推断 |
|---|---|---|
| 删除后 merged 不见旧文件 | whiteout、下层 blob | 历史层不存在 |
| 修改一个文件空间增多 | upper 的 copy-up、层打包 | 所有 snapshotter 都用 OverlayFS |
| mount 失败 | 权限、传播、目录/文件系统 | OCI layer digest 有错 |
练习
- 先预测在 upper 删除 lower 的路径后,lower 与 merged 分别看到什么;在专用 VM 对照
find、mountinfo、挂载清理。 - 预测把同一文件在不同构建层连续修改三次后,最终镜像的历史层还能查到哪些旧字节;在自己的测试 registry 检查按 digest 保存的 blob,不要放真实密钥。
上一篇:10:OCI 镜像结构;下一篇:12:镜像分发。
系列总目录:从进程隔离到运行时与编排。






