数据截至 (上游 commit 9194847b2cf8)
冷启动三板斧
这是 Beam 最值得学的一章。README 第一句卖点就是「Launch containers in under a second」。一个几 GB 的 AI 镜像,怎么可能一秒内起来?答案是:根本不等它下载完,以及根本不重新启动。
1. 它要解决的小问题
传统容器冷启动慢在两件事上:
- 拉镜像:一个带 PyTorch/CUDA 的镜像动辄 5-10GB,下载 + 解压就要几十秒到几分钟。
- 启进程:即使镜像在本地,应用本身的初始化(import 一堆库、加载模型)也要几秒到几十秒。
Beam 对这两件分别下药:第一件用 CLIP 懒加载 + FUSE,第二件用 CRIU 检查点恢复。
2. 第一招:CLIP 懒加载镜像
思路 / 直觉
关键洞察:容器启动时其实只读了镜像里很小一部分文件(几个二进制、几个 .so、入口脚本)。绝大多数文件这辈子可能都不会被读。那为什么要先把整个镜像下完?
Beam 的做法(依赖外部库 github.com/beam-cloud/clip):把镜像打包成一种叫 CLIP 的归档格式,里面是「文件元数据索引 + 内容寻址的数据块」。启动时只挂载这个索引(很小),把它呈现为一个普通文件系统(FUSE)。当容器真去 open() 某个文件时,FUSE 才按需从远端仓库把那一块数据拉回来(并缓存)。
传统: [下载整个 5GB 镜像] ──────────────► [解压] ──► [起容器] 慢
CLIP: [拉 CLIP 索引 (小)] ─► [FUSE 挂载] ─► [起容器] 快
│
容器读文件时才触发 ↓
[按需回源拉这一块] ──► 缓存
真实实现
入口是 worker 启动阶段 ② 的 loadContainerImage(lifecycle.go:840),它调 imageClient.PullLazy。
PullLazy(image.go:278)的流程很能说明问题——它叫 pull 但几乎不「下载」:
PullLazy:
mountedImageHit? 已经挂载过 → 直接命中返回(多容器复用同一挂载)
lockImageMount 本地锁(同一镜像并发只挂一次)
prepareLazyImageArchive 确保 CLIP 归档在本地(只是索引层面)
├ pullImageFromRegistry 从 S3 镜像仓库取归档
└ processPulledArchive 解析 CLIP 元数据
acquireRemoteImageMountLock 跨 worker 的分布式挂载锁
mountLazyImageArchive ★ clip.MountArchive → 起 FUSE server
真正「让镜像可读」的一行在 image.go:963 附近:start, serverErrors, server, err := clip.MountArchive(options)——把 CLIP 归档挂成 FUSE。之后那个 bundle 路径 imageMountPath/{imageId} 就是一个能直接当容器 rootfs 的目录,但底下的数据是懒拉的。
几个工程细节:
- 多级命中检查:
PullLazy里mountedImageHit被查了好几次(拿锁前后各查),因为拿锁期间别的 goroutine 可能已经挂好了——典型的 double-checked locking。 - 本地锁 + 远程锁两层:
lockImageMount(本进程内)+acquireRemoteImageMountLock(跨 worker),保证同一镜像不被重复挂载、重复回源。 - OCI vs CLIP 存储模式:归档可以是原生 CLIP 格式或 OCI 格式(
archiveStorageMode/isOCIStorageMode,image.go:648),挂载选项据此不同。 - 内容上报给缓存:
contentReporter把这个 stub 标记为「最近用过」(touchRecentStub),让缓存层把它的内容保温——下次别的 worker 起同一镜像更快。
v2 镜像没有预烤 config.json
注意 readBundleConfig(lifecycle.go:1022)的一个分支:对 CLIP v2 镜像,磁盘上没有现成的 config.json,它直接从 CLIP 归档里嵌的元数据推导出 OCI spec(deriveSpecFromV2Image/buildSpecFromCLIPMetadata,lifecycle.go:1055),把 Env/WorkingDir/Entrypoint/Cmd 拼成 spec。少一次磁盘读,也让镜像更自包含。
3. 第二招:CRIU 检查点 / 恢复(热启动)
思路 / 直觉
懒加载解决了「拉镜像慢」,但「应用初始化慢」(import torch、把模型权重加载进显存)还在。CRIU(Checkpoint/Restore In Userspace)的思路是:
既然每次启动都要做一遍同样的初始化,那做完一次,把整个进程的内存状态拍个快照存起来,下次直接从快照恢复,跳过 import 和加载。
首次: 起容器 → import + 加载模型(慢) → 进程就绪 ──► [CRIU dump 拍快照]
存到缓存
再次: [CRIU restore 从快照恢复] ──► 进程瞬间就绪 跳过初始化
真实实现
CRIU 能力从 RunContainer 一开始就做门控(lifecycle.go:631):runtime 不支持 CheckpointRestore 就关掉。CRIUManager 接口(criu.go:438)定义两个核心动作:
// 来自 pkg/worker/criu.go:58 附近
type CRIUManager interface {
CreateCheckpoint(ctx, runtime, checkpointId, request) (string, error) // 拍快照
RestoreCheckpoint(ctx, runtime, opts) (int, error) // 从快照恢复
// ...
}
恢复路径 attemptRestoreCheckpoint(criu.go:512):检查点状态可用时,起一个 goroutine 调 criuManager.RestoreCheckpoint,恢复成功后把恢复出来的 PID 转发到 startedChan(和正常启动同一个信号通道,所以下游监控逻辑无感)。失败则清理掉残留的运行时容器,回退到正常冷启动。
拍快照路径 startAutoCheckpoint(criu.go:476,旧名 attemptAutoCheckpoint):shouldCreateCheckpoint 判断该不该拍(首次跑、且配置开了自动检查点),是就先拿 acquireCheckpointCreateLock 防重入,再起 goroutine 调 createCheckpoint。
第 01 章提过:调度器在 Scheduler.Run 里就会把可用检查点附到请求上(scheduler.go:408 调 attachLatestCheckpoint),所以 worker 拿到请求时就知道「这个能走恢复」。
前提与限制(很重要)
CRIU 不是白来的,门槛不低。真正的前提是池配置 + 缓存 + GPU 状态,而不是某个隔离运行时(worker.go:538-543 初始化处):
| 前提 | 为什么 |
|---|---|
池开了 CRIUEnabled | CRIU 是 per-pool 的显式开关;没开的池根本不初始化 CRIUManager(worker.go:538-543) |
| 缓存必须可用 | 检查点快照存在缓存里(cacheManager.CheckpointRoot()),cacheManager==nil 直接 warn「C/R unavailable」并不启用 |
| 运行时声明支持 CRIU | 由 Capabilities().CheckpointRestore 门控(lifecycle.go:631)。本仓里 runc 和 gVisor 都声明为 true(runc.go:64、runsc.go:92),所以这一项在两种隔离运行时下都满足,不是 gVisor 的拦路项 |
| GPU 有特殊处理 | 显存状态的检查点要专门逻辑(criu_nvidia.go),IsCRIUAvailable(gpuCount) 会判断 |
4. 三招怎么配合
一次「热」启动的理想路径:
请求到 worker
│
├─ PullLazy:CLIP 归档已挂载 → 命中,~0 拉取 (招1)
│
├─ specFromRequest:v2 镜像从 CLIP 元数据推 spec (招1.5)
│
└─ spawn → 有可用检查点?
├─ 是 → CRIU restore,进程瞬间就绪,跳过初始化 (招2)
└─ 否 → 正常 runc 起,跑完首次后 auto-checkpoint 拍快照
第一次跑某个 stub 是「半冷」(懒加载快、但要走初始化并拍快照);之后命中检查点就是「热」。
5. 巧妙之处
- 「pull 不下载」:把
PullLazy做成「挂索引 + 按需回源」,把冷启动从「下载时间」解耦成「首次读取时间」。这是整个性能故事的地基(image.go:278、clip.MountArchive)。 - double-checked + 本地/远程双锁:同一镜像在并发和跨 worker 两个维度上都只挂一次、只回源一次(
mountedImageHit多次检查 + 两层锁)。 - 恢复 PID 复用正常启动通道:
attemptRestoreCheckpoint把恢复的 PID 喂进startedChan,让 OOM/指标/状态机这些下游逻辑完全不用区分「冷起」还是「恢复」。 - 检查点元数据前置到调度:调度器就把 checkpoint 附到请求上,worker 无需再查。
6. 边界与局限
- 首次访问罚单:懒加载把成本推迟到「第一次读文件」。一个重 IO 的首请求(比如要遍历大量小文件)反而可能比预下载慢。
- CRIU 脆弱:开着 TCP 连接、特殊设备、某些 GPU 状态都可能让恢复失败。
CheckpointOpts(runtime.go:57)里有AllowOpenTCP(runtime.go:61)/SkipInFlight(runtime.go:62)控制拍快照时怎么处理 TCP;恢复侧的RestoreOpts(runtime.go:68)则有TCPClose(runtime.go:74)控制恢复时是否关掉 TCP 连接。失败就回退冷启动。 - CRIU 是池级可选能力,不取决于隔离运行时:能否拿到热启动由「池是否开
CRIUEnabled+ 缓存是否可用 + GPU/TCP 状态」决定。本仓里 runc 和 gVisor 的Capabilities().CheckpointRestore都声明为true,所以选哪个运行时本身不会把 CRIU 关掉——不存在「选了 gVisor 就拿不到热启动、只能安全 vs 速度二选一」这种权衡。 - 强依赖缓存层:CLIP 回源和 CRIU 快照都靠
cacheManager,缓存不可用会显著降速(worker.go:253有 warn 日志)。
7. 横向对比
这一章是 Beam 和同 shelf「agent 沙箱」类项目最该被拿来对比的地方。很多沙箱项目要么用常驻容器(不缩零、贵),要么用朴素 docker run(冷启动几十秒)。Beam 用懒加载 + 检查点把「serverless(缩零省钱)」和「快(亚秒起)」这对矛盾同时拿下——代价是依赖一套相当重的缓存/CLIP/CRIU 基础设施。如果你在为 agent 选「跑不可信代码的沙箱」,这正是要权衡的:Beam 的 Sandbox 抽象(04-abstractions.md)就建在这套机制上。
8. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 加载镜像入口 | pkg/worker/lifecycle.go | loadContainerImage |
| 懒加载主流程 | pkg/worker/image.go | ImageClient.PullLazy |
| 准备归档 | pkg/worker/image.go | prepareLazyImageArchive、pullImageFromRegistry、processPulledArchive |
| FUSE 挂载 | pkg/worker/image.go | mountLazyImageArchive、clip.MountArchive(调用处) |
| 挂载命中/双锁 | pkg/worker/image.go | mountedImageHit、lockImageMount、acquireRemoteImageMountLock |
| 存储模式 | pkg/worker/image.go | archiveStorageMode、isOCIStorageMode |
| v2 spec 推导 | pkg/worker/lifecycle.go | deriveSpecFromV2Image、buildSpecFromCLIPMetadata |
| CRIU 接口 | pkg/worker/criu.go | CRIUManager |
| 恢复 | pkg/worker/criu.go | attemptRestoreCheckpoint、RestoreCheckpoint |
| 拍快照 | pkg/worker/criu.go | attemptAutoCheckpoint、createCheckpoint |
| CRIU 初始化门控 | pkg/worker/worker.go | InitializeCRIUManager(调用处) |
| 检查点能力门控 | pkg/worker/lifecycle.go | RunContainer(开头 caps 检查) |