跳到主要内容

沙箱模板:如何构建这台会跑代码的机器

30 秒导读: 前面几章讲的是「代码进了沙箱之后怎么跑」——SDK 客户端发请求、FastAPI 路由接请求、内核桥把代码喂给 Jupyter、富结果把图表 DataFrame 抽出来。这一章讲的是这台机器本身是怎么造出来的:一份声明式的 template.py 脚本,从一个干净的 python:3.13 镜像出发,把 Python/R/JavaScript/Bash/Java 五种内核、一个独立的服务进程、以及把它们拉起来的启动逻辑,一层层叠成一个可复用的沙箱镜像(E2B 里叫 template)。给谁看:想自定义预装包、想调一个起不来的服务、或者只是好奇「运行时长啥样」的人。


1. 这是什么(零基础也能懂)

一句话定义: template/ 目录是 Code Interpreter 沙箱的出厂配方——它描述了「一台能安全跑用户代码的虚拟机」需要装什么、怎么启动。

它要解决的问题。 你想让 AI 生成的代码跑在一个隔离环境里,还要支持画图、跑 R、跑 Java。这个环境不能每次现装(太慢),得提前烤成一个镜像,启动时秒开。这份配方就是「怎么烤这个镜像」。

类比:一份 Dockerfile,但是用 Python 写的。 如果你用过 Docker,template.py 就是一个「代码版的 Dockerfile」:from_image 相当于 FROM,apt_install 相当于 RUN apt install,copy 相当于 COPY,最后的 set_start_cmd 相当于 CMD。区别是它用的是 E2B 的 Template DSL(领域特定语言,即用函数链表达构建步骤),而且同一份配方能同时产出两种东西:E2B 云上的 microVM 模板,或一个本地能跑的 Docker 镜像。

烤出来的这台机器里跑着两个进程:

进程干什么端口
Jupyter Server管理内核、真正执行代码8888
FastAPI 服务(uvicorn)对外的 HTTP/WS 门面,前几章讲的 /execute 等路由49999

用起来什么样。 造这台机器只要一行命令(生产版):

# template/README.md:23
python build_prod.py # 烤出 code-interpreter-v1 模板
SKIP_CACHE=true python build_prod.py # 忽略层缓存,干净重烤

烤好之后,SDK 侧 Sandbox.create(template="code-interpreter-v1") 就能秒开一台这样的机器。

本节不碰底层。往下我们看这份配方是怎么一层层叠出来的。


2. 顶层全景(它大概怎么转)

整个 template/ 的核心是一个函数:make_template()(template/template.py:4)。它返回一个 Template 对象——一串还没执行的构建步骤。真正触发「烤镜像」的是四个 build_*.py 入口,它们各自调 make_template(...) 拿到配方,再交给 Template.build(...)Template.to_dockerfile(...)

怎么读下面这张图:从上到下是「一份配方 → 四个入口 → 两种产物」。

template.py::make_template(kernels, is_docker, ready, debug)
┌─────────────────────────────────────────────────────────┐
│ from_image("python:3.13") │
│ apt/npm/pip 装依赖 │
│ 按需装 5 种 Jupyter 内核 │
│ 建独立 server venv + 装 server/requirements.txt │
│ 拷 matplotlibrc / jupyter 配置 / ipython 启动脚本 │
│ 拷 systemd 单元 或 start-up.sh(看 is_docker) │
│ set_start_cmd(...) │
└─────────────────────────────────────────────────────────┘
│ 返回未执行的 Template
┌──────────────────┬─────────────────┼──────────────────┬──────────────────┐
▼ ▼ ▼ ▼
build_prod.py build_ci.py build_debug.py build_docker.py
(生产, systemd) (CI, systemd) (调试, systemd) (本地, Docker)
│ │ │ │
▼ ▼ ▼ ▼
Template.build Template.build Template.build Template.to_dockerfile
→ code-interpreter │ E2B_TESTS_... │ +debug drop-in → 打印 Dockerfile
-v1 模板 │ 模板 │ +wait_for_timeout 给 docker build

部件一句话职责:

