借来镜像,自己开机 左边一只集装箱上印着 python:3.12-slim,开口处滑出 4 层镜像,被压平成一块 rootfs.ext4 磁盘;磁盘插进右边一台带火焰标志的 microVM,机器里有自己的内核、systemd 和 envd,python3 从快照恢复后约 80 ms 就已经在运行。 借来镜像,自己开机 FROM python:3.12-slim → rootfs.ext4 → microVM python:3.12-slim 4 layers · OCI rootfs.ext4 压平成一块盘 microVM · Firecracker python3 已在运行 systemd · envd 自己的 Linux 内核 /dev/vda ← rootfs.ext4 快照恢复 ≈ 80 ms

E2B 是一个给 AI 智能体用的云端沙箱:智能体写出来的代码,丢进沙箱里跑,跑坏了也伤不到别人。想让沙箱里预装自己的环境,要先做一个“模板”。最省事的做法,是直接交一份 Dockerfile:

FROM python:3.12-slim
RUN pip install flask
COPY app.py /home/user/app.py
CMD ["python", "/home/user/app.py"]
TypeScript
import { Template, Sandbox } from 'e2b'
await Template.build(Template().fromDockerfile('Dockerfile'), 'flask-demo')
const sbx = await Sandbox.create('flask-demo')
await sbx.commands.run('curl -s localhost:5000')

这份 Dockerfile 和平时给 docker build 用的没有任何区别,基础镜像也是 Docker Hub 上的 python:3.12-slim。那 E2B 是不是就是一个托管在云上的 Docker?沙箱是不是一个容器?可 E2B 的文档又反复说,每个沙箱是一台 Firecracker 虚拟机。

镜像和虚拟机是两种东西。一份 Dockerfile 交进去,出来的为什么是虚拟机?中间 Docker 到底出了多少力?我们就从这份 Dockerfile 出发,跟着它走一遍。

一、沙箱的隔离边界

现象:容器里看到的是宿主的内核

先在本机用 Docker 跑一下同一个基础镜像,看看容器里面是什么样子:

Terminal window
$ docker run --rm python:3.12-slim sh -c 'tr "\0" " " </proc/1/cmdline; echo; uname -r; ls /sbin/init'
sh -c tr "\0" " " </proc/1/cmdline; echo; uname -r; ls /sbin/init
6.18.44-fc-v139
ls: cannot access '/sbin/init': No such file or directory
$ uname -r
6.18.44-fc-v139

三行输出各说明一件事。容器里的 1 号进程就是我们传进去的那条 sh -c 命令;uname -r 打印的内核版本和宿主机一模一样;镜像里连 /sbin/init 都没有。

原因:容器只是被隔开的进程

为什么会这样?因为容器并不是一台“小机器”。docker run 最终调用的 runc 做的事情是:在宿主机上创建一个进程,给它套上一组 namespace(让它看不到别的进程、网络和挂载点)和 cgroup(限制它能用多少 CPU 和内存),把根目录切换到镜像解出来的那堆文件上,然后执行入口命令。

所以容器里没有自己的内核,所有系统调用都直接落进宿主内核;也不需要 init,入口命令本身就是 1 号进程。这让容器又轻又快,但也意味着容器之间、容器和宿主之间的墙,全靠同一个内核来守。

对于自己写的服务,这道墙通常够用。可 E2B 要跑的是 AI 生成的、谁也没审过的代码,而且是很多租户的代码挤在同一批机器上。内核只要出一个能提权的漏洞,墙就没了。2026 年 4 月公开的 Copy Fail(CVE-2026-31431)就是这样的例子:普通进程借 AF_ALG 套接字和 splice() 改写共享的页缓存,从而拿到 root。页缓存在整台宿主机上是共享的,所以它能跨容器、跨租户1。

解决:每个沙箱一台 microVM

E2B 的做法是不跟别人共享内核:每个沙箱都是一台 Firecracker 虚拟机2。Firecracker 是 AWS 为 Lambda 和 Fargate 开发的虚拟机监视器,基于 KVM,只模拟网卡、块设备、vsock 等少数几种设备3,官方规格是启动不超过 125 ms、每台 VM 的管理开销不超过 5 MiB4。它要解决的正是“既要虚拟机的隔离,又要接近容器的轻量”。

