跳到主要内容

冷启动三板斧

这是 Beam 最值得学的一章。README 第一句卖点就是「Launch containers in under a second」。一个几 GB 的 AI 镜像,怎么可能一秒内起来?答案是:根本不等它下载完,以及根本不重新启动

1. 它要解决的小问题

传统容器冷启动慢在两件事上:

  1. 拉镜像:一个带 PyTorch/CUDA 的镜像动辄 5-10GB,下载 + 解压就要几十秒到几分钟。
  2. 启进程:即使镜像在本地,应用本身的初始化(import 一堆库、加载模型)也要几秒到几十秒。

Beam 对这两件分别下药:第一件用 CLIP 懒加载 + FUSE,第二件用 CRIU 检查点恢复

2. 第一招:CLIP 懒加载镜像

思路 / 直觉

关键洞察:容器启动时其实只读了镜像里很小一部分文件(几个二进制、几个 .so、入口脚本)。绝大多数文件这辈子可能都不会被读。那为什么要先把整个镜像下完?

Beam 的做法(依赖外部库 github.com/beam-cloud/clip):把镜像打包成一种叫 CLIP 的归档格式,里面是「文件元数据索引 + 内容寻址的数据块」。启动时只挂载这个索引(很小),把它呈现为一个普通文件系统(FUSE)。当容器真去 open() 某个文件时,FUSE 才按需从远端仓库把那一块数据拉回来(并缓存)。

传统: [下载整个 5GB 镜像] ──────────────► [解压] ──► [起容器] 慢

CLIP: [拉 CLIP 索引 (小)] ─► [FUSE 挂载] ─► [起容器] 快

容器读文件时才触发 ↓
[按需回源拉这一块] ──► 缓存

真实实现

入口是 worker 启动阶段 ② 的 loadContainerImagelifecycle.go:411),它调 imageClient.PullLazy

PullLazyimage.go:279)的流程很能说明问题——它叫 pull 但几乎不「下载」

PullLazy:
mountedImageHit? 已经挂载过 → 直接命中返回(多容器复用同一挂载)
lockImageMount 本地锁(同一镜像并发只挂一次)
prepareLazyImageArchive 确保 CLIP 归档在本地(只是索引层面)
├ pullImageFromRegistry 从 S3 镜像仓库取归档
└ processPulledArchive 解析 CLIP 元数据
acquireRemoteImageMountLock 跨 worker 的分布式挂载锁
mountLazyImageArchive ★ clip.MountArchive → 起 FUSE server

真正「让镜像可读」的一行在 image.go:838 附近:startServer, _, server, err := clip.MountArchive(mountOptions)——把 CLIP 归档挂成 FUSE。之后那个 bundle 路径 imageMountPath/{imageId} 就是一个能直接当容器 rootfs 的目录,但底下的数据是懒拉的。

几个工程细节:

  • 多级命中检查PullLazymountedImageHit 被查了好几次(拿锁前后各查),因为拿锁期间别的 goroutine 可能已经挂好了——典型的 double-checked locking。
  • 本地锁 + 远程锁两层lockImageMount(本进程内)+ acquireRemoteImageMountLock(跨 worker),保证同一镜像不被重复挂载、重复回源。
  • OCI vs CLIP 存储模式:归档可以是原生 CLIP 格式或 OCI 格式(archiveStorageMode/isOCIStorageModeimage.go:614),挂载选项据此不同。
  • 内容上报给缓存contentReporter 把这个 stub 标记为「最近用过」(touchRecentStub),让缓存层把它的内容保温——下次别的 worker 起同一镜像更快。

v2 镜像没有预烤 config.json

注意 readBundleConfiglifecycle.go:602)的一个分支:对 CLIP v2 镜像,磁盘上没有现成的 config.json,它直接从 CLIP 归档里嵌的元数据推导出 OCI specderiveSpecFromV2Image/buildSpecFromCLIPMetadatalifecycle.go:635),把 Env/WorkingDir/Entrypoint/Cmd 拼成 spec。少一次磁盘读,也让镜像更自包含。

3. 第二招:CRIU 检查点 / 恢复(热启动)

思路 / 直觉

懒加载解决了「拉镜像慢」,但「应用初始化慢」(import torch、把模型权重加载进显存)还在。CRIU(Checkpoint/Restore In Userspace)的思路是:

既然每次启动都要做一遍同样的初始化,那做完一次,把整个进程的内存状态拍个快照存起来,下次直接从快照恢复,跳过 import 和加载。

