跳到主要内容

OCI 镜像到可启动根文件系统

30 秒导读: 你写 docker.io/library/python:3.12,microsandbox 要把它变成一块 microVM 能挂载启动的磁盘。别的运行时会把每层 tar 解包到一个目录再用 overlayfs 叠起来;microsandbox 从不解包——它把每层单独烤成一个 EROFS 只读镜像(按内容哈希缓存),然后用一张"只有元数据、数据块指针指回各层"的 fsmeta 镜像加一个 VMDK 描述文件,零拷贝地把它们拼成一块盘。本章讲清这条流水线的每一步。

本章是 microsandbox 系列的第 3 章。相邻章节:进程与生命周期见 01-lifecycle,主机↔客机通信见 02-protocol-relay,rootfs 挂进 VM 之后客机里的可写层与写时复制见 04-guest-filesystem。本章只到"生成好那块只读盘"为止。

代码全部在 crates/image/lib 下(as-of commit 517d139)。


1. 这是什么(零基础也能懂)

一句话定义

把一个 OCI/Docker 镜像(比如 python:3.12)从远端 registry 拉下来,变成一个 microVM 可以当磁盘挂载、开机就能跑的根文件系统

它要解决的问题

镜像在 registry 里的样子,和 VM 需要的样子,不是一回事:

registry 里的镜像VM 需要的 rootfs
形态一叠压缩 tar(每层一个 .tar.gz)一块能挂载的块设备 / 磁盘镜像
结构增量层,还带"删除标记"合并后的最终文件树
访问要解压、要按顺序叠内核直接寻址、只读挂载

传统做法:把每层 tar 解包到磁盘上一个目录,再用 overlayfs 把这些目录叠成一个视图。microsandbox 面向的是 microVM(轻量虚拟机,不是容器),客机内核要的是一块,不是主机上的一个目录;而且它想要内容寻址的全局缓存零数据拷贝的层复用。于是它选了另一条路(见 §5)。

用起来什么样

对上层(SDK / CLI)来说,这一大坨复杂度浓缩成一次 pull:

// 示意,非源码:真实入口是 Registry::pull()
let cache = GlobalCache::new(&cache_dir)?; // 打开全局缓存
let registry = Registry::new(Platform::host_linux(), cache)?; // linux/本机架构
let result = registry.pull(&"python:3.12".parse()?, &PullOptions::default()).await?;
// result.manifest_digest 就是这块 rootfs 的身份;
// 缓存里此刻已经躺着一个 <manifest>.vmdk,VM 挂它即可启动。

真实签名见 crates/image/lib/registry/client.rs:157Registry::pull。它返回 PullResult(crates/image/lib/pull.rs:37),里面有层的 diff_id 列表、解析好的 ImageConfig(entrypoint/env/cmd 等运行时默认值)和 manifest_digest

一句话直觉

把镜像想成"一叠透明胶片",每张胶片(层)画了一些文件、也可能划掉下面的文件。 microsandbox 不把胶片摞在灯箱上看(overlayfs),而是给每张胶片单独扫描存档(每层一个 EROFS),再画一张"总索引"说明最终每个像素来自哪张胶片(fsmeta),最后把索引和所有胶片钉成一册(VMDK)。


2. 顶层全景(它大概怎么转)

主线:从镜像引用到 .vmdk

怎么读这张图: 从上到下是一次 pull 的时间顺序;方框是阶段,右侧是落在缓存里的产物。核心是层级并发——中间那段每层各跑一条流水线。

Registry::pull(reference, options) crates/image/lib/registry/client.rs:157


① 取镜像级 flock + 早缓存命中检查 ──── 命中就直接返回 ──► (manifests/<hash>.json 齐 → 秒回)
│ 未命中 / policy=Always / force

② 拉 manifest + config,选平台 ────────────────────► 多架构 index → 挑 linux/arm64 那张
│ (fetch_manifest_and_config → parse_and_resolve_manifest)

③ 解析 config 得 diff_ids + 层描述符 ──────────────────► 校验:层数 == diff_id 数


