AI Agent 会写代码,那些代码总得找个地方跑。
跑在自己的服务进程里是不行的------Agent 生成的代码你没法预先审查,一个 rm -rf 或者一个死循环就能带走整台机器。所以业界的答案是沙箱:一次 API 调用换一个隔离的执行环境,跑完就扔。
海外这块做得最好的是 E2B,SDK 设计得很干净,托管服务开箱即用。 对很多团队来说故事到这儿就结束了。
对国内团队来说故事才刚开始。三个绕不过去的问题:
数据出不去。 Agent 处理的往往是客户的代码、客户的数据。"跑在别人家的云上" 这句话在合规评审里基本没有赢面,尤其是金融、政务、医疗这些行业。
云厂商不对。 你的基础设施在阿里云或者腾讯云上,海外沙箱服务不覆盖国内云------ 意味着每一次执行都要跨境往返,延迟和稳定性都是问题。
账单随 Agent 流量超线性增长。 Agent 的调用量本来就难预测,按次计费的东西 一旦跑起来,成本曲线不好看。
好消息是"自托管"和"扔掉 SDK 重写一遍"不是同一件事。
这篇文章从一台什么都没装的服务器开始,部署 SandrPod(开源,Apache-2.0),在前面 架一张通配符 TLS 证书,然后只改两个环境变量 ,让官方的、未经任何修改的 e2b Python SDK 连上来。最后逐个函数地跑一遍客户端接口,如实报告哪些能用、 哪些有差异、哪些还没有。
底下所有内容都是在一台全新的 CentOS Stream 10 机器上从头跑完的,输出是真的, 包括失败的部分。
一、先看形状
E2B 的架构是:一个你通过 REST 访问的控制平面,加上每个沙箱里一个叫 envd 的守护进程,控制平面把你的文件、进程、PTY 调用代理进去。
SandrPod 的架构是:一个你通过 REST 访问的控制平面,加上每个沙箱里一个叫 toolbox 的守护进程,控制平面通过一条 worker 主动拨出的反向隧道够到它。
xml
你的笔记本
│ HTTPS
▼
┌────────────────────┐
│ 控制平面 │ api.<domain> → REST
│ (server) │ <port>-<id>.<domain> → 沙箱
└─────────┬──────────┘
│ WebSocket + yamux,由 worker 主动向外拨出
┌─────────▼──────────┐
│ worker (poder) │
└─────────┬──────────┘
│ Docker API
┌─────────▼──────────┐
│ 沙箱容器 │ ← toolbox(≈ envd)住在这里
└────────────────────┘
这个形状比看上去重要。因为两边的拓扑是同构的------都是控制平面代理到沙箱内守护 进程,toolbox 待的位置正是 envd 待的位置------所以支持 E2B 的 SDK 是协议 适配,不是第二套架构。图里没有任何一个组件是为了兼容而存在的;兼容是从一个 本来就长这样的设计里掉出来的。
隧道的方向是另一个值得在意的点:worker 主动连控制平面,所以一台跑沙箱的机器 完全不需要任何入站端口。你可以把它放在 NAT 后面、放在办公室网络里、放在 一台笔记本上,它照样加入集群。这对国内的网络环境挺实用------很多机器根本申请不到 公网入口。
二、从一台空服务器开始
一台机器:8 核 15G,CentOS Stream 10,什么都没装。一个域名,NS 指向一个 带 API 的 DNS 服务商------这里用腾讯云 DNSPod,但 lego 支持的一百多家都一样。
2.1 装 Docker
bash
dnf -y install dnf-plugins-core
curl -fsSL https://download.docker.com/linux/centos/docker-ce.repo \
-o /etc/yum.repos.d/docker-ce.repo
dnf -y install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
在这个镜像上它失败了两次,两次都值得知道,因为它们是精简版云内核的通病, 跟这套东西本身无关:
lua
failed to register "bridge" driver: ... iptables --wait -t nat -A PREROUTING
-m addrtype --dst-type LOCAL -j DOCKER: Warning: Extension addrtype revision 0
not supported, missing kernel module?
xt_addrtype 在 kernel-modules-extra 包里,没装------而 dnf 找不到与当前 运行内核匹配的构建,于是它悄悄用另一个内核版本的 debug 变体满足了依赖。 与其去追内核包,不如让 Docker 换用 nftables 防火墙后端,那条路不需要这个模块:
bash
cat > /etc/docker/daemon.json <<'JSON'
{ "firewall-backend": "nftables" }
JSON
然后:
vbnet
failed to start daemon: ... IPv4 forwarding is disabled
bash
echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-docker.conf
sysctl -p /etc/sysctl.d/99-docker.conf
systemctl restart docker
perl
$ docker version --format 'server={{.Server.Version}} api={{.Server.APIVersion}}'
server=29.6.2 api=1.55
2.2 DNS:两条记录,其中一条是通配符
css
api.example.com A 203.0.113.10
*.example.com A 203.0.113.10
通配符不是可选项,而且它是这篇文章和一个 quickstart 之间唯一的区别。E2B 访问沙箱内服务的地址形如 <port>-<sandboxID>.<domain>------而沙箱 ID 在沙箱 被创建出来之前根本不存在。你没法提前枚举这些名字,也就没法提前建记录。
用什么方式建这两条都行,控制台就够。走 API 的好处是整个部署可脚本化。 配套仓库 里有一个约 90 行、只用标准库的 DNSPod TC3 签名客户端(tcdns.py):
bash
export TENCENTCLOUD_SECRET_ID=... TENCENTCLOUD_SECRET_KEY=...
./tcdns.py upsert example.com api '203.0.113.10'
./tcdns.py upsert example.com '*' '203.0.113.10'
在服务器上验证,而且要去解一个还不存在的名字------那个才是关键:
shell
$ getent ahostsv4 8000-abc.example.com | head -1
203.0.113.10
2.3 通配符证书,走 DNS-01
Let's Encrypt 只通过 DNS-01 挑战签发通配符证书,所以 ACME 客户端需要 DNS 服务商的凭据。lego 有约 150 家的插件, 国内几家主流云都在列。
bash
export TENCENTCLOUD_SECRET_ID=... TENCENTCLOUD_SECRET_KEY=...
lego --email you@example.com --accept-tos \
--dns tencentcloud --dns.propagation-wait 90s \
-d example.com -d '*.example.com' \
--path /etc/lego run
--dns.propagation-wait 值得给得宽松些。lego 会去轮询权威 NS 查它刚写进去 的 TXT 记录,实测 DNSPod 每个域名要一百秒左右才能稳定返回:
ini
[INFO] [*.example.com] acme: Checking DNS record propagation. [nameservers=183.60.83.19:53,183.60.82.98:53]
[INFO] Wait for propagation [timeout: 1m0s, interval: 2s]
[INFO] [*.example.com] The server validated our request
ruby
$ openssl x509 -in /etc/lego/certificates/example.com.crt -noout -ext subjectAltName
DNS:*.example.com, DNS:example.com
注意文件名:lego 用第一个 -d 给证书包命名,不是用通配符那个。
续期是一个每天跑的 systemd timer,剩余不足 30 天才真的动作,续成功后给反代 发信号重载:
ini
# /etc/systemd/system/lego-renew.service
[Service]
Type=oneshot
EnvironmentFile=/root/sandrpod-deploy/tencent.env
ExecStart=/usr/local/bin/lego --email you@example.com --accept-tos \
--dns tencentcloud --dns.propagation-wait 90s \
-d example.com -d *.example.com \
--path /etc/lego renew --days 30
ExecStartPost=-/usr/bin/docker kill --signal=SIGUSR1 sandrpod-caddy
2.4 四个容器
yaml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: sandrpod
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
POSTGRES_DB: sandrpod
volumes: [pgdata:/var/lib/postgresql/data]
networks: [sandrpod]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sandrpod -d sandrpod"]
interval: 5s
server:
image: ghcr.io/sandrpod/server:v0.5.5
command:
- "-port=8080"
- "-db=postgres://sandrpod:${POSTGRES_PASSWORD}@postgres:5432/sandrpod?sslmode=disable"
environment:
SANDRPOD_TOKEN: ${SANDRPOD_TOKEN:?}
SANDRPOD_E2B_DOMAIN: example.com # ← 打开 E2B 的 Host 路由
ports:
- "127.0.0.1:8080:8080" # 管理 API,只绑回环
networks: [sandrpod]
poder:
image: ghcr.io/sandrpod/poder:v0.5.5
volumes: [/var/run/docker.sock:/var/run/docker.sock]
environment:
API_URL: http://server:8080
SANDRPOD_TOKEN: ${SANDRPOD_TOKEN:?}
REGION: local
PROVIDER_TYPE: local
SANDRPOD_NETWORK: sandrpod
networks: [sandrpod]
caddy:
image: caddy:2-alpine
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /etc/lego/certificates:/certs:ro
networks: [sandrpod]
networks:
sandrpod:
name: sandrpod # ← 必须钉死,见下
里面有四处是承重的。
SANDRPOD_TOKEN: ${SANDRPOD_TOKEN:?}。 那个 :? 让 Compose 在变量为空时 拒绝启动。空 token 意味着每个请求都以匿名管理员身份执行------在笔记本上还能活, 在公网 IP 上是灾难。起不来比起错了强。
SANDRPOD_NETWORK: sandrpod,以及网络上的 name: sandrpod。 worker 创建 沙箱容器时把这个字符串原样传给 Docker API,所以它必须是网络的真实 Docker 名字------而 Compose 默认会给网络名加项目名前缀,除非你钉死。这个搞错了故障很恶心: 沙箱创建成功、落在另一个网络上、每一次执行都挂到超时为止。
REGION: local。 E2B 网关调度到 provider=local / region=local 这个本地 底座上。一个注册成比如 cn-guangzhou 的 worker 会被调度器过滤掉,于是 Sandbox.create() 返回 500: no available local poder found,而 /api/v1/poders 还乐呵呵地显示它 ONLINE。(如果你想让 Sandbox.create() 去真的开云主机,改设 SANDRPOD_E2B_PROVIDER。)
127.0.0.1:8080:8080。 一旦设了 SANDRPOD_E2B_DOMAIN,该域名下的每个 主机名都归 E2B 网关管------原生管理 API 在 api.example.com 上会返回 404。所以 把它绑到回环、通过 SSH 访问,顺带的好处是管理面完全不在公网上。
Caddy 的配置很短,而它的一个默认行为在干实事:
caddyfile
{
auto_https off # 证书归 lego 管;通配符只能走 DNS-01
admin off
}
:443 {
tls /certs/example.com.crt /certs/example.com.key
reverse_proxy sandrpod-server:8080 {
flush_interval -1 # 流式命令输出不能被缓冲
}
}
:80 {
redir https://{host}{uri} permanent
}
一个 :443 块伺候所有主机名,因为通配符证书对它们全都有效,而后端本身就是按 Host 路由的。Caddy 默认转发原始 Host 头 ------这正是整套路由机制的基础。 换成 nginx 你必须显式写 proxy_set_header Host $host;,否则每个沙箱 URL 都会 悄无声息地不再指向沙箱。
shell
$ docker compose up -d --wait
Container sandrpod-postgres Healthy
Container sandrpod-server Healthy
Container sandrpod-poder Healthy
Container sandrpod-caddy Healthy
2.5 签一个长得像 E2B 的 API key
E2B SDK 在客户端就按 /^e2b_[0-9a-f]+$/ 校验 key,所以签发时就用这个形状, 可以直接当 E2B_API_KEY 用。服务端只存 hash,裸 key 只显示一次。
bash
curl -s -X POST -H "Authorization: Bearer $SANDRPOD_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"demo","role":"user"}' \
http://127.0.0.1:8080/api/v1/tokens
json
{"key":"e2b_2c287f65...","prefix":"e2b_2c287f653086","name":"demo","role":"user"}
三、客户端只改两个环境变量
这就是客户端侧的全部改动。不用 fork,不用打补丁,没有 shim:
bash
pip install e2b e2b-code-interpreter # 官方包,原封不动
export E2B_DOMAIN=example.com
export E2B_API_KEY=e2b_2c287f65...
python
from e2b import Sandbox
sbx = Sandbox.create()
print(sbx.sandbox_id, sbx.is_running())
print(sbx.commands.run("echo hello from $(hostname); uname -m").stdout)
sbx.files.write("/tmp/hi.txt", "written through the E2B SDK\n")
print(sbx.files.read("/tmp/hi.txt"))
sbx.kill()
python
e2bb62bc53334e83f0f True
hello from 6c22852f8f0b
x86_64
written through the E2B SDK
从一台笔记本经公网测的,所以下面这些数字包含真实往返时延,不只是服务端耗时:
| 中位数 | |
|---|---|
Sandbox.create() → 可用 |
2.7 秒 |
commands.run 往返 |
217 毫秒 |
run_code 往返 |
194 毫秒 |
(冷镜像拉取后的第一次调用会慢很多,预留大约 20 秒,只有一次。)
四、逐个函数验一遍
e2b 2.35.0 与 e2b-code-interpreter 2.9.0,打上面那套部署。 下面每一行都真的执行过。
生命周期 ------ 11/11
| 调用 | 结果 |
|---|---|
Sandbox.create() |
e2b6c1b0f2b8a72dd40,2.9 秒 |
is_running() |
True |
get_info() |
state=running,envd_version=0.2.0,cpu/内存有值 |
set_timeout(600) |
正常------映射到沙箱的闲置 TTL |
Sandbox.list() |
能找到 |
Sandbox.list(query=SandboxQuery(metadata={...})) |
能找到------metadata 能往返也能过滤 |
Sandbox.connect(id) |
重新接上 |
pause() / 用 connect() 恢复 |
True,然后 echo back → back |
kill(),再 is_running() |
True,然后 False |
文件系统 ------ 11/11
write · read · read(format="bytes") · exists · get_info · make_dir · list · rename · remove · write_files(批量 multipart)· watch_dir
less
watch_dir → 2 个事件: [('watched.txt', CREATE), ('watched.txt', WRITE)]
命令 ------ 7/7
前台 run · 非零退出抛 CommandExitException(exit_code=3) · run(background=True) 返回真实 pid · list · send_stdin · kill · 以及增量流式:
ini
connect (streaming) → 3 个独立分块,exit=0
是三个分块,不是一坨缓冲后的整体------这正是 Caddy 配置里 flush_interval -1 在保护的东西。
PTY ------ 1/1
create / send_stdin / resize / kill,一个真实的终端会话:
ini
killed=True, 回显 'pty-works'=True, 16 帧
代码解释器 ------ 14/14(run_code 5、有状态 2、context 5、图表 2)
有状态内核,这是这个 SDK 的核心价值:
python
sbx.run_code("a = 100")
sbx.run_code("a * 2").text # → '200'
context 是相互独立的命名空间,restart 清空一个而不销毁它:
ini
create → 1bba3fa6bdceaa3e...,写入 z=7
list → 1 个 context
隔离性 → context 内 z=7,默认 context 里 NameError
restart → NameError: name 'z' is not defined
remove → 已删除
错误是结构化返回的,不是一段文本:
csharp
run_code("1/0") → ZeroDivisionError: division by zero
图表是被捕获的,不只是打印出来。沙箱内画的 matplotlib 图会以 PNG 形式出现在 Execution.results[].png 里,还带结构化的图表元数据:
bash
matplotlib → PNG 19276 字节, 头部 b'\x89PNG'
图表元数据 BarChart

