调研对象:E2B、CubeSandbox、OpenSandbox、agent-sandbox(源码级)
前四篇是平台侧:创建、生命周期、存储、网络。本篇是沙箱内侧------Agent 发命令、读文件、拿输出,实际打到哪套接口。
一、问题
这一层拆开看,主要是五件事:
- 「Agent 位置与通道」:daemon(守护进程)在 guest(虚拟机客户机)/ 容器里还是宿主上;请求走公网域名,还是控制面带外直连
- 「进程执行」:Agent / SDK 调 API,让沙箱内 daemon fork 出进程跑命令,再回传 stdout / stderr 与退出码------不是 Jupyter 那种有状态代码执行。细节包括同步或流式、stdin / PTY(伪终端)、信号、进程组、超时、孤儿清理;kill 打单个 PID 还是整组
- 「文件 API」:base64 塞进 JSON(体积膨胀、大文件吃亏),还是裸字节流;路径圈禁是词法检查(只看字符串),还是解析 symlink(符号链接真实落点)
- 「有状态代码执行」:Jupyter kernel 焊进 daemon,还是留给模板;MCP(Model Context Protocol,给 Agent 接工具的协议)这类端点放哪
- 「安全模型」:daemon 常以 root 跑,API 本身就是边界;token 存原值还是只存 digest(哈希摘要);默认开不开鉴权
envd 已经是事实标准。Cube 直接装进 guest(49983、Connect-RPC、Basic user 头沿用);agent-sandbox 的 runtime KEP 把它列为两大范式之一,并带「envd 当 runtime」示例。但当前 E2B 源码里的 envd 已重写为 chi + Connect,「Jupyter 网关已删除」------标准指的是进程 / 文件协议;Cube 的 49999 Jupyter 是旧血统遗留。
第二节按这五个维度逐家拆开;第三节再横向对照。
二、四家怎么做
每家统一按:位置与通道 → 进程 → 文件 → 有状态代码 → 安全;产品特有能力(MCP、热升级、版本耦合)收在末尾。
1. CubeSandbox
「Agent 位置与通道」
guest 里两个 Agent,分工严格:
| Agent | 通道 | 职责 |
|---|---|---|
| 「envd」(用户面) | 49983 Connect-RPC(gRPC 风格 RPC,跑在 HTTP 上),走 CubeProxy 通配域名 | 进程 / 文件 / PTY / Jupyter / 拉起 MCP |
| 「cube-agent」(基础设施面) | VM 内 PID 1(init);vsock 接 CubeShim(ttrpc) | 把 OCI 镜像在 guest 里跑成容器:Create/Start/Exec/Kill/Delete、cgroup、指标 |
「「VM 套容器」是什么」(容易和「沙箱在 Pod 里」搞混):
less
ounter(lineounter(lineounter(lineounter(lineounter(line宿主机 KVM └── MicroVM(沙箱的硬件边界;是宿主的直接客户机,不是跑在 Pod 里面) └── cube-agent(PID 1) └── OCI 容器(用户镜像) └── 其中再跑 envd 等
沙箱创建在宿主机之上。K8s 模式下 cube-node Pod 里跑的是 CubeShim 等管理面;VM 本身始终是宿主 KVM 客户机------不是嵌套虚拟化,也不是「Pod 里再套一台虚拟机」。
为什么 guest 里还要按容器跑,而不是像 E2B 那样摊成盘直接跑进程?隔离要 VM,生态要容器:对外接 containerd / Shim v2,镜像、Exec、sidecar、按容器 cgroup 采指标都得成立。内层容器只是同内核里的 namespace / cgroup / 挂载再 exec,不是再套一层虚拟机;相对 MicroVM 本身,这点开销通常不是瓶颈。换成「直接跑」模型更简单,但会丢掉运行时 OCI 语义------那是产品选型,不是因为容器更快。
「cube-agent」 就是改过的 Kata agent + rustjail:宿主 CubeShim 经 vsock 下发 OCI 生命周期,一 VM ≈ 一 Pod。它负责「把镜像变成环境」;Agent 日常 run command 仍走 envd。SDK 自写 Connect-JSON,不依赖 e2b 包。
「进程执行」
用户面与 E2B 同范式:调 guest 里的 envd(经 CubeProxy 到 49983),不是 cube-agent。
- 「接命令」 ---
process.Process/Start;命令固定/bin/bash -l -c(login shell,拿完整 PATH);Connect-Timeout-Ms硬墙钟截止(按真实时间到点就停) - 「起进程」 --- 仍由 envd fork / exec(旧血统实现)
- 「定身份」 --- Basic user 头表达「以哪个用户跑」,默认偏 root
- 「回传输出」 --- 事件流里 stdout / stderr 仍是 「base64 塞进 JSON」(兼容旧 envd,大文件更吃亏;E2B 主线已改裸流)
- 「信号 / PTY」 --- 走同一
process.Process服务;信号用字符串枚举(如SIGNAL_SIGKILL)
「文件 API」
双通道:
/filesHTTP:读写大文件- Filesystem Connect-RPC:ListDir / Stat / Remove / Move / MakeDir / WatchDir(目录监听)
「有状态代码执行」
- Jupyter 仍留在 49999(旧 envd 血统;E2B 主线已删)
- MCP 网关不进平台、进模板:SDK 经 envd 以 root 起
mcp-gateway;平台只做校验、启动、路由------沙箱里跑什么由模板决定
「安全模型」
X-Access-Token+ Basic user 头(无密码,只用来表达「以哪个用户跑」)- 默认用户偏 root(兼容旧 envd「未指定用户」直接拒绝)
「版本耦合」
模板内存快照固化了 guest 里的 cube-agent------节点升级不等于沙箱升级;彻底升级只能删了重建。兼容的是已停更的旧 envd(base64、Jupyter);E2B 主线这两样已经淘汰。
2. E2B
「Agent 位置与通道」
- envd 单端口
0.0.0.0:49983;orchestrator(节点侧编排)以 slot IP 直连,不经公网入站 - 控制面与用户面混布同一端口:靠
x-internal标记 + 节点代理对控制路径一律 404------隔离靠路由,不靠端口分拆 - 已重写为 chi + Connect;旧 JSON-over-HTTP / Jupyter 网关已不存在
和 Cube 的对照:E2B 构建期也吃 Docker / OCI(拉镜像 → 抽成 ext4 rootfs → 注入 envd → 打快照),但那只是**「文件从哪来」;运行时 guest 里是普通 Linux + envd,「没有」** Docker / containerd / 内层 OCI 运行时。租户隔离交给整台 MicroVM;envd 只做「在这台小机器上起进程」(fork/exec、切用户、进 cgroup),不是简化版容器------namespace、多容器生命周期那些,因为有了 VM 就不必在 guest 里再演一遍。
「进程执行」
SDK 调 commands.run("python clean.py")(底层是 process.Process/Start),guest 里的 envd 大致走这几步:
- 「接命令」 --- Connect-RPC 收到请求;冻结期间直接拒绝(fork 进已冻结 cgroup 会卡在 exec 前,拖死 Go 的 GC STW,连 thaw 都救不回来)
- 「起进程」 --- wrapper 先写
oom_score_adj=100(OOM 时优先杀用户进程、保住 daemon),再exec换成真正命令------shell 被顶替,kill 该 pid 即 kill 用户命令;minimal 镜像缺 ionice / nice 时优雅降级 - 「定身份 / cgroup」 ---
syscall.Credential直设 uid/gid,不走 sudo;CLONE_INTO_CGROUP让子进程生而进对应 cgroup,没有「先 fork 再迁移」的窗口 - 「回传输出」 --- stdout / stderr 流式推;结束后退出码保留约 30s(断连重连还能查结局);无人订阅时输出直接丢弃,不能回放
- 「结束 / kill」 --- 目前只杀单个 PID,不做进程组。孙进程若还握着管道写端,读端会一直等不到 EOF------源码注释里的失败模式
「文件 API」
- 目录走 RPC;大文件走裸 HTTP,全程无 base64
ServeContent支持 Range(断点续传);copy_file_range(内核零拷贝拼接)做 compose/files另有签名 URL------凭证编进 URL 哈希,token 本身不上 wire
「有状态代码执行」
Jupyter 已删;所谓 code interpreter 也只是普通 command + Python------和上面的进程执行是同一条路,不是独立的有状态 kernel。runtime 收窄到进程与文件本体。
「安全模型」
鉴权三通道:
- 常规:
X-Access-Token(memguard:内存里加密持有,降低明文落盘风险) /files:签名 URL/init:MMDS(Firecracker 元数据口,不经普通出站网络)做 hash 校验
升级后 fail-closed(不确定就不放行):新 envd 在 token 恢复前只放行 /init 和 /health。
硬伤:token 未设置时签名校验直接放行------/init 之前 guest 内任何进程可用 root 文件 API。daemon 以 root 跑;root fallback 经 X-Envd-Defaults 打指标------按异常监控,不是设计目标。
「热升级」
冻结 → 序列化进程表 → fd(文件描述符)挪到固定槽位 → syscall.Exec 同 PID 换二进制 → pidfd re-adopt(用 pidfd 把旧进程重新接管回来)。成功标志是连接被重置------旧进程不会正常回包。
3. OpenSandbox
「Agent 位置与通道」
daemon 叫 「execd」,始终跑在沙箱里面(和 E2B 的 envd 同级)。三种底层,投放方式不同,目的一样------让沙箱里出现一个可打的 HTTP 服务:
| 底层 | execd 怎么进沙箱 |
|---|---|
| 「Docker」 | 先起一个临时容器,从专用镜像里把 execd 二进制拷进目标目录(如 /opt/opensandbox/),再改入口脚本在后台拉起它------不是运行时反复 docker exec |
| 「K8s」 | 用 initContainer(业务容器启动前跑的一次性容器)把 execd 等文件拷进共享卷,业务容器起来后就能用 |
| 「fsb」(Firecracker 快路径) | 构建模板时就把 execd 写进镜像,恢复出来自带 |
共同点:单端口 「44772」 ;命令一律 HTTP → 沙箱内 execd → fork/exec。控制面只解析「这个沙箱的 IP/端口是啥」,SDK 拿到后**「直连 execd」**,控制面不在数据路径上。
「进程执行」
含义与 E2B 相同:SDK 发命令,execd 在沙箱内起进程,回传输出与退出码。例如跑 python clean.py → POST /command → execd fork → SSE 推日志。相对 envd,实现几乎处处反向:
- 「接命令」 --- 协议是 SSE(Server-Sent Events,HTTP 单向事件流),不是 Connect-RPC;头惰性提交------首个事件写出前还没定成 SSE,执行前的同步错误仍能返回普通 JSON。命令可以是一段 shell,也可以是原生 argv;没有 bash 则回退 sh
- 「起进程」 ---
Setpgid把自己和孩子放进同一进程组,方便整组杀掉 - 「定身份」 --- 请求可带 uid/gid,
syscall.Credential直设(细节见安全模型) - 「回传输出」 --- 先写入私有目录
/tmp/opensandbox-execd/(权限 0700,防 workload 预置恶意 symlink),再经 SSE 增量推给客户端;保留约 24h(E2B 只留退出码 30s)。完成后多 sleep 200ms,给客户端与 sidecar 一点收敛时间 - 「结束 / kill」 ---
Kill(-pid)(负号 = 打整组);kill 前检查 done channel,防 pid 被内核复用后误杀新进程。PTY 另有单 holder(可写)+ 多 viewer(只读),takeover=1可抢占
「文件 API」
/files+/directories全套- 另有身份前缀版
/v1/filesystem/{uid}/{gid}/...:每请求 fork 独立 worker,带所选凭证执行,不继承 execd 的补充组
「有状态代码执行」
和「进程执行」分开:
- 「Jupyter」 (
/code):四家里唯一还活着的内置实现;每语言一个持久 kernel(变量 / 导入跨调用保留);sql 直连沙箱内 MySQL - 「bash session」:无 TTY,但 env / cwd 跨调用保持------介于一次性 command 与 Jupyter 之间
「安全模型」
- 每请求可带 uid/gid;same-identity(请求身份与当前进程相同)必须走无凭证路径------即便 uid 不变,设 Credential 也会触发 setgroups,能力已 drop 的沙箱里必然 EPERM(权限拒绝)
- 鉴权机制在、默认关:本仓库 server 不调用
/internal/init注入 token;token 空即全放行 - Docker
publish_host默认0.0.0.0(绑所有网卡)------机制存在不等于被调用
4. agent-sandbox
定位和其他三家不同:「不卖自己的 runtime 产品」 ,而是在 K8s 上游做标准化(KEP-539.2)------规定「进程 / 文件 API 该长什么样」,并给一个参考实现 「sandboxd」。灵感来自 OpenSandbox 的 REST 与 E2B 的 envd。协议按操作拆:进程走 gRPC(长连接流式推输出),文件走 REST(大文件别再包 base64)。
「Agent 位置与通道」
sandboxd 跑在沙箱 「Pod 里面」(命令实际执行在「托管 sandboxd 的那个容器」里)。对外两个端口:
| 端口 | 协议 | 干什么 |
|---|---|---|
:9090 |
gRPC | ProcessService------起进程、推输出、发信号 |
:8080 |
HTTP | FilesystemService------读写文件、健康检查、metadata |
怎么让客户端连上它:复用 K8s 已有能力------kubectl port-forward、Pod IP、或 headless Service,「没有」自研公网代理。注意:官方 sandbox-router 上游禁了 HTTP/2,「带不了 gRPC」,所以 runtime 流量不能指望走 router。
sandboxd 怎么进 Pod(两种常见拓扑):
| 拓扑 | 做法 | 效果 |
|---|---|---|
| 「A. 专用 runtime 容器」 | Pod 里一个极简容器专门跑 sandboxd | 隔离强;要加工具得重建这个镜像 |
| 「B. 二进制注入」 | initContainer 把 sandboxd 静态二进制拷进业务镜像的共享目录 | 免重建;命令跑在**「你的」**业务镜像里,能直接用里面的 python 等 |
踩坑:若把 sandboxd 挂成 「sidecar」 却只共享 volume------共享的是文件,不是「能看见对方镜像里的工具」。看起来像 B,实际命令仍跑在 sidecar 自己的根文件系统里,业务镜像里的软件找不到。
另外:官方示例支持直接把 「E2B 的 envd 当 runtime」 挂上(标准 Linux 容器即可)------标准接口与参考实现可以拆开用。
「进程执行」
含义仍是:客户端发命令 → sandboxd 在 Pod 内起进程 → 流式回传输出与退出码。例如 gRPC Start 跑 python clean.py。相对 envd / execd,sandboxd 刻意做「最小集」:
- 「接命令」 ---
Start是 server-stream:先InitEvent(带上 daemon 自己发的 process_id)→ 若干段 stdout/stderr(「裸 bytes,不 base64」 )→ 最后恰好一个ExitEvent。另有一元Execute:跑完再一次性返回。可选 PTY - 「起进程」 --- 身份 = sandboxd 所在容器的用户,「不做」请求级 uid 切换;也「没有」 API 级 timeout------超时交给调用方自己管
- 「会话 ID」 --- 虚拟 PID registry:daemon 内存里发单调递增 ID,不绑 Linux 真实 PID(只要求「当前活着的进程之间不撞号」)
- 「回传输出」 --- 流式即焚,不落盘、不保留(对比 execd 24h、envd 退出码 30s)
- 「结束 / kill」 ---
SendSignal用Kill(-pid)打进程组。daemon 自己要退出时:SIGTERM 广播给孩子 → 宽限 10s → SIGKILL 清道夫
「文件 API」
GET/PUT/DELETE /v1/files/{path}:文件回原始字节流,目录回 JSON------同一 URL,按类型变响应- PUT 原子写:先写临时文件再 rename,读端要么全旧要么全新
- 路径圈禁较严:已存在路径先 EvalSymlinks(解析符号链接到真实路径)再检查仍在根内;新文件则解析「最近已存在的祖先目录」------防止用 symlink 把写入拐出沙箱根
「有状态代码执行」
KEP 把 Jupyter 列在 Full 能力级,但 「sandboxd 参考实现未做」------需要自己当普通进程在 workload 里起。
「安全模型」
- 默认不鉴权:相信 K8s 信任链(NetworkPolicy 限制谁能连到 Pod,port-forward 要过 apiserver)。「脱离集群后这个默认不成立」
- 可选
--auth-token-sha256:Pod 里只放 token 的 digest(哈希),不放原值------命令行、环境变量、子进程 env 就算被抄走,也推不出原 token - token 是 NetworkPolicy 旁边的纵深,不是替代;验的是**「集群外驱动客户端」**,不是沙箱里的代码(沙箱内代码本来就够得着本机 API)
/v1/metadata:只下发允许前缀的环境变量;名字里带 TOKEN / SECRET / PASSWORD 等标记的一律不下发
三、横向对比
1. Agent 位置与通道
- E2B:guest 内只有 envd;orchestrator 带外直连;控制面 / 用户面混布单端口。OCI 止于构建期,运行时不跑容器
- Cube:双 Agent------envd 走 CubeProxy(用户命令);cube-agent 走 vsock(OCI 生命周期)。沙箱是宿主上的 MicroVM,VM 内再套容器接生态
- OpenSandbox:execd 在沙箱内;控制面解析端点,SDK 直连
- agent-sandbox:sandboxd 在 Pod 内;通道靠 K8s 原语
只做执行与文件时,端点解析 + 直连够用;要注入 env、挂卷、冻结、原地升级,协议会变重。E2B / Cube 都用 VM 做强隔离;分叉在 guest 内还要不要保留容器运行时------要则换 OCI / containerd 兼容(Cube),不要则路径更短(E2B)。
2. 进程与文件
- kill:OpenSandbox / sandboxd 做进程组 + pid 复用防御;E2B 单 PID 是已知短板
- 命令包装:
exec替换、无 shell 残留 - 输出生命周期:即焚(sandboxd)/ 30s 只留 exit(envd)/ 24h 全量(execd)------产品选择
- 退出码:全员
128+signal(信号致死的惯例写法),死后可查 - 超时:envd 当 API 契约;sandboxd 外包
- 文件编码:E2B 新版 / sandboxd / OpenSandbox 裸流;Cube 仍 base64-in-JSON
- 路径圈禁:sandboxd 的 symlink 解析最完整
3. Jupyter / MCP
E2B 删了 → Cube 因兼容还留 → OpenSandbox 内置 → KEP 未落地。代码执行在往 runtime 之上的可选服务挪;kernel 本质是长命进程。MCP 一致收窄:不进平台、进模板。
4. 鉴权
- 事实开放:OpenSandbox(server 不注入)+ E2B
/init前的/files - 静态 token / 签名 URL:E2B
- digest-only:sandboxd
- Cube:token + Basic,默认用户偏 root
形态上都有解,默认启用普遍偏弱。验收要看默认路径有没有真正调用鉴权,不能只看代码在不在。
四、自研建议
- 协议分治,且兼容 envd:进程 Connect / gRPC 流,文件 REST 裸流;提供 49983 + Basic user 兼容层。
- 进程组 kill + pid 复用防御是底线。
- 命令包装抄 E2B:exec 替换 + OOM 偏置 +
CLONE_INTO_CGROUP。 - 身份分层,root fallback 可观测;same-identity 走无凭证路径;sudo 是模板能力,不是 API 能力。
- 鉴权抄 sandboxd digest;OpenSandbox、E2B 两个事实开放案例进评审清单;文件下载加签名 URL。
- 控制面与用户面分端口或显式分权;混布单端口至少公网不可达 init。
- 输出保留做成产品决策;退出码保留窗口必备。
- 热升级 v1 可不做,接口留白:进程表可序列化、升级窗口 fail-closed。
- Jupyter 不进 runtime;MCP 抄 Cube------平台只做校验 / 启动 / 路由。
- 可观测性搭 init 便车;若做 pause,冻结期间拒绝 spawn。
五、小结
Agent 循环消费的是:命令边界清楚(进程组、OOM 偏置)、结局可查(退出码窗口)、产物可取回(流式文件)。断连、超时、暂停、daemon 升级都不该逼它重跑上一步。
envd 协议是生态入口;进程组 kill 与 digest 鉴权是底线;Jupyter / MCP 进模板不进平台。四家默认鉴权都不理想------「机制被默认调用」要写进验收。
下一篇:编排调度与资源管理。