④ 每层并发跑流水线(信号量限并发 ≤16):
┌───────────────────────────────────────────┐
│ 下载 .tar.gz ─► 校验压缩层 digest │──► layers/<blob>.tar.gz
│ 解压+摄取 tar ─► 建 FileTree,算 diff_id │──► (顺带校验 diff_id)
│ 写 EROFS 只读镜像 │──► layers/<diff_id>.erofs
└───────────────────────────────────────────┘


⑤ 合并所有层的树(处理 whiteout/opaque)→ 写 fsmeta ──► fsmeta/<manifest>.erofs


⑥ 拼 VMDK 描述文件([fsmeta][layer0][layer1]…) ──────► vmdk/<manifest>.vmdk ← VM 挂这个

部件一句话职责

部件干什么在哪(crates/image/lib/)
Registry拉取编排器:锁、缓存、平台、进度、并发registry/client.rs:56
PullPolicy / PullOptions决定"要不要联网、要不要强制重来"pull.rs:13 / pull.rs:28
RegistryAuth匿名 or 用户名密码鉴权auth.rs:18
Platform多架构镜像里选哪张 manifestplatform.rs:17
Layer单层的下载 + 断点续传 + digest 校验layer/download.rs:35
GlobalCache内容寻址的全局缓存 + 目录布局cache/store.rs:56
DigestOCI 摘要 sha256:…,内容寻址的键digest.rs:13
tar ingesttar 流 → FileTree,处理 whiteouttar/ingest.rs:190
FileTree / merge内存文件树 + 层合并tree/model.rs:161 / :689
write_erofs把一棵树写成 EROFS 只读镜像erofs/writer.rs:149
write_fsmeta写"只有元数据、指回层数据"的 EROFSerofs/fsmeta.rs:97
write_vmdk_descriptor把多个 EROFS 拼成一块虚拟盘stitch/vmdk.rs:20
ext4 format_ext4给可写上层格式化空 ext4(→04)ext4/formatter.rs

3. 核心原理(逐个机制,由浅入深)

3.1 拉取管线:什么时候才联网

要解决的小问题: 拉镜像很贵(下载 + 解压 + 建镜像)。绝大多数时候本地缓存已经有了,不该重复干活;但有时又要强制刷新。怎么用一个开关表达清楚?

两个正交的旋钮。 microsandbox 把"要不要联网"和"要不要重做本地产物"拆成两个独立字段(pull.rs:28):

// 真实定义 crates/image/lib/pull.rs:28
pub struct PullOptions {
pub pull_policy: PullPolicy, // 何时联系 registry
pub force: bool, // 即使有缓存也重下、重建
}

PullPolicy 三态(pull.rs:13):

取值含义典型场景
IfMissing(默认)缓存全在就用缓存,否则拉日常启动
Always总是拉 manifest 校验新鲜度,但 digest 没变的层仍复用:latest
Never绝不联网,本地没有就报 NotCached离线 / 气隙环境

早缓存命中是"全都在才算数"。 pull_inner 第一件事(拿到镜像级锁之后)就是 resolve_cached_pull_result_async(client.rs:1457):它读持久化的镜像元数据,然后逐项检查——每层 EROFS 在不在、fsmeta 在不在、VMDK 在不在,任何一样缺了都判未命中、走完整拉取。只有全齐才秒回(client.rs:332)。

校验镜像有效不是看"文件存不存在",而是看"字节对不对齐"。 一个被中断的写会留下半个文件。is_valid_erofs_artifact 用一个便宜又靠谱的判据:非空 长度是 4096 的整数倍(EROFS 块大小),不满足就当它无效(cache/store.rs:427,EROFS_ALIGNMENT_BYTES = 4096:35)。

3.2 平台选择:一个引用,多张 manifest

要解决的小问题: python:3.12 在 registry 里往往是一张"胖 manifest"(OCI index),下面挂着 amd64、arm64、armv7… 各一张真 manifest。要挑出跟本机匹配的那张。

先认出这是不是 index,再挑。 parse_and_resolve_manifest(client.rs:537)先从 JSON 里嗅 mediaType,若是 index 就进 resolve_platform_manifest(client.rs:560)遍历候选:OS 必须等、架构必须等,variant(如 armv7 的 v7)优先精确匹配、否则退而求其次取架构匹配的第一张(client.rs:596-609)。attestation 那种伪 manifest 直接跳过。

