09已经能让自编探针作为进程退出,但无法用一个可分发制品重建其根文件系统。OCI 镜像表示的是若干内容寻址的字节和元数据,而非正在运行的进程。以这一区别为出发点读 `index.json`,比背一串 Docker 命令更容易定位“拿到的是哪个制品”。

从索引走到字节

本地 OCI image layout 的入口是 oci-layout 与 index.json。索引中的 descriptor 用 media type、digest、size 指向 manifest,可能列有平台信息;manifest 再指向 image config 与一组层。config 描述操作系统、架构、根文件系统的 diff_ids 及进程默认参数等;层可以是 tar gzip 的变更集。给定 sha256:<hex>,实际 blob 路径是 blobs/sha256/<hex>,应以文件真实字节核验 digest 和 size,不能只看 JSON 里字符串长得像摘要。

一个特别容易混淆的点是压缩层 descriptor 的 digest 与 config 中 DiffID:前者计算压缩后的 blob 字节,后者计算该层解压后的 tar 字节。两者通常不相等,虽然指的是同一个变更集的不同表示。多层的顺序也重要;把层排序后再解包不能凭“它们有同样的摘要集合”推断相同的根目录。manifest digest 变化表示 manifest 的字节变化,不等于宿主当前某个进程的身份;镜像 ID、index digest 和 tag 也不要混写。

先决定从哪个入口解析

OCI image layout 是文件系统中的一套布局:oci-layout 声明布局版本,index.json 列出指向 manifest 等对象的描述符,blobs/<算法>/<hex> 存放原始字节。索引本身不是 PID 列表,也不保留容器曾经运行的日志。若 index 包含两个不同 platform 的 manifest 描述符,首先按目标操作系统、架构及相关 platform 条件选对应对象,而不是读取第一个条目并宣称“这就是镜像”。仓库里的 oci_layout.py 示例索引恰好只写一个 Linux/本机架构条目,所以它的校验器选择 [0];在真实多平台镜像中照搬这个行为会选错平台。选对描述符以后,再按 mediaType、digest、size 找到其字节,继续走 manifest → config 与 layers,不能从索引跳到 config 就省掉 manifest。

image config 有两个性质不同的部分:os、architecture 等与平台相关的元数据,以及 rootfs.diff_ids、命令、环境变量等运行默认配置。Cmd 声明默认命令不是“已经在这台主机执行的命令”,也不能保证切换根以后路径和解释器必定存在。manifest 可以引用同一个 config 但以不同压缩方式引用相应的层 blob,导致 manifest 与压缩 digest 变化;配置和未压缩层字节若确实保持不变,其 DiffID 仍可保持不变。这个“可以”有前提:压缩格式改变时 mediaType 也需相符,而重新生成 tar 的元数据或字节顺序变动会使 DiffID 一起变化,不能用文件树表面一样就推断 tar 字节也相同。

计算摘要时必须选择正确的字节域

sha256:<hex> 是对确定的字节序列计算的,不是 JSON 语义值的散列。manifest 被重新序列化,即使字段含义差不多,只要空格、属性顺序等影响实际字节,manifest digest 就会变化;由 index 指向它的描述符也必须更新。压缩层的 digest 依存储 blob 原样计算,config 的 DiffID 依相应解压后 tar 的字节计算;拿 sha256sum layer.tar.gz 与 config 中 diff_ids 比较,本来就选错了对象。由于 digest 是内容寻址,描述符的 size 同样应与实际 blob 长度一致:只有 digest 且没有校验读到的真实 bytes,仍可能把传输截断错误留到后续解包阶段才报。

对多层镜像来说,按 manifest 的 layers 顺序把变更集作用到文件系统,才能得到最终 rootfs;相同的 DiffID 集合换个次序不保证相同文件树。OCI 配置还定义 ChainID 用以标识顺序应用的层链;不要把 ChainID 写成“最后一层压缩包的摘要”。一层删除前层文件时,层归档用 whiteout 等变更语义表达删除,简单对每个 tar 解压到一个同名目录不足以解释最终视图。这一层的逻辑变更集与运行机器使用何种 snapshotter 存储、是否使用 OverlayFS 再次分离:镜像 tar 描述内容,后者负责准备本机可供进程读取的挂载视图,11 篇分别追踪。

用已有校验器核对它真正验证了什么

本仓库示例创建时先把 probe_app.py 以确定模式、时间戳等字段写入 tar,再 gzip 压缩;分别计算压缩 blob digest 与原 tar 的 DiffID,保存 config、manifest,再写 index。验证时从索引第一条 descriptor 顺序读 manifest、config、layer,对每个实际 blob 校验 size 与 digest,对 gzip 解压后的原始 tar 字节计算 SHA-256 并与 config 的 DiffID 比对。负例复制一份自建 layout,修改压缩层中的一个字节而不修改 manifest 中原描述符;再次校验必然需要重新读取真实字节,观察 digest 不匹配并退出非零。原始日志里的 EXIT_CODE 属于脚本这一次的读取与失败,不是 mock 掉核验器生成的“预期输出”。

它没有验证索引本身是否被可信来源签名、没有验证从 registry 拉取时的认证,也没有证实 tar 在真实解包器里可安全应用。具体地说,示例没有把 index.json 的字节拿去与外部已信任 digest 比较;如果修改索引中的平台字段但保持 manifest 引用不变,现有校验器仍可能对后续 blob 全部返回成功,这只是说明它没有认证索引的这部分元数据。读者可在私有副本上修改字段进行第二个练习,分别记录索引内容变了、manifest digest 是否实际变化、校验器返回码;不能把示例验证器推崇成可处理任意不可信镜像的完整合规实现。

完整 rootfs 和内容可信还隔着什么

