数据截至 (上游 commit 771b20c93db8)
沙箱模板:如何构建这台会跑代码的机器
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.py | make_template()——唯一的配方定义,所有入口都调它 |
template/build_prod.py | 生产构建入口,产出 code-interpreter-v1 |
template/build_ci.py | CI 构建入口,别名从环境变量取,把 template_id 写回 GitHub Actions |
template/build_debug.py | 调试构建入口,开 debug=True + 固定超时 ready |
template/build_docker.py | 把配方转成 Dockerfile 文本(不 build) |
template/start-up.sh | Docker 路径的启动脚本(非 systemd) |
template/systemd/*.service | 生产/CI 路径的两个 systemd 单元 |
template/jupyter_server_config.py | Jupyter 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-44),它们装进的是全局 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 集合按需安装。
默认五种全装;python 和 javascript 强制常驻:
# template/template.py:10
enabled_kernels = set(["python", "javascript"] + kernels)
即便调用者传 kernels=["r"],python 和 javascript 也会被并进集合——因为服务本身依赖它们(比如 Docker/debug 构建就只传 ["python","javascript"])。
各内核的装法:
| 内核 | 怎么装 | 引用 |
|---|---|---|
| python | ipython kernel install --name 'python3' | template/template.py:47 |
| r | apt 装 r-base + R 里 IRkernel::installspec | template/template.py:50-56 |
| javascript | npm -g 装 e2b 自家 fork 的 ijavascript + ijsinstall | template/template.py:59-63 |
| bash | pip install bash_kernel + bash_kernel.install | template/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/*.py | IPython 每个 python 会话启动时自动 exec | 注册 e2b 的富结果格式器(第 4 章),如 0002_data.py 装 E2BDataFormatter/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):ExecStart起jupyter 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.py 调 Template.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。