目标平台默认锁死 linux。 microVM 客机永远是 Linux,所以 Platform::host_linux()(platform.rs:57)只探测本机架构(x86_64→amd64aarch64→arm64),OS 硬编码 linux

同一套逻辑还塞给了底层 oci-client。 RegistryBuilder::build 在构造 oci_client::Client 时把 platform_resolver 也设成 resolve_platform_digest(builder.rs:99client.rs:1325),这样连底层库自己解析 index 时也用同一个平台偏好,不会各挑各的。

3.3 层下载:内容寻址 + 断点续传 + 并发安全

要解决的小问题: 层可能几百 MB;下载可能中断;多个进程可能同时拉同一个镜像。既不能重复下,也不能下坏,还不能互相踩。

内容寻址是一切的地基。 每层的键是它的 Digest(sha256:…),文件名由 Digest::to_path_safe(): 换成 _ 得来(digest.rs:46)。同一层无论被多少镜像引用,在缓存里只有一份 layers/<digest>.tar.gz

下载三态,让"续传"落地。 determine_download_start(layer/download.rs:348)先看已存在的 .part 文件,给出:

.part 不存在 / 空 → Fresh 从头下
.part 大小 == 期望 && 整文件哈希对得上 → Complete 直接改名成品,连网都不用连
.part 大小 < 期望 → Resume(n) 从第 n 字节继续(HTTP Range)
.part 大小 > 期望 / 哈希不对 → 删掉,Fresh

哈希是边下边算,不事后重读。 下载循环里 hasher.update(&chunk) 增量喂 SHA-256(layer/download.rs:238),下完直接比对压缩层 digest,不匹配就删文件报 DigestMismatch(layer/download.rs:271)。续传时会先把已有字节喂进 hasher 再接着下(:226)。

并发用跨进程文件锁(flock)兜底。 下载前先对 <blob>.download.lock 上排他锁(layer/download.rs:88,锁实现在 cache/lock.rs:45,Unix 走 libc::flock)。拿到锁后再查一遍成品在不在——因为等锁期间别的进程可能已经下完了(layer/download.rs:108)。写完永远是先写 .part 再原子 rename(:281),并发读者绝不会看到半个文件。

一个刻意的设计:锁文件从不删。client.rs:325 的注释——稳定的 inode 防止 TOCTOU:两个进程如果对同一路径 flock 了不同 inode(因为中间有人删了重建),锁就形同虚设。

3.4 tar 摄取与树模型:把层变成一棵文件树

要解决的小问题: 一层 tar 里不只有普通文件,还有目录、符号链接、硬链接、设备节点,以及 OCI 特有的删除标记(whiteout)。要把这些忠实地变成一棵可合并的内存树,还要防路径穿越攻击。

摄取即建树。 ingest_tar(tar/ingest.rs:190)逐条读 tar entry,按类型建 TreeNode(tree/model.rs:121:普通文件 / 目录 / 符号链接 / 字符&块设备 / FIFO / socket),插进 FileTree

顺手算出 diff_id 做内容信任校验。 外层 ingest_compressed_tar(tar/ingest.rs:416)按压缩类型(gzip/zstd 由 media type 判定)套一个解码器,再包一层 HashingReader(:483)——它在解压流经过时算 SHA-256。这个哈希就是解压后diff_id。回到主流水线,拿它跟 config 里签过名的 diff_id 比对(client.rs:890-899),对不上就是层被篡改,报 DigestMismatch压缩层 digest 保下载完整性,解压 diff_id 保内容信任,两道关。

whiteout:把 OCI 删除语义翻成 overlayfs 原生表示。 classify_whiteout(tar/ingest.rs:652)识别两种约定,并当场转换:

tar 里的名字OCI 含义转成树里的什么
.wh.<name>本层删除下层的 <name>一个 (major=0,minor=0) 的字符设备节点
.wh..wh..opq本目录对下层"不透明"(藏掉下层全部内容)父目录加 trusted.overlay.opaque=y xattr