容器和 E2B 沙箱的隔离边界 左边是 Docker:两个容器只是宿主机上带 namespace 和 cgroup 的进程,系统调用都落进同一个宿主内核 6.18.44。右边是 E2B:每个沙箱是一台 Firecracker 虚拟机,里面有 systemd、envd 和用户进程,以及自己的 6.1.158 内核,宿主上只看到一个 Firecracker 进程。 Docker · 一台宿主机 E2B · 一台沙箱节点 系统调用 系统调用 virtio · KVM virtio · KVM 容器 A runc PID 1 python3 ns pid net mnt cgroup cpu mem 容器 B runc PID 1 node ns pid net mnt cgroup cpu mem 宿主内核 · Linux 6.18.44 两个容器共用 两个容器的系统调用落进同一个内核, 内核出一个漏洞,所有的墙就同时失效。 沙箱 A Firecracker PID 1 systemd envd :49983 进程 python3 内核 6.1.158 沙箱 B Firecracker PID 1 systemd envd :49983 进程 node 内核 6.1.158 宿主内核 + KVM 每台 VM 是一个 Firecracker 进程 沙箱里的系统调用先落进它自己的内核, 要碰到宿主,还得再越过 Firecracker 这一层。
图 1 · 容器共用宿主的内核,E2B 沙箱各有一个自己的内核

在宿主机上看,一个沙箱就是一个 Firecracker 进程,E2B 还给它单独分配了 cgroup 和 network namespace2。沙箱里面则是一个完整的 Linux:有自己的内核,1 号进程是 systemd。内核由 E2B 自己编译,默认是 6.1.1585;E2B 在关于 Copy Fail 的文章里提到,出问题的那个加密接口在他们的内核里根本没有编进去1。我核对了仓库里的内核配置,确实写着 # CONFIG_CRYPTO_USER_API_AEAD is not set。

容器的问题解决了,可新的问题跟着来了:既然沙箱是虚拟机,为什么模板里写的还是 Docker 镜像?

二、镜像在 E2B 里的角色

虚拟机需要一块盘

一台虚拟机要开机,需要两样东西:一个内核,和一块装着根文件系统的磁盘。内核 E2B 自己提供了,剩下的就是那块盘:盘里要有 Python、有 flask、有我们的 app.py。

这块盘怎么做?最直觉的办法是像做云主机镜像那样,从一个操作系统安装盘开始,装好再存成磁盘镜像。可这样每个用户都得学一套新的打包方式。而“把一个 Linux 用户态环境打成包、放在仓库里随取随用”这件事,Docker 镜像已经做了十年:Docker Hub 上有现成的 python、node、ubuntu,几乎每个开发者都会写 Dockerfile。Docker 镜像的格式后来被标准化成 OCI 镜像规范,本质上就是一组 tar 包(每一层一个)加一份 JSON 配置。

所以 E2B 借的是镜像这个打包格式,而不是 Docker 这个运行时。

拉取、解包、压平

模板构建在 E2B 的构建节点上进行,负责的服务叫 template-manager。它拿到 python:3.12-slim 这个名字之后做的事,在源码里写得很清楚67:

  1. 用 Google 的 go-containerregistry 库直接向镜像仓库请求镜像。这一步没有 Docker 守护进程参与,只是普通的 HTTP 下载。
  2. 在镜像的几层之上再追加一层 E2B 自己的文件:沙箱里的代理程序 /usr/bin/envd、一个备用的 busybox、开机后要跑的 provision.sh,以及让 systemd 开机自启 envd 的预设文件。
  3. 创建一个 ext4 磁盘文件并挂载,把每一层 tar 并行解到各自的目录,再用 overlayfs 把这些目录叠起来,得到合并后的完整视图,最后把这个视图整个复制进 ext4,收缩到实际大小。
python:3.12-slim 镜像变成 rootfs.ext4 的过程 镜像仓库里的 python:3.12-slim 有 4 层和一份配置。template-manager 在 4 层之上追加 E2B 层(envd、init、provision.sh、systemd 预设),用 overlayfs 合并 5 层后复制进 rootfs.ext4;配置里只有 Env 被拷进模板元数据,Cmd 不使用。 镜像仓库 · python:3.12-slim template-manager 产物 只拷 Env 镜像层 4 层 tar 15f1c8eb… 87.7 MB Debian e1fa2746… 4.94 MB b361fa3b… 41.4 MB Python dca2a125… 16.4 kB 镜像配置 config Env PATH LANG … 保留 Cmd ["python3"] 不用 E2B 追加层 第 5 层 /usr/bin/envd 代理 /usr/bin/init busybox provision.sh 补齐系统 00-e2b.preset 自启 envd overlayfs 只读合并 lowerdir+ ×5 层 复制 合并视图 rootfs.ext4 磁盘文件 内容 5 层合并后的文件 大小 按上限建,再收缩 之后 挂进 VM 继续构建 模板元数据 metadata envVars PATH LANG … startCmd 由模板指定 虚线:配置不进磁盘, 只有环境变量被记下来。
图 2 · 镜像在 E2B 里只是原材料:几层 tar 被压平成一块 ext4 磁盘,配置里只留下环境变量

