﻿---
title: "E2B 和 Docker 是什么关系"
description: "一份普通的 Dockerfile 原封不动就能变成 E2B 沙箱，可 E2B 又说每个沙箱是一台 Firecracker 虚拟机。顺着这份 Dockerfile 往下追：为什么不直接用容器，镜像进了 E2B 之后变成了什么，虚拟机怎么启动得和容器一样快，它创建沙箱和 Kubernetes 创建 Pod 有什么不同、又怎么跑在 Kubernetes 上，最后反过来看沙箱里怎么跑 Docker。"
pubDate: 2026-10-18
updatedDate: 2026-10-19
tags: ["E2B", "Docker", "Firecracker", "Kubernetes", "沙箱", "源码分析"]
hero: "./hero.svg"
refs:
  - id: e2b-arch
    title: "E2B Infrastructure Architecture"
    url: https://github.com/e2b-dev/infra/blob/main/docs/ARCHITECTURE.md
    collect: "github-e2b-dev-infra-architecture-md"
    site: GitHub · e2b-dev/infra
    note: "本文读的是 2026-10-18 的提交 `d110b92`；Orchestrator、Envd、Sandbox creation、Pause and resume、Template build 各节"
  - id: e2b-oci
    title: "oci.go：从 OCI 镜像到 ext4"
    url: https://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/core/oci/oci.go
    collect: "github-e2b-dev-infra-packages-orchestrator-pkg-t"
    site: GitHub · e2b-dev/infra
    note: "`GetPublicImage`、`ToExt4`、`createExport`、`unpackRootfs`；叠层在 `core/filesystem/ext4.go` 的 `MountOverlayFS`"
  - id: e2b-rootfs
    title: "rootfs.go：在镜像上追加 E2B 自己的一层"
    url: https://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/core/rootfs/rootfs.go
    collect: "github-e2b-dev-infra-packages-orchestrator-pkg-t-2"
    site: GitHub · e2b-dev/infra
    note: "`CreateExt4Filesystem`、`additionalOCILayers`；镜像配置里只保留 `Env` 见 `phases/base/builder.go`"
  - id: e2b-provision
    title: "provision.sh：把容器镜像补成能开机的系统"
    url: https://github.com/e2b-dev/infra/blob/main/packages/orchestrator/pkg/template/build/phases/base/provision.sh
    collect: "github-e2b-dev-infra-packages-orchestrator-pkg-t-3"
    site: GitHub · e2b-dev/infra
    note: "按 `/etc/os-release` 识别发行版；各发行版要装的包在 `phases/base/distro/distro.go`；默认用户在 `phases/finalize/configure.sh`"
  - id: e2b-dfparser
    title: "dockerfileParser.ts：把 Dockerfile 翻译成模板步骤"
    url: https://github.com/e2b-dev/E2B/blob/main/packages/js-sdk/src/template/dockerfileParser.ts
    collect: "github-e2b-dev-e2b-packages-js-sdk-src-template"
    site: GitHub · e2b-dev/E2B
    note: "本文读的是提交 `62a17df`；`handleInstruction`、`IGNORED_INSTRUCTIONS`、`applyStartCmd`"
  - id: e2b-kernel
    title: "fc-kernels：E2B 给沙箱编的内核"
    url: https://github.com/e2b-dev/infra/tree/main/firecracker/fc-kernels
    collect: "github-e2b-dev-infra-firecracker-fc-kernels"
    site: GitHub · e2b-dev/infra
    note: "默认内核版本 `vmlinux-6.1.158` 在 `packages/shared/pkg/featureflags/flags.go` 的 `DefaultKernelVersion`"
  - id: e2b-howitworks
    title: "How it works"
    url: https://docs.e2b.dev/template/how-it-works
    collect: "e2b-template-how-it-works"
    site: E2B 文档
    note: "“load the sandbox in ~80ms any time later with all the processes already running”"
  - id: e2b-docker-example
    title: "Docker"
    url: https://docs.e2b.dev/template/examples/docker
    collect: "e2b-template-examples-docker"
    site: E2B 文档
    note: 在沙箱里装 Docker 和 Docker Compose 的官方模板示例
  - id: e2b-bs2
    title: "Introducing Build System 2.0"
    url: https://e2b.dev/resources/introducing-build-system-2-0
    collect: "e2b-resources-introducing-build-system-2-0"
    site: E2B
    date: 2025-10-16
  - id: e2b-copyfail
    title: "Not affected by Copy Fail: here's why"
    url: https://e2b.dev/blog/not-affected-by-copy-fail-heres-why
    collect: "e2b-not-affected-by-copy-fail-heres-why"
    site: E2B
    date: 2026-04-30
    note: "CVE-2026-31431；E2B 的自述"
  - id: fc-spec
    title: "Firecracker Specification"
    url: https://github.com/firecracker-microvm/firecracker/blob/main/SPECIFICATION.md
    collect: "github-firecracker-microvm-firecracker-specifica"
    site: GitHub · firecracker-microvm
    note: "启动 ≤125 ms，VMM 内存开销 ≤5 MiB"
  - id: docker-checkpoint
    title: "docker checkpoint"
    url: https://docs.docker.com/reference/cli/docker/checkpoint/
    collect: "docker-reference-cli-docker-checkpoint"
    site: Docker 文档
    note: "“This command is experimental”，依赖 CRIU"
  - id: docker-sandboxes
    title: "Docker Sandboxes"
    url: https://www.docker.com/products/docker-sandboxes/
    collect: "docker-products-docker-sandboxes"
    site: Docker
    note: "“Run Claude Code, Codex, and your other agents … in MicroVM environments”"
  - id: e2b-embed-k8s
    title: "E2B Embed on one Kubernetes node"
    url: https://github.com/e2b-dev/infra/blob/main/embed/kubernetes/README.md
    site: GitHub · e2b-dev/infra
    note: "orchestrator 所在 Pod 用 `hostNetwork`、`hostPID` 和特权容器，`nsenter` 进节点"
  - id: e2b-sd
    title: "servicediscovery：api 怎么找到 orchestrator"
    url: https://github.com/e2b-dev/infra/blob/main/packages/shared/pkg/servicediscovery/config.go
    site: GitHub · e2b-dev/infra
    note: "`K8S-PODS` 模式读 `POD_NAMESPACE`、`POD_LABELS`；同目录 `kube/pods.go` 按标签列出 Running 的 Pod，优先取宿主 IP"
  - id: k8s-cri
    title: "Container Runtime Interface (CRI)"
    url: https://kubernetes.io/docs/concepts/architecture/cri/
    site: Kubernetes 文档
  - id: k8s-slo
    title: "Pod startup latency SLI/SLO"
    url: https://github.com/kubernetes/community/blob/master/sig-scalability/slos/pod_startup_latency.md
    site: Kubernetes SIG Scalability
    note: "无状态 Pod，不含拉镜像和 init 容器，p99 ≤ 5 s"
  - id: k8s-runtimeclass
    title: "Runtime Class"
    url: https://kubernetes.io/docs/concepts/containers/runtime-class/
    site: Kubernetes 文档
  - id: fc-readme
    title: "Firecracker README"
    url: https://github.com/firecracker-microvm/firecracker
    collect: "github-firecracker-microvm-firecracker"
    site: GitHub · firecracker-microvm
    note: 由 AWS 开发，用于 Lambda 和 Fargate；已集成进 Kata Containers 等容器运行时
