上个月我们压测平台出了一起不大不小的故障一台 8C16G 的编译节点被某个从 CI 里溜出来的 coding agent 占满 CPU排查时发现是 agent 的“安装依赖”指令没有做任何隔离直接把宿主机整个文件系统当成了工作目录遍历完了家目录后又去扫 /etc最后还是在任务超时杀掉容器才缓过来。这类事故在 AI 编码代理普及以后只会越来越多——模型能写代码、能执行命令但我们对它的“手”几乎没有约束。我们后来做了一套 OpenSandbox Agent Runtime 的工程方案把 agent 的执行环境整体关进沙箱同时保留它干活所需的灵活性这篇就聊聊我们为什么这么设计以及落地过程中踩过的坑。如果你也在做 coding agent 的工程化、给 AI 任务搭隔离环境这篇应该能帮你少走不少弯路。1. 为什么 Coding Agent 需要一只被握住的手1.1 让 Agent 放手乱跑的代价很多人一开始觉得Coding Agent 无非是“自动帮你跑命令的工具”像 CI 一样跑完就销毁能有什么风险真跑起来才发现问题比想象中多。首先是资源风险。模型给出的命令经常是链式的装依赖、跑测试、启动服务、又装了一个全局包。如果每个动作都没有限额一个 agent 任务能把整台机器的 CPU、内存、磁盘打满。我们那次故障就是 agent 连续 fork 多个编译进程每个进程吃满单核一瞬间把节点拖死。更隐蔽的是磁盘agent 测试程序时会往 /tmp 写大量临时文件不限制的话一晚能吃掉几十 GB。其次是数据风险。Agent 要读项目代码、读环境变量、跑数据库迁移。如果它直接跑在宿主机上它就等于拿到了这台机器上所有用户的所有权限。内部代码库、密钥、配置文件任何一个读取动作如果没有审计出了问题都难以追溯。我们内部有个安全同事说过一句话让我印象很深“agent 没有恶意但它可能有一个错误的理解。”比如它看到当前用户有 sudo 权限就真的去改系统配置这在本地调试没问题在共享 CI 节点上就是事故。第三个风险是污染。Agent 跑一次任务会在系统里留下各种依赖、进程、缓存、后台服务。多个 agent 共用宿主机时A 任务留下的残留进程可能影响 B 任务的端口、环境变量甚至导致测试结果互相干扰。这类问题排查起来最崩溃因为现象是随机出现的。1.2 沙箱不是安全产品是运行边界我们最初也想过“只要加权限控制就行”比如给 agent 一个低权限用户禁止写某些目录。试了一段时间后发现这条路走不通coding agent 之所以叫 agent就是因为它需要自己探索环境、自己安装依赖、自己改配置。你把它权限限制得太死它就开始“撒谎”——明明没权限任务还显示成功最后交付的产物根本不可用。所以真正该做的不是限制它的能力而是给它划定一个明确的、可预期的活动范围这个范围就是沙箱。这个思路上的转变很关键。我们不是在给 agent 做“安全加固”而是在给它规划一个正常的运行边界。类比一下就是新人入职你不会直接给他生产库的写入权限但你会给他一台装了全套工具的开发机让他在里面随便折腾。沙箱就是这台“开发机”只不过它是程序化创建、自动回收、整套环境都可以被审查的。把沙箱当成运行边界还有个好处它的规则可以按照 task 来定制。比如做后端编译的任务给它完整的 GCC、CMake、Go 工具链做数据分析的任务给它 Python、Jupyter、数据库客户端但无论哪种任务都不需要它访问宿主机上的 Kubernetes 配置、其他项目的环境变量。这样既能保证 agent 干活利索又把爆炸半径控制住了。1.3 为什么最终选型 OpenSandbox市面上有很多隔离方案我们内部也做了对比才决定叫 OpenSandbox 这套东西。这里说的 OpenSandbox 是我们内部的工程代号不是买来的现成产品底层组合了 gVisor、Kata Containers 以及我们自研的一个 runtime 编排层。下面这张表可以说明我们当时的选择逻辑方案启动速度隔离强度可观测性工程改造成本适用场景纯 Docker container快弱共享内核好低日常测试非可信代码gVisorrunsc较快较强用户态内核中中多租户需要平衡速度与安全Kata/Firecracker 微虚机慢强独立内核中中高高安全要求低并发OpenSandboxgVisor 自研 Runtime较快较强定制化好高coding agent 高频执行场景我们最终没有只选 Docker是因为 coding agent 的执行场景比较特殊它的命令不是“一次性的”而是“多轮、交互式、有状态的”。Agent 会先 clone 代码再装依赖再跑测试再根据报错修改代码再重跑。如果每一次命令都起一个全新容器agent 的思维链就断了。OpenSandbox 的特点是一个 agent session 对应一个长期存活的沙箱实例命令通过 Runtime 转发进去中途可以追加文件、调整资源、读取输出。这个长期存活的 session 模型是普通 docker run 很难做好的。2. Agent Runtime 在沙箱里的角色2.1 Runtime 该管什么、不该管什么Runtime 这个名字听起来很抽象你可以把它理解成“沙箱里的管家”。它不是操作系统也不是容器运行时它是连接模型、工具和沙箱环境的那一层。具体来说一个合格的 Agent Runtime 要管好几件事。命令执行是核心。模型生成的自然语言指令要先被解析成结构化工具调用比如run_command(command, timeout)、read_file(path)、write_file(path, content)。Runtime 把这些调用翻译成沙箱内的真实操作然后等结果返回。这里有一个很容易被忽略的点命令输出是有长度的。Agent 跑一个编译任务输出可能是几千行如果全部塞回给模型上下文直接爆炸。所以 Runtime 必须做输出截断、行数限制和关键片段提取。进程和生命周期也要管。Agent 可能会启动一个后台服务比如npm run dev然后以为自己完成任务了。Runtime 要能在任务结束时把整个进程树杀掉而不是留下一个孤儿进程。我们这里用了一个“前室”进程init process的设计沙箱内所有进程的前 1 号进程由 Runtime 拉起任务结束时杀掉这个 init 进程整个沙箱的进程树全部回收。Runtime 不该管的是模型的“思考”。它不应该尝试去理解 agent 要干什么、不该对命令做语义上的判断。它的职责是“执行并反馈结果”而不是“理解并纠正”。一旦 Runtime 开始自作聪明地翻译命令你会陷入无穷无尽的白名单配置和误判。正确姿势是让模型把需求表达成标准工具调用Runtime 只做最表层的参数校验和安全检查。2.2 执行生命周期设计我们设计的沙箱 session 生命周期大致是 5 个状态create → start → exec → pause/clone → destroy。创建阶段Runtime 向 OpenSandbox 控制面请求一个沙箱实例指定镜像、配额、网络策略。启动阶段沙箱内核和 init 进程起来执行环境就绪。执行阶段是最长的模型多次通过exec发送命令每次执行都带着独立的 timeout 和输出大小限制。这里我们加了一个很有意思的能力clone也就是热复制。当一个任务开始时会做一些固定步骤拉镜像、装依赖、初始化环境这些步骤很费时间我们就可以在一个模板沙箱上执行完后把整个沙箱快照复制出 10 个副本每个 agent 从同一个“已准备好”的环境开始。这个机制帮我们把平均任务时间缩短了大概一截。销毁阶段最容易出漏洞。我们要求所有 session 在超时后强制销毁哪怕里面还有任务在跑。很多故障都是“任务结束了但沙箱没回收”导致的资源泄漏。我们后来加了一个 watchdog每次 exec 结束都会续签一次 session 的租约租约超时就直接调用销毁接口宁可让任务失败也不能让沙箱泄漏在宿主上。2.3 文件系统把工具链送进去但不让它带出来Coding Agent 对文件系统的需求分两类一类是项目文件另一类是工具链。项目文件在沙箱里放在/workspace下这个目录是每个 session 独立挂载的不与其他 session 共享。工具链则分为两层基础工具链GCC、Python、Node、Git直接打进沙箱镜像放在只读层动态生成的缓存pip cache、node_modules放在一个独立可写分区并且限制大小。这里有个要点沙箱的根文件系统最好是只读的可写区域只有/workspace、/tmp、/cache。这样即使 agent 不小心执行了rm -rf /它也只能删掉自己的临时文件动不了工具链和镜像层。我们在实践中发现这个设计还能带来额外好处因为核心工具链都是只读的多个沙箱实例可以共享同一份底层镜像的内存页缓存内存占用大幅下降。还有一点一定要注意不要让沙箱直接挂载宿主机上的项目目录。虽然这样方便但 agent 一旦跑出问题直接污染的就是真实代码。正确做法是把项目文件复制或 tar 流入沙箱的/workspace任务结束后只把 Git diff 和指定产物传出来。我们内部有一句话“代码进沙箱diff 出沙箱其它东西都不准过。”2.4 网络策略用白名单替代“无网”一开始我们为了省心给所有沙箱默认断网。结果 agent 的完成任务率低得离谱——它要拉依赖、查文档、调 API完全离线根本没法干活。后来改成默认有网但受控效果才好起来。网络层我们做了三件事。第一默认禁止沙箱主动监听外部端口。也就是说agent 在沙箱里启动的服务只能在沙箱内自己访问外面的用户、外面的进程连不进去。这个通过 iptables 在宿主侧做端口映射和过滤就能实现。第二出口流量走一个 egress 代理按域名和端口做白名单。比如允许访问 PyPI、npm registry、GitHub 这些做开发必备的站点其它域名默认拒绝。第三DNS 解析走我们自己的内网 DNS避免 agent 在沙箱里自己改 /etc/hosts 然后访问内网地址。做了这几层之后agent 可以正常下载依赖但无法访问内部数据库、Kubernetes API 和未授权的服务。我们当时踩过一个坑有 agent 在沙箱里 curl 内网元数据服务的 IP幸好这个 IP 段和 MinIO 存储挂了同一个网络差点让它把备份文件拉出去。后来我们在出口代理上把私网 IP 段全部加入了默认拒绝名单这类风险才算堵住。2.5 可观测性沙箱外看到的数据才可信Agent 在沙箱里执行我们怎么知道它到底干了什么日志肯定要记但更关键的是数据要从沙箱外面拿不能信沙箱内部的自我报告。因为 agent 完全可能执行rm /var/log/*把自己的日志清了或者用资源耗尽的方式让监控失联。我们设计的可观测性分三层。第一层是运行时指标CPU、内存、磁盘、进程数全部从宿主机侧 cgroup 读取每个沙箱对应一组独立的 cgroup 路径。第二层是命令审计日志每一次 exec 的时间、命令、输出摘要、退出码统一发到内部的审计系统天然带有 session ID 关联。第三层是链路追踪从“模型输出工具调用”到“Runtime 执行命令”再到“沙箱内 shell 完成”整条链路串成一个 trace方便定位到底是模型理解错了还是执行环境的问题。有了这三层数据我们才能在 agent 看起来“表现正常”但实际跑偏时及时发现问题。之前有一个 caseagent 在沙箱里反复重试一个编译命令每次都是 5 秒超时退出从模型侧看就是“编译失败”我们通过审计日志才发现原来前一次任务留下的后台进程还占着编译锁跟当前任务无关。没有这些外部数据这种问题根本没法定位。3. OpenSandbox 部署与接入实操记录3.1 部署前的环境检查把 OpenSandbox 跑起来之前有几项前置检查一定要做不然后面会到处碰壁。内核版本是第一个坎。如果底层用 gVisor宿主内核只要不太老通常都能跑如果要支持 cgroup v2 和可移植的资源配额建议直接用内核 5.10 以上的发行版。我们当时有几台老节点还是内核 3.10跑 docker 没问题但 gVisor 和 cgroup v2 直接不支持只能先用那批旧节点跑普通容器任务把新节点留给沙箱平台。第二个是检查 KVM 或用户态内核是否可用。如果用 Kata/Firecracker 这类微虚机方案需要确认/dev/kvm存在并且当前用户有权限访问。我们用 gVisor 为主所以对 KVM 依赖不大但如果要跑一些需要虚拟化指令的负载比如某些老版本的数据库测试还是得准备微虚机模式。第三个是要确认镜像仓库和内网 DNS。OpenSandbox 创建沙箱时需要从镜像仓库拉取 base 镜像这个仓库必须在所有节点上都能访问并且提前配好认证否则一键部署脚本会在拉镜像这步卡半天。我们遇到的第一个部署失败就是 registry 证书过期导致大量沙箱创建失败。3.2 控制面组件与配置项拆解OpenSandbox 不是单二进制它由几个组件组成API Server创建、销毁、查询沙箱、Node Agent跑在每台宿主机上负责拉起真正的沙箱、Scheduler决定沙箱分配在哪台节点、以及 Runtime Client内嵌在你的 agent 执行链路里。我们部署的时候用 docker-compose 先起了一套单机版验证没问题后才上了 Kubernetes。配置项里我觉得最值得好好调的是这几项配置项作用我们生产用的值sandbox_cpu_quota单沙箱 CPU 限额2 核cgroup 的 cpu.cfs_quota_ussandbox_memory_limit单沙箱内存上限4GiBsandbox_pids_limit最大进程数防 fork bomb512sandbox_tmp_size/tmp 分区大小2GiBtmpfsegress_mode网络策略模式proxy_whitelistidle_ttl空闲自动销毁时间30 分钟还有两个容易被忽略的配置image_pull_policy和session_max_lifetime。前者我们强制改成IfNotPresent避免每次创建沙箱都去仓库拉镜像冷启动慢到怀疑人生后者设成了 4 小时不管任务有没有跑完到时间强制回收防止 agent 陷入死循环长期占用资源。3.3 Runtime 接入流程与核心代码接入 Runtime 可以分三步走先解决“能不能执行”再解决“执行得好不好”最后解决“出事了能不能追”。第一步在代码里引入 sandbox client。我们这边用一个简单封装来创建 sessionfrom opensandbox import SandboxClient, SandboxConfig cfg SandboxConfig( imagetrusted/agent-python:3.11, cpu_quota2, memory_limit4Gi, pids_limit512, networkwhitelist, egress_policypypi,npm,github,internal_docs, ) client SandboxClient(endpointsandbox-api.internal:8443) session client.create(cfg) print(session.id) # 拿这个 id 关联整个任务链路第二步把 agent 的工具函数从“本地执行 shell”替换成“沙箱内执行”。这是改动量最大的一步因为很多 agent 框架的run_command函数直接调subprocess现在要把所有命令转发到 sessiondef run_command(command: str, timeout: int 120) - CommandResult: return session.exec( commandcommand, timeouttimeout, max_output_lines500, workdir/workspace, )注意这里的max_output_lines很关键。模型上下文窗口有限几千行编译日志传回去基本属于浪费 token。我们内部的默认值是 500 行再多的话会把“尾部摘要”和“错误关键字”提取出来返回给模型。第三步接审计和 trace。每次 exec 结束我们会在异步线程把命令、退出码、输出的 1KB 摘要发到审计系统并在日志里带上session_id、agent_task_id。这个链路必须从一开始就建好不然后期想排查历史问题会发现数据是断的。3.4 资源配额给 50 路并发算一笔账很多同学部署完沙箱就直接按“一个任务一个沙箱”的方式跑结果账号下 10 个任务同时启动节点直接 OOM。资源配额这件事一定要先算清楚再设计调度策略。假设我们的核心业务是给 50 个并发 coding agent 任务提供环境。如果每个沙箱给 2 核 4G理论上需要 100 核 200G 内存。但实际上不是每个任务都在 CPU 密集阶段大部分时间模型在“思考”沙箱里只是挂着等待下一次 exec。所以不能按峰值简单乘法而是要按“并发活跃比例”估算。我们生产环境的经验值是50 个 session 大概同时有 15 个处于真实执行状态所以宿主机资源可以按 30% 峰值预留配合热池和自动扩缩容来兜底。热池里常驻 5 个预启动沙箱有新任务进来直接领走一个冷启动时间从 8 秒降到 2 秒以内。等任务量上去再动态扩容节点。这里还要特别注意内存超卖问题。gVisor 类的沙箱虽然隔离了进程但内存统计还是从宿主机 cgroup 看。我们把每个沙箱的内存 limit 设为硬限制宁可让任务 OOM 也不能让 50 个沙箱一起把宿主机打挂。oom_score_adj也统一调到比较高确保极端情况下系统优先杀掉沙箱进程而不是宿主关键进程。4. 压测、调参与性能数据4.1 我们怎么设计压测场景说实话刚开始做压测时我们犯过一个错只测“启动沙箱”和“跑一条 echo”的耗时结果显得性能特别好但一上真实任务就暴露问题。后来我们吸取教训把压测场景分成了三类分别对应 coding agent 的真实使用节奏。第一类是命令密集场景模拟 agent 连续执行 100 条命令包括读文件、改小文件、跑短命令。这类场景主要考验 Runtime 的转发效率和沙箱 exec 的启动延迟。第二类是编译构建场景进入沙箱后拉依赖、跑一次完整编译看沙箱能不能稳定支撑重负载会不会 OOM 或卡死。第三类是混合访问场景让 agent 同时写文件、访问网络、跑后台服务模拟真实任务的并发特征。压测工具我们没自己造直接用写好的 agent 框架跑一批标准任务每个任务里有固定的工具调用序列同时记录 P50/P95/P99 延迟。这里我建议你也这么干不要用纯压力工具打接口因为那测不出真实 agent 的行为特征最好是把真实任务的调用序列抽出来反复回放。4.2 冷启动、热池与会话回收压测里最直观的指标是冷启动。我们的镜像里有 Python、Node、GCC 等常用工具链体积大概 1.8GB。冷启动一个沙箱平均 8 秒其中拉镜像和初始化文件系统占了大头。这个速度对交互式开发场景来说偏慢所以我们做了热池。热池的原理很简单提前用一个模板任务把沙箱创建好装好常用依赖然后通过 sandbox 的 snapshot 能力复制出若干“就绪实例”放着。新任务来了直接复用就绪实例省掉启动时间。实测之后热池条件下的“任务资源准备”时间降到了 1.5 秒左右体验好了非常多。但热池也有代价模板依赖要更新模板任务本身要定期重跑而且快照复制会占用额外磁盘。我们的处理方式是只在核心业务上开热池普通一次性任务仍然走冷启动。另外会话回收我们也加了一条“双保险”空闲 30 分钟自动销毁同时最常生命周期 4 小时强制销毁。压测时特意模拟了“任务失败后 agent 不退出”的场景确认 watchdog 最终会把 session 清掉不会让沙箱泄漏成僵尸。4.3 调优过程中动过的几个关键旋钮最先调的是exec的超时机制。一开始我们把超时设置得很长比如 300 秒。结果发现 agent 一旦陷入某条命令的循环会白白占住沙箱。后来改成默认 120 秒并且允许模型在结果里看到“timeout”提示后决定下一步动作。这个改动很影响任务体验agent 不会因为超时直接挂掉反而学会了主动重试或简化命令。第二个重要旋钮是输出大小限制。我们一开始只限制行数没限制单行长度。结果有一次 agent 跑cat /var/log/syslog一条 10MB 的单行日志直接撑爆了返回通道。后来把单行长度限制到 4KB超出部分用truncated标记。这看起来是个小细节但在真实场景中能避免很多线上事故。第三个旋钮是 /tmp 的挂载类型。默认情况下 /tmp 是在容器层上写文件文件多了以后沙箱冷启动都变慢。我们改成 tmpfs 挂载并且限制 2GB。tmpfs 的好处是写入速度快而且沙箱销毁时内存自动回收不占磁盘。坏处是数据不持久但 /tmp 本来就不该持久所以这个取舍很划算。第四个是 mirror 仓库的配置。沙箱里拉 pip 和 npm 包默认走公网速度慢且不稳定。我们在 egress 白名单里增加了内部镜像源并在创建沙箱时注入环境变量PIP_INDEX_URL、npm_config_registry。仅仅这一个调整构建类任务的耗时平均降低了 40% 以上强烈建议你做同样的事情。5. 常见问题与排查速查5.1 沙箱启动失败第一步看哪沙箱启动失败是大家问得最多的。先说结论绝大多数启动失败都和四件事有关镜像仓库、内核特性、资源限额、网络配置。第一步先看控制面日志里沙箱的 create 请求有没有到 node agent。如果卡在ImagePullBackOff大概率是镜像仓库认证或者网络问题如果已经到 node 但一直Creating那要检查宿主机的磁盘空间镜像层展开也需要空间如果创建后立刻退出多半是资源限额配得太小连 init 进程都跑不起来。我们这边还碰到过一个奇葩情况同一镜像在 A 节点能跑在 B 节点启动失败最后发现是 B 节点内核版本太老gVisor 需要的某些 syscall 不支持换了内核版本就好了。参照这个顺序排查基本半小时内能定位。别一上来就怀疑平台问题先确认自己传的参数和宿主机环境是干净的。5.2 沙箱内网络异常从 DNS 到代理的全链路排查沙箱里pip install超时或curl无响应这类问题在我们这边出现过不少次。排查顺序是先看 egress 代理是否在宿主机上运行再看白名单是否包含了目标域名最后看沙箱内 DNS 是否正常。有段时间我们频繁遇到“第一次请求成功第二次请求卡死”的情况后来发现是沙箱内复用连接的问题。代理对空闲连接有自己的超时但沙箱内的客户端不知道把失效连接拿来重用就卡住了。排查时我们用nsenter进入沙箱所在的网络命名空间在里面起一个 Python 脚本分别测试短连接和长连接才复现出来。解决方法是给沙箱注入环境变量让常见客户端禁用连接复用或者调短代理的 keepalive 时间。另一个高频问题是 IPv6。宿主机开启了 IPv6但沙箱内没有启用导致某些服务解析到 AAAA 记录后一直等待超时。我们直接在沙箱的 sysctl 里关闭了 IPv6问题立刻消失。如果你也排查类似问题可以先cat /proc/net/if_inet6看沙箱内有没有 IPv6 地址没有的话就关掉或者配好转发策略。5.3 沙箱把宿主机搞“卡”了资源隔离的漏网之鱼理论上沙箱做了资源隔离宿主机不应该被拖垮。但我们确实遇到过几次沙箱导致宿主机 load 飙高的情况根源都在资源隔离的盲区。第一次是磁盘 IO。程序在沙箱里疯狂写文件cgroup 的 memory/cpu 限制住了但 IO 带宽没有限制导致宿主机磁盘写延迟飙到几百毫秒影响到同节点的其他服务。后来我们在创建沙箱时给块设备加上了 IO 权重限制并且把 /tmp 从磁盘挂载改成 tmpfs问题不再出现。第二次是进程号PID耗尽。某个沙箱里 agent fork 了非常多线程虽然单个沙箱的 pids_limit 设了 512但宿主机的全局kernel.pid_max是有限的大量并发 session 加起来可能把宿主机 pid 空间挤爆。我们给每台宿主机设置了 session 数量的上限同时调高了kernel.pid_max双保险。第三次是/etc/hosts被沙箱内部修改。这不算资源问题但影响很隐蔽agent 改了 hosts 文件后其它 session 的网络解析也被影响了。原因是我们的 hosts 文件挂载方式是所有沙箱共享同一个宿主机文件。改成每个沙箱独立 bind mount 一份 hosts问题解决。排查网络问题时要记住网络命名空间隔离不代表文件系统也隔离两个维度的配置要看清楚。5.4 排查工具箱与一句话心得最后整理一下我们内部用得最多的排查命令都是手工 DEBUG 时的救命工具crictl ps/crictl inspect sandbox_id查看沙箱在宿主侧的状态比看控制面日志更接近真相。nsenter -t pid -n进入沙箱网络命名空间排查 DNS、连通性。cat /sys/fs/cgroup/sandbox_cgroup/memory.current查看真实内存使用比free可靠。iptables -t nat -L -n -v和iptables -L -n -v检查端口映射和出口过滤是否生效。strace -p init_pid当沙箱内命令僵死时看 init 进程在等什么系统调用。一句话心得沙箱出问题先信宿主机侧的数据再信沙箱内部的输出永远不要让 agent 自己解释它执行环境的事故原因。这套 OpenSandbox Agent Runtime 的方案跑了大半年我个人的体感是它最大的价值不是“把 agent 关起来”而是让我们敢把 agent 从 demo 环境放到真正的工程流程里。有了沙箱以后coding agent 可以在我们没有心理压力的情况下自己折腾、试错、探索而我们的工作从“盯住它别乱来”变成了“观察它怎么干活再把边界调整得更合理”。如果你也在做类似的 agent 工程化建议不要一上来就追求最全的隔离功能先把“会话生命周期”和“资源硬限制”这两件事做好你就能躲开大多数线上事故。后面我们把 schedule 策略、多集群调度和模板快照的版本管理再补齐这个平台就算真正长成该有的样子了。