注意第 3 步里的两个细节。每一层的解包用的是 chrootarchive,源码注释特意写明这和 Docker 守护进程解包镜像层是同一套机制;用 overlayfs 叠层,也和 Docker 默认的 overlay2 存储驱动是同一个思路。不同的是,Docker 把叠好的 overlayfs 直接当作容器的根目录,E2B 只拿它当一个中间视图,最终要的是一个普通的磁盘文件,因为虚拟机只认块设备。

镜像里被丢掉的东西

一个 Docker 镜像除了文件,还有一份配置:环境变量、默认用户、工作目录、启动命令。E2B 从这份配置里只拷走了 Env7,我们的 python:3.12-slim 里写着的 Cmd ["python3"] 不会被使用。

这也说得通。容器的启动命令就是容器本身,命令退出容器就停了;而虚拟机开机以后,1 号进程是 init,应用只是它管着的一个服务。“沙箱里一开机要跑什么”在 E2B 里由模板单独指定,叫 start command。

磁盘做好了,里面有 Debian、Python 和 envd。这块盘能不能直接拿去开机?

三、从文件系统到可启动的机器

现象:镜像里没有 init

回到第一节那条命令的输出:python:3.12-slim 里没有 /sbin/init。这不是这个镜像偷懒,绝大多数容器镜像都是这样,因为容器不需要 init,多装一个 systemd 只会让镜像变大。

但虚拟机需要。内核启动以后要找一个 init 当 1 号进程,由它来挂载文件系统、拉起服务;envd 也要靠它开机自启。除此之外,一个能让智能体正常干活的环境还需要 sudo、对时的 chrony、curl、git 这些东西,精简过的容器镜像里通常都没有。

补齐:provision 和 configure

E2B 的办法是在构建时把缺的东西补上。追加层里那个 busybox 被放在 /usr/bin/init,让这块盘至少能先开起来;开机以后跑 provision.sh8。它先读 /etc/os-release 判断这是哪个发行版,再用对应的包管理器安装缺少的包。对 Debian 和 Ubuntu,要装的是:

systemd systemd-sysv openssh-server sudo chrony socat curl ca-certificates
fuse3 iptables git nfs-common less nftables iputils-ping jq iproute2

Alpine 则装 openrc 来当 init。镜像里如果连 /etc/os-release 都没有(比如 distroless 或者 FROM scratch),就直接拒绝,不去猜。之后的 configure 脚本再创建默认用户 user,给它免密码的 sudo。这就是为什么 E2B 沙箱里默认用户不是容器里常见的 root,而是 user。

这说明 E2B 对基础镜像有一个隐含要求:它得是一个“完整的发行版”,能装得上 systemd。镜像只负责提供大部分文件,能不能开机,是 E2B 自己补出来的。

Dockerfile 指令的翻译

那开头那份 Dockerfile 里的 RUN pip install flask 呢?是先 docker build 一遍吗?

也不是。fromDockerfile() 在我们自己的电脑上就把 Dockerfile 解析掉了,翻译成 E2B 模板的一个个步骤9,发给服务端的是这些步骤而不是 Dockerfile。服务端执行 RUN 时,是把 VM 开起来,通过 envd 在里面跑这条命令,跑完把 VM 暂停,和上一层的差异就是新的一层,按内容哈希缓存2。每条指令的去向如下:

Dockerfile 指令在 E2B 里的去向
指令在 docker build 里在 E2B 模板里
FROM以这个镜像为底拉取、解包成 ext4,--platform 忽略
RUN在临时容器里执行,结果存成一层在构建 VM 里经 envd 执行,暂停后的差异存成一层;--mount 等参数忽略并警告
COPY / ADD把构建上下文里的文件放进镜像把本地文件上传进 VM
ENV / WORKDIR / USER / ARG都记下来,供后续步骤和运行时使用
CMD / ENTRYPOINT容器的入口进程按 Docker 的规则合并成 start command,默认等 20 秒视为就绪
EXPOSE / VOLUME / LABEL元数据忽略:模板里没有对应的概念
其他如 HEALTHCHECK、ONBUILD警告“不支持”并跳过

E2B 认 Dockerfile 的语法,但每条指令都换了一种执行方式;整个过程里没有任何地方调用 docker build。

图 3 · Dockerfile 只是一种描述模板的写法,执行它的是 E2B 自己的构建流程

这条路也不是一开始就这样。早期的 E2B 让用户写 e2b.Dockerfile,CLI 在本地调用 Docker 构建镜像再推到 E2B;2025 年 10 月的 Build System 2.0 把模板改成用代码描述、在 E2B 的机器上构建,Dockerfile 只作为兼容写法保留了下来10。今天 CLI 里还留着一点痕迹:e2b template migrate 遇到翻译不了的 Dockerfile,会建议你自己 docker build 推到任意镜像仓库,再在模板里 fromImage 引用它。