这两个常量在 tree/model.rs:25-30。转成 overlayfs 原生表示,是为了后面合并时能统一处理(§3.5),也为了 EROFS 直接就是 overlayfs 能理解的下层。

路径规范化 = 安全边界。 normalize_path(tar/ingest.rs:524)剥掉 ./ 和前导 /,拒绝任何 .. 分量(防路径穿越写出 rootfs),并施加长度 / 深度上限(默认 4096 / 128,tree/model.rs:18-19)。测试 normalize_rejects_dotdotingest_accepts_absolute_path_in_tar 覆盖了这些边界。

大文件不进内存,spool 到磁盘。 ≥ 64 KiB(SPOOL_THRESHOLD,tree/model.rs:84)的文件体不塞进 Vec,而是流式写到一个共享 spool 文件,树里只记 (offset, len)(tar/ingest.rs:330FileData::Spooltree/model.rs:47)。这让摄取几个 GB 的层也不爆内存。

3.5 层合并:算出"最终每个文件来自哪一层"

要解决的小问题: N 层从下往上叠,上层覆盖下层、whiteout 删除下层。要算出合并后的最终文件树,并且——关键——记住每个最终文件的数据来自第几层(后面 fsmeta 要按这个指回层数据块)。

一趟合并,同时产出树和 provenance。 merge_layers_with_provenance(tree/model.rs:689)从底层到顶层依次 merge_directory_with_provenance,维护一张 路径 → 层号 的 provenance 表:

遇到 whiteout 字符设备 → 从合并树删掉该项,并连带删掉它及子孙的 provenance
遇到 opaque 目录 → 清空该目录已有内容,再叠本层内容
遇到普通文件/目录 → 覆盖,并记 provenance[路径] = 当前层号

判定 whiteout 用 is_whiteout_device(:802,认 major=0 minor=0 的字符设备),判定 opaque 用 xattr(:806)。

合并后把 overlayfs 指令"吃掉"。merge_layer(那个会保留 whiteout 供再叠)不同,provenance 版消费掉 whiteout 和 opaque xattr,输出的是干净的最终态(:704 附近调用 strip_opaque_xattrs,定义在 :792)。因为 fsmeta 要的是"最终有哪些文件",不是"怎么叠出来的"。

3.6 EROFS 只读镜像:把树烤成内核能挂的格式

要解决的小问题: 有了内存里的树,怎么变成 microVM 内核能直接只读挂载的东西?

EROFS 是什么。 EROFS(Enhanced Read-Only File System)是 Linux 内核里的只读文件系统,专为"一次生成、多次挂载、不改"的场景设计——正好是容器/VM 的 rootfs。microsandbox 自己实现了一个最小 EROFS 写入器(erofs/writer.rs)和读取器(erofs/reader.rs,只支持自己写出的那个子集)。

每层单独烤一个 EROFS。 主流水线在每层摄取完树后,spawn_blocking 里调 write_erofs(erofs/writer.rs:149)把这一层的树写成 layers/<diff_id>.erofs,并返回一张 ErofsDataMap(:41)——记录每个文件的数据落在镜像里的第几块、多大。这张 map 是后面 fsmeta"指回数据块"的凭证。

write_erofs 分五趟(writer.rs:153-170):规划布局并分配 NID → 写数据块(顺便填 data map)→ 写元数据(inode + 目录项)→ 写超级块 → 补齐到 4096 块 + 512 扇区对齐(为了 virtio-blk)。目录项按名字排序打包进块,内核对每块做二分查找(writer.rs:372 附近的注释)。

写完立刻把树里的文件数据丢掉。 层 EROFS 一落盘,tree.strip_file_data()(tree/model.rs:522)把树里每个文件的数据清空、只留目录结构和元数据——因为接下来的 fsmeta 合并只需要结构,数据已经在层 EROFS 里了。这是控制内存的关键一手。

3.7 fsmeta + VMDK:零拷贝地把层拼成一块盘