首次: 起容器 → import + 加载模型(慢) → 进程就绪 ──► [CRIU dump 拍快照]
存到缓存
再次: [CRIU restore 从快照恢复] ──► 进程瞬间就绪 跳过初始化

真实实现

CRIU 能力从 RunContainer 一开始就做门控(lifecycle.go:259):runtime 不支持 CheckpointRestore 就关掉。CRIUManager 接口(criu.go:58)定义两个核心动作:

// 来自 pkg/worker/criu.go:58 附近
type CRIUManager interface {
CreateCheckpoint(ctx, runtime, checkpointId, request) (string, error) // 拍快照
RestoreCheckpoint(ctx, runtime, opts) (int, error) // 从快照恢复
// ...
}

恢复路径 attemptRestoreCheckpointcriu.go:122):检查点状态可用时,起一个 goroutine 调 criuManager.RestoreCheckpoint,恢复成功后把恢复出来的 PID 转发到 startedChan(和正常启动同一个信号通道,所以下游监控逻辑无感)。失败则清理掉残留的运行时容器,回退到正常冷启动。

拍快照路径 attemptAutoCheckpointcriu.go:94):shouldCreateCheckpoint 判断该不该拍(首次跑、且配置开了自动检查点),是就调 createCheckpoint

第 01 章提过:调度器在 Scheduler.Run 里就会把可用检查点附到请求上(scheduler.go:230),所以 worker 拿到请求时就知道「这个能走恢复」。

前提与限制(很重要)

CRIU 不是白来的,门槛不低。真正的前提是池配置 + 缓存 + GPU 状态,而不是某个隔离运行时(worker.go:347 初始化处):

前提为什么
池开了 CRIUEnabledCRIU 是 per-pool 的显式开关;没开的池根本不初始化 CRIUManagerworker.go:347
缓存必须可用检查点快照存在缓存里(cacheManager.CheckpointRoot()),cacheManager==nil 直接 warn「C/R unavailable」并不启用
运行时声明支持 CRIUCapabilities().CheckpointRestore 门控(lifecycle.go:259)。本仓里 runc 和 gVisor 声明为 truerunc.go:61runsc.go:72),所以这一项在两种隔离运行时下都满足,不是 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:279clip.MountArchive)。
  • double-checked + 本地/远程双锁:同一镜像在并发和跨 worker 两个维度上都只挂一次、只回源一次(mountedImageHit 多次检查 + 两层锁)。
  • 恢复 PID 复用正常启动通道attemptRestoreCheckpoint 把恢复的 PID 喂进 startedChan,让 OOM/指标/状态机这些下游逻辑完全不用区分「冷起」还是「恢复」。
  • 检查点元数据前置到调度:调度器就把 checkpoint 附到请求上,worker 无需再查。

6. 边界与局限

  • 首次访问罚单:懒加载把成本推迟到「第一次读文件」。一个重 IO 的首请求(比如要遍历大量小文件)反而可能比预下载慢。
  • CRIU 脆弱:开着 TCP 连接、特殊设备、某些 GPU 状态都可能让恢复失败。CheckpointOptsruntime.go:57)里有 AllowOpenTCPruntime.go:61)/SkipInFlightruntime.go:62)控制拍快照时怎么处理 TCP;恢复侧的 RestoreOptsruntime.go:68)则有 TCPCloseruntime.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.goloadContainerImage
懒加载主流程pkg/worker/image.goImageClient.PullLazy
准备归档pkg/worker/image.goprepareLazyImageArchivepullImageFromRegistryprocessPulledArchive
FUSE 挂载pkg/worker/image.gomountLazyImageArchiveclip.MountArchive(调用处)
挂载命中/双锁pkg/worker/image.gomountedImageHitlockImageMountacquireRemoteImageMountLock
存储模式pkg/worker/image.goarchiveStorageModeisOCIStorageMode
v2 spec 推导pkg/worker/lifecycle.goderiveSpecFromV2ImagebuildSpecFromCLIPMetadata
CRIU 接口pkg/worker/criu.goCRIUManager
恢复pkg/worker/criu.goattemptRestoreCheckpointRestoreCheckpoint
拍快照pkg/worker/criu.goattemptAutoCheckpointcreateCheckpoint
CRIU 初始化门控pkg/worker/worker.goInitializeCRIUManager(调用处)
检查点能力门控pkg/worker/lifecycle.goRunContainer(开头 caps 检查)