到这里,一份 Dockerfile 已经变成了一台装好 flask 的虚拟机。可是虚拟机要开机,开机要时间:如果每次创建沙箱都要冷启动一遍,怎么可能和容器一样快?

四、快照:把启动变成恢复

冷启动的代价

Firecracker 本身启动很快,但那只是虚拟机监视器的部分。之后内核要初始化,systemd 要拉起一串服务,envd 要就绪,然后才轮到我们的 python app.py,而它还要导入 flask、监听端口。换成 Jupyter 这类应用,光启动就要好几秒。

容器为什么没有这个问题?因为它根本不“开机”,docker run 只是建几个 namespace,然后 exec 入口进程。可即便如此,应用自己的启动时间也一秒都省不掉:容器每次都是从零开始跑这个进程的。

解决:把开好机的状态存下来

E2B 的思路是,既然开机这件事每次都一样,那就只做一次。构建的最后一步(finalize)把 VM 开起来,跑 start command,再反复执行 ready command 直到它返回成功,确认应用真的就绪了;然后把整台 VM 暂停,连内存带磁盘带 CPU 状态一起存下来,得到 memfile、rootfs.ext4、snapfile 三个文件,上传到对象存储2。所谓模板,就是这份快照。

之后每次 Sandbox.create(),都不是开机,而是恢复这份快照。

构建一次快照,每次创建都是恢复 构建模板只做一次:解包镜像、provision、执行 RUN 各步、跑 start 和 ready 命令、暂停并拍快照,得到 memfile 和 snapfile。每次创建沙箱时,orchestrator 选节点、取快照、用 userfaultfd 按需加载内存、用 NBD 写时复制挂盘,然后恢复 VM,进程已经在运行。作为对照,docker run 每次都要新建 namespace 并从头启动入口进程。 构建模板 · 只做一次 创建沙箱 · 每次 对照 · docker run · 每次 解包镜像 → rootfs.ext4 provision systemd · sudo · envd RUN 各步 每步一层,哈希缓存 start + ready 等应用真的就绪 暂停 · 快照 memfile · snapfile Sandbox.create() SDK → API 选节点 best-of-K 取快照 本地缓存 / 对象存储 映射内存和盘 内存缺页加载 · 盘写时复制 resume + /init 进程已在运行 docker run 镜像层已在本地 建 namespace exec 入口进程 应用从头启动
图 4 · 容器每次都从零启动应用,E2B 把“开好机、应用已就绪”的那一刻存下来,每次只是恢复它

恢复也不是把几个 GB 的内存一次性读回来。orchestrator 用 Linux 的 userfaultfd 接管 VM 的内存缺页:VM 恢复后访问到哪一页,才去取哪一页;磁盘则通过 NBD 挂一个写时复制层,沙箱写的东西落在自己的层里,底下的模板磁盘所有沙箱共用2。构建的最后还有一步 optimize,专门记录一次全新恢复会碰到哪些内存页,以后创建时提前预取。E2B 的文档给出的数字是约 80 ms 就能载入沙箱,而且“所有进程都已经在运行”11。

这里能看出 E2B 模板和 Docker 镜像最本质的区别。Docker 镜像是静态的文件,docker run 每次都要把应用从头跑起来;E2B 模板是一台暂停着的、正在运行的机器,开头那个 flask 进程在构建时就已经启动并监听了端口,创建沙箱时它只是“醒过来”。

暂停也是一次快照

同一套机制还带来了一个容器很难做到的能力:暂停和恢复。对一个运行中的沙箱调用 pause,orchestrator 会再拍一次快照,只保存和模板相比变化了的内存页和磁盘块,上传后就可以释放这台机器;之后 resume,沙箱可以在别的节点上回来,内存里的变量、打开的进程都还在2。

Docker 也有 docker pause,但它只是用 cgroup freezer 把进程冻住,内存还占在原来的宿主机上,机器重启就没了。真正把进程状态存下来的 docker checkpoint 依赖 CRIU,至今还标着实验性12。对一个虚拟机来说,整机快照是天然的;对共享内核的进程来说,要把它和内核里的状态干净地剥出来,就难得多。

快照让一台虚拟机的“启动”快到了毫秒级。可是“在一群机器里挑一台,拉起一个隔离的运行环境”,这件事业界早有一套标准做法。E2B 为什么不直接用 Kubernetes?

五、创建流程:与 Kubernetes 的对照

今天提到“创建一个 workload”,大多数人想到的是 Kubernetes:写一份 YAML,kubectl apply,一个 Pod 就在集群里的某台机器上跑起来了。嫌容器隔离不够,Kubernetes 也能通过 RuntimeClass 把 Pod 交给 Kata Containers 这类运行时,放进虚拟机里13,Firecracker 也被集成进了这类运行时3。那 E2B 为什么还要自己管创建?先把两边“创建一次”的路径摆在一起看。

