SandrPod

English

从一台空服务器开始,自建一套兼容 E2B 的沙箱服务

一台机器,一个域名,四个容器。跑完之后,未经修改的官方 E2B SDK 连的是你自己的基础设施——并且我们逐个功能验过。


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 主动拨出的反向隧道够到它。

        你的笔记本
             │  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

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

在这个镜像上它失败了两次,两次都值得知道,因为它们是精简版云内核的通病,跟这套东西本身无关:

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 防火墙后端,那条路不需要这个模块:

cat > /etc/docker/daemon.json <<'JSON'
{ "firewall-backend": "nftables" }
JSON

然后:

failed to start daemon: ... IPv4 forwarding is disabled
echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-docker.conf
sysctl -p /etc/sysctl.d/99-docker.conf
systemctl restart docker
$ docker version --format 'server={{.Server.Version}} api={{.Server.APIVersion}}'
server=29.6.2 api=1.55

2.2 DNS:两条记录,其中一条是通配符

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):

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'

在服务器上验证,而且要去解一个还不存在的名字——那个才是关键:

$ 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 家的插件,国内几家主流云都在列。

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

以上是 lego 4.x 的写法。lego 5 把所有参数挪到了 run 子命令下,把 --dns.propagation-wait 改名为 --dns.propagation.wait,还去掉了 renew 子命令——如果 lego --version 显示的是 5.x,请照部署指南里的写法来。

--dns.propagation-wait 值得给得宽松些。lego 会去轮询权威 NS 查它刚写进去的 TXT 记录,实测 DNSPod 每个域名要一百秒左右才能稳定返回:

[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
$ openssl x509 -in /etc/lego/certificates/example.com.crt -noout -ext subjectAltName
    DNS:*.example.com, DNS:example.com

注意文件名:lego 用第一个 -d 给证书包命名,不是用通配符那个。

续期是一个每天跑的 systemd timer,剩余不足 30 天才真的动作,续成功后给反代发信号重载:

# /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 四个容器

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 的配置很短,而它的一个默认行为在干实事:

{
    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 都会悄无声息地不再指向沙箱。

$ 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 只显示一次。

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
{"key":"e2b_2c287f65...","prefix":"e2b_2c287f653086","name":"demo","role":"user"}

三、客户端只改两个环境变量

这就是客户端侧的全部改动。不用 fork,不用打补丁,没有 shim:

pip install e2b e2b-code-interpreter     # 官方包,原封不动
export E2B_DOMAIN=example.com
export E2B_API_KEY=e2b_2c287f65...
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()
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

watch_dir  →  2 个事件: [('watched.txt', CREATE), ('watched.txt', WRITE)]

命令 —— 7/7

前台 run · 非零退出抛 CommandExitException(exit_code=3) · run(background=True) 返回真实 pid · list · send_stdin · kill · 以及增量流式:

connect (streaming)  →  3 个独立分块,exit=0

是三个分块,不是一坨缓冲后的整体——这正是 Caddy 配置里 flush_interval -1 在保护的东西。

PTY —— 1/1

create / send_stdin / resize / kill,一个真实的终端会话:

killed=True, 回显 'pty-works'=True, 16 帧

代码解释器 —— 14/14(run_code 5、有状态 2、context 5、图表 2)

有状态内核,这是这个 SDK 的核心价值:

sbx.run_code("a = 100")
sbx.run_code("a * 2").text        # → '200'

context 是相互独立的命名空间,restart 清空一个而不销毁它:

create                 → 1bba3fa6bdceaa3e…,写入 z=7
list                   → 1 个 context
隔离性                 → context 内 z=7,默认 context 里 NameError
restart                → NameError: name 'z' is not defined
remove                 → 已删除

错误是结构化返回的,不是一段文本:

run_code("1/0")  →  ZeroDivisionError: division by zero

图表是被捕获的,不只是打印出来。沙箱内画的 matplotlib 图会以 PNG 形式出现在 Execution.results[].png 里,还带结构化的图表元数据:

matplotlib → PNG          19276 字节, 头部 b'\x89PNG'
图表元数据                BarChart

在自托管沙箱里生成的 matplotlib 折线图

端口与预览 —— 1/1

在沙箱里起个服务,问它的外部地址,然后打开:

sbx.commands.run("cd /workspace && python3 -m http.server 8000 &", background=True)
host = sbx.get_host(8000)     # 8000-e2ba4581bfc832c3459.example.com
$ curl https://8000-e2ba4581bfc832c3459.example.com/index.html
<h1>served from the sandbox</h1>

不需要凭据,因为浏览器发不了——能猜到那个不可预测的主机名本身就是凭证, E2B 也是这么设计的。同一个通配符域名下的 envd RPC 接口不在此列:

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

get_metrics()  →  1 个采样, cpu=0%, mem=1044590592

五、这个过程炸出来的东西

把这套东西立起来,暴露了三个真实缺陷。它们此前一直没被发现,因为之前那轮兼容性测试跑的是明文 HTTP 的调试监听端口——请求根本不经过 <port>-<sandboxID>.<domain> 这个 Host 路由,所以这三个问题一个都不可能出现。

三个都在 v0.5.2 修掉了,每个都配了回归测试,并且确认过把修复回滚后测试会失败。这是"为什么要在真实域名上做这件事而不是在笔记本上"最诚实的论据:调试监听端口只能告诉你协议实现了,只有通配符 Host 路由能告诉你它实现得对。

还没有的

如实列出来,因为在集成的时候才发现更糟:

调用 状态
create_snapshot() 405 —— 未实现
fork() 405 —— 未实现
创建时的 envVars 接受,但真实 SDK 还没有走到这条路径

get_mcp_url() 和 download_url() 确实返回格式正确的 URL (50005-<id>.<domain>/mcp 和 49983-<id>.<domain>/files?path=…);这轮扫描只验证了它们能被生成,没有验证每一个消费方都能用。

总计 50 项检查,48 项通过。 两项失败就是上面那两个 405。


六、长期养着它要付出什么

一台机器。这套栈空转时是四个容器;沙箱是按需创建的 Docker 容器,按你为每个沙箱设的闲置 TTL(set_timeout)回收。证书续期是一个 systemd timer。Postgres 里存的是沙箱、任务、worker 和 API token 的 hash——备份它,其余的东西都能从 Compose 文件重建出来。

横向扩展是加 worker,不是把这台机器加大:每个 poder 通过自己拨出的隧道连控制平面,所以第二台机器——另一个云、另一个区域、办公室 NAT 后面的一台盒子—— 只要跑一个容器就加入了,防火墙一条入站规则都不用改。

对国内部署来说这条尤其实用:你可以把控制平面放在有公网 IP 的那台机器上,把真正跑代码的 worker 放在内网、放在专有云、甚至放在客户自己的机房里,数据始终没有离开那个网络边界。


自己跑一遍

上面所有东西——Compose 文件、Caddyfile、tcdns.py、lego 的 systemd 单元,以及两轮测试扫描——都在配套仓库里。从一台空服务器到扫描通过,一共四个脚本。

如果你换一个 DNS 服务商或者换一个发行版,最先该看的两个地方是通配符记录和 Host 头,顺序就是这个。这篇里其余的部分都很顺。

SandrPod 是开源项目,Apache-2.0 — github.com/sandrpod/sandrpod。更多文章(英文)。