---

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

```dockerfile
FROM python:3.12-slim
RUN pip install flask
COPY app.py /home/user/app.py
CMD ["python", "/home/user/app.py"]
```

```ts
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 跑一下同一个基础镜像，看看容器里面是什么样子：

```console
$ 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。页缓存在整台宿主机上是共享的，所以它能跨容器、跨租户[^e2b-copyfail]。

### 解决：每个沙箱一台 microVM

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="e2bd-iso-title e2bd-iso-desc">
<title id="e2bd-iso-title">容器和 E2B 沙箱的隔离边界</title>
<desc id="e2bd-iso-desc">左边是 Docker：两个容器只是宿主机上带 namespace 和 cgroup 的进程，系统调用都落进同一个宿主内核 6.18.44。右边是 E2B：每个沙箱是一台 Firecracker 虚拟机，里面有 systemd、envd 和用户进程，以及自己的 6.1.158 内核，宿主上只看到一个 Firecracker 进程。</desc>
<defs>
<marker id="e2bd-iso-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="380" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="348" rx="8"/>
<text class="t-zone" x="36" y="44">Docker · 一台宿主机</text>
<rect class="dd-zone" x="408" y="16" width="376" height="348" rx="8"/>
<text class="t-zone" x="428" y="44">E2B · 一台沙箱节点</text>
<path class="dd-edge" d="M114 182 V250" marker-end="url(#e2bd-iso-arrow)"/>
<path class="dd-edge" d="M294 182 V250" marker-end="url(#e2bd-iso-arrow)"/>
<text class="t-note" x="122" y="222">系统调用</text>
<text class="t-note" x="302" y="222">系统调用</text>
<path class="dd-edge" d="M506 208 V250" marker-end="url(#e2bd-iso-arrow)"/>
<path class="dd-edge" d="M686 208 V250" marker-end="url(#e2bd-iso-arrow)"/>
<text class="t-note" x="514" y="234">virtio · KVM</text>
<text class="t-note" x="694" y="234">virtio · KVM</text>
<rect class="dd-card" x="32" y="72" width="164" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 104 V78 Q32.6 72.6 38 72.6 H190 Q195.4 72.6 195.4 78 V104 Z"/>
<line class="dd-edge" x1="32" y1="104" x2="196" y2="104"/>
<text class="t-name" x="48" y="93">容器 A</text>
<text class="t-note" x="180" y="93" text-anchor="end">runc</text>
<line class="dd-hair" x1="48" y1="130" x2="180" y2="130"/>
<line class="dd-hair" x1="48" y1="156" x2="180" y2="156"/>
<text class="t-mono" x="48" y="121">PID 1</text>
<text class="t-val" x="100" y="121">python3</text>
<text class="t-mono" x="48" y="147">ns</text>
<text class="t-val" x="100" y="147">pid net mnt</text>
<text class="t-mono" x="48" y="173">cgroup</text>
<text class="t-val" x="100" y="173">cpu mem</text>
<rect class="dd-card" x="212" y="72" width="164" height="110" rx="6"/>
<path class="dd-card-head" d="M212.6 104 V78 Q212.6 72.6 218 72.6 H370 Q375.4 72.6 375.4 78 V104 Z"/>
<line class="dd-edge" x1="212" y1="104" x2="376" y2="104"/>
<text class="t-name" x="228" y="93">容器 B</text>
<text class="t-note" x="360" y="93" text-anchor="end">runc</text>
<line class="dd-hair" x1="228" y1="130" x2="360" y2="130"/>
<line class="dd-hair" x1="228" y1="156" x2="360" y2="156"/>
<text class="t-mono" x="228" y="121">PID 1</text>
<text class="t-val" x="280" y="121">node</text>
<text class="t-mono" x="228" y="147">ns</text>
<text class="t-val" x="280" y="147">pid net mnt</text>
<text class="t-mono" x="228" y="173">cgroup</text>
<text class="t-val" x="280" y="173">cpu mem</text>
<rect class="dd-card" x="32" y="256" width="344" height="36" rx="6"/>
<text class="t-name" x="48" y="279">宿主内核 · <tspan class="t-acc">Linux 6.18.44</tspan></text>
<text class="t-note" x="360" y="279" text-anchor="end">两个容器共用</text>
<text class="t-say" x="32" y="324">两个容器的系统调用落进同一个内核，</text>
<text class="t-say" x="32" y="344">内核出一个漏洞，所有的墙就同时失效。</text>
<rect class="dd-card" x="424" y="72" width="164" height="136" rx="6"/>
<path class="dd-card-head" d="M424.6 104 V78 Q424.6 72.6 430 72.6 H582 Q587.4 72.6 587.4 78 V104 Z"/>
<line class="dd-edge" x1="424" y1="104" x2="588" y2="104"/>
<text class="t-name" x="440" y="93">沙箱 A</text>
<text class="t-note" x="572" y="93" text-anchor="end">Firecracker</text>
<line class="dd-hair" x1="440" y1="130" x2="572" y2="130"/>
<line class="dd-hair" x1="440" y1="156" x2="572" y2="156"/>
<line class="dd-hair" x1="440" y1="182" x2="572" y2="182"/>
<text class="t-mono" x="440" y="121">PID 1</text>
<text class="t-val" x="492" y="121">systemd</text>
<text class="t-mono" x="440" y="147">envd</text>
<text class="t-val" x="492" y="147">:49983</text>
<text class="t-mono" x="440" y="173">进程</text>
<text class="t-val" x="492" y="173">python3</text>
<text class="t-mono" x="440" y="199">内核</text>
<text class="t-val" x="492" y="199"><tspan class="t-acc">6.1.158</tspan></text>
<rect class="dd-card" x="604" y="72" width="164" height="136" rx="6"/>
<path class="dd-card-head" d="M604.6 104 V78 Q604.6 72.6 610 72.6 H762 Q767.4 72.6 767.4 78 V104 Z"/>
<line class="dd-edge" x1="604" y1="104" x2="768" y2="104"/>
<text class="t-name" x="620" y="93">沙箱 B</text>
<text class="t-note" x="752" y="93" text-anchor="end">Firecracker</text>
<line class="dd-hair" x1="620" y1="130" x2="752" y2="130"/>
<line class="dd-hair" x1="620" y1="156" x2="752" y2="156"/>
<line class="dd-hair" x1="620" y1="182" x2="752" y2="182"/>
<text class="t-mono" x="620" y="121">PID 1</text>
<text class="t-val" x="672" y="121">systemd</text>
<text class="t-mono" x="620" y="147">envd</text>
<text class="t-val" x="672" y="147">:49983</text>
<text class="t-mono" x="620" y="173">进程</text>
<text class="t-val" x="672" y="173">node</text>
<text class="t-mono" x="620" y="199">内核</text>
<text class="t-val" x="672" y="199"><tspan class="t-acc">6.1.158</tspan></text>
<rect class="dd-card" x="424" y="256" width="344" height="36" rx="6"/>
<text class="t-name" x="440" y="279">宿主内核 + KVM</text>
<text class="t-note" x="752" y="279" text-anchor="end">每台 VM 是一个 Firecracker 进程</text>
<text class="t-say" x="424" y="324">沙箱里的系统调用先落进它自己的内核，</text>
<text class="t-say" x="424" y="344">要碰到宿主，还得再越过 Firecracker 这一层。</text>
</svg>
</div>
<figcaption>图 1 · 容器共用宿主的内核，E2B 沙箱各有一个自己的内核</figcaption>
</figure>

