分层去重的写时复制块存储:rootfs 与内存 diff
30 秒导读: E2B 的每个沙箱都有一块根文件系统(rootfs)和一份内存快照。它们不是各存一整份大文件,而是被切成块,表达成一叠 diff 层:base 模板占底层,之后每次改动只记录变过的块。读某个偏移时,先按一张映射表定位到"这个块归哪个 build 层",本地没有就从远端只拉这一块。这套设计让"从快照秒级恢复沙箱"和"增量地攒出模板"共用同一套块存储引擎。
本章讲这套块存储引擎:块设备怎么抽象、写时复制怎么做、层怎么叠、块怎么去重、缺块怎么惰性拉、快照头是什么格式。至于内存缺页(UFFD)如何消费这些块,见 03-fast-resume-uffd.md;模板构建如何写出这些层只在本章点到为止,细节见生命周期章节。
1. 这是什么(零基础也能懂)
一句话定义: 把 rootfs 和内存快照当成一块虚拟磁盘,这块磁盘的内容不是一整份文件,而是若干"只记录改动"的层(diff)按内容叠加而成;运行时的写入落到一个独立的可写层,原层永不被改。
它解决什么问题? 想象你要同时跑上千个沙箱,每个都基于同一个几百 MB 的模板:
- 如果每个沙箱都复制一整份 rootfs + 内存,磁盘和网络会瞬间爆炸。
- 如果恢复一个沙箱要先把整份内存快照下载下来,"秒级恢复"就无从谈起。
这套块存储的答案是三件事叠在一起:
| 技巧 | 白话 | 好处 |
|---|---|---|
| 分层(layering) | 只存"和父层不一样的块" | 上千沙箱共享同一个 base,增量极小 |
| 去重(dedup) | 连和父层一样的块都不存 | diff 更小、恢复要拉的更少 |
| 写时复制(COW) | 运行时的写落到独立可写层 | 只读底层可被无数沙箱共享 |
| 惰性拉取(lazy fetch) | 只有真正被读到的块才从远端下载 | 恢复不必等整份快照到齐 |
一句话直觉: 像 Git。base 模板是初始 commit;每次 pause 沙箱产出一个"只含 diff 的 commit";恢复时你 checkout 出的那份磁盘,是把这一串 commit 叠起来算出来的,而且文件内容按需才从远端仓库拉。
它长什么样(概念代码,示意非源码):
// 恢复一个沙箱:并不下载整份 rootfs,只是搭一个"叠好的视图"
device := assembleFromHeader(header) // 按 Header.Mapping 把各 build 层叠成一块虚拟盘
overlay := block.NewOverlay(device, cache) // 再套一层可写缓存:写落到 cache,读缺块回落到 device
// 沙箱开始跑;它读到某个偏移时,才触发那一块的惰性拉取
本节不出现底层细节。记住一件事:磁盘 = 一叠 diff 层 + 一个可写层,块按需才真正存在。
2. 顶层全景(它大概怎么转)
2.1 两个关键角色:块设备 与 头
整套系统围绕两类对象:
- 块设备(block device):对上暴露"给我第 off 处 length 字节"的接口,对下决定这些字节从哪来(本地文件、全零、还是远端)。定义在
packages/orchestrator/pkg/sandbox/block/device.go:34的ReadonlyDevice/Device接口。 - 头(Header):一块虚拟盘的"目录",记录每个偏移的块归哪个 build 层、在那个层的存储里排第几。定义在
packages/shared/pkg/storage/header/header.go:29的Header。
2.2 部件一句话职责
| 部件 | 干什么 | 文件:符号 |
|---|---|---|
Local | 把一个本地文件当只读块设备(base 模板 rootfs) | block/local.go:17 Local |
Empty | 一个"全是零"的只读块设备(空内存) | block/empty.go:15 Empty |
Cache | mmap 出来的可写层,记录哪些块脏了/被清零 | block/cache.go:51 Cache |
Overlay | 把只读底层 + 可写 Cache 叠成一个可写设备(COW 的核心) | block/overlay.go:14 Overlay |
Chunker | 远端对象的本地惰性缓存:缺块才按 chunk 拉 | block/streaming_chunk.go:26 Chunker |
Header / Mapping | 偏移 → build 层的映射;快照的"目录" | header/header.go:29、header/compact.go:18 |
File | 把一叠 build 的 diff 组合成一块可读虚拟盘 | sandbox/build/build.go File.planRead |
StorageProvider | 远端后端(GCS/S3/本地 FS),按 C-space 取帧 | shared/pkg/storage/storage.go:107 |
2.3 一次"读一个块"的旅程(高层,不进代码)
怎么读这张图:从上到下是一次读的下钻顺序,命中即停。
虚拟盘偏移 off (想读一块)
│
▼
┌────────────────────┐
│ File.planRead │ 查 Header.Mapping:off 归哪个 build 层?
│ (build.go) │ (uuid.Nil 段 = 空洞,直接填零)
└─────────┬──────────┘
│ 命中 build X 的 diff
▼
┌────────────────────┐ 本地 mmap 有? ┌───────────────┐
│ StorageDiff │──────── 有 ─────▶│ 直接返回该块 │
│ └ Chunker.Slice │ └───────────────┘
└─────────┬──────────┘
│ 没有(BytesNotAvailableError)
▼
┌────────────────────┐
│ 远端对象存储 │ 按 FrameTable 把 U-space 偏移翻成 C-space 帧,
│ storage_*.go │ 只取这一帧、解压、写进 mmap,标记已缓存
└────────────────────┘
关键点:读永远先问本地,缺了才拉远端,而且拉的是"含这个偏移的那一帧",不是整份文件。 这就是"只取被访问的块"。
3. 核心原理(逐个机制,由浅入深)
3.1 块设备抽象与写时复制(Overlay)
要解决的小问题: 上千个沙箱基于同一个 base rootfs。base 必须只读、可共享;可每个沙箱又要能写盘。怎么让"写"不污染共享的 base?
思路: 写时复制。底层设备只读,另开一个可写层 Cache;读先问可写层,缺了才回落到只读底层;写只落到可写层。 底层一个字节都不改,于是能被任意多沙箱共享。
图示(怎么读:写只走 cache,读缺块才回落 device):
运行中的沙箱
│写 │读
▼ ▼
┌ ───────────────────────────┐
│ Overlay │
│ cache (可写 mmap 层) ──┼─ 写落这里;读先查这里
│ device (只读底层) ──┼─ 读缺块(BytesNotAvailable)才落这里
└───────────────────────────┘
device = Local(base 文件) / Empty(全零) / Chunker(远端)
真实实现: Overlay.ReadAt 逐块先问 cache,拿到 BytesNotAvailableError 才转 device:
// overlay.go:37-49(节选)
n, err := o.cache.ReadAt(p[blockOff:blockOff+o.blockSize], off+blockOff)
if err == nil { continue } // 可写层命中
if !errors.As(err, &BytesNotAvailableError{}) { ... }
n, err = o.device.ReadAt(ctx, p[blockOff:...], off+blockOff) // 回落只读底层
而写只碰 cache,底层纹丝不动:Overlay.WriteAt 直接 o.cache.WriteAt(p, off)(block/overlay.go:71)。
只读底层的三种形态:
| 底层 | 代表 | Slice 行为 | 文件 |
|---|---|---|---|
Local | base 模板 rootfs 文件 | 从本地文件 ReadAt | block/local.go:86 |
Empty | 空内存 / 未初始化区 | 直接返回一段零(make([]byte, length)) | block/empty.go:56 |
Chunker | 存在远端的某层 diff | 缺块惰性拉(见 3.3) | block/streaming_chunk.go:72 |
Empty 有个精妙细节:它把整块盘映射成一个 BuildId: uuid.Nil 的 BuildMap(block/empty.go:26),uuid.Nil 是"空洞"哨兵——上层遇到它直接填零、永不读存储,零字节因此天然免存、免传。
可写层的关键:清零 = 打洞(punch hole)。 当沙箱把某块写成全零,Cache 不去存一堆零,而是 MADV_REMOVE 把底层页释放掉,并把该块标记为 Zero:
// cache.go:471-475 punchHole
if err := unix.Madvise((*c.mmap)[off:off+length], unix.MADV_REMOVE); err != nil {
clear((*c.mmap)[off : off+length]) // 内核不支持就退化为清零
}
写路径 WriteAtWithoutLock 还会把连续同态(全零/非零)的块合并成一次批量拷贝或一次打洞(block/cache.go:498 的 flush 闭包),对应 NBD 的 detect-zeroes=unmap 语义。
3.2 分层:一个 build 由多层 diff 叠成,按内容寻址
要解决的小问题: 恢复一个沙箱时,它的 rootfs 逻辑上是"base + 第 1 层改动 + 第 2 层改动 + …"。读某个偏移,怎么快速知道"这个块的最新版本在哪一层"?
思路:一张事先算好的映射表(Mapping)。 不是读的时候逐层去试,而是每次产出新层时就把映射合并好:每个偏移直接指向"最上面有数据的那一层"。
图示(怎么读:每一列是同一个偏移,取最上面有数据的层):
偏移 → 0 1 2 3 4 5 6 7
build C · · █ · · · █ · ← 最近一次 pause 的 diff(只改了 2、6)
build B · █ █ █ · · · · ← 某个模板层
build A █ █ █ █ █ █ █ █ ← base 模板(覆盖全盘)
─────────────────────────────────────
合成结果 A B C B A A C A ← Mapping 里每个偏移记下的归属
按内容寻址(content addressing): 每个 build 有唯一的 BuildId(UUID),它的数据存在远端 {buildID}/rootfs.ext4、{buildID}/memfile 这样的路径下(shared/pkg/storage/paths.go:66 DataFile)。层与层之间靠 BuildId 引用,而不是靠"第几层"——所以同一个 base build 能被无数子快照共享。
映射的数据结构 BuildMap: 一段虚拟盘范围 → 某个 build 的存储里的一段(header/mapping.go:14):
type BuildMap struct {
Offset uint64 // 虚拟盘里的起始偏移
Length uint64
BuildId uuid.UUID // 归哪个 build(uuid.Nil = 空洞)
BuildStorageOffset uint64 // 在那个 build 的存储文件里的偏移
}
层怎么合并成一张表: 新 diff 产出时,MergeMappings 把新层叠到旧层之上(重叠处新层胜出,header/mapping.go:52),NormalizeMappings 再把相邻的同 build 段并起来(header/mapping.go:219)。合并有一处必须小心的正确性点:同 build 的相邻段只有在存储偏移也连续时才能合并,否则会把读悄悄映射到错误的字节——注释在 header/mapping.go:207-217 明确说了这一点。
读时怎么用这张表: File.planRead 从 off 开始,反复 GetShiftedMapping 问"当前偏移归谁",把一次读拆成若干"落到某个 build 的段":
// build.go:212-240(节选)
mappedToBuild, err := h.GetShiftedMapping(ctx, off+int64(n))
...
if mappedToBuild.BuildId == uuid.Nil { // 空洞
clear(p[n : n+int(readLength)]) // 直接填零,不碰存储
continue
}
diff, err := b.cachedBuild(ctx, mappedToBuild.BuildId, ...) // 找到这一层的 diff 设备
segments = append(segments, readSegment{ srcOff: int64(mappedToBuild.Offset), diff: diff, ... })
GetShiftedMapping 内部是对 Mapping 的一次二分查找(header/compact.go:240 SearchOffset),再把虚拟偏移换算成 build 内的存储偏移(header/header.go:161)。
3.3 惰性拉取:按块从远端流式取
要解决的小问题: 恢复沙箱时,某层 diff 存在远端。等它整份下载完再启动就太慢。能不能只在真正读到某块时才去拉那一块?
思路: 用 Chunker 包住远端对象。读先查本地 mmap;缺块时,定位"含这个偏移的 chunk"(压缩数据里就是一帧),后台起一个 goroutine 流式把这个 chunk 读进 mmap,读到哪就唤醒等到哪一块的读者。
图示(怎么读:从上到下,命中即返回;缺块走一次后台拉取):
Slice(off) ─▶ cache.Slice 命中? ─是─▶ 直接返回(source=mmap)
│否 (BytesNotAvailableError)
▼
locateChunk:定位含 off 的 chunk(压缩=一帧;非压缩=4MB 对齐)
▼
getOrCreateSession:同 chunk 已有在飞的会话就复用,不重复拉
▼
后台 goroutine runFetch:OpenRangeReader 流式读入 mmap
▼ 每读一批 → advance(bytesReady) 唤醒等待者
registerAndWait 等到"本块就绪" ─▶ setIsCached(整块)→ 下次走命中快路
真实实现的几处要点:
-
命中快路 vs 缺块慢路 在
Chunker.Slice(streaming_chunk.go:72):先c.cache.Slice(off, length),只有返回BytesNotAvailableError才进入 fetch 循环。 -
同一 chunk 只拉一次。
getOrCreateSession(streaming_chunk.go:127)在锁内扫描在飞会话,contains命中就复用;否则新建一个fetchSession并脱离调用方的取消信号起后台 goroutine(context.WithoutCancel),这样第一个读者即使超时走了,拉取仍继续、造福后续读者。 -
"读到哪、唤醒到哪"的等待机制。
fetchSession用一个原子的bytesReady(只增)记录"从 chunk 起已就绪的字节数"。progressiveFetch每读一批就advance(totalRead)(streaming_chunk.go:318);读者registerAndWait先走无锁快路bytesReady.Load() >= endByte,不满足才上条件变量等(fetch_session.go:85-124)。 -
先标缓存,再放行读者。
runFetch在整块拉完后先setIsCached(整块)再setDone(),注释点明这是为了闭合getOrCreateSession里的 TOCTOU 窗口(streaming_chunk.go:246-249)。 -
chunk 边界从哪来。
locateChunk(streaming_chunk.go:353):压缩数据用FrameTable.LocateUncompressed按帧对齐,非压缩数据按storage.MemoryChunkSize(4 MB)对齐。
谁在缓存里跟踪"哪些块到了": Tracker(block/tracker.go:22)。它用两个 roaring 位图分别记 Dirty(有实体数据)和 Zero(已知全零),两者不相交。isCached 判定一段是否"每块都已观测到"就是问 tracker.Present(tracker.go:98),用位图基数求和一次算完。
顺带一提:prefetch。 PrefetchTracker(block/prefetch_tracker.go:40)按访问顺序记录哪些块被读到、是读还是写触发的(Add,prefetch_tracker.go:65)。这份访问序(PrefetchData,:86)可用于构建时决定"哪些块该主动预取",让下次恢复的缺页更少。它只记录访问,不改变上面的读路径。
3.4 去重:内存 / rootfs diff 的块级去重
要解决的小问题: 沙箱运行时把某块内存/磁盘"写了又写",最后和 base 里那块一模一样。朴素做法会把它当"脏块"存进 diff,白白占空间、恢复时白白多拉。能不能连这种"改了但等于父层"的块也别存?
思路:导出 diff 前,逐页和父 层比对。 三种结局:全零页 → 记成 Empty(免存);和父层不同 → 记成 Dirty(存进 diff);和父层字节相同 → 去重掉,恢复时交给父层来供。
图示(一页的三条去向):
┌─ 全零? ─是─▶ pageEmpty(不存,标空洞)
脏页 srcPage ──┼─ 和父层相同? ─是─▶ 去重(不存,恢复时读父层这一帧)
└─ 都不是 ────▶ pageDirty(存进 diff 文件)
真实实现: 分类逻辑在 dedupCompare(block/dedup.go:90),按 header.PageSize(4 KiB)逐页判定:
// dedup.go:130-162(节选)
if header.IsZero(srcPage) { plan.pageEmpty.Add(pageIdx); continue } // 全零
...
basePage, _ := base.Slice(ctx, pageOff, header.PageSize)
if !bytes.Equal(srcPage, basePage) { plan.pageDirty.Add(pageIdx); continue } // 和父层不同 → 存
// 相同 → 落入 parentByKey,按"父层要取的那一帧"归组,准备去重
IsZero(header/diff.go:24)是个小优化:先采样首/中/尾三字节快速否掉大多数非零页,再用 bytes.Equal(b[:n-1], b[1:]) 确认全等。
去重的成本权衡(精妙处): 去重虽省空间,却可能让恢复时碎片化——本来一帧能供的块,现在散落到父层多帧,冷恢复要多发好几次网络取。DedupBudget(block/dedup.go:51)因此设了两道"回补"闸门:
promoteCheapFrames:若某个父层帧"很便宜"(去重省下的取回,比把它整帧塞进 diff 更不划算),就把它提升回 diff 存下来(dedup.go:338)。compactBlockWindows:限制单个块最多跨多少个取回窗口,超了就把最便宜的父层页提升进 diff(dedup.go:225)。
被提升的页和父层字节相同,所以提升绝不改变恢复出的镜像,只影响"存多少 / 取多少"的取舍。这是本模块工程含量最高的一段。
去重后怎么落盘: Cache.Dedup(block/cache.go:296)先跑 dedupCompare 定分类,再 dedupDrain 把 pageDirty 的页紧凑打包(按 PageSize 无空隙)写出一个新 cache 文件(dedup.go:207),并返回一份 DiffMetadata(Dirty/Empty 位图 + 块大小)。内存快照的调用点在 fc/memory.go:135。
rootfs 的 diff 导出(不去重的那条) 走 Cache.ExportToDiff(block/cache.go:109):它把脏块用 copy_file_range 拷进输出文件——在 XFS 上自动走 reflink(块级共享,零拷贝),不支持时回退到普通拷贝(cache.go:159-192)。调用点在 rootfs/nbd.go:108。
从位图到映射: 无论哪条路,产出的 DiffMetadata 最后经 ToDiffHeader(header/metadata.go:127)变成一个新 Header——脏块映射到本 build,空块映射到 uuid.Nil,再和父 Header 的映射 MergeMappings + NormalizeMappings。这就把 3.2 那张"叠层映射表"接了起来。
3.5 快照头与磁盘格式:目录怎么存、怎么演进
要解决的小问题: 上面那张 Mapping 表本身也要存(作为每层的 .header sidecar)。当内存按 4 KiB 页去重后,一层能有上百万条映射;头如果存得笨,自己就成了内存和带宽的大头。
两个地址空间(理解压缩的前提): 压缩后,同一份数据有两套偏移。读路径要在它们之间翻译。
| 空间 | 是什么 | 谁用 |
|---|---|---|
| U-space(uncompressed) | 解压后的逻辑偏移,也就是虚拟盘偏移 | 上层所有逻辑、Mapping |
| C-space(compressed) | 压缩文件里的物理字节偏移 | 真正向远端发 range 请求时 |
FrameTable(shared/pkg/storage/compress_frame_table.go:72)就是这两套空间的换算表:数据按帧独立压缩,每帧记 (StartU, StartC, SizeU, SizeC),LocateUncompressed(:244)/LocateCompressed 靠对 StartU 二分完成 U↔C 定位。远端读因此只需 LocateCompressed(off) 取一帧、解压(storage_google.go:614-624)。未压缩数据用哨兵 UncompressedFrameTable(:81)直接跳过翻译。
头格式的三代演进:
| 版本 | 映射怎么存 | 为什么 | 文件 |
|---|---|---|---|
| V3 | 未压缩、定长记录,无 Builds/FrameTable | 早期模板管理器用的简单格式 | serialization_v3.go |
| V4 | [Metadata][flags][size][LZ4(Builds + 映射)] | 引入压缩构建,带 FrameTable | serialization_v4.go |
| V5 | 同 V4 外壳,映射改列式 + varint | 页级去重让映射爆到百万条,V4 存不下也压不好 | serialization_v5.go |
V5 的关键在 writeV5MappingSection(serialization_v5.go:79):把 offset/length/storage 三列分开、按块索引存增量(delta)、varint 变长编码,再把 16 字节的 UUID 去重进一张按下标引用的表。注释解释了动机——V4 把三个 8 字节整数和整枚 UUID 逐条重复,交错的唯一值让 LZ4 压不动;V5 的列式规整数据能被 LZ4 大幅折叠(serialization_v5.go:15-30)。
内存里也要省。 加载后的 Mapping(header/compact.go:18)是个列式紧凑结构,每条从 40 字节(一个 BuildMap)压到 14 字节:offset/length/storage 存成 uint32 块索引,BuildId 去重进一张 uint16 索引的表。因为合并后的 Header 会被缓存最长 25 小时,在快照密集的节点上这些切片会主导主机内存——所以连反序列化都直 接重建紧凑列,避免中间的 []BuildMap(serialization_v5.go:183 readV5MappingSection)。
读写头的入口: SerializeHeader/DeserializeBytes 按版本分派(header/serialization.go:24、:43),LoadHeader/StoreHeader(:83、:128)负责从远端取/存,并对"未完成上传的头"和"超过反序列化上限的头"做防呆拒绝。
3.6 远端存储后端:内容存哪、怎么压着传
要解决的小问题: 上面所有"从远端拉一帧"最终要落到某个对象存储。E2B 要同时支持 GCS、S3 和本地文件系统,还要在上传时就把数据压好、切好帧。
统一接口: StorageProvider(shared/pkg/storage/storage.go:107)抽象了 OpenBlob(整存整取,如 .header)和 OpenSeekable(range 读,如数据文件)。GetStorageProvider(:305)按 STORAGE_PROVIDER 环境变量选后端:
| 后端 | 实现 | range 读入口 |
|---|---|---|
| GCS(默认) | NewGCP(storage_google.go:77) | gcpObject.OpenRangeReader(:599) |
| S3 | newAWSStorage(storage_aws.go:48) | awsObject.OpenRangeReader(:257) |
| 本地 FS | newFileSystemStorage(storage_fs.go:43) | fsObject.OpenRangeReader(:318) |
三者的 OpenRangeReader 签名一致:接 U-space 的 (offsetU, length, frameTable),压缩时用 FrameTable 翻成 C-space 只取该帧再套解压 reader,未压缩就直读(见 storage_google.go:599-632)。
压缩上传: compressStream(compress_upload.go:114)是上传侧的流水线——readLoop(:187)按帧大小切数据,每帧丢给一个压缩 worker 并行压,压完攒够一个 part 就并发上传;边压边算整份数据的 SHA-256(内容指纹)。压缩产出的每帧尺寸最后组成 FullFrameTable 返回,写进头——读侧就靠它做 U↔C 翻译。这样"按内容寻址、只取被访问的帧"在上传时就被准备好了。
4. 巧妙之处(可带走的技术)
-
空洞即零,零免存免传。 空区统一映射成
uuid.Nil段,读到就填零、从不碰存储(build.go:223);写全零则打洞MADV_REMOVE(cache.go:471)。零成本地"消失"。 -
去重与碎片的显式预算。 不是"能去就去",而是用
DedupBudget把"少存的字节"和"冷恢复多发的网络取"放到同一杆秤上,只在划算时才去重、否则把便宜的父帧回补进 diff(dedup.go:338、:225)。这是很少见的、把"存储省"和"恢复快"当成一个联合优化的设计。 -
拉取脱离调用方生 死。 后台
runFetch用context.WithoutCancel起(streaming_chunk.go:152),第一个读者超时也不打断拉取;bytesReady无锁快路让高并发读者几乎零锁开销(fetch_session.go:85)。 -
头格式为"百万映射"专门演进。 V5 的列式 + varint + UUID 去重(
serialization_v5.go:79)和内存里 14 字节/条的紧凑Mapping(compact.go:18),都是为了不让"目录"本身拖垮主机——因为合并头要缓存 25 小时。 -
合并映射的正确性红线。 同 build 相邻段只有存储偏移连续才合并,否则会把读悄悄指向错误字节(
mapping.go:207)。一个看似无害的优化被明确设了护栏。 -
reflink 零拷贝导出。 rootfs diff 导出用
copy_file_range,在 XFS 上自动块级共享(cache.go:159),导出一份 diff 几乎不额外占盘。
5. 边界与局限(诚实)
-
块必须对齐。 写路径要求 offset/length 是块大小整数倍(
cache.go:487),这是 NBD 的不变量;Overlay.Slice对非块大小的长度直接未实现(overlay.go:67返回 "not implemented"),因为那要跨 cache/device 拼字节。 -
映射粒度统一为 PageSize(4 KiB)。
NewHeader用PageSize压缩映射(header.go:95),Mapping的 uint32 块索引在 4 KiB 粒度下把单文件上限卡在约 16 TiB(compact.go:34),对沙箱绰绰有余但不是无限。块大小本身由调用方传入(rootfs 用RootfsBlockSize4 KiB;内存快照的 diff 去重也统一按 4 KiB 页比对)。 -
头有硬上限。 反序列化对帧数、映射条数、未压缩块大小都设了上限(如
serialization_v5.go的maxV5MappingEntries),StoreHeader还在写侧对称拦截超限头(serialization.go:160),防止"能上传却每次恢复都失败"把快照永久变砖。 -
本章不覆盖的: 内存缺页如何通过 userfaultfd 消费这些块——那是 03-fast-resume-uffd.md;沙箱编排与生命周期见 02-sandbox-lifecycle.md;模板构建如何攒出这些层本章只点到
ToDiffHeader/ExportToDiff为止。
6. 代码地图(导航索引)
| 主题 | 文件 | 符号 |
|---|---|---|
| 块设备接口(只读/可写/COW/去重源) | packages/orchestrator/pkg/sandbox/block/device.go | ReadonlyDevice、Device、CachePeeker、DiffSource |
| 写时复制叠加 | block/overlay.go | Overlay、NewOverlay、Overlay.ReadAt、EjectCache |