两条创建路径

先看 Kubernetes。kubectl 把 Pod(或者生成 Pod 的 Deployment)交给 apiserver,apiserver 校验后写进 etcd,就返回了。之后的事情由别的组件接力完成:scheduler 盯着还没分配节点的 Pod,先过滤掉放不下的节点,再给剩下的打分,选中后写回一个 binding;目标节点上的 kubelet 盯着分到自己身上的 Pod,通过 CRI 让 containerd 先建一个 Pod 沙箱(一个只负责占住 network namespace 的 pause 容器,CNI 插件在这一步给它分配一个集群内唯一的 IP),再拉镜像、创建并启动容器14。容器跑起来以后,还要等 readiness 探针通过,Pod 才会被加进 Service 的后端。

再看 E2B。Sandbox.create() 发给 API,API 鉴权、找到模板对应的那次构建,挑一台节点,然后向那台节点上的 orchestrator 发一个 gRPC Create,并且一直等着。orchestrator 给 VM 的内存接上 userfaultfd,给磁盘接上写时复制的 NBD 设备,再拿一套网络,然后让 Firecracker 载入快照、恢复运行,给 envd 发 /init。envd 应答后 Create 才返回,API 才把 201 回给 SDK2。

Kubernetes 创建 Pod 与 E2B 创建沙箱的路径 Kubernetes:apiserver 写入 etcd 后 kubectl 即返回,scheduler、kubelet 依次 watch 接力,kubelet 经 CRI 建 pause 容器并由 CNI 分配 IP,再拉镜像、启动容器、等 readiness 探针。E2B:API 鉴权后选一台节点,同步调用 orchestrator 的 Create;orchestrator 并行准备 uffd 内存、NBD 磁盘和预建网络,Firecracker 恢复快照,envd 完成 /init 后才把 201 返回给调用方。 Kubernetes · kubectl apply -f web.yaml E2B · Sandbox.create('flask-demo') apiserver 写入 etcd scheduler 过滤打分 · bind kubelet 发现分给自己 RunPodSandbox pause · CNI 分 IP PullImage 各层拉完解压 StartContainer 应用从头启动 readiness 探针 kubectl 在这里就返回 虚线是 watch:组件之间不互相调用, 各自盯着 etcd 里的对象接力往下做。 SLO:p99 ≤ 5 s(不含拉镜像) API 鉴权 · 找 build 选节点 按负载挑一台 orchestrator gRPC Create uffd ← memfile NBD ← rootfs netns ← 网络池 Firecracker 载入 · 恢复 envd /init 201 · 沙箱已可用 实线是同步调用:API 等 orchestrator,orchestrator 等 envd, 响应回到调用方的那一刻,沙箱里的进程已经在运行。
图 5 · Kubernetes 写下期望就返回,由各组件接力完成;E2B 一路同步等到沙箱可用才返回

异步与同步

图里最显眼的差别是线的虚实。Kubernetes 是异步的:apiserver 把期望写进 etcd 就返回,kubectl 拿到的成功只表示“记下来了”。Pod 什么时候真正跑起来,它不对调用方负责,后面靠各组件 watch 接力慢慢收敛,调用方要自己再去等。社区给自己定的目标是 p99 不超过 5 秒,这还不算拉镜像的时间15。这种设计换来的是自愈:节点挂了,控制器会在别处把 Pod 补回来。

E2B 是同步的:它管拉起,响应回到调用方的那一刻,沙箱里的进程已经在运行。智能体要一个沙箱,是为了马上执行下一行代码;沙箱没有副本,挂了也不会被补回来,“写下期望、慢慢收敛”对它没有意义。何况它最快的那一步是从快照恢复,而 CRI 里只有建沙箱、拉镜像、建容器、启动容器这几个操作14,没有“从一份内存快照恢复一台机器”。

所以 E2B 不用 Kubernetes 拉起沙箱,但可以用 Kubernetes 拉起它自己。

E2B 跑在 Kubernetes 上的架构

E2B 的架构文档说,它推荐的部署方式基于 Kubernetes2,但公开的清单只有一个单节点的部署包16。下面的多节点布局,是按架构文档里的角色划分和服务发现的代码17推出来的。