这是整章最巧的一步,单独拉到 §4 讲透。一句话预告:合并后的 rootfs 不复制任何文件数据——fsmeta 是一张只装元数据的 EROFS,它的文件 inode 用"块索引"通过 EROFS 设备表指向各层 EROFS 里的数据块;VMDK 描述文件再把 [fsmeta][layer0][layer1]… 摆成一块连续虚拟盘。


4. 深入实现:fsmeta 设备表与 VMDK 拼接

4.1 为什么不直接写一个"合并后的大 EROFS"

技术上完全可以:把合并树连数据一起写成一个巨大的 EROFS。但那样每拉一个新镜像都要把所有共享层的数据再拷一遍——python:3.12python:3.12-slim 共享的 glibc 层会被复制两份。microsandbox 的选择是:层数据只存一份(在各自的 layers/<diff_id>.erofs 里),合并只在"元数据 + 指针"层面发生。

4.2 fsmeta:只有元数据的 EROFS

write_fsmeta(erofs/fsmeta.rs:97)接收合并树、provenance 表、和各层的 ErofsDataMap,产出一张特殊 EROFS:

  • 目录、符号链接:照常用扁平布局,数据(目录项)就在 fsmeta 自己里。
  • 普通文件:用 chunk-based inode(EROFS_INODE_CHUNK_BASED,format.rs:45)——inode 本身不含数据,而是跟一串块索引,每个索引写着"到几号设备、第几块去取"。

设备表把"层"编号成"设备"。 fsmeta 头部写一张设备表(write_device_table,fsmeta.rs:212),每层一个槽位,记录该层 EROFS 的总块数和它在最终虚拟盘里的起始块地址(mapped_blkaddr,累加得出)。设备 0 是 fsmeta 自己,设备 i+1 是第 i 层。

块索引 = (设备号, 起始块)。 write_chunk_indexes(fsmeta.rs:912)对每个 chunk-based 文件,用 provenance 查到它来自第几层,取 device_id = layer_idx + 1,再从那层的 data map 查到文件的起始块,写进索引条目(fsmeta.rs:919-934)。于是内核读这个文件时,顺着 inode → 块索引 → 设备表 → 落到某层 EROFS 的某块,数据一个字节都没搬

fsmeta.erofs 各层 EROFS(独立缓存,零拷贝)
┌───────────────────────────┐
│ 超级块 + 设备表 │ 设备1 → layers/<diffA>.erofs
│ dev1: blocks=…, addr=… │───┐ 设备2 → layers/<diffB>.erofs
│ dev2: blocks=…, addr=… │ │
├───────────────────────────┤ │ /usr/bin/python 的数据:
│ 目录树 inode(数据在本地) │ │ inode(chunk-based)
│ /usr/bin/python inode: │ └──► 块索引[(dev=1, block=1234), …]
│ 块索引 ─────────────────────────► 实际字节在 设备1 的 1234 块
└───────────────────────────┘

4.3 VMDK:把多个 EROFS 摆成一块盘

设备表里的"起始块地址"要落到一个真实的连续地址空间里。write_vmdk_descriptor(stitch/vmdk.rs:20)干这个:它写一个 VMDK flat 描述文件(纯文本,不含数据),把 [fsmeta][layer0][layer1]… 每个文件当作一个 extent,声明"从盘的第几扇区起、映射到哪个文件"。

VMDK 描述文件(twoGbMaxExtentFlat) 虚拟盘地址空间
RW <n0> FLAT "…/fsmeta.erofs" 0 ┌──────────┐ 0
RW <n1> FLAT "…/layerA.erofs" n0 │ fsmeta │
RW <n2> FLAT "…/layerB.erofs" n0+n1 ├──────────┤ n0 ← 设备表里 dev1 的 addr
… │ layerA │
├──────────┤ ← dev2 的 addr
│ layerB │
└──────────┘

每个 extent 必须 512 字节对齐(所以 §3.6 里 EROFS 结尾要补到 512 扇区),超过 2 GiB 的文件拆成多行(MAX_EXTENT_SECTORS,stitch/vmdk.rs:9)。VM 挂载这个 .vmdk,内核只认识一块盘;盘头是 fsmeta(块 0 的 EROFS 超级块),内核挂载它、顺着设备表和块索引就能读到后面各层的数据。这就是"按引用组装 rootfs"的全貌。

