数据截至 (上游 commit aae05d825bb5)
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:177 的 Registry::pull。它返回 PullResult(crates/image/lib/pull.rs:55),里面有层的 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:59 |
PullPolicy / PullOptions | 决定"要不要联网、要不要强制重来" | pull.rs:13 / pull.rs:28 |
RegistryAuth | 匿名 or 用户名密码鉴权 | auth.rs:18 |
Platform | 多架构镜像里选哪张 manifest | platform.rs:17 |
Layer | 单层的下载 + 断点续传 + digest 校验 | layer/download.rs:35 |
GlobalCache | 内容寻址的全局缓存 + 目录布局 | cache/store.rs:65 |
Digest | OCI 摘要 sha256:…,内容寻址的键 | digest.rs:13 |
tar ingest | tar 流 → FileTree,处理 whiteout | tar/ingest.rs:190 |
FileTree / merge |