E2B 跑在 Kubernetes 上的架构 控制面的 api、client-proxy、redis 是普通 Deployment。api 向 kube-apiserver 列出 Running 的 orchestrator Pod,取宿主 IP,再直接对 5008 端口同步调用 Create。沙箱节点上,kubelet 只管 orchestrator 这一个 Pod,它开着 hostNetwork、hostPID 和特权模式,申请 4Gi 的 2 MiB hugepage;Firecracker 虚拟机是它在宿主上的子进程,Kubernetes 看不见。client-proxy 的流量经 5007 端口转发到沙箱里的 envd。 Kubernetes 控制面 E2B 控制面 · 普通 Deployment 沙箱节点 · node-a kube-apiserver Pod 的期望与状态 ① api 按标签列出 Running 的 orchestrator Pod,取 hostIP api Sandbox.create 入口 client-proxy 进沙箱的流量 redis 沙箱在哪台节点 ② 同步 Create · :5008 ③ 经 :5007 转发到 envd kubelet 只看得见这个 Pod K8s 管到这一层: 异步拉起,挂了重建 Pod orchestrator hostNetwork true hostPID true privileged true · nsenter hugepages-2Mi 4Gi ports 5008 · 5007 Firecracker sb-7f3a · envd Firecracker sb-91c2 · envd Firecracker sb-c04e · envd … 虚线框里的 VM 是 orchestrator 在宿主上的子进程,K8s 不知道它们存在; Pod 被删除或重启,这台节点上的沙箱就跟着结束。 其他沙箱节点相同 …
图 6 · Kubernetes 只拉起 orchestrator 这一个 Pod,沙箱由 orchestrator 自己同步拉起

控制面的 api、client-proxy、redis 都是无状态的普通 Deployment,和任何 Web 服务一样由 Kubernetes 调度、扩缩和重启。沙箱节点是单独的一组机器,要能用 /dev/kvm,并预留好 2 MiB 的 hugepage。每台沙箱节点上跑一个 orchestrator Pod;构建节点上是同一个程序以 template-manager 模式运行。这个 Pod 和普通 Pod 很不一样:它共用宿主的网络和 PID namespace,以特权模式 nsenter 进节点,自己去配内核模块和 iptables 规则,向 Kubernetes 申请的 hugepages-2Mi 就是留给沙箱的内存池16。

api 怎么找到这些节点?配置 SERVICE_DISCOVERY_PROVIDER=K8S-PODS 以后,它按标签向 kube-apiserver 列出处于 Running 状态的 orchestrator Pod,取它们的宿主 IP,然后直接对 hostIP:5008 发 gRPC17。Kubernetes 在这里只提供一份“哪些节点还活着”的名单,创建沙箱的那次调用并不经过它。

于是两层各管各的:Kubernetes 异步地拉起 orchestrator,挂了就重建;orchestrator 同步地拉起沙箱,一个个 Firecracker 进程是它在宿主上的子进程,kubelet 一个也看不见。代价也在这里:orchestrator 的 Pod 被删除或重启,这台节点上的沙箱就跟着结束16。Kubernetes 的自愈能把 orchestrator 补回来,补不回沙箱。

沙箱起来了,可它跑在远处某台机器的一台虚拟机里。我们怎么在里面执行命令、读写文件?

六、沙箱内外的通信

用 Docker 时,进容器执行命令是 docker exec。这条命令的路径是 CLI → dockerd → containerd → shim → runc,最后由 runc 调用 setns 加入目标容器的 namespace,再 exec 我们的命令。它之所以行得通,是因为容器就是宿主机上的进程,宿主上有权限的人可以直接“走进去”。

虚拟机没有这扇门。宿主上看不到 VM 里的进程,也没法 setns 进去。所以 E2B 在每台 VM 里都放了一个代理程序 envd:它由 systemd 在开机早期启动,监听 49983 端口,用 Connect RPC 提供进程服务(启动进程、流式读 stdout、写 stdin、发信号、开 PTY)和文件系统服务2。第二节里追加层的 /usr/bin/envd 和 systemd 预设,就是为它准备的。

docker exec 和 E2B commands.run 的路径 docker exec 从 CLI 经 dockerd、containerd、shim 和 runc,最后 setns 进入容器,全程在同一台宿主机上。E2B 的 commands.run 发 HTTPS 请求到 49983-沙箱ID.e2b.app,client-proxy 查到沙箱所在节点,转给该节点的 orchestrator 代理,再进入 VM 里的 envd,由 envd 启动进程。 docker exec · 全在同一台宿主机上 E2B commands.run · 跨网络进入 VM docker exec web ls dockerd containerd shim → runc setns → ls 每一跳都在宿主机上:能看见容器进程,才能 setns 走进去。 commands.run('ls') SDK,任意位置 client-proxy 49983-<id>.e2b.app orchestrator 节点代理 :5007 VM 内 envd :49983 → ls Connect RPC · 进程 / 文件服务
图 7 · 进不了 VM 的 namespace,就在 VM 里放一个代理,从网络上和它说话

SDK 发出的请求打到 49983-<沙箱ID>.e2b.app 这样的地址,边缘的 client-proxy 从 Redis 里查出沙箱在哪个节点,转给那个节点上的 orchestrator,再进 VM 交给 envd。请求要带一个 access token,它是创建沙箱时通过 Firecracker 的元数据服务(MMDS)送进 VM 的2。沙箱里起的 web 服务也走同一条路,把端口号换成 5000 就能从外面访问开头那个 flask。