4.4 相位级幂等:只重建缺的那部分

materialize_layers_and_fsmeta(client.rs:674)不是无脑重来,而是先看哪些产物已在:层 EROFS、fsmeta、VMDK 各查一遍(client.rs:709-716)。

已有状态动作
层 + fsmeta + VMDK 全在,非 force直接返回,啥都不干
层 + fsmeta 在,只缺 VMDK只重拼 VMDK(regenerate_vmdk_only,client.rs:1169)
fsmeta 缺强制层重新产出树(否则缓存层会短路成 tree=None),再生成 fsmeta
某些层缺只补那几层

fsmeta/VMDK 生成同样用镜像级 flock 保护、拿锁后再查一遍(client.rs:994),防并发重复生成。写产物一律临时文件 + 原子 rename(client.rs:1124:1147)。

4.5 并发流水线的骨架

层任务不是顺序跑,而是每层 tokio::spawn 一条任务,用一个信号量限制同时活跃数(client.rs:738,上限 MAX_LAYER_PIPELINE_CONCURRENCY = 16,:49;实际取值按 CPU 核数算,layer_pipeline_concurrency,:1526)。任务内部:拿信号量 → 查缓存(命中且非 force 就返回 tree=None)→ 下载 → 拿层级 flock → 再查一遍 → 摄取 tar → 校验 diff_id → 写 EROFS → strip 树。全部完成后 wait_for_layer_tree_pipeline(client.rs:1425)收集结果,保留第一个错误并整体失败。

重复层的坑。 一个 manifest 里可能有相同 digest 的重复层。代码对此有专门处理:重复 diff_id 时只有第一个任务真建 EROFS,其余拿到缓存返回 tree=None,合并阶段再按 diff_id 把树克隆回各位置(client.rs:1026-1058)。强制重下会和"另一个任务正要读同名 tar"竞争,所以下载故意不 force、只有 materialization 才 force(client.rs:730 附近的长注释)。


5. 巧妙之处(可借鉴的技术)

  • "按引用合并"而非"拷贝合并"。 层数据只存一份,合并只发生在元数据 + 指针层。共享层在多个镜像间天然零成本复用。核心在 fsmeta 的 chunk-based inode + 设备表(erofs/fsmeta.rs:912)加 VMDK 拼接(stitch/vmdk.rs:20)。

  • 直接生成目标格式,跳过"解包到目录"这一步。 别的运行时:tar → 解包成目录 → overlayfs 挂载。microsandbox:tar → 内存树 → 直接烤 EROFS。少一次全量落盘、产物即挂载单元、天然内容寻址可缓存(erofs/writer.rs:149)。

  • 两道 digest 关,职责分明。 压缩层 digest 边下边算保下载完整性(layer/download.rs:238);解压 diff_id 保内容信任(config 里签过名,client.rs:890)。

  • 相位级幂等 + 内容寻址,让重复 pull 几乎免费。 早缓存命中检查逐项验产物(client.rs:1457),有效性用"4096 对齐"这个便宜判据(cache/store.rs:427)。

  • 跨进程 flock + 原子 rename + 从不删锁文件。 多个进程/沙箱可安全并发拉同一镜像;续传靠 .part 三态机(layer/download.rs:348);稳定 inode 防 TOCTOU(client.rs:325)。

  • 大文件 spool 到磁盘,树只留指针。 摄取多 GB 层不爆内存(SPOOL_THRESHOLD,tree/model.rs:84);写完 EROFS 立刻 strip_file_data(:522)进一步降内存。

  • 快照 = 内容寻址的清单。 snapshot::Manifest(snapshot/manifest.rs:97)用规范字节形式(字段声明序、map 键排序、无省略)的 SHA-256 作为身份(:198),跨进程/平台稳定;它用 manifest_digest 指回不可变的镜像下层、用 upper 记录可写上层(ext4),从而把"从某镜像+某次改动"钉成一个可复现的构件。