当前示例把 Python 源文件放进只有一层的归档,image config 中的默认 Cmd 指向 /probe_app.py,却既没带 Python 解释器也没提供脚本可直接执行的完整入口。即使所有 digest 校验通过,启动它仍缺少可执行条件;rootfs 的文件名不是“这个程序确实能运行”的证据。如果改用完整镜像,需同时核验 platform 是否匹配运行节点、镜像层能否按规范解包、工作目录与入口是否存在、运行时生成的配置是否与期望相同,再验证进程真的启动并处理探针请求。把静态布局检验、内容来源验证和运行时成功放在不同记录列,才能判断哪一段链路尚未知。

另一个边界是 tag 可以随时间指向不同 digest,manifest digest 与 index digest 也不一定是同一个对象;“今天用 example:latest 拉到的镜像”没有足够信息重现另一时刻运行的字节。下游实验需要保留解析 tag 时得到的 digest、所选 platform 和实际层集合,才能把后续的 runc 进程追溯到确定内容。digest 只证明与给定摘要一致;如果摘要本身来自未经验证的索引或恶意发布者,字节仍可能是恶意的。签名、来源策略与执行权限在 33 篇再作处理,本篇不偷换概念。

如何把一次失败定位到正确的一跳

若校验 manifest descriptor 的 digest 失败,先问实际读取的 manifest blob 是否被修改、索引仍是否指向旧摘要;此时还没必要猜 tar 解压器出了什么问题。若 manifest 可读、某层 blob 的 digest 失败,应比较该描述符的字节长度与实际 blob,并核对是否把压缩层和未压缩层弄混。若压缩 blob 的摘要正确、DiffID 却不匹配,则要看解压后 tar 字节是否与 config 的预期同源,而不是调整运行时 CPU 限额。还有一种失败是所有摘要都匹配,应用却启动不了;这时才继续检查入口路径、shebang、动态链接器与 rootfs 文件及权限。这四条故障路径用同一个“镜像坏了”标签会在排障时不断跳层。

例如用同一个探针打两份压缩层:未压缩 tar 完全相同,但第二份重新 gzip 时写入不同时间戳,压缩 blob digest 可能变,DiffID 仍与同一个 config 对应;manifest 中层 descriptor 必须换成第二份 blob 的摘要和长度,manifest 自身也会随之变更。另一种改动是将 tar 中同一个文件的权限位从 0644 调成 0755,文件内容不变而原 tar 字节变,DiffID 应变化,config 里的预期列表也必须更新。两组是不同的字节变更,不能把“压缩格式变化”和“文件内容变化”混为一谈。该扩展对照只有做法与预期,当前仓库的真实日志只覆盖创建、校验和篡改压缩层失败,不记录这两组未实际执行的输出。

镜像平台选择也必须和 digest 一起保留。若在多平台索引中选择 amd64 manifest,却在 arm64 节点执行,将看到的 exec format error 可能来自二进制架构与节点不匹配;但相反地,即便索引报告 arm64,也不能仅凭索引字段就断言里面的每个二进制确实为 arm64,因为描述符并不替运行时审计可执行文件。证据链从“索引说目标平台是什么”走到“config 声明的架构是什么”,最后才到真实机器是否能执行;三项不同层的证明不可相互替代。对本篇示例只有一条索引条目的特殊情况,这一步只能证明选择分支简单,不证明通用多平台实现已通过。

清理静态实验也有边界:负例只修改自己复制的 layout,不触及真实 registry 中的 blob 或他人的镜像缓存;结束时保存两份目录的 digest 列表和每条命令退出码,再仅移除两份自建目录,检查路径不存在。若创建阶段就失败,没有 manifest 描述符可读,应该写 NOT_RUN 的校验分支而非构造一个“预期的摘要”。本篇附件能在没有容器运行时的环境完成字节级静态实验,但页面生成、包 SHA-256 校验和 OCI layout 内容校验是三种不同的检查,三者都不能代替容器启动后核对实际 rootfs 与服务响应。

可校验的静态实验

独立源码包里的 `oci_layout.py` 将共用 `probe_app.py` 放进一层,生成 index、manifest、config 和 compressed layer,每个写入的 blob digest 都从真实字节计算。原始日志记录创建、重新打开 blob 校验、拷贝到私有目录篡改压缩层一个字节、校验失败退出 1、移除两份自建目录返回 0。入口与清理说明完整命令。负例不是脚本硬编码“失败”字样:校验器从索引逐级读取存储字节,在 size/digest 不符时抛出错误。资料按 image-spec v1.1.1 固定,示例实现本身在仓库中可读。

这个示范层只有 Python 源码,没有解释器、动态库与可执行入口,故不能直接 runc run,更不能算一次容器实验。也未连接 registry 或执行任意 OCI 镜像实现。真实镜像要在专用 VM 使用所选构建器构建完整 rootfs,记录 index/manifest/config/layer 的摘要和运行时解包结果;本机缺构建器与运行时,相关验收仍 NOT_RUN。

现象 查哪层 边界
拉到一个 tag index/manifest digest tag 可变,不是内容地址
blob 校验失败 文件字节、descriptor size/digest 不把解压后 DiffID 当压缩摘要
镜像存在不能启动 config/平台、运行时及根文件 摘要正确不保证有解释器

练习

  1. 先预测 gzip 重压缩后 manifest 的层摘要与 diff_ids 怎样变化;只改变压缩方式、保留解压后的 tar 字节,分别计算 SHA-256 证实预测。
  2. 先预测仅篡改索引中的平台字段会影响哪些 digest;在私有拷贝中修改字段,再核算索引、manifest 和配置的摘要,指出核验器当前覆盖与未覆盖的环节。

上一篇:09:教学启动器;下一篇:11:镜像层与本机快照。

系列总目录:从进程隔离到运行时与编排。

参考资料