端口与预览 ------ 1/1
在沙箱里起个服务,问它的外部地址,然后打开:
python
sbx.commands.run("cd /workspace && python3 -m http.server 8000 &", background=True)
host = sbx.get_host(8000) # 8000-e2ba4581bfc832c3459.example.com
javascript
$ curl https://8000-e2ba4581bfc832c3459.example.com/index.html
<h1>served from the sandbox</h1>
不需要凭据,因为浏览器发不了------能猜到那个不可预测的主机名本身就是凭证, E2B 也是这么设计的。同一个通配符域名下的 envd RPC 接口不在此列:
bash
GET https://49983-<id>.example.com/files → 401
GET https://49983-<id>.example.com/files + key → 200
如果你这套部署里所有消费方都是能带 key 的程序,设 SANDRPOD_E2B_PRIVATE_PORTS=1,预览 URL 也会要求凭据。
指标 ------ 1/1
ini
get_metrics() → 1 个采样, cpu=0%, mem=1044590592
还没有的
| 调用 | 状态 |
|---|---|
create_snapshot() |
405 ------ 未实现 |
fork() |
405 ------ 未实现 |
创建时的 envVars |
接受,但真实 SDK 还没有走到这条路径 |
get_mcp_url() 和 download_url() 确实返回格式正确的 URL (50005-<id>.<domain>/mcp 和 49983-<id>.<domain>/files?path=...);这轮扫描 只验证了它们能被生成,没有验证每一个消费方都能用。
自己跑一遍
上面所有东西------Compose 文件、Caddyfile、tcdns.py、lego 的 systemd 单元, 以及两轮测试扫描------都在配套仓库里。 从一台空服务器到扫描通过,一共四个脚本。
- SandrPod:github.com/sandrpod/sa...(Apache-2.0)
pip install sandrpod-cli
如果你换一个 DNS 服务商或者换一个发行版,最先该看的两个地方是通配符记录和 Host 头,顺序就是这个。这篇里其余的部分都很顺。