把浏览器搬进 Agent 沙箱:Lexmount 在 CubeSandbox 上的一线实战
编者按| Agent 浏览器运行时是一个"复合工作负载"。它对 AI Agent 沙箱的运行有四个核心层面的硬需求:出站网络能否连到真实互联网、批量拉起时的启动时序稳不稳、每个沙箱的运行时状态能否被动态注入、真实容量能不能被准确感知。
任何一条无法实现,Agent 的行为都会开始漂移。Lexmount 团队在把浏览器运行时接入 Cube Sandbox 的实践中,逐一碰上并解决了这四个问题。本文是他们的一手技术复盘,也顺带回答了一个更本质的问题:轻量沙箱在追求启动性能时究竟放弃了什么,业务侧又该如何补齐。
一、应用背景和分层策略
1.1 Lexmount 业务背景
Lexmount 自研的 Agent 专属浏览器运行时(Lexmount Insight Flow),核心的应用场景是:让 AI 智能体大规模、稳定地执行网页任务。这个业务形态天然对底层沙箱提出了几条"必须成立"的要求——能真实访问互联网(浏览器任务的前提)、能批量并发拉起(多用户并行 / 强化学习 batch rollout)、能给每个沙箱注入不同的运行时状态(多租户 API Key、任务上下文)、能准确知道单机能起多少个(不能空跑虚高)。
选型阶段,我们在开源社区寻找"能轻量起大量沙箱"的工具。实测对比过 OpenSandbox 和 Cube Sandbox 后,我们发现 Cube 的资源占用更低,因此最终选择了后者。从 v0.1.0 跟进到 v0.5.1,围绕上面四条边界,我们分别踩到了公网不通、模板创建失败、环境变量丢失、资源虚假超卖四个坑。
1.2 分层策略:不是所有 Agent 都要整个塞进沙箱
在讲具体问题之前,先分享我们对沙箱化的整体路径策略——它决定了后面四个问题各自出现在哪条链路上。我们把沙箱化拆成两条路径,由业务编排层按风险等级、资源占用和业务形态做策略路由。
第一条路径只隔离 Bash。 通用对话、问答、轻量编排这类低风险、短执行的场景,Agent Loop 本身保留在业务编排层,只有 Bash/exec 工具调用进入沙箱执行。沙箱用完即弃,内部无状态。这条路径的优势是轻——沙箱生命周期短、高频启停、不占常驻资源。
第二条路径把整个 Agent Runtime 放进沙箱。 代码开发、浏览器自动化、长时任务这类高风险、重资源、需要常驻或原生交互的场景,Agent 常驻进程整体进入沙箱,享有独立 CPU/内存和持久工作区。这条路径优势是隔离彻底——Agent 在沙箱内有完整运行环境,随时启停,和常驻微服务相比资源差异不大。
两条路径共享同一套 Cube 控制面,由控制面按需创建或复用轻量/完整 Sandbox,并统一编排生命周期。后续还有强化学习 batch rollout 的计划——批量短生命周期沙箱,这也是 Cube 这类轻量沙箱最契合的场景之一。
二、出站网络:让沙箱真的能"打开网页"
对浏览器运行时来说,网络是第一硬约束。Agent 打开的每个页面、每次调用的每个 API,都要从沙箱里出到公网。Cube v0.1.0 上手第一天,我们就撞上了这条边界。
Cube v0.1.0 在五一节前发布,创建的沙箱连不上公网。我们换了多个环境都复现,排除了自身环境问题。虽然社区在节后就发布了修复版,但升级后新坑来了——提示创建成功,但模板偶尔建不出来,偶发建出来还是无法联网。
我们写了很多测试脚本和用例,确认 DNS 和直连公网 IP 都超时,先排除了域名解析问题。接着抓包,发现 SNAT session 已经建立,但连接卡在 SYN-SENT,说明 TCP 三次握手没完成。进一步检查宿主机默认路由和出口网卡,均正确。到这一步,IP 层和路由层都没问题,我们开始怀疑 IP 层以下。
让 AI 帮忙检查了一遍 network-agent 的源码,发现了一个可疑点:getGatewayMacAddr() 这个函数会把网卡上"第一个 reachable 邻居"当作网关。在多邻居环境下,这个 MAC 可能并不是默认网关的 MAC——如果选错了,SYN 包虽然从网卡发出去了,但在 L2 层被送到了错误的 next hop,根本到不了网关,连接自然超时。
为了实锤这个判断,我们用 tcpdump 抓了出口网卡的包。结果很清楚:SYN 确实从 enp3s0 发出了,但目的 MAC 属于一个普通邻居,而不是默认网关。
因此,我们的修复思路是改 getGatewayMacAddr() 的逻辑:先从接口默认路由解析出 gateway IP,再查该 IP 对应的 neighbor entry,确保选到的是真正的网关 MAC,而不是碰巧排在第一位的 reachable 邻居。同时补了两个测试用例:多邻居场景下非网关 MAC 排在前面的情况,以及缺失默认路由的情况。这个 PR(#224)已被合并。排查过程中,我们还额外发现了一个 MAC 地址被覆盖的问题,也单独提了修复。
这条边界踩通之后,第二条路径(整个 Runtime 塞进沙箱)才真正具备了跑浏览器任务的前提。但很快,下一条边界就来了——不是一个沙箱能不能上网,而是一次能不能拉起很多个沙箱。
三、批量启动:拉起一百个沙箱前,先解决"进程已起 ≠ 就绪"
Agent 浏览器运行时的第二个业务诉求是并发。一个用户会同时开好几个页面任务、多个用户会同时使用 Agent,未来 RL rollout 还要批量并发拉起短生命周期沙箱。这些场景共同的特点是:一台机器上会短时间内密集触发沙箱创建请求。
密集创建会自然导向一个问题:启动时序竞态。我们遇到的第二个坑也是在这里。当时的报错信息是 unknown service cubelet.services.images.v1.Images,看起来像是 Cubelet 本身的问题。
最初我们确实优先怀疑 Cubelet——版本不匹配?Images 服务未注册?Cubelet 初始化异常?但检查发现 Cubelet 进程和 gRPC 端口都已经起来了,问题只在重启或整套 one-click 启动后出现,单独等一段时间或调整启动顺序后可恢复。因此开始怀疑依赖初始化竞态。
对照启动脚本,我们找到了根因。旧逻辑是拉起 network-agent 后立即启动 Cubelet,直到 Cubelet 启动以后才用 /healthz 检查 network-agent。而 Cubelet 初始化网络插件只等 30 秒——network-agent 启动较慢时会导致 Cubelet 初始化不完整,后续 Images 服务不可用。
因此,"network-agent 没起好"不是进程没启动,而是进程已经存在、但 /readyz 尚未通过,Cubelet 就被过早拉起了。这也解释了为什么看起来是 Cubelet 报错,根因却在上游依赖未就绪。/healthz 检查的是进程存在,/readyz 检查的是依赖服务注册完成——两者不能互相替代。
我们的修复方案是把启动顺序改过来:先等 network-agent 的 /readyz 通过,再启动 Cubelet 及后续服务。同时把脚本和 Cubelet 内部等待时间都延长到 120 秒,新增了 NETWORK_AGENT_READY_TIMEOUT 环境变量供部署方按机器性能调优。这个 PR(#304)已被合并。
表面看这是一个部署脚本问题,但放到浏览器运行时的业务场景里,它是一个"能不能规模化"的问题——如果批量拉起时依赖服务还没注册就被调用,创建就会大批量失败。业务侧的应对是:所有依赖跨进程 gRPC 的组件,就绪判定统一以 /readyz 为准,而不是 /healthz 或进程存在。
四、动态状态:多租户 API Key 该怎么塞进沙箱
浏览器运行时的第三个诉求是"每个沙箱的运行时状态可以按需注入"——不同用户、不同任务的 API Key、上下文标签、下游服务地址都不同,我们不可能给每种组合都做一个模板。理想情况下:控制面按请求生成一组 env,创建沙箱时传进去,Agent 起来后就能读到。
但我们撞上了 Cube 的一条架构边界。往沙箱里直跑 Agent,需要传 API Key 等环境变量启动。但接口传一组 env 进去,根本没生效。读源码后发现:CreateSandboxRequest.containers 被硬编码为 vec![],传入的环境变量值在这一步被掐掉了。
我们提了 PR(#634),尝试从 CubeAPI 的初始化链路入手修复,把 env_vars 接入 container spec。但维护者反馈"与原架构设计不兼容",PR 被关闭。
被关闭的技术理由,后来我们理解了。Cube 的沙箱创建走的是 snapshot restore,而不是重跑 entrypoint。模板的运行时进程在模板制作阶段已经启动并完成了内存快照,沙箱创建是从这个快照恢复,而不是重新执行 entrypoint。因此,PID-1 的 Process.Env 改动影响不到 snapshot 时已经存在的进程——那些进程保留的是快照时刻的环境,不会重新读取 per-sandbox 注入的变量。
envd-backed 的命令执行(commands.run / run_code)走的是另一条路:envd 的 process.Start 从一个 in-process 的 defaults.EnvVars map 中读取并注入环境变量,这个 map 由 envd 的 /init endpoint 填充(由 #566 处理)。两条路径互补但不重叠。
在确认这不是底层缺陷,而是 snapshot restore 的架构约束后,我们继续采用了在早期的实践过程中已经沉淀出来的解决办法,即采用"控制面代理、沙箱内执行"的两段式设计:控制面对外提供 Init API,完成鉴权、身份字段保护、目标实例解析和失败重试;真正的 /v1/init 运行在 CubeSandbox 内的 Adapter 或 Agent 业务进程中,通过内存配置更新、同进程环境更新或重启子进程触达 Agent。幂等由 Sandbox 控制面和内部 Adapter 或 Agent 业务进程共同保证。
回头看这个架构约束,我们认同维护者的判断。snapshot restore 带来的启动时延降低是实实在在的——热启动 P95 在亚秒级,这恰恰是浏览器运行时能"批量并发"的前提。而 env 注入本身是偏业务的场景,由业务侧在恢复后显式注入,比依赖 entrypoint 重跑更可控。这是一条典型的"上层性能换下层灵活性"的取舍——Cube 选择在启动性能上激进,把状态注入的责任明确交回业务侧;业务侧知道边界之后,用两段式设计补上,链路依然清晰。
五、真实容量:从"能起 N 个"到"敢用 N 个"
浏览器运行时是重资源工作负载——每个沙箱里都有一个完整 Runtime 加浏览器实例。业务方最关心的一个数字是:一台机器上到底能跑多少个。我们最早给出的数字,事后来看是虚高的。
早期版本(v0.2.x)在一台 16C/32G 的裸金属服务器上,我们尝试不停创建 Cube,得到了一个比较大的数值。升级新版本后发现创建 30+ 就触发了资源限制报错,和之前的认知差别较大。
排查过程中,开始主要怀疑自己的控制面和生命周期管理模块有 bug。排除自身控制面和生命周期管理逻辑的问题后,我们开始梳理 Cube Sandbox 的代码,在 AI 辅助下定位到根因:v0.2.x 的 one-click 部署里,CubeMaster 使用脚本向 Redis 写了一组 mock 的资源消耗指标,这让 CubeMaster 对资源消耗的感知失效——看起来还有很多余量,实际已经满了。v0.3.0 版本已经修复了该问题。
这个坑本质是在为历史上的测试不够严谨买单。此前"不停创建空跑沙箱"得到的容量数字是虚高的——没有真实流量,沙箱不真正占资源,自然能起很多。升级后 mock 指标被修掉、资源锁真正生效,虚高的数字才被打回原形。
我们后来总结:对于容量评估,最直接的信号是实际创建请求能否成功,需要综合 CubeMaster 的实时节点健康状态、剩余配额、宿主机的 free/top 等信号指标,进行交叉验证,形成系统性的容量监控和压测结论,这些单独看都有可能成为"不可信"的信号。
这一条对我们的启示不止是"修一个上报 bug"——它反过来定义了我们后续做容量规划的原则:任何单一信号都不能作为容量结论的依据;容量必须在业务负载真实压上去的条件下测。对浏览器运行时来说,这意味着测试用例必须包含真实页面加载、真实的 Agent 请求,而不是空跑一批沙箱看能起多少。
六、当前状态与后续规划
当前我们在生产前验证阶段停留在 v0.5.1,计划后续体验 v0.6.0 的 K8s / Volume 能力。后续会进客户私有化场景,强依赖 K8s 兼容(客户基建用 K8s),因此我们对于 v0.6.0 的 K8s / Volume 验证是下一步重点。
后续规划中还有强化学习 batch rollout 的批量沙箱需求——大量短生命周期沙箱并发创建、执行、回收。这也是 Cube 这类轻量沙箱和我们业务场景的契合点。
总结一下可供参考的经验
四个方向摸索下来,有几点感受值得沉淀,也希望能给正在评估或使用 Cube 的同行提供参考:
- 出站网络问题先怀疑 L2。 当 SNAT session 建立但连接卡在
syn_sent、且宿主机路由正确时,问题大概率在 L2 next hop 选择上——tcpdump直接看 SYN 的目的 MAC 是不是网关 MAC,是最快的实锤方式。这条经验对基于 microVM + 自定义网络平面的沙箱都适用,不局限于 Cube。 - 批量场景下,
/healthz不能替代/readyz。 单机低并发时 30 秒足够,一旦拉起频次上来,依赖服务未注册就被调用会大批量失败。所有跨进程依赖,就绪判定统一以/readyz为准。 - 容量数字不能空跑测出来。 早期资源上报 bug 叠加"空跑无真实消耗",会让容量数字被显著高估。容量评估必须在业务负载真实压上去的条件下做,且需要多源信号交叉验证。
Agent 浏览器运行时是一个复合工作负载,它对底层沙箱的压测比大多数场景都苛刻。我们从 v0.1.0 一路跟到 v0.5.1,每一条边界都读到了源码根因,也提了自己的修复。希望这份记录对同样在做 Agent Runtime 的团队有所参考。