在宿主机上看，一个沙箱就是一个 Firecracker 进程，E2B 还给它单独分配了 cgroup 和 network namespace[^e2b-arch]。沙箱里面则是一个完整的 Linux：有自己的内核，1 号进程是 systemd。内核由 E2B 自己编译，默认是 `6.1.158`[^e2b-kernel]；E2B 在关于 Copy Fail 的文章里提到，出问题的那个加密接口在他们的内核里根本没有编进去[^e2b-copyfail]。我核对了仓库里的内核配置，确实写着 `# 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` 这个名字之后做的事，在源码里写得很清楚[^e2b-oci][^e2b-rootfs]：

1. 用 Google 的 `go-containerregistry` 库直接向镜像仓库请求镜像。这一步没有 Docker 守护进程参与，只是普通的 HTTP 下载。
2. 在镜像的几层之上再追加一层 E2B 自己的文件：沙箱里的代理程序 `/usr/bin/envd`、一个备用的 busybox、开机后要跑的 `provision.sh`，以及让 systemd 开机自启 envd 的预设文件。
3. 创建一个 ext4 磁盘文件并挂载，把每一层 tar 并行解到各自的目录，再用 overlayfs 把这些目录叠起来，得到合并后的完整视图，最后把这个视图整个复制进 ext4，收缩到实际大小。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="e2bd-img-title e2bd-img-desc">
<title id="e2bd-img-title">python:3.12-slim 镜像变成 rootfs.ext4 的过程</title>
<desc id="e2bd-img-desc">镜像仓库里的 python:3.12-slim 有 4 层和一份配置。template-manager 在 4 层之上追加 E2B 层（envd、init、provision.sh、systemd 预设），用 overlayfs 合并 5 层后复制进 rootfs.ext4；配置里只有 Env 被拷进模板元数据，Cmd 不使用。</desc>
<defs>
<marker id="e2bd-img-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="e2bd-img-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="420" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="264" height="388" rx="8"/>
<text class="t-zone" x="36" y="44">镜像仓库 · python:3.12-slim</text>
<rect class="dd-zone" x="296" y="16" width="232" height="388" rx="8"/>
<text class="t-zone" x="316" y="44">template-manager</text>
<rect class="dd-zone" x="544" y="16" width="240" height="388" rx="8"/>
<text class="t-zone" x="564" y="44">产物</text>
<path class="dd-edge" d="M264 132 H284 Q288 132 288 140 V258 Q288 266 296 266 H306" marker-end="url(#e2bd-img-arrow)"/>
<path class="dd-edge" d="M412 200 V218" marker-end="url(#e2bd-img-arrow)"/>
<path class="dd-edge-acc" d="M512 266 H520 Q528 266 528 258 V125 Q528 117 536 117 H554" marker-end="url(#e2bd-img-arrow-acc)"/>
<path class="dd-edge dd-dash" d="M148 308 V372 Q148 380 156 380 H592 Q600 380 600 372 V314" marker-end="url(#e2bd-img-arrow)"/>
<text class="t-note" x="404" y="372" text-anchor="middle">只拷 Env</text>
<rect class="dd-card" x="32" y="64" width="232" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H258 Q263.4 64.6 263.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="264" y2="96"/>
<text class="t-name" x="48" y="85">镜像层</text>
<text class="t-note" x="248" y="85" text-anchor="end">4 层 tar</text>
<line class="dd-hair" x1="48" y1="122" x2="248" y2="122"/>
<line class="dd-hair" x1="48" y1="148" x2="248" y2="148"/>
<line class="dd-hair" x1="48" y1="174" x2="248" y2="174"/>
<text class="t-mono" x="48" y="113">15f1c8eb…</text>
<text class="t-val" x="136" y="113">87.7 MB</text>
<text class="t-note" x="248" y="113" text-anchor="end">Debian</text>
<text class="t-mono" x="48" y="139">e1fa2746…</text>
<text class="t-val" x="136" y="139">4.94 MB</text>
<text class="t-mono" x="48" y="165">b361fa3b…</text>
<text class="t-val" x="136" y="165">41.4 MB</text>
<text class="t-note" x="248" y="165" text-anchor="end">Python</text>
<text class="t-mono" x="48" y="191">dca2a125…</text>
<text class="t-val" x="136" y="191">16.4 kB</text>
<rect class="dd-card" x="32" y="224" width="232" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 256 V230 Q32.6 224.6 38 224.6 H258 Q263.4 224.6 263.4 230 V256 Z"/>
<line class="dd-edge" x1="32" y1="256" x2="264" y2="256"/>
<text class="t-name" x="48" y="245">镜像配置</text>
<text class="t-note" x="248" y="245" text-anchor="end">config</text>
<line class="dd-hair" x1="48" y1="282" x2="248" y2="282"/>
<text class="t-mono" x="48" y="273">Env</text>
<text class="t-val" x="88" y="273">PATH LANG …</text>
<text class="t-note" x="248" y="273" text-anchor="end">保留</text>
<text class="t-mono" x="48" y="299">Cmd</text>
<text class="t-val" x="88" y="299">["python3"]</text>
<text class="t-note" x="248" y="299" text-anchor="end">不用</text>
<rect class="dd-card" x="312" y="64" width="200" height="136" rx="6"/>
<path class="dd-card-head" d="M312.6 96 V70 Q312.6 64.6 318 64.6 H506 Q511.4 64.6 511.4 70 V96 Z"/>
<line class="dd-edge" x1="312" y1="96" x2="512" y2="96"/>
<text class="t-name" x="328" y="85">E2B 追加层</text>
<text class="t-note" x="496" y="85" text-anchor="end">第 5 层</text>
<line class="dd-hair" x1="328" y1="122" x2="496" y2="122"/>
<line class="dd-hair" x1="328" y1="148" x2="496" y2="148"/>
<line class="dd-hair" x1="328" y1="174" x2="496" y2="174"/>
<text class="t-mono" x="328" y="113">/usr/bin/envd</text>
<text class="t-note" x="496" y="113" text-anchor="end">代理</text>
<text class="t-mono" x="328" y="139">/usr/bin/init</text>
<text class="t-note" x="496" y="139" text-anchor="end">busybox</text>
<text class="t-mono" x="328" y="165">provision.sh</text>
<text class="t-note" x="496" y="165" text-anchor="end">补齐系统</text>
<text class="t-mono" x="328" y="191">00-e2b.preset</text>
<text class="t-note" x="496" y="191" text-anchor="end">自启 envd</text>
<rect class="dd-card" x="312" y="224" width="200" height="84" rx="6"/>
<path class="dd-card-head" d="M312.6 256 V230 Q312.6 224.6 318 224.6 H506 Q511.4 224.6 511.4 230 V256 Z"/>
<line class="dd-edge" x1="312" y1="256" x2="512" y2="256"/>
<text class="t-name" x="328" y="245">overlayfs</text>
<text class="t-note" x="496" y="245" text-anchor="end">只读合并</text>
<line class="dd-hair" x1="328" y1="282" x2="496" y2="282"/>
<text class="t-mono" x="328" y="273">lowerdir+</text>
<text class="t-val" x="408" y="273">×5 层</text>
<text class="t-mono" x="328" y="299">复制</text>
<text class="t-val" x="408" y="299">合并视图</text>
<rect class="dd-card" x="560" y="64" width="208" height="110" rx="6"/>
<path class="dd-card-head" d="M560.6 96 V70 Q560.6 64.6 566 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="560" y1="96" x2="768" y2="96"/>
<text class="t-name" x="576" y="85"><tspan class="t-acc">rootfs.ext4</tspan></text>
<text class="t-note" x="752" y="85" text-anchor="end">磁盘文件</text>
<line class="dd-hair" x1="576" y1="122" x2="752" y2="122"/>
<line class="dd-hair" x1="576" y1="148" x2="752" y2="148"/>
<text class="t-mono" x="576" y="113">内容</text>
<text class="t-val" x="624" y="113">5 层合并后的文件</text>
<text class="t-mono" x="576" y="139">大小</text>
<text class="t-val" x="624" y="139">按上限建，再收缩</text>
<text class="t-mono" x="576" y="165">之后</text>
<text class="t-val" x="624" y="165">挂进 VM 继续构建</text>
<rect class="dd-card" x="560" y="224" width="208" height="84" rx="6"/>
<path class="dd-card-head" d="M560.6 256 V230 Q560.6 224.6 566 224.6 H762 Q767.4 224.6 767.4 230 V256 Z"/>
<line class="dd-edge" x1="560" y1="256" x2="768" y2="256"/>
<text class="t-name" x="576" y="245">模板元数据</text>
<text class="t-note" x="752" y="245" text-anchor="end">metadata</text>
<line class="dd-hair" x1="576" y1="282" x2="752" y2="282"/>
<text class="t-mono" x="576" y="273">envVars</text>
<text class="t-val" x="648" y="273">PATH LANG …</text>
<text class="t-mono" x="576" y="299">startCmd</text>
<text class="t-val" x="648" y="299">由模板指定</text>
<text class="t-say" x="616" y="336">虚线：配置不进磁盘，</text>
<text class="t-say" x="616" y="356">只有环境变量被记下来。</text>
</svg>
</div>
<figcaption>图 2 · 镜像在 E2B 里只是原材料：几层 tar 被压平成一块 ext4 磁盘，配置里只留下环境变量</figcaption>
</figure>

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