文件干什么
template/template.pymake_template()——唯一的配方定义,所有入口都调它
template/build_prod.py生产构建入口,产出 code-interpreter-v1
template/build_ci.pyCI 构建入口,别名从环境变量取,把 template_id 写回 GitHub Actions
template/build_debug.py调试构建入口,开 debug=True + 固定超时 ready
template/build_docker.py把配方转成 Dockerfile 文本(不 build)
template/start-up.shDocker 路径的启动脚本(非 systemd)
template/systemd/*.service生产/CI 路径的两个 systemd 单元
template/jupyter_server_config.pyJupyter Server 的配置(root_dir、CORS、关鉴权等)
template/jupyter-healthcheck.sh轮询 :8888/api/status 确认 Jupyter 起来了

主线走一遍(高层): 入口脚本调 make_template() → 得到配方 → Template.build() 把配方在 E2B 云端跑一遍烤成镜像 → 构建末尾 set_start_cmd 声明「启动时执行什么」+「怎么算 ready」→ 每次 Sandbox.create() 从这个镜像开一台机器,执行启动命令、等 :49999/health 返回 200 才算就绪。


3. 核心原理(逐个机制,由浅入深)

3.1 一份配方,从 python:3.13 起,链式叠层

make_template()Template() DSL 的方法链表达构建步骤,每个方法返回一个新的 Template,像搭积木一样往上叠。开头这段先定基础:

# template/template.py:12-44 (make_template 内)
template = (
Template()
.from_image("python:3.13") # 基础镜像
.set_user("root")
.set_workdir("/root")
.set_envs({ ... "JAVA_VERSION": "11", "IJAVA_VERSION": "1.3.0", "R_VERSION": "4.5.*" })
.apt_install(["build-essential", "curl", "git", "jq", "sudo", "fonts-noto-cjk", ...])
.run_cmd("curl -fsSL https://deb.nodesource.com/setup_20.x | bash -")
.apt_install("nodejs") # Node 20,给 JS 内核用
.copy("requirements.txt", "requirements.txt")
.pip_install("--no-cache-dir -r requirements.txt") # 内核环境的科学计算栈
)

这里 pip_install 装的是 template/requirements.txt——numpy/pandas/matplotlib/scikit-learn/plotly 等一大堆数据科学包(template/requirements.txt:1-45),它们装进的是全局 Python,也就是 Jupyter 的 python3 内核会用到的环境。注意 fonts-noto-cjk + 后面的 matplotlibrc 是一对:让 matplotlib 画图时能正确显示中日韩文字(见 3.4)。

关键细节: PIP_NO_CACHE_DIR=1--no-cache-dir 到处出现,是为了不让 pip 缓存把镜像撑大——镜像越小,冷启动越快。

3.2 五种 Jupyter 内核,按需装

Jupyter 本身只是个「内核管理器」,真正跑代码的是内核(kernel)。这台机器支持五种语言,每种对应一个内核,靠 enabled_kernels 集合按需安装。

默认五种全装;pythonjavascript 强制常驻:

# template/template.py:10
enabled_kernels = set(["python", "javascript"] + kernels)

即便调用者传 kernels=["r"],python 和 javascript 也会被并进集合——因为服务本身依赖它们(比如 Docker/debug 构建就只传 ["python","javascript"])。

各内核的装法:

内核怎么装引用
pythonipython kernel install --name 'python3'template/template.py:47
raptr-base + R 里 IRkernel::installspectemplate/template.py:50-56
javascriptnpm -ge2b 自家 fork 的 ijavascript + ijsinstalltemplate/template.py:59-63
bashpip install bash_kernel + bash_kernel.installtemplate/template.py:66-69
java手动下 OpenJDK 11 + 下载安装 IJava 内核template/template.py:72-83

为什么 JavaScript 内核要用 fork? 装的不是官方 ijavascript,而是:

# template/template.py:59-63
template = template.npm_install(
"--unsafe-perm git+https://github.com/e2b-dev/ijavascript.git",
g=True,
).run_cmd("ijsinstall --install=global")

指向 github.com/e2b-dev/ijavascript——E2B 维护的分支,推测(inferred)是为了让 JS 内核的输出/显示钩子跟这套富结果协议(第 4 章)对齐,官方版本满足不了。

3.3 服务进程住在独立 venv,和内核环境隔离

一个容易忽略的设计:跑用户代码的环境跑 FastAPI 服务的环境两套 Python,互不污染。

# template/template.py:86-95 (Common setup)
template = (
template
.copy("server", ".server") # 把服务代码拷进 .server/
.run_cmd("python -m venv .server/.venv") # 建独立虚拟环境
.run_cmd(".server/.venv/bin/pip install --no-cache-dir -r .server/requirements.txt")
)
  • 全局 Python:装了 numpy/pandas 等一大坨(3.1),是内核跑代码的地方,用户 pip install 也污染这里。
  • .server/.venv:只装 fastapi/uvicorn/httpx/websockets/pydantic 六个包(template/server/requirements.txt:1-6),是服务进程的地方。

为什么隔离? 用户在内核里 pip install 一个乱七八糟版本的库,不能把服务进程的 fastapi/uvicorn 搞崩。服务的依赖被钉死在自己的 venv 里,稳如泰山。

3.4 一堆配置文件,各就各位

Common setup 之后,配方把几份配置拷到 home 下的固定位置:

# template/template.py:98-106
template.copy("matplotlibrc", ".config/matplotlib/.matplotlibrc") # 中日韩字体
.copy("jupyter-healthcheck.sh", ".jupyter/jupyter-healthcheck.sh") # 健康检查脚本
.copy("jupyter_server_config.py", ".jupyter/") # Jupyter 配置
.make_dir(".ipython/profile_default/startup")
.copy("ipython_kernel_config.py", ".ipython/profile_default/")
.copy("startup_scripts", ".ipython/profile_default/startup") # IPython 启动脚本

各自的角色:

文件落到哪角色
matplotlibrc~/.config/matplotlib/.matplotlibrc一行 font.family: sans-serif, Noto Sans CJK JP,让图里中日韩字不乱码(template/matplotlibrc:1)
jupyter_server_config.py~/.jupyter/Jupyter Server 的运行配置,见 3.5
jupyter-healthcheck.sh~/.jupyter/启动时轮询,确认 Jupyter 真起来了,见 3.6
startup_scripts/*.pyIPython 每个 python 会话启动时自动 exec注册 e2b 的富结果格式器(第 4 章),如 0002_data.pyE2BDataFormatter/E2BChartFormatter

ipython_kernel_config.py 是一份很大的 IPython 内核默认配置(全是被注释的默认项),startup_scripts 才是真正注入行为的地方——这些脚本在每次 python 内核启动时被自动执行,把 DataFrame → e2b/data、matplotlib Figure → e2b/chart 的格式器挂上去。

3.5 Jupyter 配置里藏着一个 CVE 修复

jupyter_server_config.py 关掉了一堆安全检查(因为这是个只跑一次性代码的隔离沙箱),但最关键的一行是钉死 root_dir:

# template/jupyter_server_config.py:16
c.ServerApp.root_dir = "/home/user"

注释里解释了原因(template/jupyter_server_config.py:8-15):会话是用一个纯 uuid 的相对路径创建的(见 第 2 章server/contexts.py)。systemd 下 Jupyter 没有 WorkingDirectory,进程 cwd 是 /;而 jupyter-server 2.18.0 起(CVE-2026-35397 路径穿越加固)会拒绝 root 目录为 / 的会话创建,报 <uuid> is outside root contents directory,导致服务根本起不来。把 root_dir 钉到 /home/user(和执行 cwd 一致)才修好。

其余几项都是「隔离沙箱专用」的放行:

配置作用
allow_origin = "*"允许任意来源跨域
allow_remote_access = True允许非本地 Host 头
disable_check_xsrf = True关 CSRF(anonymous compute 场景)
allow_root = True允许以 root 跑
iopub_data_rate_limit = 1e9把输出限速抬到 1GB/s,不截断大输出

3.6 启动 + ready:先确认 Jupyter,再拉 FastAPI

无论哪条路径,启动逻辑都是同一套「先 Jupyter 后 server」的顺序,靠 jupyter-healthcheck.sh 保证顺序。

jupyter-healthcheck.sh 就是个轮询循环:

# template/jupyter-healthcheck.sh:8-20
for i in $(seq 1 $MAX_RETRIES); do # 最多 50 次
status_code=$(curl ... "http://localhost:8888/api/status")
if [ "$status_code" -eq 200 ]; then
echo "Jupyter Server is healthy"; exit 0
fi
sleep 0.2
done
exit 1

它探的是 Jupyter 内部的 8888 端口——注意 Jupyter 端口在这套代码里是硬编码的 8888(Jupyter 默认端口),server/consts.py:1 也写死 JUPYTER_BASE_URL = "http://localhost:8888",并没有 JUPYTER_PORT 这样的环境变量。

对外的 ready 信号探的是另一个端口——FastAPI 服务的 :49999/health:

# template/template.py:143-146
if ready is None:
ready = wait_for_url("http://localhost:49999/health")
return template.set_start_cmd(start_cmd, ready)

/health 由 FastAPI 提供(server/main.py:74)。所以两级校验:内部脚本确认 Jupyter(:8888)活了 → 服务才起 → 外部 wait_for_url 确认服务(:49999)活了 → 整台机器才算 ready。


4. 深入实现:两种启动路径(systemd vs Docker)

make_template 的下半段有个 is_docker 分叉,产出两种完全不同的启动方式。这是理解「生产 vs 本地」差异的关键。

4.1 systemd 路径(生产 / CI)

is_docker=False 时,拷两个 systemd 单元进去:

# template/template.py:108-114
if not is_docker:
template = template.copy("systemd/jupyter.service", "/etc/systemd/system/jupyter.service") \
.copy("systemd/code-interpreter.service", "/etc/systemd/system/code-interpreter.service")

启动命令就一句 sudo systemctl start jupyter(template/template.py:141)。剩下的靠两个 unit 之间的依赖关系自动编排:

sudo systemctl start jupyter


jupyter.service ────Wants───▶ code-interpreter.service
起 Jupyter(:8888) (Requires + After + PartOf jupyter.service)
ExecStart: jupyter server ExecStartPre: jupyter-healthcheck.sh ← 先探 :8888
ExecStart: uvicorn main:app :49999
  • jupyter.service(template/systemd/jupyter.service):ExecStartjupyter server --IdentityProvider.token=""(空 token,免鉴权);Wants=code-interpreter.service 让它带起服务;StandardOutput=null——生产环境 Jupyter 的 stdout 被丢掉(见 4.3 调试)。
  • code-interpreter.service(template/systemd/code-interpreter.service):Requires=/After=/PartOf=jupyter.service 表示它依附 Jupyter,Jupyter 停它也停;ExecStartPre 先跑健康检查脚本(确认 :8888 起来),ExecStart 才起 uvicorn 监听 :49999--workers 1 --timeout-keep-alive 640 是长连接流式执行需要的(第 3 章)。

4.2 Docker 路径(本地开发)

is_docker=True 时,不用 systemd(容器里通常没有 systemd),改拷 start-up.sh:

# template/template.py:121-124, 138-139
else:
template = template.copy("start-up.sh", ".jupyter/start-up.sh").run_cmd("chmod +x ...")
...
start_cmd = "sudo --preserve-env=E2B_LOCAL /root/.jupyter/start-up.sh"

start-up.sh 手动把两个进程都拉起来:

# template/start-up.sh:14-16
start_code_interpreter & # 后台:先跑 healthcheck,再 uvicorn :49999
MATPLOTLIBRC=... jupyter server --IdentityProvider.token="" >/dev/null 2>&1 # 前台:Jupyter

start_code_interpreter 函数(template/start-up.sh:3-12)先跑 jupyter-healthcheck.sh,失败就 exit 1,成功才 uvicorn main:app。逻辑和 systemd 版一样,只是手写编排代替了 unit 依赖。Docker 路径还多做一步:创建 user 用户、加 /home/user、写 sudoers(template/template.py:126-134),因为 systemd 镜像里这些由平台准备好了。

is_docker=True 只由 build_docker.py 用,配合 Makefile:

# Makefile
start-template-server:
docker run ... -it $$(python template/build_docker.py | docker build -q ./template -f -)

build_docker.pyTemplate.to_dockerfile(...) 只打印 Dockerfile 文本,管道给 docker build 现场构建,docker run 本地跑——不碰 E2B 云。

README 的警告(template/README.md:96-100): Docker 路径(start-up.sh)和生产 systemd 路径「会分叉」——Docker 下能起来的服务,systemd 下未必起得来(3.5 那个 CVE 就是典型)。所以 CI/生产的真身是 systemd。

4.3 debug 路径:让崩溃可见

生产 systemd 把 Jupyter 的 stdout 丢到 /dev/null,一旦 Jupyter 起不来你什么都看不到。debug=True 修这个:

# template/template.py:115-120
if debug:
template = template.copy(
"systemd/jupyter-debug.conf",
"/etc/systemd/system/jupyter.service.d/debug.conf", # systemd drop-in
)

这个 drop-in 就一行,把丢弃的 stdout 改导到 journal:

# template/systemd/jupyter-debug.conf:4-5
[Service]
StandardOutput=journal

于是 journalctl -u jupyter 就能看到 Jupyter 的请求/错误日志。build_debug.py 还把 ready 从 wait_for_url(/health) 换成 wait_for_timeout(60_000)(template/build_debug.py:14)——固定等 60 秒就算完成,不管服务有没有起来。这样即便服务在崩溃循环,构建也能 finalize,你才有机会开一台沙箱去看日志。

debug_logs.py 是配套的诊断脚本:开一台 debug 沙箱,依次跑 systemctl status + journalctl + curl 探两个端口,把结果打出来(template/debug_logs.py:13-33)。Makefile 的 make debug-template 就是 build_debug.py && debug_logs.py


5. 四个构建入口对比

四个 build_*.py 都调 make_template(),差别在参数产物:

入口kernelsis_dockerdebugready产物 / 别名SKIP_CACHE
build_prod.py默认全 5 种FalseFalse/healthTemplate.buildcode-interpreter-v1支持,读 env(build_prod.py:9)
build_ci.py默认全 5 种FalseFalse/healthTemplate.build$E2B_TESTS_TEMPLATE,写 template_id 回 GitHub
build_debug.py仅 python/jsFalseTrue超时 60sTemplate.buildcode-interpreter-debug
build_docker.py仅 python/jsTrueFalse/health(不用)Template.to_dockerfile → 打印文本

只有生产入口支持 SKIP_CACHE:

# template/build_prod.py:9-17
skip_cache = os.getenv("SKIP_CACHE", "false").lower() == "true"
Template.build(make_template(), alias="code-interpreter-v1", cpu_count=2, memory_mb=2048,
skip_cache=skip_cache, ...)

SKIP_CACHE=true 会忽略层缓存整份重烤——上游改了某层但缓存没失效时用得上。build_ci.py 特别之处是把烤出的 template_id 写进 $GITHUB_OUTPUT(build_ci.py:16-19),供后续 CI 步骤(比如跑测试)引用。


6. 三条路径差异总表

维度生产(build_prod / build_ci)Docker(build_docker)调试(build_debug)
is_dockerFalseTrueFalse
进程编排systemd 两个 unitstart-up.sh 手动systemd 两个 unit
启动命令systemctl start jupytersudo ... start-up.shsystemctl start jupyter
装的内核全 5 种python + jspython + js
Jupyter stdout/dev/null(丢弃)>/dev/null 2>&1(丢弃)journal(可见)
ready 条件wait_for_url(/health)wait_for_url(/health)wait_for_timeout(60s)
额外用户设置平台准备自建 user + sudoers平台准备
产物E2B 云模板本地 Docker 镜像E2B 云模板(可诊断)
用途线上 / CI本地快速迭代排查起不来的服务

7. 巧妙之处(可借鉴)

  • 服务与用户代码双 venv 隔离。 服务依赖钉死在 .server/.venv,用户在内核里怎么 pip install 都崩不到服务(template/template.py:89-93)。这是「运行不可信代码」场景里很实在的一招。
  • 两级 ready:内部脚本探 Jupyter,外部信号探服务。 healthcheck 脚本确认 :8888,wait_for_url 确认 :49999——启动顺序被依赖关系(systemd ExecStartPre / shell &&)强制,避免服务在 Jupyter 没起来时空转(template/jupyter-healthcheck.sh + template/template.py:143-146)。
  • debug 用「固定超时」代替「健康就绪」。 生产要求 /health 通过才算成功,但那样服务崩了构建就直接超时失败、拿不到可诊断的沙箱;debug 改成「等 60 秒无脑 finalize」,专门为「让崩溃现场活下来」设计(template/build_debug.py:14)。
  • 一份配方,双产物。 同一个 make_templateis_docker 分叉,既能出 E2B microVM 模板,又能出本地 Docker 镜像(template/build_docker.py)。本地迭代快,线上跑真身。
  • CVE 修复钉进配置。 root_dir="/home/user" 这行不是随手写的,而是踩了 jupyter-server 2.18.0 路径穿越加固的坑后写的注释级修复(template/jupyter_server_config.py:8-16)。

8. 边界与局限

  • Jupyter 端口硬编码 8888,无环境变量可调。 三处写死(jupyter-healthcheck.shserver/consts.py:1server/messaging.py:70),没有 JUPYTER_PORT 之类的开关。
  • Docker 路径与生产会分叉。 README 明说(template/README.md:96-100):start-up.sh 路径下能起的服务,systemd 下未必能起,反之亦然。本地验证过不代表线上没问题。
  • 服务 venv 只装了 6 个包。 想给服务本身加依赖,得改 template/server/requirements.txt 而不是 template/requirements.txt(后者是内核环境)。
  • ipython_kernel_config.py 基本是空壳。 三万多字节几乎全是被注释的默认项;真正注入行为的是 startup_scripts/,别去那份大文件里找逻辑。

9. 代码地图(导航索引)

主题文件路径符号 / 关键行
配方主函数template/template.pymake_template
基础镜像 + 依赖template/template.pyfrom_image("python:3.13")(:13)
内核集合逻辑template/template.pyenabled_kernels(:10)
JS 内核 forktemplate/template.pye2b-dev/ijavascript(:59-63)
服务 venvtemplate/template.py.copy("server", ".server")(:89-93)
systemd/docker 分叉template/template.pyif not is_docker(:108) / is_docker(:121)
ready 定义template/template.pywait_for_url(".../health")(:143-146)
生产构建template/build_prod.pyskip_cache / alias="code-interpreter-v1"
CI 构建template/build_ci.pyE2B_TESTS_TEMPLATE / GITHUB_OUTPUT
调试构建template/build_debug.pydebug=True / wait_for_timeout(60_000)
Docker 构建template/build_docker.pyTemplate.to_dockerfile
Docker 启动脚本template/start-up.shstart_code_interpreter
Jupyter 单元template/systemd/jupyter.serviceWants=code-interpreter.service
服务单元template/systemd/code-interpreter.serviceExecStartPre / uvicorn ... :49999
debug drop-intemplate/systemd/jupyter-debug.confStandardOutput=journal
Jupyter 配置template/jupyter_server_config.pyc.ServerApp.root_dir(:16)
健康检查template/jupyter-healthcheck.sh轮询 :8888/api/status
诊断脚本template/debug_logs.pyCMDS
内核依赖template/requirements.txtnumpy/pandas/matplotlib/...
服务依赖template/server/requirements.txtfastapi/uvicorn/...
字体配置template/matplotlibrcNoto Sans CJK JP
富结果启动脚本template/startup_scripts/0002_data.pyE2BDataFormatter(见 第 4 章)
构建命令Makefilestart-template-server / debug-template

相关章节: 服务路由怎么用这些端口 → 02-server-routing.md;内核消息协议(为什么 --timeout-keep-alive 640)→ 03-kernel-messaging.md;startup_scripts 注册的格式器 → 04-rich-results.md;全景与阅读地图 → index.md