6. 边界与局限

  • 本章只到"生成只读盘"。 rootfs 挂进 VM 之后客机里的可写上层、写时复制、主机穿透属于 04-guest-filesystem。ext4 formatter(ext4/formatter.rs)在本 crate 里,但它格式化的是空的可写上层,不是镜像内容——本章只顺带一提。

  • SDK 层的 Image 句柄封装(sdk/rust/lib/image)不在本章;本章讲的是 crates/image 这个底层 crate。

  • EROFS reader 是"够用就好"。 erofs/reader.rs 明确只支持自己写出的子集:扩展 inode、非压缩、排序目录项,不支持共享 xattr / 压缩 / chunk(reader.rs:1-8 的注释)。它主要给 Append 补丁等场景读回自己的镜像用(read_file_from_erofs,reader.rs:981),不是通用 EROFS 实现。

  • 快照链是半成品。 SnapshotFormat::Qcow2Manifest::parent 字段已在 schema 里占位,但今天只产 Rawparent 恒为 null(snapshot/manifest.rs:43:107)——为将来 qcow2 backing chain 预留、不做 schema 迁移。

  • OS 恒为 Linux。 平台解析只为多架构 index 选架构,OS 硬编码 linux(platform.rs:57);非 Linux 客机不在设计内。


7. 横向对比

同 shelf 的其它沙箱运行时(e2b、aio-sandbox、cua、k8s-agent-sandbox)大多复用现成容器栈(containerd / Docker / Firecracker 自带的镜像路径),把"镜像 → rootfs"外包给底层。microsandbox 的特别之处是自己从 OCI registry 一路手写到 EROFS/VMDK,以换取:内容寻址的全局缓存、零拷贝层复用、以及对 microVM 块设备启动路径的完全掌控。代价是要自己实现 registry 客户端、tar 摄取、EROFS/ext4 写入器——本 crate 的分量,大半来自这份"不外包"。


8. 代码地图(导航索引)

用符号名 grep 比行号抗漂移。全部相对 crates/image/lib/

主题文件符号
对外入口 / 拉取编排registry/client.rsRegistry::pullRegistry::pull_inner
拉取策略 / 选项 / 结果pull.rsPullPolicyPullOptionsPullResult
早缓存命中检查registry/client.rsresolve_cached_pull_result_async
层级并发编排registry/client.rsmaterialize_layers_and_fsmetalayer_pipeline_concurrency
只重拼 VMDKregistry/client.rsregenerate_vmdk_only
manifest 解析registry/manifest.rsOciManifest::parseis_index
平台选择registry/client.rsplatform.rsresolve_platform_manifestresolve_platform_digestPlatform::host_linux
客户端构造 / TLS / 平台 resolverregistry/builder.rsRegistryBuilder::build
鉴权auth.rsconfig.rsRegistryAuthRegistryAuth::Basic
镜像 config 解析config.rsImageConfig::parse
层下载 / 续传 / 校验layer/download.rsLayer::downloaddetermine_download_startDownloadStart
内容寻址摘要digest.rsDigestDigest::to_path_safe
全局缓存布局cache/store.rsGlobalCachelayer_erofs_pathis_valid_erofs_artifact
跨进程锁cache/lock.rsopen_lock_filelock_exclusiveflock_unlock
tar 摄取tar/ingest.rsingest_compressed_taringest_tarHashingReader
whiteout / 路径规范化tar/ingest.rsclassify_whiteoutnormalize_path
文件树模型tree/model.rsFileTreeTreeNodeFileData
层合并 + provenancetree/model.rsmerge_layers_with_provenanceis_whiteout_device
层 EROFS 写入erofs/writer.rswrite_erofsErofsDataMap
fsmeta 写入 / 设备表 / 块索引erofs/fsmeta.rswrite_fsmetawrite_device_tablewrite_chunk_indexes
EROFS 常量 / 格式erofs/format.rsEROFS_INODE_CHUNK_BASEDEROFS_BLKSIZ
最小 EROFS 读取器erofs/reader.rsErofsReaderread_file_from_erofs
VMDK 拼接stitch/vmdk.rswrite_vmdk_descriptor
可写上层 ext4(→04)ext4/formatter.rsformat_ext4format_ext4_with_tree
快照清单snapshot/manifest.rsManifestManifest::digestSnapshotFormat