### 镜像里被丢掉的东西

一个 Docker 镜像除了文件，还有一份配置：环境变量、默认用户、工作目录、启动命令。E2B 从这份配置里只拷走了 `Env`[^e2b-rootfs]，我们的 `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.sh`[^e2b-provision]。它先读 `/etc/os-release` 判断这是哪个发行版，再用对应的包管理器安装缺少的包。对 Debian 和 Ubuntu，要装的是：

```text
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 模板的一个个步骤[^e2b-dfparser]，发给服务端的是这些步骤而不是 Dockerfile。服务端执行 `RUN` 时，是把 VM 开起来，通过 envd 在里面跑这条命令，跑完把 VM 暂停，和上一层的差异就是新的一层，按内容哈希缓存[^e2b-arch]。每条指令的去向如下：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="e2bd-df-title">Dockerfile 指令在 E2B 里的去向</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="e2bd-df-title">
<colgroup><col style="width:22%"><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col">指令</th><th scope="col">在 docker build 里</th><th scope="col" class="acc">在 E2B 模板里</th></tr>
</thead>
<tbody>
<tr><th scope="row" class="mono">FROM</th><td>以这个镜像为底</td><td>拉取、解包成 ext4，<code>--platform</code> 忽略</td></tr>
<tr><th scope="row" class="mono">RUN</th><td>在临时容器里执行，结果存成一层</td><td>在构建 VM 里经 envd 执行，暂停后的差异存成一层；<code>--mount</code> 等参数忽略并警告</td></tr>
<tr><th scope="row" class="mono">COPY / ADD</th><td>把构建上下文里的文件放进镜像</td><td>把本地文件上传进 VM</td></tr>
<tr><th scope="row" class="mono">ENV / WORKDIR / USER / ARG</th><td colspan="2">都记下来，供后续步骤和运行时使用</td></tr>
<tr><th scope="row" class="mono">CMD / ENTRYPOINT</th><td>容器的入口进程</td><td>按 Docker 的规则合并成 start command，默认等 20 秒视为就绪</td></tr>
<tr><th scope="row" class="mono">EXPOSE / VOLUME / LABEL</th><td>元数据</td><td class="warn">忽略：模板里没有对应的概念</td></tr>
<tr><th scope="row">其他</th><td>如 <code>HEALTHCHECK</code>、<code>ONBUILD</code></td><td class="warn">警告“不支持”并跳过</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">E2B 认 Dockerfile 的语法，但每条指令都换了一种执行方式；整个过程里没有任何地方调用 docker build。</p>
</div>
<figcaption>图 3 · Dockerfile 只是一种描述模板的写法，执行它的是 E2B 自己的构建流程</figcaption>
</figure>

这条路也不是一开始就这样。早期的 E2B 让用户写 `e2b.Dockerfile`，CLI 在本地调用 Docker 构建镜像再推到 E2B；2025 年 10 月的 Build System 2.0 把模板改成用代码描述、在 E2B 的机器上构建，Dockerfile 只作为兼容写法保留了下来[^e2b-bs2]。今天 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` 三个文件，上传到对象存储[^e2b-arch]。所谓模板，就是这份快照。

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 440" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="e2bd-snap-title e2bd-snap-desc">
<title id="e2bd-snap-title">构建一次快照，每次创建都是恢复</title>
<desc id="e2bd-snap-desc">构建模板只做一次：解包镜像、provision、执行 RUN 各步、跑 start 和 ready 命令、暂停并拍快照，得到 memfile 和 snapfile。每次创建沙箱时，orchestrator 选节点、取快照、用 userfaultfd 按需加载内存、用 NBD 写时复制挂盘，然后恢复 VM，进程已经在运行。作为对照，docker run 每次都要新建 namespace 并从头启动入口进程。</desc>
<defs>
<marker id="e2bd-snap-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="e2bd-snap-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="440" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="136" rx="8"/>
<text class="t-zone" x="36" y="44">构建模板 · 只做一次</text>
<rect class="dd-zone" x="16" y="168" width="768" height="136" rx="8"/>
<text class="t-zone" x="36" y="196">创建沙箱 · 每次</text>
<rect class="dd-zone" x="16" y="320" width="768" height="104" rx="8"/>
<text class="t-zone" x="36" y="348">对照 · docker run · 每次</text>
<path class="dd-edge" d="M168 82 H178" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M316 82 H326" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M464 82 H474" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M612 82 H622" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M168 234 H178" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M316 234 H326" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M464 234 H474" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M612 234 H622" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M168 386 H178" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M316 386 H326" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M464 386 H474" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge" d="M612 386 H622" marker-end="url(#e2bd-snap-arrow)"/>
<path class="dd-edge-acc" d="M760 82 H768 Q776 82 776 90 V192 Q776 200 768 200 H404 Q396 200 396 208 V212" marker-end="url(#e2bd-snap-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="136" height="36" rx="6"/>
<text class="t-name" x="100" y="87" text-anchor="middle">解包镜像</text>
<text class="t-note" x="100" y="124" text-anchor="middle">→ rootfs.ext4</text>
<rect class="dd-card" x="180" y="64" width="136" height="36" rx="6"/>
<text class="t-name" x="248" y="87" text-anchor="middle">provision</text>
<text class="t-note" x="248" y="124" text-anchor="middle">systemd · sudo · envd</text>
<rect class="dd-card" x="328" y="64" width="136" height="36" rx="6"/>
<text class="t-name" x="396" y="87" text-anchor="middle">RUN 各步</text>
<text class="t-note" x="396" y="124" text-anchor="middle">每步一层，哈希缓存</text>
<rect class="dd-card" x="476" y="64" width="136" height="36" rx="6"/>
<text class="t-name" x="544" y="87" text-anchor="middle">start + ready</text>
<text class="t-note" x="544" y="124" text-anchor="middle">等应用真的就绪</text>
<rect class="dd-card" x="624" y="64" width="136" height="36" rx="6"/>
<text class="t-name" x="692" y="87" text-anchor="middle">暂停 · <tspan class="t-acc">快照</tspan></text>
<text class="t-note" x="692" y="124" text-anchor="middle"><tspan class="t-acc">memfile</tspan> · snapfile</text>
<rect class="dd-card" x="32" y="216" width="136" height="36" rx="6"/>
<text class="t-mono" x="100" y="239" text-anchor="middle">Sandbox.create()</text>
<text class="t-note" x="100" y="276" text-anchor="middle">SDK → API</text>
<rect class="dd-card" x="180" y="216" width="136" height="36" rx="6"/>
<text class="t-name" x="248" y="239" text-anchor="middle">选节点</text>
<text class="t-note" x="248" y="276" text-anchor="middle">best-of-K</text>
<rect class="dd-card" x="328" y="216" width="136" height="36" rx="6"/>
<text class="t-name" x="396" y="239" text-anchor="middle">取<tspan class="t-acc">快照</tspan></text>
<text class="t-note" x="396" y="276" text-anchor="middle">本地缓存 / 对象存储</text>
<rect class="dd-card" x="476" y="216" width="136" height="36" rx="6"/>
<text class="t-name" x="544" y="239" text-anchor="middle">映射内存和盘</text>
<text class="t-note" x="544" y="276" text-anchor="middle">内存缺页加载 · 盘写时复制</text>
<rect class="dd-card" x="624" y="216" width="136" height="36" rx="6"/>
<text class="t-mono" x="692" y="239" text-anchor="middle">resume + /init</text>
<text class="t-note" x="692" y="276" text-anchor="middle">进程已在运行</text>
<rect class="dd-card" x="32" y="368" width="136" height="36" rx="6"/>
<text class="t-mono" x="100" y="391" text-anchor="middle">docker run</text>
<rect class="dd-card" x="180" y="368" width="136" height="36" rx="6"/>
<text class="t-name" x="248" y="391" text-anchor="middle">镜像层已在本地</text>
<rect class="dd-card" x="328" y="368" width="136" height="36" rx="6"/>
<text class="t-name" x="396" y="391" text-anchor="middle">建 namespace</text>
<rect class="dd-card" x="476" y="368" width="136" height="36" rx="6"/>
<text class="t-name" x="544" y="391" text-anchor="middle">exec 入口进程</text>
<rect class="dd-card" x="624" y="368" width="136" height="36" rx="6"/>
<text class="t-name" x="692" y="391" text-anchor="middle">应用从头启动</text>
</svg>
</div>
<figcaption>图 4 · 容器每次都从零启动应用，E2B 把“开好机、应用已就绪”的那一刻存下来，每次只是恢复它</figcaption>
</figure>

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

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

### 暂停也是一次快照

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

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

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

## 五、创建流程：与 Kubernetes 的对照

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

### 两条创建路径

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

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 480" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="e2bd-k8s-title e2bd-k8s-desc">
<title id="e2bd-k8s-title">Kubernetes 创建 Pod 与 E2B 创建沙箱的路径</title>
<desc id="e2bd-k8s-desc">Kubernetes：apiserver 写入 etcd 后 kubectl 即返回，scheduler、kubelet 依次 watch 接力，kubelet 经 CRI 建 pause 容器并由 CNI 分配 IP，再拉镜像、启动容器、等 readiness 探针。E2B：API 鉴权后选一台节点，同步调用 orchestrator 的 Create；orchestrator 并行准备 uffd 内存、NBD 磁盘和预建网络，Firecracker 恢复快照，envd 完成 /init 后才把 201 返回给调用方。</desc>
<defs>
<marker id="e2bd-k8s-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="e2bd-k8s-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="480" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="204" rx="8"/>
<text class="t-zone" x="36" y="44">Kubernetes · kubectl apply -f web.yaml</text>
<rect class="dd-zone" x="16" y="236" width="768" height="228" rx="8"/>
<text class="t-zone" x="36" y="264">E2B · Sandbox.create('flask-demo')</text>
<path class="dd-edge dd-dash" d="M136 98 H150" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge dd-dash" d="M260 98 H274" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge dd-dash" d="M384 98 H398" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge" d="M508 98 H522" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge" d="M632 98 H646" marker-end="url(#e2bd-k8s-arrow)"/>
<rect class="dd-card" x="32" y="72" width="104" height="52" rx="6"/>
<text class="t-name" x="84" y="94" text-anchor="middle">apiserver</text>
<text class="t-note" x="84" y="113" text-anchor="middle">写入 etcd</text>
<rect class="dd-card" x="156" y="72" width="104" height="52" rx="6"/>
<text class="t-name" x="208" y="94" text-anchor="middle">scheduler</text>
<text class="t-note" x="208" y="113" text-anchor="middle">过滤打分 · bind</text>
<rect class="dd-card" x="280" y="72" width="104" height="52" rx="6"/>
<text class="t-name" x="332" y="94" text-anchor="middle">kubelet</text>
<text class="t-note" x="332" y="113" text-anchor="middle">发现分给自己</text>
<rect class="dd-card" x="404" y="72" width="104" height="52" rx="6"/>
<text class="t-mono" x="456" y="94" text-anchor="middle">RunPodSandbox</text>
<text class="t-note" x="456" y="113" text-anchor="middle">pause · CNI 分 IP</text>
<rect class="dd-card" x="528" y="72" width="104" height="52" rx="6"/>
<text class="t-mono" x="580" y="94" text-anchor="middle">PullImage</text>
<text class="t-note" x="580" y="113" text-anchor="middle">各层拉完解压</text>
<rect class="dd-card" x="652" y="72" width="116" height="52" rx="6"/>
<text class="t-mono" x="710" y="94" text-anchor="middle">StartContainer</text>
<text class="t-note" x="710" y="113" text-anchor="middle">应用从头启动</text>
<rect class="dd-derived" x="652" y="140" width="116" height="36" rx="6"/>
<text class="t-note" x="710" y="163" text-anchor="middle">readiness 探针</text>
<path class="dd-edge" d="M710 124 V134" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge-acc" d="M84 124 V152" marker-end="url(#e2bd-k8s-arrow-acc)"/>
<text class="t-note" x="92" y="148"><tspan class="t-acc">kubectl 在这里就返回</tspan></text>
<text class="t-say" x="32" y="180">虚线是 watch：组件之间不互相调用，</text>
<text class="t-say" x="32" y="200">各自盯着 etcd 里的对象接力往下做。</text>
<text class="t-note" x="636" y="200" text-anchor="end">SLO：p99 ≤ 5 s（不含拉镜像）</text>
<path class="dd-edge" d="M136 324 H150" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge" d="M260 324 H274" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge" d="M384 324 H398" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge" d="M392 324 V296 Q392 288 400 288 H402" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge" d="M392 324 V352 Q392 360 400 360 H402" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge" d="M532 288 H536 Q544 288 544 296 V324"/>
<path class="dd-edge" d="M532 360 H536 Q544 360 544 352 V324"/>
<path class="dd-edge" d="M532 324 H560" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge" d="M672 324 H686" marker-end="url(#e2bd-k8s-arrow)"/>
<path class="dd-edge-acc" d="M732 350 V400 Q732 408 724 408 H92 Q84 408 84 400 V356" marker-end="url(#e2bd-k8s-arrow-acc)"/>
<rect class="dd-card" x="32" y="298" width="104" height="52" rx="6"/>
<text class="t-name" x="84" y="320" text-anchor="middle">API</text>
<text class="t-note" x="84" y="339" text-anchor="middle">鉴权 · 找 build</text>
<rect class="dd-card" x="156" y="298" width="104" height="52" rx="6"/>
<text class="t-name" x="208" y="320" text-anchor="middle">选节点</text>
<text class="t-note" x="208" y="339" text-anchor="middle">按负载挑一台</text>
<rect class="dd-card" x="280" y="298" width="104" height="52" rx="6"/>
<text class="t-name" x="332" y="320" text-anchor="middle">orchestrator</text>
<text class="t-note" x="332" y="339" text-anchor="middle">gRPC Create</text>
<rect class="dd-card" x="404" y="274" width="128" height="28" rx="6"/>
<text class="t-mono" x="468" y="292" text-anchor="middle">uffd ← memfile</text>
<rect class="dd-card" x="404" y="310" width="128" height="28" rx="6"/>
<text class="t-mono" x="468" y="328" text-anchor="middle">NBD ← rootfs</text>
<rect class="dd-card" x="404" y="346" width="128" height="28" rx="6"/>
<text class="t-mono" x="468" y="364" text-anchor="middle">netns ← 网络池</text>
<rect class="dd-card" x="568" y="298" width="104" height="52" rx="6"/>
<text class="t-name" x="620" y="320" text-anchor="middle">Firecracker</text>
<text class="t-note" x="620" y="339" text-anchor="middle">载入 · 恢复</text>
<rect class="dd-card" x="692" y="298" width="76" height="52" rx="6"/>
<text class="t-name" x="730" y="320" text-anchor="middle">envd</text>
<text class="t-mono" x="730" y="339" text-anchor="middle">/init</text>
<text class="t-note" x="408" y="400" text-anchor="middle"><tspan class="t-acc">201 · 沙箱已可用</tspan></text>
<text class="t-say" x="32" y="436">实线是同步调用：API 等 orchestrator，orchestrator 等 envd，</text>
<text class="t-say" x="32" y="456">响应回到调用方的那一刻，沙箱里的进程已经在运行。</text>
</svg>
</div>
<figcaption>图 5 · Kubernetes 写下期望就返回，由各组件接力完成；E2B 一路同步等到沙箱可用才返回</figcaption>
</figure>


### 异步与同步

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

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

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

### E2B 跑在 Kubernetes 上的架构

E2B 的架构文档说，它推荐的部署方式基于 Kubernetes[^e2b-arch]，但公开的清单只有一个单节点的部署包[^e2b-embed-k8s]。下面的多节点布局，是按架构文档里的角色划分和服务发现的代码[^e2b-sd]推出来的。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 480" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="e2bd-onk8s-title e2bd-onk8s-desc">
<title id="e2bd-onk8s-title">E2B 跑在 Kubernetes 上的架构</title>
<desc id="e2bd-onk8s-desc">控制面的 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。</desc>
<defs>
<marker id="e2bd-onk8s-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="e2bd-onk8s-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="480" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="240" height="152" rx="8"/>
<text class="t-zone" x="36" y="44">Kubernetes 控制面</text>
<rect class="dd-zone" x="272" y="16" width="512" height="152" rx="8"/>
<text class="t-zone" x="292" y="44">E2B 控制面 · 普通 Deployment</text>
<rect class="dd-zone" x="16" y="184" width="768" height="280" rx="8"/>
<text class="t-zone" x="36" y="212">沙箱节点 · node-a</text>
<rect class="dd-card" x="36" y="64" width="200" height="52" rx="6"/>
<text class="t-name" x="136" y="86" text-anchor="middle">kube-apiserver</text>
<text class="t-note" x="136" y="105" text-anchor="middle">Pod 的期望与状态</text>
<text class="t-note" x="36" y="140">① api 按标签列出 Running 的</text>
<text class="t-note" x="36" y="158">orchestrator Pod，取 hostIP</text>
<path class="dd-edge" d="M292 90 H242" marker-end="url(#e2bd-onk8s-arrow)"/>
<rect class="dd-card" x="292" y="64" width="140" height="52" rx="6"/>
<text class="t-name" x="362" y="86" text-anchor="middle">api</text>
<text class="t-note" x="362" y="105" text-anchor="middle">Sandbox.create 入口</text>
<rect class="dd-card" x="452" y="64" width="140" height="52" rx="6"/>
<text class="t-name" x="522" y="86" text-anchor="middle">client-proxy</text>
<text class="t-note" x="522" y="105" text-anchor="middle">进沙箱的流量</text>
<rect class="dd-card" x="612" y="64" width="152" height="52" rx="6"/>
<text class="t-name" x="688" y="86" text-anchor="middle">redis</text>
<text class="t-note" x="688" y="105" text-anchor="middle">沙箱在哪台节点</text>
<path class="dd-edge-acc" d="M340 116 V230" marker-end="url(#e2bd-onk8s-arrow-acc)"/>
<text class="t-note" x="350" y="214"><tspan class="t-acc">② 同步 Create · :5008</tspan></text>
<path class="dd-edge" d="M560 116 V230" marker-end="url(#e2bd-onk8s-arrow)"/>
<text class="t-note" x="570" y="214">③ 经 :5007 转发到 envd</text>
<rect class="dd-card" x="36" y="236" width="150" height="52" rx="6"/>
<text class="t-name" x="111" y="258" text-anchor="middle">kubelet</text>
<text class="t-note" x="111" y="277" text-anchor="middle">只看得见这个 Pod</text>
<path class="dd-edge" d="M186 262 H210" marker-end="url(#e2bd-onk8s-arrow)"/>
<text class="t-note" x="36" y="316">K8s 管到这一层：</text>
<text class="t-note" x="36" y="334">异步拉起，挂了重建</text>
<rect class="dd-card" x="216" y="236" width="232" height="162" rx="6"/>
<path class="dd-card-head" d="M222 236 H442 Q448 236 448 242 V268 H216 V242 Q216 236 222 236 Z"/>
<text class="t-name" x="232" y="257">Pod</text>
<text class="t-mono" x="432" y="257" text-anchor="end"><tspan class="t-acc">orchestrator</tspan></text>
<line class="dd-hair" x1="216" y1="294" x2="448" y2="294"/>
<line class="dd-hair" x1="216" y1="320" x2="448" y2="320"/>
<line class="dd-hair" x1="216" y1="346" x2="448" y2="346"/>
<line class="dd-hair" x1="216" y1="372" x2="448" y2="372"/>
<text class="t-mono" x="232" y="285">hostNetwork</text>
<text class="t-val" x="432" y="285" text-anchor="end">true</text>
<text class="t-mono" x="232" y="311">hostPID</text>
<text class="t-val" x="432" y="311" text-anchor="end">true</text>
<text class="t-mono" x="232" y="337">privileged</text>
<text class="t-val" x="432" y="337" text-anchor="end">true · nsenter</text>
<text class="t-mono" x="232" y="363">hugepages-2Mi</text>
<text class="t-val" x="432" y="363" text-anchor="end">4Gi</text>
<text class="t-mono" x="232" y="389">ports</text>
<text class="t-val" x="432" y="389" text-anchor="end">5008 · 5007</text>
<path class="dd-edge-acc" d="M448 318 H490" marker-end="url(#e2bd-onk8s-arrow-acc)"/>
<rect class="dd-derived" x="496" y="236" width="272" height="162" rx="6"/>
<rect class="dd-card" x="512" y="252" width="240" height="36" rx="6"/>
<text class="t-name" x="528" y="275">Firecracker</text>
<text class="t-mono" x="736" y="275" text-anchor="end">sb-7f3a · envd</text>
<rect class="dd-card" x="512" y="296" width="240" height="36" rx="6"/>
<text class="t-name" x="528" y="319">Firecracker</text>
<text class="t-mono" x="736" y="319" text-anchor="end">sb-91c2 · envd</text>
<rect class="dd-card" x="512" y="340" width="240" height="36" rx="6"/>
<text class="t-name" x="528" y="363">Firecracker</text>
<text class="t-mono" x="736" y="363" text-anchor="end">sb-c04e · envd</text>
<text class="t-note" x="632" y="392" text-anchor="middle">…</text>
<text class="t-say" x="36" y="428">虚线框里的 VM 是 orchestrator 在宿主上的子进程，K8s 不知道它们存在；</text>
<text class="t-say" x="36" y="448">Pod 被删除或重启，这台节点上的沙箱就跟着结束。</text>
<text class="t-note" x="768" y="448" text-anchor="end">其他沙箱节点相同 …</text>
</svg>
</div>
<figcaption>图 6 · Kubernetes 只拉起 orchestrator 这一个 Pod，沙箱由 orchestrator 自己同步拉起</figcaption>
</figure>

控制面的 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` 就是留给沙箱的内存池[^e2b-embed-k8s]。

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

于是两层各管各的：Kubernetes 异步地拉起 orchestrator，挂了就重建；orchestrator 同步地拉起沙箱，一个个 Firecracker 进程是它在宿主上的子进程，kubelet 一个也看不见。代价也在这里：orchestrator 的 Pod 被删除或重启，这台节点上的沙箱就跟着结束[^e2b-embed-k8s]。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）和文件系统服务[^e2b-arch]。第二节里追加层的 `/usr/bin/envd` 和 systemd 预设，就是为它准备的。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="e2bd-exec-title e2bd-exec-desc">
<title id="e2bd-exec-title">docker exec 和 E2B commands.run 的路径</title>
<desc id="e2bd-exec-desc">docker exec 从 CLI 经 dockerd、containerd、shim 和 runc，最后 setns 进入容器，全程在同一台宿主机上。E2B 的 commands.run 发 HTTPS 请求到 49983-沙箱ID.e2b.app，client-proxy 查到沙箱所在节点，转给该节点的 orchestrator 代理，再进入 VM 里的 envd，由 envd 启动进程。</desc>
<defs>
<marker id="e2bd-exec-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="e2bd-exec-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="300" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="124" rx="8"/>
<text class="t-zone" x="36" y="44">docker exec · 全在同一台宿主机上</text>
<rect class="dd-zone" x="16" y="156" width="768" height="128" rx="8"/>
<text class="t-zone" x="36" y="184">E2B commands.run · 跨网络进入 VM</text>
<path class="dd-edge" d="M192 82 H202" marker-end="url(#e2bd-exec-arrow)"/>
<path class="dd-edge" d="M332 82 H342" marker-end="url(#e2bd-exec-arrow)"/>
<path class="dd-edge" d="M472 82 H482" marker-end="url(#e2bd-exec-arrow)"/>
<path class="dd-edge" d="M612 82 H622" marker-end="url(#e2bd-exec-arrow)"/>
<path class="dd-edge" d="M192 222 H202" marker-end="url(#e2bd-exec-arrow)"/>
<path class="dd-edge" d="M360 222 H370" marker-end="url(#e2bd-exec-arrow)"/>
<path class="dd-edge-acc" d="M516 222 H526" marker-end="url(#e2bd-exec-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="160" height="36" rx="6"/>
<text class="t-mono" x="48" y="87">docker exec web ls</text>
<rect class="dd-card" x="212" y="64" width="120" height="36" rx="6"/>
<text class="t-name" x="272" y="87" text-anchor="middle">dockerd</text>
<rect class="dd-card" x="352" y="64" width="120" height="36" rx="6"/>
<text class="t-name" x="412" y="87" text-anchor="middle">containerd</text>
<rect class="dd-card" x="492" y="64" width="120" height="36" rx="6"/>
<text class="t-name" x="552" y="87" text-anchor="middle">shim → runc</text>
<rect class="dd-card" x="632" y="64" width="136" height="36" rx="6"/>
<text class="t-mono" x="700" y="87" text-anchor="middle">setns → ls</text>
<text class="t-say" x="32" y="124">每一跳都在宿主机上：能看见容器进程，才能 setns 走进去。</text>
<rect class="dd-card" x="32" y="204" width="160" height="36" rx="6"/>
<text class="t-mono" x="48" y="227">commands.run('ls')</text>
<text class="t-note" x="112" y="264" text-anchor="middle">SDK，任意位置</text>
<rect class="dd-card" x="212" y="204" width="148" height="36" rx="6"/>
<text class="t-name" x="286" y="227" text-anchor="middle">client-proxy</text>
<text class="t-note" x="286" y="264" text-anchor="middle">49983-&lt;id&gt;.e2b.app</text>
<rect class="dd-card" x="380" y="204" width="136" height="36" rx="6"/>
<text class="t-name" x="448" y="227" text-anchor="middle">orchestrator</text>
<text class="t-note" x="448" y="264" text-anchor="middle">节点代理 :5007</text>
<rect class="dd-card" x="536" y="204" width="232" height="36" rx="6"/>
<text class="t-name" x="652" y="227" text-anchor="middle">VM 内 <tspan class="t-acc">envd</tspan> :49983 → ls</text>
<text class="t-note" x="652" y="264" text-anchor="middle">Connect RPC · 进程 / 文件服务</text>
</svg>
</div>
<figcaption>图 7 · 进不了 VM 的 namespace，就在 VM 里放一个代理，从网络上和它说话</figcaption>
</figure>

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

## 七、在沙箱里运行 Docker

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

E2B 的官方文档就给了这样一个模板[^e2b-docker-example]：

```ts
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 环境里”运行[^docker-sandboxes]。给不可信的智能体代码用，容器的边界不够，这一点上两家的判断是一样的。

## 八、回到开头的问题

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

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="e2bd-cmp-title">同一份 Dockerfile，两种运行方式</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="e2bd-cmp-title">
<colgroup><col style="width:20%"><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col"></th><th scope="col">Docker 容器</th><th scope="col" class="acc">E2B 沙箱</th></tr>
</thead>
<tbody>
<tr><th scope="row">镜像格式</th><td colspan="2">OCI 镜像，同一个 <code>python:3.12-slim</code>，同样的 Dockerfile 语法</td></tr>
<tr><th scope="row">构建</th><td><code>docker build</code>，每条 RUN 在临时容器里跑</td><td>E2B 构建节点，每条 RUN 在 Firecracker VM 里经 envd 跑</td></tr>
<tr><th scope="row">根文件系统</th><td>overlayfs 直接当根目录</td><td>压平成 <code>rootfs.ext4</code>，作为 VM 的块设备</td></tr>
<tr><th scope="row">内核</th><td>宿主的内核</td><td class="good">自己的内核（默认 6.1.158）</td></tr>
<tr><th scope="row">1 号进程</th><td>入口命令本身</td><td>systemd，应用是它管的服务</td></tr>
<tr><th scope="row">创建</th><td>新建 namespace，从头启动应用</td><td class="good">恢复快照，应用已在运行（约 80 ms）</td></tr>
<tr><th scope="row">暂停</th><td>cgroup 冻结，内存留在原机；checkpoint 仍是实验性</td><td>整机快照，可在别的节点恢复</td></tr>
<tr><th scope="row">进去执行命令</th><td><code>docker exec</code> → runc <code>setns</code></td><td>HTTPS → client-proxy → envd</td></tr>
<tr><th scope="row">隔离边界</th><td class="warn">namespace + cgroup，共享内核</td><td class="good">KVM 虚拟机，不共享内核</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">只有第一行是两边共用的；从第二行开始，E2B 走的都是虚拟机的路。</p>
</div>
<figcaption>图 8 · E2B 借了 Docker 的镜像和生态，没有借它的运行时</figcaption>
</figure>

## 小结

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 的镜像和生态，自己做了运行时。

## 参考资料