七、在沙箱里运行 Docker

到这里,E2B 和 Docker 的关系看起来是“只借镜像,不借运行时”。可这个关系还能反过来:既然沙箱是一台有自己内核的完整虚拟机,Docker 需要的 namespace、cgroup、overlayfs 在这个内核里都有,那就可以在沙箱里面装 Docker。

E2B 的官方文档就给了这样一个模板18:

TypeScript
const template = Template()
.fromUbuntuImage('24.04')
.runCmd('curl -fsSL https://get.docker.com | sudo sh')
.runCmd('sudo docker run --rm hello-world')

构建时建议给 2 个 vCPU 和 2 GB 内存,之后在沙箱里 sudo docker run、docker compose up 都能用。智能体需要起一个数据库、跑一组微服务来测试自己写的代码时,这很有用。

这样套起来以后,两层隔离各管各的事:外层的 Firecracker 虚拟机是安全边界,防的是不可信代码伤到宿主和其他租户;里层的容器只负责组织,把数据库和应用分开,即使里层被突破,也还在这个租户自己的 VM 里。

有意思的是,Docker 自己也走到了同一个结论。它为 Claude Code、Codex 这类编码智能体推出的 Docker Sandboxes,官网写的是让智能体“在 MicroVM 环境里”运行19。给不可信的智能体代码用,容器的边界不够,这一点上两家的判断是一样的。

八、回到开头的问题

现在可以回答开头那个问题了:E2B 是托管的 Docker 吗?不是。它和 Docker 共享的是镜像这一层,往下的运行方式完全不同。

同一份 Dockerfile,两种运行方式
Docker 容器E2B 沙箱
镜像格式OCI 镜像,同一个 python:3.12-slim,同样的 Dockerfile 语法
构建docker build,每条 RUN 在临时容器里跑E2B 构建节点,每条 RUN 在 Firecracker VM 里经 envd 跑
根文件系统overlayfs 直接当根目录压平成 rootfs.ext4,作为 VM 的块设备
内核宿主的内核自己的内核(默认 6.1.158)
1 号进程入口命令本身systemd,应用是它管的服务
创建新建 namespace,从头启动应用恢复快照,应用已在运行(约 80 ms)
暂停cgroup 冻结,内存留在原机;checkpoint 仍是实验性整机快照,可在别的节点恢复
进去执行命令docker exec → runc setnsHTTPS → client-proxy → envd
隔离边界namespace + cgroup,共享内核KVM 虚拟机,不共享内核

只有第一行是两边共用的;从第二行开始,E2B 走的都是虚拟机的路。

图 8 · E2B 借了 Docker 的镜像和生态,没有借它的运行时

小结

  1. E2B 沙箱是容器吗? 不是。容器共用宿主内核,对不可信代码不够安全,所以 E2B 让每个沙箱都是一台有自己内核的 Firecracker 虚拟机。
  2. 那为什么模板要写 Docker 镜像? 虚拟机需要一块装好环境的根文件系统盘,Docker 镜像是现成的、人人会用的打包格式。E2B 不经过 Docker 守护进程,直接从仓库拉镜像,解包压平成 ext4,配置里只留下环境变量。
  3. 这块盘能直接开机吗? 不能。容器镜像通常没有 init,E2B 在构建时用 provision 补上 systemd、sudo、envd 等,再按 Dockerfile 翻译出来的步骤在 VM 里逐条执行。
  4. 虚拟机怎么启动得那么快? 不启动,而是恢复。构建时把应用跑到就绪再拍快照,创建时按需加载内存、写时复制挂盘,进程本来就在运行。同一机制也让沙箱能暂停到别的机器上恢复。
  5. 为什么不直接用 Kubernetes? Kubernetes 是异步的,写下期望就返回,不管什么时候拉起;E2B 是同步的,一路等到从快照恢复的沙箱可用才返回,而 CRI 里也没有“从快照恢复”这一步。所以 E2B 让 Kubernetes 拉起自己的 orchestrator,沙箱由 orchestrator 自己拉起,Kubernetes 看不见。
  6. 怎么在 VM 里执行命令? 进不了 namespace,就在 VM 里放一个 envd,SDK 通过网络和它说话。
  7. 反过来呢? 沙箱是完整的 VM,可以在里面装 Docker:外层 VM 负责安全,里层容器负责组织。

一句话概括:E2B 借了 Docker 的镜像和生态,自己做了运行时。

参考资料

