E2B 的沙箱设计:diff 链、UFFD、NBD 与跨机恢复
E2B 给 AI Agent 提供沙箱:每个沙箱是一台 Firecracker 微虚拟机,几百毫秒内就能拿到手,用完可以暂停,过几个小时再恢复,里面的进程、内存、文件都和暂停前一模一样。它的后端是开源的,仓库在 e2b-dev/runtime(原名 e2b-dev/infra),整套用 Go 写成。
这篇文章只讲其中最有意思的一块:沙箱的数据怎么在机器之间流动。暂停发生在节点 A,恢复可能落在节点 B,这中间内存和磁盘是怎么存的、怎么拼的、怎么按需取回来的。涉及四样东西:diff 链、userfaultfd、NBD 和对等节点读取。前三个名词不少人第一次见,所以先把它们讲清楚,再串起整条路径。
先认识几个名词
Firecracker 快照
Firecracker 是 AWS 开源的轻量虚拟机监控器,一台微虚拟机就是宿主机上的一个 Firecracker 进程。它能给正在运行的虚拟机拍快照,一份快照由三部分组成:
- snapfile:虚拟机的“机器状态”,包括 vCPU 寄存器、中断控制器、virtio 设备队列的状态。很小,KB 到 MB 级。
- memfile:客户机内存的完整镜像。给虚拟机 2 GiB 内存,这个文件就是 2 GiB。
- rootfs:根文件系统所在的磁盘镜像。严格说它不属于 Firecracker 的快照,但恢复时必须是拍快照那一刻的磁盘,否则内存里的文件系统缓存和磁盘对不上。
从快照恢复,相当于让虚拟机“从拍快照那一刻接着跑”。内核不用重新启动,进程不用重新拉起,这是 E2B 启动快的根本原因。
orchestrator
E2B 的集群分两层。上面是控制面:对外的 REST API、记录状态的 PostgreSQL 和 Redis。下面是一排跑沙箱的节点,每台节点上跑一个叫 orchestrator 的常驻进程。
orchestrator 可以类比成 Kubernetes 里 kubelet 加容器运行时的组合,只不过它管的是虚拟机。API 通过 gRPC 叫它创建、暂停、恢复、销毁沙箱,它负责把事情真正做出来:拉起 Firecracker 进程,准备网络命名空间和 tap 设备,准备磁盘和内存的数据来源,把快照文件从对象存储取到本地缓存,暂停时把差异算出来传回去。本文要讲的 userfaultfd 处理器、NBD 服务、对等节点的 chunk 服务,全都跑在 orchestrator 这个进程里。
用户的 SDK 不直接和 orchestrator 说话。SDK 调 API,API 选好节点再转给 orchestrator;访问沙箱里服务的流量则经过 client-proxy 直接到节点,不经过 API。
userfaultfd(UFFD)
先回忆一下缺页。进程看到的是虚拟地址,虚拟地址要映射到物理内存页才能访问。很多页在第一次被访问前根本没有分配物理内存,CPU 访问它时会触发缺页异常,陷入内核,由内核决定给这个地址放什么:匿名内存就分一页清零的物理页,文件映射就从文件里读出对应的内容。处理完,内核让那条访存指令重新执行,进程毫无察觉。
userfaultfd 是 Linux 提供的一个系统调用,名字直译就是“用户态缺页文件描述符”,常缩写成 UFFD。它让一个进程对内核说:这段内存的缺页别由你处理,交给我。登记之后,这段内存里某一页第一次被访问时,内核会:
- 挂起触发缺页的线程;
- 往 userfaultfd 这个文件描述符里写一条事件,里面有出错的地址;
- 等。
另一个线程(甚至另一个进程)从这个文件描述符里读出事件,自己想办法弄到这一页该有的数据,然后调用 ioctl(UFFDIO_COPY):内核把数据拷进那一页、建好页表,再唤醒被挂起的线程。如果这一页应该全是 0,就用 UFFDIO_ZEROPAGE,连数据都不用准备。
这个机制最初是为 QEMU 的“后拷贝”热迁移做的:虚拟机先在目标机器上跑起来,内存页用到哪页再从源机器拉哪页。E2B 用它做的是同一件事,只不过数据源从“源机器”换成了“本地缓存、别的节点或对象存储”。
NBD
NBD(Network Block Device)是 Linux 里一个很老的驱动。加载之后会出现 /dev/nbd0、/dev/nbd1 这样的设备,对系统里的其他程序来说,它们就是普通磁盘,可以格式化、挂载、当虚拟机的硬盘。不同之处在于:对这块“磁盘”的每次读写,内核都会翻译成一条消息,经一个 socket 发给某个用户态程序,叫 NBD 服务端;读请求返回什么数据,写请求落到哪里,全由服务端决定。
名字里有 Network,是因为它最初用来把远程机器上的磁盘挂到本地。但 socket 不一定要跨网络:E2B 用 socketpair 在本机建一对互通的 Unix socket,一端交给内核的 nbd 驱动,另一端由 orchestrator 自己读,于是 NBD 服务端就是 orchestrator 里的几个协程,所谓“进程内的 NBD 服务”。
E2B 需要它,是因为 Firecracker 的磁盘只认一个文件路径,它对文件做的就是普通的读和写。可 E2B 想要的磁盘是“只读模板之上叠一层这台沙箱自己的修改”,而且模板的大部分块还在远端,读到才去取。这种逻辑没法塞进 Firecracker,于是用 NBD 造一个假磁盘交给 Firecracker,真正的逻辑写在服务端。
build 和 header
E2B 里每一份快照都叫一个 build,有自己的 build ID。模板构建出来是一个 build,沙箱每暂停一次产生一个新的 build。对象存储里按 build ID 分目录,每个目录下有 memfile、rootfs.ext4、snapfile、metadata.json,以及两份索引:memfile.header 和 rootfs.ext4.header。
header 是本文的关键。它回答一个问题:这份内存(或磁盘)的第 N 块,数据存在哪个 build 的文件里、在那个文件的什么位置。有了它,一个 build 就不用存完整的数据,只存自己改过的块。
整体架构
下图是 E2B 的组件和它们之间的关系,可以点按钮依次看控制面、沙箱流量和跨机的数据路径。
控制面这边:
- API 处理沙箱的生命周期请求。它负责鉴权,查 PostgreSQL 找到模板对应的 build,用一个“随机挑 K 台、选负载最低”的算法选节点,然后通过 gRPC 调那台节点的 orchestrator。运行中的沙箱记在 Redis 里。
- client-proxy 处理访问沙箱的流量。
https://3000-<沙箱ID>.域名这样的请求进来,它从域名里解析出沙箱 ID 和端口,查 Redis 得到沙箱在哪台节点,直接转发过去。
- template-manager 负责构建模板,和 orchestrator 是同一个二进制,只是角色不同。
节点这边,每台机器一个 orchestrator,每个沙箱一个 Firecracker 进程,虚拟机里跑着一个叫 envd 的小守护进程,SDK 让沙箱“执行命令”“读写文件”,最终都是 envd 在做。
和本文有关的是图里节点内部的几样东西:UFFD 处理器给虚拟机供内存,NBD 服务给虚拟机供磁盘,本地缓存放着模板和暂停产生的 diff,ChunkService 让别的节点能读这份缓存。最底下的对象存储(GCS 或 S3)是所有 build 的最终归宿。
这里有一条设计原则:控制面只决定沙箱放在哪,数据不经过它。不管是用户访问沙箱的流量,还是几个 GB 的内存快照,都在节点之间、节点和对象存储之间直接流动。
沙箱就是被恢复的快照
E2B 的出发点是一句话:沙箱就是一个被恢复的快照。
模板不是一个等着启动的磁盘镜像,而是一台已经开过机的虚拟机的快照。构建模板时,template-manager 会真的启动一台 Firecracker,把 Docker 镜像的内容装进去,执行用户写的构建步骤,跑起启动命令,然后拍快照,把四个文件传到对象存储。
之后的三种操作走的都是同一条路:
- 新建沙箱:恢复模板的快照。
- 暂停后恢复:暂停时拍了一个新快照,恢复它。
- 分叉(fork):给正在跑的沙箱原地拍一个快照,再从这个快照恢复出若干个新沙箱。
暂停产生的快照和模板的格式完全一样,都是一个 build,只是它的文件里存的是相对父 build 的差异。所以对 orchestrator 来说,“从模板新建”和“从暂停恢复”没有区别,都是给我一个 build ID,我把它恢复出来。这一点对跨机很重要:任何一台节点,只要能读到这个 build 和它的祖先,就能恢复这台沙箱。
diff 链与 header
沙箱暂停一次,内存里通常只有一小部分页被改过,磁盘上也只有少量块被写过。每次都把 2 GiB 内存和几 GiB 磁盘完整存一份,存储和上传都扛不住。所以 E2B 的每个 build 只存相对父 build 改过的块,一串 build 形成一条 diff 链:模板 T → 第一次暂停 P1 → 第二次暂停 P2 → ……
问题是恢复时怎么知道某一块该从哪个 build 取。最笨的办法是从 P2 开始往回找,P2 没有就看 P1,再没有看 T。E2B 不这么做,它在每个 build 里放一份 header,直接记下完整视图:每一段连续的块属于哪个 build、在那个 build 文件里的偏移。下面的演示从模板开始,暂停两次,再看恢复时怎么查表:
header 的结构在 packages/shared/pkg/storage/header 里,核心是两部分。
一部分是 Metadata:块大小 BlockSize、设备总大小 Size、本 build 的 BuildId、最底层模板的 BaseBuildId,以及代数 Generation,每暂停一次加一。
另一部分是映射表,由一条条 BuildMap 组成:
type BuildMap struct {
Offset uint64 // 在完整视图里的起始字节
Length uint64 // 长度
BuildId uuid.UUID // 数据存在哪个 build 里
BuildStorageOffset uint64 // 在那个 build 文件里的偏移
}暂停时生成新 header 的方法很直接:拿父 build 的映射表,把这次改过的块盖上去,盖上去的那些块指向新 build。新 build 的文件里,改过的块是紧凑排列的:第 2、3、9 块被改了,文件里就只有三块,依次放在 0、1、2 号位置,header 负责记住“完整视图第 9 块 = P1 文件第 2 块”。全是 0 的块不存数据,映射到一个特殊的空 build,读的时候直接返回 0。
几个粒度值得记一下:根文件系统的映射以 4 KiB 为一块(RootfsBlockSize);内存的映射以页为单位(普通页 4 KiB,大页 2 MiB);而从对象存储取内存数据时,一次取一个 4 MiB 的 chunk(storage.MemoryChunkSize)。映射粒度细,是为了暂停时只存真正改过的东西;取数据的粒度粗,是因为对象存储的每次请求都有固定的延迟,一次多取一些,邻近的页之后就不用再走网络。
header 本身也有版本:V3 不压缩,V4 支持按帧压缩数据文件并在 header 里记录帧表,V5 把映射表改成更紧凑的列式编码。这些细节不影响理解主线。
暂停:怎么算出差异
暂停请求到了 orchestrator,大致按这个顺序走:
- 停住虚拟机,让 Firecracker 写出 snapfile。
- 找出内存里的脏页。默认问 Firecracker 要一份脏页集合,它从内核的页表信息里读出哪些页被写过;如果沙箱恢复时开了 userfaultfd 的写保护跟踪,也可以直接用 orchestrator 自己记下的集合。脏页内容先拷进一块 memfd(Linux 的匿名内存文件)。
- 内存去重(可选)。脏页不一定真的变了:有的页被写过又写回原值,有的页被清零了。orchestrator 在后台把每个脏页和父 build 里同一位置的页逐一比较,内容一样的丢掉,继续指向父 build;全 0 的记为空。只有真正不同的页才写进新 build 的 memfile。
- 找出磁盘的脏块。根文件系统的写入本来就进了这台沙箱自己的写时复制缓存(下文 NBD 一节会讲),缓存里被写过的块就是磁盘的 diff。
- 生成两份新 header,把这次的 diff 盖在父 header 上。
- 放进本地缓存,再异步上传。新 build 先作为一个完整可用的模板登记到本机的模板缓存里,这时暂停请求就可以返回了;上传到对象存储在后台进行。
第 6 步意味着,暂停刚结束的一段时间里,这个 build 的最新一层只存在于源节点的本地盘上。这段时间有多长,取决于 diff 的大小和网络,几秒到几十秒都有可能。后面讲的跨机机制,处理的主要就是这段窗口。
恢复内存:userfaultfd
恢复时,E2B 不让 Firecracker 把 memfile 读进内存。原因很简单:大部分内存页在恢复后的几秒里根本不会被碰到,提前读是浪费;而且 memfile 本身是一条 diff 链,散在好几个 build 里,有的还在远端。
Firecracker 支持一种内存后端叫 UFFD。加载快照时,它只为客户机内存建一片空的匿名映射,把这片内存登记到一个 userfaultfd 上,然后通过一个 Unix socket,把这个文件描述符连同内存区域表(每段区域的起始地址、大小、页大小、对应 memfile 的偏移)一起发给 orchestrator。文件描述符跨进程传递用的是 Unix socket 的 SCM_RIGHTS 附带消息。
从此,客户机内存的每一页第一次被访问时,都要经过下面这个流程:
orchestrator 里的 UFFD 处理器(packages/orchestrator/pkg/sandbox/uffd)是一个循环:从 userfaultfd 读事件,用出错地址减去所在区域的起点算出 memfile 里的偏移,查 header 得到“哪个 build、哪个偏移”,然后去取数据。取数据的顺序是本地缓存、对等节点、对象存储,取到的 4 MiB chunk 留在本地缓存里。数据到手后调 UFFDIO_COPY 填页,内核唤醒客户机的 vCPU 线程。
这样做的代价是首次访问每一页都多了一次缺页往返,数据不在本地时还要加上网络延迟。为了把这部分延迟藏起来,E2B 加了预取。模板的 metadata.json 里有一个 prefetch.memory 字段,记录一串块号和访问类型(读、写):
{
"prefetch": {
"memory": { "indices": [12, 13, 40, 41], "access_types": ["r", "r", "w", "r"], "block_size": 4096 },
"origin": "build"
}
}这份列表来自模板构建的最后一个阶段 optimize:构建器把刚做好的模板实际恢复一次,记下恢复后哪些页被碰到、按什么顺序。暂停产生的快照也可以有,做法是暂停后在后台“试恢复”一次收集。真正恢复沙箱时,预取器按列表把相邻的块合并成大段读取,在客户机碰到之前就用 UFFDIO_COPY 填好。
恢复磁盘:进程内的 NBD 服务
内存是按页懒加载,磁盘也一样,只不过用的是块设备这一层。下面演示一次读、一次写、再一次读,以及暂停时缓存怎样变成 diff:
创建沙箱时,orchestrator 从一个设备池里领一个空闲的 /dev/nbdN,建一对或几对 socketpair,通过 netlink 告诉内核:这个 nbd 设备用这些 socket 连到服务端,块大小多少,超时多少。socket 的另一端交给几个协程,它们解析内核发来的 NBD 请求(READ、WRITE、FLUSH、TRIM、WRITE_ZEROES),转给后端处理(pkg/sandbox/nbd)。
后端是一个 Overlay(pkg/sandbox/block),两层:
- 下层是只读的根文件系统:按 header 拼出 diff 链,某一块不在本地就和内存一样,从对等节点或对象存储取,取到的放进本地缓存。同一台节点上用同一个模板的所有沙箱,共享这一层。
- 上层是写时复制缓存:一个本机上的稀疏文件,以 4 KiB 为块,配一张位图记录哪些块被写过。写请求只进这一层;读请求先查位图,写过就读缓存,没写过就读下层。
Firecracker 的磁盘配置里填的就是 /dev/nbdN,在客户机里它是 /dev/vda。Firecracker 完全不知道背后有 diff 链和远端存储。
暂停时,缓存里被写过的块就是这次的磁盘 diff。新版本还可以在暂停时只把缓存“摘下来”,在后台用 reflink(文件系统层面的写时复制拷贝)封存成 diff 文件,暂停请求不用等这一步。
NBD 的代价是每次 I/O 都要从内核经 socket 到用户态再回来,多了上下文切换和数据拷贝。Kimi 的 AgentENV 在同样的位置用的是 ublk,靠 io_uring 和共享内存把这部分开销压得更低,见 Kimi AgentENV:大规模 Agent 训练的执行环境。E2B 选 NBD,换来的是一个所有内核都有、行为稳定的老驱动。
跨机:调度、对等节点和对象存储
有了上面这些,就可以讲跨机的部分了。设想沙箱 S 在节点 A 上暂停,产生 build P2,它的父层是 P1 和模板 T。现在用户要恢复 S。
第一选择:放回源节点
API 的快照记录里存着这个快照是在哪台节点上拍的(origin node)。恢复时,API 把它作为首选节点传给放置算法;只要这台节点的 CPU 型号兼容、orchestrator 版本支持需要的功能,就直接放上去,不参与随机挑选(packages/api/internal/orchestrator/placement)。CPU 要检查,是因为快照里保存了 vCPU 的状态,换一种不兼容的 CPU 恢复会出问题。
放回源节点的好处是显而易见的:P2、P1、T 都在它的本地缓存里,内存缺页和磁盘读全部命中本地,一次对象存储读都不需要。
分叉是例外。从一个快照分叉出几十个沙箱,全挤在源节点上不合理,所以 fork 出来的沙箱不带首选节点,正常分散。
换一台节点:先问源节点
源节点满了,API 只好把 S 放到节点 B。B 要恢复 P2,需要 P2、P1、T 三层的数据。T 和 P1 早就传到了对象存储,B 自己的缓存里可能也有;麻烦的是 P2,它可能还在上传。
E2B 的做法是让 orchestrator 之间直接互读。这套机制在 packages/orchestrator/pkg/sandbox/template/peerclient 和 peerserver 里,由一个功能开关控制:
- 登记。A 暂停完、把 P2 放进本地缓存的同时,在 Redis 里写一条
peer:P2 → A 的地址,带过期时间。
- 按层路由。B 打开 P2 时,对每个 build 单独查 Redis:P2 有记录,就从 A 读;P1、T 没有记录,就走自己的缓存或对象存储。一条 diff 链里的不同层,可以来自不同的地方。
- 读数据。A 的 orchestrator 上跑着一个 gRPC 服务
ChunkService,只有四个方法:
| 方法 | 作用 |
|---|---|
GetBuildFileSize | 问 memfile / rootfs.ext4 的大小 |
GetBuildFileExists | 问 snapfile、metadata.json 是否存在 |
ReadAtBuildSeekable | 按偏移和长度读 memfile / rootfs.ext4,流式返回 |
GetBuildBlob | 整个取回 snapfile、metadata.json、两份 header,流式返回 |
A 从自己的本地 diff 缓存里找到数据,按 1 MiB 一条消息发回。B 的 UFFD 处理器和 NBD 服务并不知道数据来自 A,它们只是调用存储接口的 ReadAt,下面的 peerStorageProvider 决定去哪里读。
- 切换。每个响应都带一个
PeerAvailability,里面有两个标志。not_available表示 A 这里没有,B 退回对象存储。use_storage表示 A 已经传完了,B 收到后把这一层的uploaded标志置位,此后这一层的读全部直接走对象存储,不再打扰 A。A 这边上传一完成,也会删掉 Redis 里的peer:P2。
所以对等读只是一条加速路径,不是必经之路:A 不可达、Redis 里没记录、A 说没有,B 都会退回对象存储。它解决的只是“刚暂停完、还没传完”这段窗口里,别的节点也能马上恢复,而不是等上传结束。
一个细节:临时 build ID(#3166)
回到暂停流程的第 3 步:内存去重。去重分两段,先比较(每个脏页和父层比,得出哪些页要存、哪些丢掉,产出 header),再整理(把要存的页紧凑地写进新的 memfile)。脏页多的时候,比较要花好几秒。
问题出在:去重比较完之前,新 build 没有 header,也就没法恢复。如果沙箱刚暂停,用户马上又要恢复,请求落回源节点,就只能干等去重。PR #3166 解决了这个问题,同时小心地把影响限制在源节点内部。下面按时间顺序演示:
思路是:恢复真正需要的数据,“父层 + 脏页”,在暂停那一刻就齐了,脏页就躺在 memfd 里;慢的只是为上传做的压缩整理。于是暂停时先生成一份临时 header(ToProvisionalDiffHeader):所有脏页都算在一个新造的临时 build ID 名下,存储偏移就等于设备偏移,因为数据原样放在 memfd 里,不用紧凑排列。同一台节点上的恢复拿这份 header 立刻就能开始,缺页时直接从 memfd 读。去重比较完成后,得到真正的 header,orchestrator 用 compare-and-swap 把读路径换过去;memfd 要等换完才释放。
这个临时 build ID 有一个致命的局限:它只在源节点上有意义。它没有登记在 Redis 里,对象存储里也没有这个目录,数据只在 A 的一块内存文件里。如果它泄露出去,拿到它的人就会得到一张读不通的映射表。所以 PR 里所有可能把 header 传出源节点的地方,都改成了只认“持久 header”:
- 对等读:B 经
GetBuildBlob要memfile.header时,A 的headerSource调DurableHeader,去重还没比完就阻塞等着,拿到持久 header 才发给 B。所以跨节点恢复在这段窗口里享受不到加速,必须等去重完成。
- 再次暂停:如果沙箱在窗口里恢复后又被暂停,新快照的父层也取持久 header,免得一份上传的快照继承了指向临时 ID 的映射。
- 调度元数据:恢复响应里带给 API 的元数据也用持久 header,但这里用不阻塞的
DurableHeaderNow,还没就绪就先只报磁盘部分,免得把恢复请求本身拖慢。
上传的始终是持久 header 和去重后的数据,存进对象存储的东西和改动前一个字节都不差。PR 描述里的测试数据:人为让去重比较卡 15 秒,紧跟暂停的同机恢复从约 14.9 秒降到约 0.5 秒。这个功能由开关 memfd-dedup-inflight-serve 控制,默认关闭,逐步放量。
把整条路串起来
最后用一次跨机恢复,把前面的东西串一遍:
- 沙箱 S 在 A 上暂停。内存脏页进 memfd、去重;磁盘写缓存里的脏块成为 diff;生成新 header,build P2 登记进 A 的本地缓存和 Redis,后台开始上传。
- 用户恢复 S。API 首选 A,A 满了,放到 B。
- B 的 orchestrator 打开 P2:查 Redis 发现 P2 在 A 上,从 A 取 snapfile、metadata 和持久 header;P1、T 走自己的缓存或对象存储。
- B 给新的 Firecracker 准备磁盘:领一个
/dev/nbdN,后端是 Overlay,只读层是按 P2 的 rootfs header 拼出的 diff 链,上层是一块空的写缓存。
- Firecracker 加载 snapfile,内存走 UFFD,把 userfaultfd 交给 B 的 orchestrator。预取器按 prefetch 列表开始填热页。
- 虚拟机接着跑。每次缺页、每次磁盘读,都先查 header 定位到 build,再按“本地缓存 → A → 对象存储”的顺序取数据。
- A 的上传完成,回答
use_storage,B 之后对 P2 的读改走对象存储。
回头看,E2B 的跨机设计可以概括成三句话。数据格式上,每个 build 只存差异,header 给出完整视图,所以任何节点都能拼出任何快照。数据通路上,内存靠 userfaultfd、磁盘靠 NBD,都是用到才取,所以恢复不用等数据搬完。调度上,能回源节点就回源节点,回不去就先从源节点读、等上传完再换对象存储,所以暂停后马上在别处恢复也不必等上传。
代码位置
| 内容 | 路径 |
|---|---|
| header、映射、临时 header | packages/shared/pkg/storage/header |
| UFFD 处理器、预取 | packages/orchestrator/pkg/sandbox/uffd |
| NBD 设备池和请求分发 | packages/orchestrator/pkg/sandbox/nbd |
| Overlay、写缓存、去重 | packages/orchestrator/pkg/sandbox/block |
| 对等读的客户端和服务端 | packages/orchestrator/pkg/sandbox/template/peerclient、peerserver |
| ChunkService 定义 | packages/orchestrator/chunks.proto |
| 放置与首选节点 | packages/api/internal/orchestrator/placement |
| 架构总览 | docs/ARCHITECTURE.md |