沙箱生命周期:Firecracker microVM 的编排
30 秒导读: 当 orchestrator 收到"给我一个沙箱"的 gRPC 请求,它要在这台节点上把一个 Firecracker microVM 真正拉起来——装配内存、磁盘、网络、cgroup,启动 FC 进程,等 VM 里的
envd守护进程应答,然后才把它标成"可路由"。本章讲的就是这条装配线,以及它反过来怎么把 沙箱暂 停成快照或销毁掉。快照内部、内存惰性加载、块存储、网络槽的实现分别交给 03/04/05。
1. 这章讲什么(先建立直觉)
上一章 01-architecture 讲了"一次创建沙箱"从 API 到 orchestrator 的旅程。 本章把镜头推到单台节点内部:一个沙箱在这里如何被拉起、暂停、销毁。
先给一个心智模型。一个"沙箱"在 orchestrator 里其实是一组东西被串在一起:
- 一个真实的 Firecracker 进程(跑着 guest 内核和用户的代码);
- 它的四样底层资源:内存后端、rootfs、网络槽、cgroup;
- 一个 清理栈(
Cleanup),记录"拆的时候要反着做哪些事"; - 一条 就绪判定:VM 里的
envd守护进程能应答了,沙箱才算"起来了"。
把这些装好、点火、确认心跳,再登记进"活沙箱表",就是一次启动;反过来对称拆掉,就是一次停止。
关键角色一览:
| 角色 | 是什么 | 在哪 |
|---|---|---|
Factory | 沙箱的工厂,持有网络池/设备池/cgroup 管理器等共享资源,CreateSandbox/ResumeSandbox/RebootSandbox 都是它的方法 | packages/orchestrator/pkg/sandbox/sandbox.go:331 Factory |
Sandbox | 一个活沙箱的句柄,内嵌 Resources(槽/rootfs/内存)和 Metadata | sandbox.go:254 Sandbox |
fc.Process | 对一个 Firecracker 进程的封装 | packages/orchestrator/pkg/sandbox/fc/process.go:135 Process |
Cleanup | 后进先出的清理栈,保证资源对称释放 | packages/orchestrator/pkg/sandbox/cleanup.go:20 Cleanup |
Map | 节点上所有沙箱的注册表,管"可见性/可路由"的状态机 | packages/orchestrator/pkg/sandbox/map.go:172 MarkRunning |
2. 三种启动类型:Create / Resume / Reboot
一切从一个区分开始。同一台节点上"拉起一个 VM"有三种语义完全不同的姿势,代码用三个常量把它们
钉死,并作为 start_type 打进启动指标:
// packages/orchestrator/pkg/sandbox/sandbox.go:64 StartType
const (
StartTypeCreate StartType = "create" // 冷启动(模板构建)
StartTypeResume StartType = "resume" // 从快照恢复(运行时最常见的路径)
StartTypeReboot StartType = "reboot" // 从快照 rootfs 冷启动(仅文件系统恢复)
)
三者的差别,一张表看清:
| 启动类型 | 常量 | 典型场景 | 恢复了什么 | 内存后端 | 就绪判定 |
|---|---|---|---|---|---|
| 冷启动 | StartTypeCreate | 模板构建时从零引导 | 无,全新引导 | NoopMemory(不惰性缺页) | 冷启动引导后等 envd |
| 秒级恢复 | StartTypeResume | 运行时最常见:从内存快照恢复 | 内存 + 进程 + socket + 文件系统 | Uffd(惰性缺页,见 03) | WaitForEnvd |
| 文件系统恢复 | StartTypeReboot | 从"仅文件系统"快照恢复 | 只有文件系统;RAM/进程/socket 全丢 | NoopMemory(全新匿名 RAM) | WaitForEnvd |
它们各对应一个入口函数:
StartTypeResume→Factory.ResumeSandbox(sandbox.go:694)——这是用户沙箱日常"唤醒"走的路。StartTypeReboot→Factory.RebootSandbox(reboot.go:39)——它内部复用CreateSandbox做冷启动,只是把start_type记为reboot。StartTypeCreate→Factory.CreateSandbox(sandbox.go:390)——模板构建时的冷引导走这里。
一个重要观察:orchestrator 的 gRPC Create 处理器并不叫"新建",它其实是"恢复"。它读快照
元数据,按"是不是仅文件系统快照"在 reboot 与 resume 之间二选一:
// packages/orchestrator/pkg/server/sandboxes.go:230
if meta.IsFilesystemOnly() {
sbx, err = s.sandboxFactory.RebootSandbox(ctx, template, config, runtime, ...)
} else {
sbx, err = s.sandboxFactory.ResumeSandbox(ctx, template, config, runtime, ...)
}
为什么必须由快照自己的元数据来裁决?因为内存快照的 rootfs 可能缺了只存在 guest page cache
里的写——那些写只有在内存恢复时才被带回来。对内存快照做冷启动,会挂载一块不一致的磁盘。
RebootSandbox 因此设了一道安全闸,拒绝对非"仅文件系统"的快照冷启动(reboot.go:63)。
3. 顶层全景:一次 Resume 的旅程
先看最常见的 ResumeSandbox。它的骨架是:并发装配四样资源 → 造 FC 进程 → 恢复 VM →
等 envd → 登记为可路由。
怎么读这张图:从上往下是时间顺序;中间那段四条支线是并发装配(用 Promise 并行跑),
后面几步才是串行。
ResumeSandbox (sandbox.go:694)
│
│ ==== 并发装配资源(promise) ====
├─ 网络槽 getNetworkSlot ──────→ 细节见 05
├─ rootfs NBDProvider(overlay)──────→ 细节见 04
├─ 内存 serveMemory(uffd) ──────→ 细节见 03
└─ cgroup createCgroup
│
│ ==== 串行点火 ====
▼
fc.NewProcess 装 FC 启动脚本 / unshare 挂载空间 / 备好 socket
│
▼
fc.Resume loadSnapshot(挂 uffd)→ resumeVM → 写 MMDS 元数据
│
▼
WaitForEnvd 反复 POST /init,直到 guest 里的 envd 回 204
│
▼
MarkRunning 进 live 表 → 现在可被 Get / 路由 / 计数
一句话主线:资源先并行备齐,FC 才能被 loadSnapshot 拉起;VM 恢复运行后还不算数,必须
等 envd 应答;应答了才把沙箱"点亮"为可路由。 下面逐段拆开。
4. 装配资源:Create 与 Resume 的主流程
4.1 四样资源,装进 Resources
不管 Create 还是 Resume,一个沙箱最终都要凑齐这四样,打包进 Resources 结构:
// packages/orchestrator/pkg/sandbox/sandbox.go:212 Resources
type Resources struct {
Slot *network.Slot // 网络槽:tap/veth/netns(见 05)
rootfs rootfs.Provider // 写时复制的根文件系统(见 04)
memory uffd.MemoryBackend // 内存后端:Uffd 或 NoopMemory(见 03)
}
Create 与 Resume 在"内存后端"上分道扬镳,这是两条路径最本质的区别:
- Create(冷启动)用
uffd.NewNoopMemory(...)(sandbox.go:528)——VM 的 RAM 是 FC 自己 新分配的匿名内存,没有"从快照惰性缺页"这回事。 - Resume(秒级恢复)用真正的
uffd.New(...)(sandbox.go:744),并在serveMemory里把它 启动起来监听 uffd socket。VM 访问某页内存时才按需从快照拉进来——这就是"秒级恢复"的核心, 机制留给 03-fast-resume-uffd。
4.2 Resume 用 Promise 并行装配
Resume 对启动延迟很敏感,所以它把慢活儿都包成 utils.Promise 并行跑:内存 uffd、 网络槽、
rootfs overlay、内存服务各起一个 promise,最后统一 Wait(sandbox.go:847-875)。
网络槽的获取就是个典型 promise——从池里借一个槽,并当场把归还登记进清理栈:
// packages/orchestrator/pkg/sandbox/sandbox.go:1657 getNetworkSlot
return utils.NewPromise(func() (*network.Slot, error) {
slot, err := networkPool.Get(ctx, networkConfig) // 从池借一个槽
if err != nil { return nil, err }
cleanup.Add(ctx, func(ctx context.Context) error { // 反向登记:拆时归还
return networkPool.ReturnAsync(ctx, slot, networkReleased, network.ReturnDelay)
})
return slot, nil
})
要记住的模式:每借到一样资源,立刻把"怎么还它"压进 cleanup。 这样无论后面哪一步失败,
defer cleanup.Run 都能把已经装好的东西对称拆干净——这是整条装配线的安全网(§7 详述)。
4.3 装配顺序与"惰性缺页前的隔离"
Resume 有一处顺序上的讲究:如果这是个网络隔离的沙箱(如快照预取用的一次性沙箱),
必须在 fcHandle.Resume 之前就 DenyEgress:
// packages/orchestrator/pkg/sandbox/sandbox.go:859
if ropts.denyEgress {
if err := ips.DenyEgress(ctx); err != nil { ... }
}
原因写在源码注释里:ResumeSandbox 会阻塞到 envd init 完成,等拿到句柄再断网就晚了——
那时 envd 初始化甚至短暂解冻的负载都可能已经出网了。这是"生命周期顺序影响安全边界"的一个缩影。
4.4 造 FC 进程 → 恢复 → 等 envd → 点亮
资源备齐后,Resume 的收尾是四步串行(对应第 3 节图的下半段):
fc.NewProcess(sandbox.go:896)——组装但还没启动 Firecracker(见第 5 节)。fcHandle.Resume(sandbox.go:1024)——真正loadSnapshot+resumeVM。sbx.WaitForEnvd(ctx, StartTypeResume, ...)(sandbox.go:1061)——等 guest 就绪(见第 6 节)。f.Sandboxes.MarkRunning(ctx, sbx)(sandbox.go:1076)——登记为可路由。
注意 MarkRunning 排在 WaitForEnvd 之后:沙箱在 envd 应答前不可被路由,避免流量打到
一个还没准备好的 VM。Create 冷启动默认在函数末尾就 MarkRunning(sandbox.go:635),但 reboot
路径会用 WithDeferredMarkRunning() 把它推迟到 envd 就绪之后(见第 8 节),对齐 Resume 的路由保证。
5. Firecracker 集成:fc/ 包怎么点火
fc/ 包把"和 Firecracker 打交道"这件事收拢成几个文件,各司其职:
| 文件 | 职责 | 关键符号 |
|---|---|---|
process.go | FC 进程的启动/配置/暂停/停止/快照 | NewProcess,configure,Create,Resume,Stop,Pause,CreateSnapshot |
config.go | 版本号 → 内核/FC 二进制路径,rootfs 路径格式 | Config,RootfsPaths,ConstantRootfsPaths |
script_builder.go | 生成 FC 启动 shell 脚本 | StartScriptBuilder,startScriptV2 |
kernel_args.go | 把 map 拼成内核命令行字符串 | KernelArgs.String |
mmds.go | 定义写给 guest 的 MMDS 元数据结构 | MmdsMetadata |
memory.go | 暂停时从 FC 进程导出脏内存页(供快照,见 03) | ExportMemory,MemoryInfo |
5.1 NewProcess:组装启动脚本,但先不点火
NewProcess(process.go:159)不启动 FC,只是把命令备好。它先让 StartScriptBuilder 生成
一段 bash 启动脚本,再把它包进 unshare -m(新挂载命名空间)里:
// packages/orchestrator/pkg/sandbox/fc/process.go:195
cmd := exec.CommandContext(execCtx, "unshare", "-m", "--", "bash", "-c", startScript.Value)
那段启动脚本(模板 startScriptV2,script_builder.go:55)做三件事:把挂载空间设为私有、用
tmpfs + 软链把 rootfs 和内核摆到 FC 期望的路径、最后 进网络命名空间拉起 firecracker 二进制:
mount --make-rprivate /
mount -t tmpfs tmpfs {SandboxDir} # 一块干净的临时挂载点
ln -s {HostRootfsPath} .../rootfs.ext4 # 软链真实 rootfs
ln -s {HostKernelPath} .../vmlinux.bin # 软链内核
ip netns exec {NamespaceID} {firecracker} --api-sock {socket}
进程真正被 Start() 是在 configure(process.go:227)里,它同时:接管 stdout/stderr、通过
CLONE_INTO_CGROUP 把进程原子地放进沙箱 cgroup(process.go:254)、建好 metrics FIFO、然后
socket.Wait 等 FC 的 API socket 出现(process.go:307)——socket 一出现就能用 FC HTTP API 了。
5.2 Create:一条条 FC API 调用把机器配起来
冷启动的 Process.Create(process.go:319)是"照着 FC REST API 把 VM 配置齐全再点火"的直白流水:
| 步骤 | FC API 调用 | 干什么 |
|---|---|---|
| 引导源 | setBootSource | 内核路径 + 内核命令行参数 |
| rootfs | setRootfsDrive | 挂载根盘(带限速 rate limiter) |
| 网络 | setNetworkInterface | tap 设备 + MAC,MMDS 传输也随之就绪 |
| 机器 | setMachineConfig | vCPU 数、内存 MB、是否 hugepages |
| 熵源 | setEntropyDevice | virtio-rng |
| 气球 | installBalloon | 可选,free-page reporting/hinting |
| 元数据 | setMmds | 可选,写 guest 元数据(见 5.4) |
| 点火 | startVM | 真正开机 |
内核命令行由 KernelArgs(一个 map[string]string)拼成,Create 里定义了一串生产参数——
关掉内核日志提速、配 IPv4/IPv6、panic=1、rootflags=discard(让 ext4 对释放块发 TRIM,从而
被快照 diff 省掉)等(process.go:369)。KernelArgs.String()(kernel_args.go:13)把它排序拼成
key=value 串。
5.3 Resume:从快照拉回一整台机器
恢复路径的 Process.Resume(process.go:513)比 Create 少了"逐项配置",多了"整体加载":
- 用
errgroup并发做三件事:configure启动 FC 进程、等 uffd socket、软链 rootfs(process.go:534-585)。 - 核心是
loadSnapshot(process.go:605)——把 snapfile 交给 FC,并把 uffd socket 接上做内存惰性加载。 - 然后
resumeVM(process.go:634)解冻,setMmds补上元数据。
也就是说:Create 是"从零把零件装成机器",Resume 是"把整机快照 mmap 回来再解冻"。后者为什么快、
uffd 怎么按页缺页,是 03 的主题。
5.4 MMDS:给冷启动的 envd 递一张"身份条"
MMDS(Firecracker 的实例元数据服务)是 host 写、guest 读的一小块 JSON。E2B 用它把沙箱身份和
访问令牌哈希递给 guest 里的 envd,好让 envd 能鉴权 /init:
// packages/orchestrator/pkg/sandbox/fc/mmds.go:6 MmdsMetadata
type MmdsMetadata struct {
SandboxID string `json:"instanceID"`
TemplateID string `json:"envID"`
LogsCollectorAddress string `json:"address"`
AccessTokenHash string `json:"accessTokenHash,omitempty"`
}
关键细节:内存恢复时,身份能从恢复的 RAM 里继承;但冷启动/reboot 的 envd 没有旧状态,所以
Create 会在开机前主动写 MMDS(process.go:485),空令牌也照写(它哈希成"无令牌"值,与 Resume 对齐)。
6. 等 envd 就绪:VM 开机 ≠ 沙箱可用
FC startVM/resumeVM 返回只代表 guest 内核在跑;沙箱要能干活,得等 VM 里的 envd 守护进程
起来并应答。这道判定就是 WaitForEnvd。
6.1 WaitForEnvd → initEnvd:无限重试打 /init
// packages/orchestrator/pkg/sandbox/sandbox.go:1738 WaitForEnvd
func (s *Sandbox) WaitForEnvd(ctx context.Context, startType StartType, timeout time.Duration) (e error) {
// ... 起一个 goroutine:超时 / FC 进程提前退出 都会取消 ctx
if err := s.initEnvd(ctx, startType); err != nil { ... }
// 成功后 SetStartedAt(now),并记录一批启动 KPI 指标
}
initEnvd(envd.go:229)向 guest 的 http://<slot-ip>:49983/init 反复重试 POST,直到拿到
204 No Content(envd.go:243,envd.go:274)。整个等待被 timeout 兜底:超时或 FC 进程提前
死掉,ctx 就被取消,WaitForEnvd 失败,进而触发清理拆掉这个半死的沙箱。
WaitForEnvd 里那段指标代码有个巧思:用 startupStatsOnce 保证 uffd 启动页数只在第一次
WaitForEnvd 时采样——因为 ServeStats() 是恢复以来累计的,模板构建里 envd 二进制热替换会二次
调用 WaitForEnvd,不 gate 住就会把之后的缺页也算进"启动工作集"(sandbox.go:1771)。
6.2 就绪之后:周期性 /health 心跳
沙箱一旦 MarkRunning,Checks 会起一个后台健康循环(checks.go:58 Start → checks.go:84
logHealth),每 20 秒打一次 guest 的 /health,期望 204:
// packages/orchestrator/pkg/sandbox/health.go:16 getHealth
address := fmt.Sprintf("http://%s:%d/health", c.sandbox.Slot.HostIPString(), consts.DefaultEnvdServerPort)
// ... 期望 http.StatusNoContent(204),否则视为不健康
健康状态用一个 atomic.Bool + CompareAndSwap 做边沿触发上报:只有 健康↔不健康 翻转时才
写日志(checks.go:110-121),避免每 20 秒刷屏。这套心跳的间隔/超时是常量:20s / 100ms(checks.go:22)。
7. 资源记账与清理:cgroup 与后进先出的清理栈
7.1 Cleanup:对称拆解的安全网
前面反复出现 cleanup.Add(...)。Cleanup(cleanup.go:20)就是一个后进先出的函数栈:装配时
正序压栈,拆解时逆序执行,天然保证"先装的后拆"。
// 示意,非源码:cleanup 的核心思想
stack := []func(){}
stack = append(stack, releaseNetworkSlot) // 先借网络
stack = append(stack, removeCgroup) // 再建 cgroup
stack = append(stack, stopFcProcess) // 最后起 FC
// 出错或收尾时,从后往前拆:
for i := len(stack) - 1; i >= 0; i-- { stack[i]() }
真实实现里 run(cleanup.go:78)先跑优先清理 priorityCleanup、再跑普通 cleanup,都逆序。
沙箱的 Stop 就是用 AddPriority 压进去的(sandbox.go:619),确保"先把 FC 停掉"排在最前。
Run 用 sync.Once 包住,幂等——多次调用只真正拆一次(cleanup.go:70)。
最底层的文件清理由 cleanupFiles(cleanup.go:103)负责:删掉 FC socket、uffd socket、rootfs 软链
这些落盘的临时物。
7.2 cgroup:原子放置 + 一键杀干净
每个沙箱有独立 cgroup,用于资源记账和可靠地杀掉整棵进程树。它在装配时由 createCgroup
(sandbox.go:1637)建好,拿到一个 FD——这个 FD 通过 CLONE_INTO_CGROUP 让 FC 进程一 fork
就落在正确的 cgroup 里(§5.1),避免"先 fork 再搬"的 竞态。
停止时,CgroupHandle.Kill(cgroup/manager.go:113)往 cgroup.kill 写个 1,内核就把整个 cgroup
里的进程一次性杀光,再 poll cgroup.events 等它清空。FC 的子孙进程不靠 numeric PID group 去信号,
而是靠 cgroup 兜底(process.go:690 注释),这样不会漏杀也不会误杀被复用的 PID。
7.3 Stop / Close / Shutdown:三个收尾动词
| 方法 | 干什么 | 幂等机制 |
|---|---|---|
Stop | 停健康检查 → 停 FC 进程 → 杀 cgroup → 停内存后端 | utils.Lazy,只有第一次真正执行(sandbox.go:1139) |
Close | 跑 cleanup.Run(逆序拆全部资源)+ 从 Map 摘除 | Cleanup.once(sandbox.go:1124) |
Shutdown | 暂停 VM → 造一次性快照(仅为触发磁盘 flush)→ Close | —(sandbox.go:1183) |
doStop(sandbox.go:1146)的顺序值得记:先 Checks.Stop() 停心跳(否则会把一个正在关的沙箱
误报成不健康),再停 FC、杀 cgroup,最后停 uffd 内存后端。
装配阶段还挂了一个后台 goroutine 守着 FC 退出:FC 进程一 Exit,就自动调 sbx.Stop
(sandbox.go:628,Resume 版在 sandbox.go:1085)——VM 自己崩了也能触发对称拆解