Footnotes

  1. Not affected by Copy Fail: here's whyE2B · 2026-04-30收藏e2b.devCVE-2026-31431;E2B 的自述https://e2b.dev/blog/not-affected-by-copy-fail-heres-why ↩ ↩2

  2. E2B Infrastructure ArchitectureGitHub · e2b-dev/infra收藏github.com本文读的是 2026-10-18 的提交 d110b92;Orchestrator、Envd、Sandbox creation、Pause and resume、Template build 各节https://github.com/e2b-dev/infra/blob/main/docs/ARCHITECTURE.md ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10

  3. Firecracker READMEGitHub · firecracker-microvm收藏github.com由 AWS 开发,用于 Lambda 和 Fargate;已集成进 Kata Containers 等容器运行时https://github.com/firecracker-microvm/firecracker ↩ ↩2

  4. Firecracker SpecificationGitHub · firecracker-microvm收藏github.com启动 ≤125 ms,VMM 内存开销 ≤5 MiBhttps://github.com/firecracker-microvm/firecracker/blob/main/SPECIFICATION.md ↩

  5. fc-kernels:E2B 给沙箱编的内核GitHub · e2b-dev/infra收藏github.com默认内核版本 vmlinux-6.1.158 在 packages/shared/pkg/featureflags/flags.go 的 DefaultKernelVersionhttps://github.com/e2b-dev/infra/tree/main/firecracker/fc-kernels ↩

  6. oci.go:从 OCI 镜像到 ext4GitHub · e2b-dev/infra收藏github.comGetPublicImage、ToExt4、createExport、unpackRootfs;叠层在 core/filesystem/ext4.go 的 MountOverlayFShttps://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/core/oci/oci.go ↩

  7. rootfs.go:在镜像上追加 E2B 自己的一层GitHub · e2b-dev/infra收藏github.comCreateExt4Filesystem、additionalOCILayers;镜像配置里只保留 Env 见 phases/base/builder.gohttps://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/core/rootfs/rootfs.go ↩ ↩2

  8. provision.sh:把容器镜像补成能开机的系统GitHub · e2b-dev/infra收藏github.com按 /etc/os-release 识别发行版;各发行版要装的包在 phases/base/distro/distro.go;默认用户在 phases/finalize/configure.shhttps://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/phases/base/provision.sh ↩

  9. dockerfileParser.ts:把 Dockerfile 翻译成模板步骤GitHub · e2b-dev/E2B收藏github.com本文读的是提交 62a17df;handleInstruction、IGNORED_INSTRUCTIONS、applyStartCmdhttps://github.com/e2b-dev/E2B/blob/main/packages/js-sdk/src/template/dockerfileParser.ts ↩

  10. Introducing Build System 2.0E2B · 2025-10-16收藏e2b.devhttps://e2b.dev/resources/introducing-build-system-2-0 ↩

  11. How it worksE2B 文档收藏docs.e2b.dev“load the sandbox in ~80ms any time later with all the processes already running”https://docs.e2b.dev/template/how-it-works ↩

  12. docker checkpointDocker 文档收藏docs.docker.com“This command is experimental”,依赖 CRIUhttps://docs.docker.com/reference/cli/docker/checkpoint/ ↩

  13. Runtime ClassKubernetes 文档kubernetes.io ↗https://kubernetes.io/docs/concepts/containers/runtime-class/ ↩

  14. Container Runtime Interface (CRI)Kubernetes 文档kubernetes.io ↗https://kubernetes.io/docs/concepts/architecture/cri/ ↩ ↩2

  15. Pod startup latency SLI/SLOKubernetes SIG Scalabilitygithub.com ↗无状态 Pod,不含拉镜像和 init 容器,p99 ≤ 5 shttps://github.com/kubernetes/community/blob/master/sig-scalability/slos/pod_startup_latency.md ↩

  16. E2B Embed on one Kubernetes nodeGitHub · e2b-dev/infragithub.com ↗orchestrator 所在 Pod 用 hostNetwork、hostPID 和特权容器,nsenter 进节点https://github.com/e2b-dev/infra/blob/main/embed/kubernetes/README.md ↩ ↩2 ↩3

  17. servicediscovery:api 怎么找到 orchestratorGitHub · e2b-dev/infragithub.com ↗K8S-PODS 模式读 POD_NAMESPACE、POD_LABELS;同目录 kube/pods.go 按标签列出 Running 的 Pod,优先取宿主 IPhttps://github.com/e2b-dev/infra/blob/main/packages/shared/pkg/servicediscovery/config.go ↩ ↩2

  18. DockerE2B 文档收藏docs.e2b.dev在沙箱里装 Docker 和 Docker Compose 的官方模板示例https://docs.e2b.dev/template/examples/docker ↩

  19. Docker SandboxesDocker收藏docker.com“Run Claude Code, Codex, and your other agents … in MicroVM environments”https://www.docker.com/products/docker-sandboxes/ ↩