Agent 沙盒基础设施实践:从 DSec 与 AgentENV 看调度层的选型与落地
Agent 沙盒基础设施实践:从 DSec 与 AgentENV 看调度层的选型与落地
引言
随着 Agentic RL 训练规模的爆发,如何为成千上万个并行 Agent 提供隔离、可复用、可快速调度的沙盒执行环境,已经成为基础设施团队绕不开的课题。
团队调度侧近期的工作重心正是这个方向——目标是对齐 DeepSeek 的 DSec 论文(arXiv:2609.22978)所描述的"调度层支持 FnCall、Container、VM 等多级运行时应对异构工作负载"的能力。目前我们的路径是:镜像分发加速在做,运行时已经落成了一套四模板形态(见 §3.12),网络和内核隔离在规划中。
平台的形态是:控制面统一编排云上 GPU 训练集群与边缘侧的机器人、传感器、摄像头,从 RL/LLM 训练一路打通到边缘部署。边缘侧通过 Kubernetes Device Plugin 把机械臂、摄像头暴露成可调度资源(资源名形如 rlinf.io/device-franka),让训练任务里的仿真环境和真机环境能用同一套声明式抽象描述。
"具身"这个定语给沙盒基础设施加了一层 DSec 和 AgentENV 都不需要面对的要求,我们会在 §1.5 展开:沙盒不只是"跑代码的地方",它还要能接真机、能跑仿真器、能渲染——这直接把运行时谱系往 VM 那一端推。
这篇博客把我们这段时间的调研、选型决策、踩过的坑整理出来,主要覆盖四个问题:Agent 沙盒到底和传统容器调度有何不同、DSec 与 Kimi 的 AgentENV 两条路线怎么选、仿真渲染类负载应该走哪条技术路径、镜像加速为什么优先落在 Nydus 上。
数据来源与可核实性声明
- DSec 的所有生产数据(Fig 2/3/5/6/7/8、Tab 1/2/3、§8.2/8.3/8.4/8.5 的全部百分比)均取自论文原文 arXiv:2609.22978v1(2026-09-19),由 PDF 逐条核对,正文中标注了章节号或图表号以便复查。
- AgentENV 的代码级细节基于 commit
9f62cbe(v0.2.4,2026-10-23);agent-sandbox 基于 commit10c3f70(2026-10-22);OpenSandbox 基于 commit230af58a(2026-08-10)。开源项目迭代快,版本对不上时请以对应 commit 为准。- 第三方对比数据(如懒加载方案冷启动耗时)来自公开基准,可能存在立场偏差,已在相应位置单独标注——选型请以自测为准。
- 涉及我们自身系统的部分以代码事实为准;未落地的能力会明确标注为"规划中"或"只有脚手架"。
本文结构(篇幅较长,可按需跳读):
| 章节 | 内容 | 适合谁读 |
|---|---|---|
| §1 | 问题域:Agent 沙盒的四个共性矛盾 + 具身场景额外的三条约束 | 所有人 |
| §2 | DSec vs AgentENV 路线对比,含 AgentENV 存储栈的四个关键设计 | 做选型决策的 |
| §3 | 运行时谱系:FnCall → Container → MicroVM → Full VM;virtio-gpu、Kata 默认形态 vs GPU 直通、具身仿真的 GPU 路线分层、会话粘性、K8s 原生路线、商业沙盒、K8s 调度与 Sandbox 现状、我们已落地的四模板实现 | 定架构的 |
| §4 | 镜像分发加速:四种懒加载方案对比、Nydus 选型、组合式环境层、懒加载的验签时序问题 | 先动手做落地的 |
| §5 | 网络与内核隔离:eBPF / iptables / egress sidecar 四条路线、agent 越狱实录、gVisor 能力边界、数据外泄防护 | 做安全的 |
| §6 | 高密度与超卖:内存共享与回收、CPU QoS、预热池设计、可观测性指标清单 | 做调优的 |
| §7 | 三阶段落地路线图 | 关心排期的 |
| 附录 | 从零理解 Guest 内核 / mmap / Page Cache,以及一次读请求的完整链路 | 需要补背景的 |
全局一张图——本文讨论的所有东西都落在这张图的某一层上:
graph TB
FW["训练 / 评测框架
Agent 任务"]
PE["调度层
按负载特征路由"]
FW --> PE
subgraph RT["运行时层(§3)"]
R1["FnCall
常驻进程池"]
R2["Container
裸容器 / gVisor"]
R3["MicroVM
Firecracker / Kata"]
R4["Full VM
QEMU"]
end
PE --> R1
PE --> R2
PE --> R3
PE --> R4
subgraph BASE["公共底座——所有运行时共用"]
B1["镜像分发加速(§4)
Nydus / 组合式环境层"]
B2["网络与内核隔离(§5)
eBPF / netns / VM 边界"]
B3["高密度与超卖(§6)
内存共享回收 / CPU QoS"]
end
R1 --> B1
R2 --> B1
R3 --> B1
R4 --> B1
R1 --> B2
R3 --> B2
R4 --> B2
R1 --> B3
R3 --> B3
R4 --> B3
读图要点:底座的三个方块是所有运行时共用的——这正是 §4.1 说"镜像加速优先级最高"的理由。任何一条运行时线上做的优化,另外几条都直接受益。
一、问题域:Agent 沙盒为什么"难"
Agent 训练对沙盒的需求和传统 CI/CD 完全不同。DSec 论文第 1 节把 agentic sandbox 负载归纳成 7 条性质,我们把它压缩成四个关键矛盾:
1.1 异构负载的范围极广
同一个训练任务里,可能既有"跑一段 Python 刷题脚本"这种毫秒级轻量负载,也有"装依赖、改代码、跑测试"的软件工程任务,还有"操作 GUI 应用、模拟 Android、运行带图形界面的商业软件"的 Computer-Use 和仿真场景。DSec 论文的措辞很直接:
A single sandbox abstraction cannot cover all of them efficiently. For example, lightweight function calls are preferable for short stateless tasks, whereas virtual machines (VMs) are better suited to workloads that require a complete commercial off-the-shelf operating system.
单一沙盒抽象无法高效覆盖所有这些负载,这是 DSec 全文反复强调的核心论点,也是它设计四级运行时的根本原因。
1.2 突发规模极端,但 CPU 长期空闲
这是 Agent 沙盒最反直觉的地方:规模和利用率同时走向两个极端。
DSec 论文 §4.1 的 Fig. 2 给出每任务创建沙盒数量的分布——Container 明显比 microVM 更激进:
| 后端 | p50 | p90 | p99 |
|---|---|---|---|
| Container | 2,528 | 7,969 | 16,388 |
| microVM | 352 | 1,835 | 4,044 |
论文正文也印证了这个方向:“a typical container task already creates thousands of sandboxes, and the tail reaches tens of thousands.”——2,528 是 thousands,16,388 是 tens of thousands,都对得上 Container 那一行。论文还写道"最大的生产任务可以一次请求多达 32K 个沙盒"。
与此同时,§4.3 的 Fig. 5 显示:约 90% 的容器和 microVM 沙盒,其平均 CPU 用量不超过申请量的 5%。论文解释了原因——Agent 在等待 LLM 生成下一步动作时,沙盒几乎什么都不做。
论文 §4.1 的 Fig. 3 把沙盒生命周期拆成三段:setup(准备依赖和工具)、tool-call(模型生成与沙盒操作交替,产生短 CPU 突发)、test(验证结果)。三段的资源画像完全不同:setup 的成本会被突发规模成倍放大,而后两段则长期占用内存却几乎不用 CPU。
这两个极端叠加的结果是:节点上可以塞进极多的沙盒。论文里有两组容易看混的密度数字,它们的口径不同,不是"峰值低于稳态":
| 数字 | 出处 | 口径 |
|---|---|---|
| 1,048 容器 / 524 microVM | §4.3 Fig. 6 | 某一天采样的单节点峰值——这一天实际跑到的高度 |
| ≥3,200 容器 / 800 microVM | §1 与 §4.3 | 跨全量生产验证过的稳定运行点——能力陈述,不是某一天的观测值 |
论文对后者的原话是 “we have observed stable operation with at least 3,200 containers or 800 microVMs per node. These are demonstrated operating points rather than hard limits.”(§4.3)——关键词是 at least 和 demonstrated:这是已验证的下界,而采样那一天只是没跑到那么高。两者不冲突。
关于"日 300 万"与"峰值并发 38 万"的口径。 这两个数字经常被并列引用,看起来像对不上:如果 38 万并发是常态,按中位寿命 17.4 分钟算,一天的创建量应该在 3000 万量级,而不是 300 万。
关键在**“峰值"两个字**。用日均反推:300 万 / 1440 分钟 ≈ 2083 个/分钟,乘以中位寿命 17.4 分钟 ≈ 平均并发 3.6 万;峰值 38 万约是均值的 10 倍。对一个"单个任务可以一次请求 32K 个沙盒”(§4.1)的突发型负载来说,10 倍的峰均比完全合理——这也正好呼应了 §4.1 的突发分布。所以两个数字并不矛盾,只是"日总量"和"瞬时峰值"两个不同口径,引用时最好不要并列摆出而不加说明。
1.3 有状态、长寿命,内存压力持续存在
论文 §4.3 的 Fig. 7 显示:容器沙盒寿命中位数 17.4 分钟,microVM 15.5 分钟,两者的 p99 都超过 3 小时。
沙盒在整个生命周期内是有状态的——文件修改、安装的依赖、启动的服务都要跨调用保持。这意味着即使 CPU 已经空闲很久,内存占用、guest page cache、host page cache 和可写状态依然被钉住。论文 §4.3 特别点出 microVM 的两处内存浪费:
- 通过虚拟块设备读取的镜像数据,可能被 host 缓存一次、再被每个 guest 缓存一次,同一份数据在 guest-host 边界两侧冗余缓存;
- guest 内部的空闲页不会主动归还 host——因为申请的内存容量通常远超实际需求,guest 内部根本感受不到回收压力。
1.4 镜像形态极其分散,fanout 极低
这是最容易被低估的一条。论文 §4.4 的 Tab. 2 给出一个生产周内活跃的环境产物:
| 后端 | 基础镜像 | workspace | 快照 | 总量 |
|---|---|---|---|---|
| Container | 11,266 | 102,171 | – | 82.8 TB |
| MicroVM | 2 | 53,590 | 4,889 | 50.9 TB |
聚合超过 130 TB——远超任何单节点能存储的量。更关键的是 §4.4 的 Fig. 8:容器镜像的 fanout 中位数只有 3,p90 也只有 28;microVM 镜像中位数 1、p90 只有 3。
低 fanout 直接导致本地镜像缓存失效——工作集太分散,单节点的缓存根本吸不住。而 §4.4 的 Tab. 3 揭示了更浪费的事实:运行时真正访问的数据只占镜像的 4.2%–13.3%。
| 镜像类型 | C++ | Go | Java | JavaScript | Python |
|---|---|---|---|---|---|
| 运行时访问占比 | 8.7% | 13.3% | 9.2% | 4.2% | 6.0% |
| 镜像体积 | 4.9 GB | 4.1 GB | 12.1 GB | 9.6 GB | 6.0 GB |
Python 容器镜像 6.0 GB,agent 实际只读 6.0%;Java 镜像 12.1 GB,只有 9.2% 被访问。全量拉取意味着 90% 以上的 I/O 是纯浪费。
而且论文点出了一个反直觉的推论:在超卖让集群接近满负载的前提下,拉镜像消耗的 CPU 和 I/O 会直接挤占正在运行的沙盒的资源。预热的做法只是把开销提前,并没有消除它——同样的数据还是要传输、要落地、要占本地存储。
这四个矛盾决定了 Agent 沙盒基础设施不是一个"把容器跑起来"的问题,而是要在运行时谱系、存储链路、网络内核边界三个维度同时做工程。
1.5 具身场景额外加的三条约束
上面的四条是 Agentic RL 的共性问题。落到具身智能,还有三条是 DSec 和 AgentENV 都不需要处理的:
约束一:沙盒要能接真机。
具身任务的 rollout 有两种形态——在仿真器里跑,或者接真实机械臂/摄像头跑。后者意味着沙盒不能只是一个"隔离的代码执行环境",它必须能访问宿主机上的真实设备。
平台的做法是通过 Device Plugin 把机械臂、摄像头注册成可调度资源(rlinf.io/device,带型号时变成 rlinf.io/device-<model>),边缘侧跑一套 embodied-runtime 组件,对外暴露 ros.controller.v1.RobotController 等 gRPC 接口;为了做真机直连,devinit init container 会用 MACVLAN 把宿主网卡塞进 Pod 的 netns。(设备接入侧的完整设计见 RLark 全栈架构 的"具身运行时"一节。)
但这里有个现实的工程妥协必须承认:为了让设备访问跑通,我们目前对非 head 角色的 Task 强制 hostNetwork: true,并对所有容器和 initContainer 强制 Privileged = true。代码里留了对应的 TODO——等新的真机访问方式落地后要去掉。
这看起来和 §5 要讲的隔离方向直接冲突,但想清楚之后其实不冲突——因为这是两类不同的负载。 具身仿真跑的是我们自己写的仿真器(Isaac Lab、ManiSkill),agent 产出的是动作数据而不是代码,威胁模型里"隔离不可信代码"这个需求根本不成立;而 §5 讲的是跑 LLM 生成代码的 agent 沙盒,那里才是必须上 VM 边界的地方。把这两类负载的隔离需求混为一谈,会得出"要么全都过度隔离、要么全都不隔离"的错误结论。正确的做法是按 workload type 分节点池——详见 §3.11。
约束二:仿真器把运行时谱系拉得更宽,而不是统一推向 VM。
这里我们一开始的推理是错的,值得记下来。最初的判断是:“仿真器要 GPU、要图形、要完整 OS,三条叠加只有 Full VM 能兜住。”
问题出在把"要 GPU"和"要 VM"当成了同一件事。 主流具身仿真器吃的是 NVIDIA 专有的计算栈——Isaac Lab 依赖 RT Core 和 CUDA,ManiSkill3 依赖 Vulkan,MuJoCo Warp 依赖 CUDA。而 virtio-gpu 只翻译图形 API,对 CUDA / PhysX / RT Core 完全无能为力。所以主流仿真器套上 VM + virtio-gpu 不是"性能打折",而是根本跑不起来。
正确的结论是:具身场景把运行时谱系拉得更宽,而不是把大家都推向 VM 那一端——主流 GPU 仿真走裸金属容器,只有 Android 移动端仿真和传统 GUI 仿真才真正需要 virtio-gpu。详细分层见 §3.5。
顺带修正前面对 gVisor 的判断:约束这里真正排除 gVisor 的是图形栈(nvproxy 只转发计算类 ioctl,不提供 OpenGL/Vulkan),纯 CUDA 计算场景 gVisor 是可评估选项——但见 §5.9,仿真器的计算与渲染通常耦合,所以实践中仍倾向别的路线。
约束三:资源在边缘侧,而且是异构的。
具身场景的计算是分层的:大规模 RL 训练在云端 GPU 集群,环境(仿真器、真机接入)可能在边缘侧甚至机器人本体上。我们的 Node 抽象里就有 cloud / edge / robot 三种类别。
这带来两个后果:一是跨站点网络成为必需(§5.5 展开);二是资源画像极度异构——不同型号的 GPU、不同架构的 CPU(x86 / ARM)、不同的设备型号,调度器必须把这些都作为 placement 的一等标签维度,而不是假设节点是同构的。
顺带说一句,TaskRole 里我们已经预留了 Actor / Rollout / Env 三种角色(Env 对应"环境/仿真器"),但目前这三个常量在 Go 代码里只声明、未被引用,只有文档在用。这算是把语义先立住、等实现跟上——也说明具身侧的环境抽象还处在早期。
二、参考方案对比:DSec vs AgentENV
当前业界公开可参考的两个标杆方案,是 DeepSeek 的 DSec 和 Kimi 团队(月之暗面 + 清华 kvcache-ai 实验室)的 AgentENV。两者同属"Agent 沙盒基础设施"赛道,但在技术路线上有明显分歧。
2.1 核心定位对比
表中 DSec 的数据全部可回溯到论文的章节/图表;AgentENV 的数据来自其 README 与文档,没有同等的测量方法披露,两者不完全可比。
| 维度 | DSec(DeepSeek) | AgentENV(Moonshot Kimi + kvcache-ai) |
|---|---|---|
| 发布形态 | arXiv:2609.22978,2026-09-19,DeepSeek-AI 与清华合作,130+ 作者 | 随 Kimi K3 开源发布,MIT 协议 |
| 运行时抽象 | 四级后端:FnCall、Container、MicroVM(Firecracker)、Full VM(QEMU) | 单级 microVM:所有沙盒都是 Firecracker |
| 核心优化目标 | 异构负载调度 + 镜像分发加速 + Agent 自建环境(pack_diff 快照复用) | RL rollout 的生命周期原语:fork / snapshot / restore |
| 关键性能 | 8192 容器突发部署 35 分钟(vs 全量拉取 60+ 分钟,1.71×,§8.2),磁盘写入减少 57%(§8.2) | boot/resume < 50ms、增量快照 < 100ms、单节点 fork 最多 16 子沙盒、9.6× 超卖比(均为项目 README 宣称值,未给出测量方法) |
| 生产规模 | 单 scale unit:~160 节点 / 30K 核 / ~250TB DRAM;日 300 万沙盒、峰值并发 ~380K、创建速率 >5,000/s(三者口径见 §1.2 的说明) | 生产环境 150 万镜像 |
| API 兼容 | 自有 SDK(libdsec) | HTTP API 兼容 E2B |
| 存储后端 | 3FS 分布式文件系统(EROFS + OverlayBD) | overlaybd + ublk,本地磁盘作为有界缓存,快照落 S3/分布式 FS |
2.2 两者其实是"同门"
DSec 论文 §7(Implementation)明确写到,它使用的 Rust 版 OverlayBD/ublk 存储库就开源在 AgentENV 的仓库里:
We use the Rust port of OverlayBD, to which we contributed, together with our Rust userspace library for ublk to form the on-demand block-storage path for Firecracker microVMs described in §5.3. … These storage components have been open sourced at https://github.com/kvcache-ai/AgentENV/tree/main/storage/overlaybd
两者在存储层用的是同一套技术栈(OverlayBD 按需加载 + ublk 用户态块设备 + 只读层共享 + 每沙盒独立写层)。这在我们评估"能不能直接拿 AgentENV 来用"时提供了显著的复用价值。
2.3 AgentENV 的哲学与局限
AgentENV 的哲学是"把 microVM 的状态操作做到极致"。它不追求覆盖所有负载类型,而是聚焦 RL 训练中最痛的环节——rollout 需要频繁暂停、恢复、分叉、回滚。
它的几个设计点值得单独说:
- 快照即一等公民:内存和文件系统的增量快照在 100ms 内完成,即使磁盘正在大量修改;快照可持久化到 S3 兼容对象存储或共享分布式文件系统,避免数据丢失。(以下 AgentENV 的性能数字均为其 README/文档的宣称值,仓库中未给出测量方法与复现脚本——引用时请当作量级参考。)
- fork 语义:一个运行中的环境可以 fork 成多个独立沙盒,用于并行 agent 工作流。
- 有界缓存:本地磁盘作为有界缓存,保留热数据、淘汰冷数据,因此聚合的镜像和快照占用可以比本地磁盘容量高出几个数量级,而集群范围的启动速度依然很快,不需要预热每一台机器。
- 内存超卖:memory ballooning 把可回收的 guest 内存还给 host,生产环境达到 9.6× 超卖比;同时通过 ublk 让 host page cache 在存储数据和内存快照数据之间共享。
一个 Agent 跑到关键节点后可以直接 fork 成几十条独立轨迹并行探索,环境恢复低于 50ms——这是它在 RL rollout 场景下能拿到极高成本节省的根本原因。
存储栈的四个关键设计
版本锚点:本节及下文涉及 AgentENV 的代码级细节(模块名、常量、参数默认值、缓存水印等),均基于 AgentENV v0.2.4(commit
9f62cbe,2026-10-23)。开源项目迭代很快,如果你在阅读时发现对不上,请先确认版本——这类实现细节半年内就可能变化。
一、内存快照恢复不用 userfaultfd,而是走块设备的 File backend。
这是个反直觉的选择。AgentENV 的仓库里有一个 storage/uffd-core 模块(userfaultfd 方案的参考实现),但它被排除在 workspace 之外,生产路径已经改成 ublk。
具体做法是:内存快照本身是一组只读的 overlaybd 层,通过 ublk 暴露成块设备;Firecracker 恢复时用 BackendType::File mmap 这个块设备作为内存后端。于是——
- 内存页走的是块设备 page cache,不是匿名内存;
- guest 首次写入时,页被 COW 到匿名内存,底层设备永不被修改;
- 冷页按需从远端取,取回后进入 page cache。
这个设计直接带来第二个特性。
二、同一快照的多个沙盒复用同一个只读块设备。
节点侧用一个 DashMap<PathBuf, Weak<SharedReadOnlyDeviceInner>> 以镜像配置路径为 key 做引用计数:同一个内存快照启动的多个沙盒拿到的是同一个只读 ublk 设备,因此天然共享同一份 Linux page cache;最后一个 Arc drop 时才异步删设备并清池。
这就是 README 里"聚合镜像和快照占用可以超出本地磁盘容量几个数量级、且不需要预热每台机器"的实现基础——不是每个沙盒复制一份快照,而是大家共享只读层、各写各的 upper。写隔离靠两件事:各自独立的 rootfs overlaybd upper(upper.data + upper.index),以及 Firecracker 对内存设备的 COW。
图解:多沙盒共享只读块设备的完整链路
上面这条设计是整个存储栈里最值得吃透的一环,下面把它从"快照文件"到"沙盒读写"完整展开一遍。
如果对 Guest 内核、mmap、Page Cache 这些概念不熟,可以先看文末的 附录:从零理解这条链路。
① 整体架构
1 | ┌──────────────────────────────────────────────────────────────────┐ |
这里有两个很容易画错的地方,我们自己在读代码前也搞混过:
- OverlayBD 不是独立进程,它是被链接进 ublk-daemon 的 Rust 库。所以 io_uring 通道在"ublk 内核驱动 ↔ ublk 用户态服务"之间,不在 OverlayBD 和 ublk 之间——后两者是同一进程内的函数调用。这也正是
download_gate能靠"进程级 in-flight 计数器"做前后台仲裁的前提(见上面的设计四)。 - 设备不是"全局唯一",而是每个快照一个。不同快照各自对应一个 ublk 设备;同一个快照的所有沙盒才共用那一个。daemon 侧给设备的逻辑名固定是
overlaybd-blk,内核分配的/dev/ublkb<N>编号不保证是 0。
② 启动流程:一个沙盒如何"接入"共享块设备
1 | ┌────────────────────────────────────────────────────┐ |
节点侧用一个 DashMap<PathBuf, Weak<SharedReadOnlyDeviceInner>>,以镜像配置路径为 key 做引用计数。沙盒 B、C 不再创建新设备,只是增加引用计数;最后一个 Arc drop 时才异步删设备并清池。
③ 读操作:三个沙盒如何共享同一份物理内存
1 | 沙盒 A 的 guest 访问某个 guest 物理地址 |
物理内存占用 = 1 页,而不是 3 页。
如果页缓存尚未命中,缺页路径是:
1 | 宿主页缓存未命中 |
注意中间那层 remote-blocks——它是 OverlayBD 自己的用户态块缓存,和宿主页缓存是两个层级。即使某块从页缓存被淘汰,只要还在 remote-blocks 里就不必回源(这对应上面设计三里 100 GB 的那份预算)。
④ 写操作:写时复制如何隔离
1 | 初始状态:三个沙盒共享同一只读页 |
ublk 设备本身永远不被写入——CoW 发生在内核的 mmap 层,每个 Firecracker 进程各有一份自己的映射,脏页天然私有。
⚠️ 这里有一个不能省的前提:映射必须是
MAP_PRIVATE。"读共享、写私有"这个性质只在
MAP_PRIVATE下成立。如果映射是MAP_SHARED+PROT_READ,对该页的写入不会触发 CoW,而是直接抛 SIGSEGV——因为共享映射的写权限由文件/设备本身决定,内核不会替你造一份私有副本。Firecracker 的 snapshot
Filebackend 用的正是MAP_PRIVATE(这也是它能同时做到"多沙盒共享只读页"和"各写各的"的原因)。离开这个 flag,整条"读共享/写私有"的论证都不成立——所以我们的设计里它是硬约束,不是实现细节。
⑤ 和 userfaultfd 方案的对比
这里要纠正一个常见误解:userfaultfd 方案并不是"每个沙盒预先复制一份快照",它同样是按需填充的。两者的真实差别在另外三点。
| userfaultfd | 只读 ublk + File backend | |
|---|---|---|
| 填充时机 | 按需(缺页时) | 按需(缺页时) |
| 缺页处理位置 | 每次都要切到用户态,由 uffd handler 读快照并拷入 guest 内存 | 内核走文件映射的缺页路径,只有真正的 I/O 才进用户态 |
| 填充后的页归属 | 落在该沙盒私有的匿名内存里 | 落在共享的页缓存里 |
| 多沙盒共享 | ❌ 每个沙盒各持一份 | ✅ 同一设备的沙盒共享同一份 |
| 写隔离 | 天然私有 | 靠 MAP_PRIVATE + mmap CoW |
所以内存占用的差异来自"共享"而不是"懒惰"。假设快照 1 GB、100 个沙盒、且每个沙盒都把 1 GB 全部读到(最坏情况)、各写 100 MB:
1 | ┌────────────────────────────────────────────────────────────┐ |
这个 10 倍差距是**"全部读到"假设下的上界**。如果实际只触达一部分页,两边都会等比例缩小,差距也随之收窄——但"共享 vs 私有"这个定性结论不变。
⑥ 各组件在共享方案里的职责
| 组件 | 角色 |
|---|---|
| OverlayBD | 内容源:把分层快照按 block range 提供数据。它不知道有几个沙盒,只管供数据 |
| ublk | 共享载体:把 OverlayBD 暴露成一块只读块设备,每个快照一个实例 |
| 页缓存 | 共享内存:相同 offset 只缓存一份物理页,天然支持多读者 |
| mmap + CoW | 隔离机制:读共享、写私有,全部由内核完成,用户态无感 |
| Firecracker | 消费者:把块设备当作 guest 内存后端(BackendType::File)直接 mmap |
| 引用计数 | 生命周期:refcount 归零才销毁 ublk 设备 |
一句话概括这条链路:共享发生在页缓存层,隔离发生在 CoW 层,而 ublk 只是把"共享的只读内容"以块设备的形式稳定地提供给所有沙盒。
三、两级 LRU + 两套独立缓存预算。
AgentENV 的缓存淘汰分两层,语义不同:
| 层级 | 处理对象 | 动作 |
|---|---|---|
| Tier 1 | 非 busy(open_count == 0 且无 pending block)的条目 |
整条删除 |
| Tier 2 | 仍被打开但空闲超时的条目(open_evict_idle_secs,默认 600s) |
只回收其缓存块,条目本身保留——open 句柄继续可用,miss 回源,后续读自动 refill |
第二层是关键:它让"打开着的设备"在长时间空闲后也能把缓存吐出来,而不需要先关掉它。
同时有两套独立的容量预算,各 100 GB 默认:remote-blocks(overlaybd 自己的块缓存,远端层用)和 commits(本地 OCI 转换产物)。前者由 overlaybd 的 file cache 淘汰,后者由 AgentENV 的后台 GC 淘汰——水印是 0.95 / 0.70,min_age 600 秒,GC 间隔 1800 秒。
而 GC 有一条引用计数式的安全约束,很值得抄:只有当某个 hard commit 的全部 referrer config 都已进入淘汰集合时,才把它计入释放字节。另外三类 commit 永不删除——被磁盘上的 source config 引用的、被 image-cache lease 持有的、被进程内 running set 引用的。lease 还分两个命名空间:runtime(运行中,瞬态)和 paused(暂停中,持久)。
四、前后台 I/O 的仲裁门。
AgentENV 有一个专门的 download_gate 模块,用两个进程级 in-flight 计数器把前台远端读(guest page fault、boot 关键路径)和后台下载块读分开,常量设计得很讲究:
BK_FLOOR_INFLIGHT = 1——后台永远不会被饿死,始终放行 1 个;STARTUP_FLOOR_INFLIGHT = 8——启动预取类读优先级更高;GATE_BACKOFF = 200ms,SANDBOX_READY_FALLBACK = 20s。
它的正确性依赖"单进程"假设——ublk daemon 托管所有设备与下载。这也是为什么 ublk daemon 被设计成单进程托管全部设备,而不是每设备一个进程。
fork 的实现:源沙盒几乎无感
fork 是 AgentENV 的招牌能力,实现顺序值得记一下,因为它直接决定了"源沙盒会不会被拖慢":
pause()源沙盒并抓一份快照配置;- 立刻 resume 源沙盒——源沙盒只是短暂暂停了一下,之后继续正常跑,ID 和过期时间都不变;
- 对每个 child spec 生成子配置(可以替换 launch-time 的 volume drive,但不能给已捕获的 Firecracker 状态追加新 drive);
join_all并行启动所有 child,单个失败就把它自己停掉。
COW 语义是:children 从同一份快照配置启动,共享 rootfs 的 lower 层和内存层,各写各的 upper;exclusive volume 按 child 分配(失败要回滚),只读 volume 共享。
有一条限制需要注意:带 volume mount 的沙盒 fork 需要 volume-aware 的 child specs,否则直接报错。
内存超卖:只做 free page reporting 的 balloon
AgentENV 的 balloon 配置很克制:
1 | let balloon = Balloon { |
amount_mib = 0 意味着它不做传统的"膨胀 balloon 逼 guest 换出",唯一目的是启用 free page reporting,让 guest 内核通过 MADV_DONTNEED 把未用页还给 host。另一个细节是:快照恢复时不再配置 balloon——balloon 状态是 vm_state.bin 的一部分,Firecracker 会自动恢复。
配合的 guest 内核启动参数里,damon_reclaim 的配置值得逐条看:
1 | page_reporting.page_reporting_order=6 |
skip_anon=Y 是最关键的一条——DAMON 只回收 file-backed(page cache)页,不碰匿名页。这和 DSec 论文 §5.2 的描述完全一致:DAMON 的职责是把散落的冷文件页释放回 buddy allocator,让 buddy 合并成高阶块,从而满足 free-page reporting 对高阶页的要求(AgentENV 这里把 order 设成 6,对应 64 个 4 KiB 页)。
另外两个生产细节:
- DAMON 的监控区间要按内存大小动态算——x86_64 上 ≤3 GiB 时取
memory_size,≤255 GiB 时取memory_size + 1 GiB,更大时+ 257 GiB,起始固定 4096。这样是为了避开 MMIO/PCI 空洞,否则 DAMON 会去监控不存在的地址区间。 - OOM 保护:Firecracker 子进程被设成
oom_score_adj = 1000(最优先被 OOM kill,从而保护 agentenv server 本身)。但这里有个坑——K8s 会从requests.memory推导容器的 oom_score_adj,如果不给 agentenv 容器设requests.memory: 8Gi,它也会是 1000,保护就失效了。同时要在 postStart 里把memory.oom.group置 0,否则默认的 1 会让整个 cgroup 一起被杀。
但这套极致设计在渲染/仿真场景下会全部失效,根源在 Firecracker 的设备模型上。下一节展开。
三、运行时选型:从 FnCall 到 Full VM 的谱系
这一章篇幅最长(11 个小节),因为"运行时选型"实际是三个不同层面的问题叠在一起。按需跳读:
| 分组 | 小节 | 回答什么问题 |
|---|---|---|
| 底层约束 | 3.1 – 3.3 | Firecracker 的设备模型天花板是什么?virtio-gpu 能做什么、不能做什么? |
| Kata 与设备直通 | 3.4 | Kata 的默认形态和 GPU 直通是两回事;VFIO 的代价与边界 |
| 具身仿真的路线 | 3.5 | 主流仿真器到底该跑在哪?Tier 1–4 怎么分? |
| 我们已有的抽象 | 3.6 – 3.7 | 沙盒抽象怎么做(http.RoundTripper);会话粘性的三种语义 |
| 生态参照 | 3.8 – 3.11 | K8s 原生 agent 沙盒、商业平台、K8s 调度与 Sandbox 现状 |
| 我们的实现 | 3.12 | 已落地的四模板形态,以及它和前面所有分析的对齐关系 |
3.1 Firecracker 为什么接不住渲染
Firecracker 是 AWS 为 serverless 场景"目的构建"的 VMM。为了最小化攻击面和启动开销,它只实现了一小撮 virtio 设备——net、block、vsock,以及后续版本陆续加上的 balloon、rng、pmem——外加 serial console 和一个最小键盘控制器。
真正卡住我们的不是设备数量,而是缺了什么:没有 virtio-gpu,没有显示控制器,没有 framebuffer,没有 USB/9P,也没有 PCI 总线和 VFIO 设备分配。前面 §2.3 和 §6.1 讲的 free page reporting、virtio-pmem 都建立在 balloon/pmem 这两个 virtio 设备之上——所以准确的表述是"设备集极窄且固定",而不是"只有五个"。
社区的技术评估结论很一致:Firecracker 不支持上述设备之外的任何设备直通,需要 GPU 的负载应当改用 QEMU 或 Cloud Hypervisor;Firecracker 没有 PCI 总线、没有 VFIO 设备分配,因此没有机制把物理 GPU 交给 guest。官方仓库的 GPU/PCIe 支持讨论目前仍停留在规划阶段,而且初始迭代连 GPU 设备的快照都不会支持。
最后一点尤其致命:即使未来 Firecracker 加上了 GPU,AgentENV 依赖的快照/fork 机制也无法覆盖 GPU 状态——GPU 有大量设备侧状态(显存、命令队列、驱动上下文)无法被快照。
AgentENV 的"不行",本质是 Firecracker 的设备模型天花板——为了 serverless 极致密度主动砍掉了 GPU 和显示。 这正是 DSec 坚持多运行时架构的根本原因。
顺带记一个 Firecracker 部署上的坑,因为它直接影响"能不能上云":Firecracker 需要 /dev/kvm,而很多云 VM 不暴露嵌套虚拟化。AgentENV 为此提供了 PVM 模式(出自论文 PVM: Efficient Shadow Paging for Deploying Secure Containers in Cloud-native Environment)——它是一种替代虚拟化模式,在不依赖传统嵌套虚拟化的前提下提供 KVM 兼容接口,装好之后仍然用 /dev/kvm 创建 Firecracker microVM。
代价是几条硬约束:host kernel 需要 CONFIG_KVM_PVM=m 且要手动 modprobe kvm_pvm;host 与 guest 必须 ABI 兼容(guest kernel 基于 Linux 6.12.33 的 pvm-612 分支);目前只支持 x86_64;而且节点只能是 kvm 或 pvm 之一——快照和暂停中的沙盒都记录了捕获时的模式,节点会拒绝恢复另一模式的状态,所以共享快照仓库时要保证只在原模式节点恢复。
这条约束在 DSec 的云爆发场景里更关键:论文 §3.4 提到它用云 VM 吸收瞬时峰值(本地利用率超过 80% 时把一部分请求 offload 到云上,200 台云 VM 吸收约 30% 的峰值溢出)。如果云 VM 不支持嵌套虚拟化,microVM 这条线在云上就跑不起来——这可能是 DSec 在云端复用"容器运行时 + EROFS 镜像加载路径"而不是照搬 microVM 的原因之一。
3.2 DSec 的四级运行时
DSec 针对异构负载提供了四种后端,隔离强度和资源开销逐级递增。论文 §2.2 的 Tab. 1 与正文可以整理成:
| 后端 | 实现方式 | 适配的工作负载 | 隔离强度 | 启动/内存开销 |
|---|---|---|---|---|
| FnCall | 常驻的预创建 CPU/GPU 容器进程池,无状态函数调用 | 刷题(OJ)类脚本执行、代码编译、算子基准测试、serverless 程序 | 最弱(复用宿主容器) | 最低 |
| Container | Docker 容器,跑在 QEMU/libvirt VM 里 | 软件工程任务(装依赖、改代码、跑测试)、通用 tool-use | 中(共享宿主内核,但外层套 VM) | 低 |
| MicroVM | Firecracker 轻量虚拟机 | 安全攻防、需要更强租户隔离的任务 | 强(独立内核) | 中 |
| Full VM | QEMU 跑完整操作系统,支持 GUI/图形渲染 | Computer-Use、Android 开发、带 GUI 的商业软件 | 最强 | 最高 |
调度层(DSec 中的 placement engine)根据任务的功能需求、兼容性要求和隔离强度动态选择后端。这正是我们调度侧要做的核心架构。
graph LR
T["Agent 任务
异构负载"] --> P{"Placement
路由决策"}
P -->|"毫秒级、无状态"| Fn["FnCall
常驻进程池"]
P -->|"软件工程 / tool-use"| Ct["Container
Docker in QEMU VM"]
P -->|"安全攻防 / 强租户隔离"| Mv["MicroVM
Firecracker"]
P -->|"GUI / Android / 渲染"| Vm["Full VM
QEMU + virtio-gpu"]
Fn --> Base["公共底座"]
Ct --> Base
Mv --> Base
Vm --> Base
Base --> I1["镜像分发
EROFS / OverlayBD 按需加载"]
Base --> I2["网络策略
eBPF 域名白名单"]
Base --> I3["资源管理
超卖 + CPU QoS"]
几个容易忽略的细节:
FnCall 不是"轻量容器",而是常驻进程池。 论文 §2.2 写道,FnCall 任务跑在可复用的预创建容器里,避免每次调用的供给开销。GPU FnCall 还支持两种模式:(i) shared 模式,多个容器共享一个 GPU 实例以最大化利用率;(ii) exclusive 模式,一个容器在其生命周期内独占 GPU 实例。论文 §7 补充了 GPU FnCall 的三个并发优化:用 NVIDIA MIG 把 GPU 切分成隔离实例、让 CPU FnCall 负责编译再把产物传给 GPU FnCall 以避免无谓占用 GPU、以及预热一个 Python 进程池提前完成运行时初始化和库导入,让请求可以直接开始执行算子。
MIG 的切分粒度是选型时要先算清楚的——不同代际差异很大:
| GPU 代际 | 典型切分档位 | 说明 |
|---|---|---|
| A100 (40/80GB) | 1g.5gb / 2g.10gb / 3g.20gb / 4g.20gb / 7g.40gb 等 | 7 个计算实例,可组合;是最常被引用的 MIG 基线 |
| H100 | 1g.10gb 起,最大 7g.80gb | 单实例显存更大,档位更少但每档更"厚" |
| 消费级 / 部分工作站卡 | 不支持 MIG | 这一条经常被忽略——想用 MIG 做算子基准,先确认卡支持 |
对我们做 GPU 算子基准(FnCall 的典型负载)来说,MIG 的价值在于隔离性:每个实例独占自己的显存和 SM 切片,跑基准不会被邻居干扰。但代价是总吞吐下降(切分后单实例的 SM 数和显存带宽都变小),所以论文里才要区分 exclusive(要性能隔离)和 shared(要吞吐)两种模式。
Container 和 FnCall 都跑在 QEMU/libvirt VM 里,不直接跑在宿主机上。 论文 §3.3 明确说明理由:VM 提供隔离的内核和网络栈,作为不可信容器与裸机之间的额外安全边界。这是一个很容易被忽略的设计——DSec 的容器不是"裸容器"。
用户要自己选后端。 论文 §2.1 特别强调 libdsec “is intentionally not a full semantic abstraction over all backends”,统一的是访问路径和操作模型,但选择后端的责任在调用方。这是个重要的架构立场:不要试图用一个抽象把不同运行时的语义差异抹平,那只会造出一个谁都跑不好的中间层。
3.3 virtio-gpu:Full VM 的图形能力关键
先划清适用范围:这一节讲的是图形能力。virtio-gpu 只翻译图形 API(OpenGL/Vulkan/显示命令),不承载 CUDA / PhysX / RT Core 这类计算栈。所以它适用于 Android 模拟器和传统 GUI 仿真器,不适用于主流具身仿真器(Isaac Lab、ManiSkill、MuJoCo Warp)——那些必须走裸金属容器或 VFIO 直通。完整的分层见 §3.5。
QEMU 拥有目前 VMM 里最完整的设备模型,其中 virtio-gpu 是让 VM 拿到图形能力的"标准件"——它在客户机里伪装成一块显卡和显示控制器,通过 VirtIO 协议半虚拟化,客户机无需独占物理 GPU 就能跑图形界面乃至 3D 渲染。QEMU 的 virtio-gpu 有三种工作模式:
| 模式 | 能力 | 原理 | 典型用途 |
|---|---|---|---|
| virtio-gpu 2D | 仅 2D | 显示命令半虚拟化,3D 由 Mesa LLVMpipe 在 CPU 上软渲 | 桌面终端、Computer-Use、无 3D 需求的 GUI |
VirGL(virtio-gpu-gl) |
OpenGL 加速 | 客户机 GL 调用被翻译成 Gallium 中间态传给宿主机 virglrenderer 在真实 GPU 上执行 | 桌面合成、轻量 3D、WebGL |
| Venus(QEMU 8.2+ 引入,后续版本完善) | Vulkan 加速 | Vulkan 命令序列化协议,客户机 Mesa venus 驱动直通宿主机 Vulkan 驱动 | 现代 3D 应用、Android 图形栈 |
此外还有 rutabaga_gfx 后端(来自 Google crosvm / Android Emulator),同时支持 OpenGL + Vulkan + Android 图形模拟,以及 vhost-user-gpu 变体——把图形栈挪到 QEMU 进程外的独立守护进程里跑,隔离性和抗干扰能力更好,对单节点几十上百个 VM 的高密度部署尤其关键。
值得补充的是,DSec 论文 §3.3 还提到它们在 full VM 内同时支持两类负载:图形 API 与宿主 OS 原生兼容的,以及渲染栈可以通过兼容层(如 DXVK)翻译成宿主原生 API 的。这对应到工程上就是:不要假设所有渲染负载都能走同一条路径,2D / OpenGL / Vulkan / 翻译层要分别验证。
补充:GUI 负载不一定都要走 Full VM。 这里有个成本差异极大的分岔,值得在选型时先问清楚——Agent 到底需不需要"看见"一个完整的桌面?
AIO Sandbox 的文档给出一组很直观的对比(同一项目内自测口径):
| 接管方式 | 带宽 | 延迟 | 说明 |
|---|---|---|---|
| VNC(完整桌面) | 10–50 Mbps | 50–200 ms | 通用,任何 GUI 程序都能接 |
| Canvas + CDP(浏览器专用) | 1–5 Mbps | 10–50 ms | 仅浏览器,但开销低一个数量级 |
对纯浏览器自动化(Web Agent)类负载,headless Chromium + CDP 的代价远低于"起一个带桌面的 VM 再 VNC 进去"。只有当负载确实是非浏览器的 GUI 程序(桌面软件、Android 模拟器、游戏)时,virtio-gpu + Full VM 才是必需的。
这也解释了为什么 AIO Sandbox 这类"all-in-one 镜像"能在单容器里同时提供 Browser / Terminal / File / VSCode / Jupyter / MCP——它用 supervisord 编排一整套服务,浏览器跑在 Xvnc + openbox 的 DISPLAY=:99.0 上,同时暴露 CDP(9222)、VNC(5900)、websockify(6080)等多条接管通道,让上层按需选路径。代价是镜像重、要求高:Chromium 需要宿主放开 seccomp(--security-opt seccomp=unconfined)并给 /dev/shm 至少 2–4 GiB。
一个必须注意的边界是:这类 all-in-one 镜像本身不提供隔离。它的文档写得很直白——daemon 账号决定文件与命令权限,工作目录不是约束边界,文件接口能触达整个宿主文件系统,没有 per-request 的 jail、chroot 或路径白名单,“把部署约束到某个目录或租户是沙盒(外层)的职责”。all-in-one 镜像解决的是"环境齐不齐",不是"跑得安不安全",两者不能互相替代。
3.4 Kata Containers:默认形态与 GPU 直通是两回事
团队近期在尝试用 Kata Containers 拉起仿真环境,遇到不少坑。但在讲坑之前,先要厘清一个概念混淆——因为"Kata + QEMU"和"Kata + VFIO"经常被当成同一件事,实际上它们差得很远。
⚠️ 本节涉及的 NVIDIA 参考实现细节(runtime class 名、版本号、feature gate、启动耗时)来自 NVIDIA 与 Kata 官方文档及社区实测,我们没有在自己的集群上完整复现过,落地前请对照官方文档逐条确认。
3.4.1 先拆三层:VMM / CPU 虚拟化 / 设备直通
这三层是独立的,混在一起看就会得出错误结论:
| 层 | 角色 | 必需性 | 用什么 |
|---|---|---|---|
| VMM / Hypervisor | 用户态进程,负责编排 VM 的设备模型和生命周期 | Kata 必须选一个 | QEMU / Cloud Hypervisor / Firecracker / Dragonball |
| CPU / 内存虚拟化 | 内核模块,加速 vCPU 和内存映射 | Kata 硬性要求 | KVM(VT-x / AMD-V) |
| 设备直通 | 把物理 PCIe 设备塞进 VM | 可选,不直通就不用 | VFIO + IOMMU |
QEMU 是 VMM,VFIO 是外设直通机制,两者完全独立。 QEMU 可以在三种模式下工作:
- QEMU + KVM——硬件加速,Kata 用这个;
- QEMU + TCG——纯软件指令翻译,跨架构仿真用(x86 host 跑 ARM guest),Kata 不支持;
- QEMU + KVM + VFIO——在上面基础上直通物理设备,只有需要 GPU/PCIe 直通时才开。
Kata 官方文档对此说得很明确:
“All supported hypervisors use KVM (Kernel-based Virtual Machine) as the underlying hardware virtualization interface on Linux.”
所以要说清楚两件容易混的事:
- “QEMU 不用 VFIO” → ✅ 完全可以,这就是 Kata 的默认形态,性能正常;
- “QEMU 不用 KVM(纯 TCG)” → ❌ Kata 不支持。Kata 的设计前提就是"有硬件虚拟化",它没提供 TCG 回退路径。这也意味着没有 VT-x/AMD-V 的机器(含大多数不暴露嵌套虚拟化的云 VM)根本跑不了 Kata。
3.4.2 Kata 的默认形态:全 virtio,零 VFIO
标准 Kata pod(跑可信代码、无 GPU 直通)的部署形态就是 “QEMU + KVM + 全 virtio,一根 VFIO 的线都不碰”:
| Pod 隔离需求 | Kata→QEMU 映射 | 底层实现 |
|---|---|---|
| Network (vNIC) | QEMU virtio-net | vhost-net / vhost-user-net |
| Storage (block) | QEMU virtio-blk/scsi | vhost-blk |
| Shared fs | QEMU virtio-fs | vhost-user(virtiofsd) |
| Agent 通信 | QEMU virtio-vsock | vhost-vsock |
| Rootfs | QEMU virtio-blk / 9pfs | — |
这条链路里没有任何一步需要 IOMMU 参与。原因很直接:IOMMU 的作用是把物理设备的 DMA 限制在某个 VM 的地址空间内,它是"直通物理设备"的前提;而 virtio 设备是 QEMU 进程自己模拟出来的"虚拟设备",不存在"直通",自然不需要 IOMMU。
这一点有个很实际的价值:§3.4.6 会讲到,GPU 直通会强制节点级卸载宿主机 NVIDIA 驱动、牺牲混部能力。而**"Kata + QEMU + 全 virtio"这个形态完全不碰宿主机 GPU**——节点可以同时保留宿主机 NVIDIA 驱动和普通 GPU 容器能力,不牺牲密度和灵活性。也就是说,"要 VM 级隔离"和"要 GPU 直通"是两个可以分开做的决定,不是捆绑的。
3.4.3 什么时候才必须上 VFIO + IOMMU
只有当要把物理 PCIe 设备交给 VM 时才需要。Kata 官方把设备分类写得很清楚:GPU/Passthrough → PCI Device → VFIO, IOMMU。
| 场景 | 需要 VFIO + IOMMU? | 原因 |
|---|---|---|
| 普通可信 pod,跑微服务 | ❌ | 全 virtio,没有物理设备直通 |
| Agent sandbox pod,跑不可信代码 | ❌ | VM 隔离由 KVM + QEMU 的虚拟机边界提供,不需要直通 |
| Agent sandbox pod + GPU 直通 | ✅ | GPU 是物理 PCIe 设备,要塞进 VM 必须走 VFIO |
| 仿真器 pod(Isaac Lab)+ GPU | ✅(如果走 Kata) | 同上 |
| 仿真器 pod(裸容器) | ❌ 不涉及 | 不走 Kata,直接用宿主驱动 |
VFIO + IOMMU 是"设备直通"的代价,不是"Kata + QEMU"的代价。 这条区分很重要——它意味着 §3.11.5 里说的"Agent Sandbox 需要 VM 隔离"并不自动意味着需要 VFIO。一个跑不可信 LLM 代码、不需要 GPU 的 agent 沙盒,用"Kata + QEMU + 全 virtio"就是最干净的形态。
两条 GPU 路线的对照(这是本节后面几节要展开的重点):
| 维度 | VFIO 直通 | virtio-gpu 半虚拟化 |
|---|---|---|
| guest 里看到的 | 真实的 NVIDIA GPU(跑 nvidia.ko 原生驱动) |
虚拟显卡(跑 virtio_gpu.ko) |
| host 侧 GPU 驱动 | 必须卸载(绑到 vfio-pci) |
正常安装 NVIDIA 驱动 |
| 性能 | 接近裸金属 | 有翻译层开销(Venus 较小、VirGL 较大) |
| CUDA / RT Core / PhysX | 完整支持 | 不支持——virtio-gpu 只走图形 API |
| 一张卡共享给多个 VM | ❌ 一卡绑一 VM | ✅ 一卡可共享多 VM |
| 硬件要求 | IOMMU + 裸金属 | 无特殊要求 |
Kata 解决的是"GPU 计算直通",不是 virtio-gpu 那种"一张物理卡共享给多个 VM 做图形渲染"。 如果目标只是轻量 GUI/3D 渲染,Kata 反而比裸 QEMU 更绕。
3.4.4 NVIDIA GPU 直通的完整链路
NVIDIA 在 Kata 仓库里维护了一份 GPU 直通参考实现(kata-qemu-nvidia-gpu,以及面向机密计算的 -tdx / -snp 两个变体),链路比想象中长:
1 | ① NVIDIA GPU Operator(宿主机侧) |
两个容易被忽略的关键设计:
其一,cold-plug 是唯一支持的模式。 参考栈明确配置 cold_plug_vfio=root-port, hot_plug_vfio=no-port——GPU 在 VM 启动那一刻就接进去,不是容器创建时热插。这意味着 Kata runtime 必须在 sandbox 创建阶段就去查 Kubelet 的 Pod Resources API 发现分配了哪些 GPU(这也是为什么 K8s 1.34 之前要显式开 KubeletPodResourcesGet feature gate)。
其二,guest rootfs 必须内置完整 NVIDIA 驱动。 宿主机上驱动已经被卸载了,所以 UVM 里要有自己的 nvidia.ko 内核模块。这是 Kata 与"容器里直接挂 /dev/nvidia0"模式最大的不同:驱动跟着 VM 走,不跟着 host 走。NVIDIA 为此专门做了两阶段 chroot 构建(先编内核模块并签名,再 chiseled 成 distro-less rootfs)。
3.4.5 硬前置条件与性能坑
前置条件清单(每一条都可能卡住落地):
| 类别 | 要求 | 说明 |
|---|---|---|
| 硬件 | 裸金属服务器 | 云 VM 一般不暴露 IOMMU,这条路基本是 .metal 实例专属 |
| BIOS | VT-d / AMD-Vi + ACS 开启 | AMD 平台部分 BIOS 把 ACS 藏在 AER 下,容易漏开 |
| 内核参数 | intel_iommu=on 或 amd_iommu=on;modprobe.blacklist=nouveau |
验证:ls /sys/kernel/iommu_groups 要能看到编号 |
| Host 驱动 | 必须卸载所有 NVIDIA 驱动 | nvidia / nvidia_uvm / nvidia_modeset 一个都不能留,否则 vfio-pci 绑不上 |
| VMM | 只能 QEMU | kata-clh / kata-fc 没有 NVIDIA GPU 参考实现 |
| Kata 版本 | kata-deploy ≥ 3.29.0 | 更老版本没有 NVIDIA RuntimeClass |
| GPU Operator | ≥ v26.3.0 | 需设 sandboxWorkloads.defaultWorkload=vm-passthrough |
| Kubernetes | ≥ 1.34(或显式开 KubeletPodResourcesGet) |
cold-plug 依赖 Pod Resources API |
| CRI | 只验证过 containerd | CRI-O 在 Kata 侧有支持,但 NVIDIA Operator 只验了 containerd |
| 内核 | 推荐 6.8+ 且启用 IOMMUFD backend | 见下面的性能坑 |
性能上的一个真实坑——社区在生产 K8s 集群上实测的数据值得记住:
- 多 GPU 场景启动极慢:8 卡 Pod 启动超过 30 分钟,且随 GPU 数量近乎指数增长;
- 根因:旧版内核(< 6.8)在做 IOMMU 映射时对每一页都走一次 page fault,而 VFIO 要映射 GB 级的 GPU BAR 空间,开销直接爆炸;
- 修复:升级到 kernel 6.8+ 并切到 IOMMUFD backend(
vfio-pci的新接口),启动时间降低一个数量级。
IOMMUFD 不是可选优化,是多 GPU 场景的必选。
3.4.6 宿主机上的 GPU 就"不存在"了
这是 VFIO 最容易被低估的后果,而且是 Linux 内核驱动模型决定的,不是 Kata 的设计选择。
一块 PCI 设备在同一时刻只能被一个内核驱动绑定。GPU 一旦被 vfio-pci 接管,宿主机的 nvidia.ko 就再也访问不到它:
1 | GPU 硬件(例如 NVIDIA A100 @ 0000:21:00.0) |
宿主机上具体失去什么:
| 能力 | 状态 | 原因 |
|---|---|---|
nvidia-smi |
❌ | 没有驱动可通信 |
| CUDA 调用 | ❌ | /dev/nvidia0、/dev/nvidiactl 不存在 |
| 图形渲染 | ❌ | 没有 DRM/KMS 节点 |
Device Plugin 的 nvidia.com/gpu |
❌ | plugin 依赖宿主机的 nvidia.ko |
| DCGM 监控 | ❌ | 同上 |
关键推论:这不是"某块 GPU"的事,而是"整个节点 GPU 资源模型"的事。 NVIDIA 文档写得很明确:
“For GPU passthrough workloads, all GPUs must be assigned to one Kata Container virtual machine. Configuring only some GPUs on a node for Kata Containers is not supported. vGPU is not supported.”
“A GPU worker node can run GPU workloads of a particular type… but not a combination of any of them.”
所以一个节点一旦标成 vm-passthrough,就只能跑 Kata GPU 负载:普通 GPU 容器跑不了(nvidia.com/gpu 资源不存在了),vGPU 也跑不了(需要宿主机的 nvidia.ko,而它已经被卸了)。
宿主机想继续用 GPU 的几条变通路线:
| 路线 | 做法 | 代价 |
|---|---|---|
| iGPU + dGPU 分工 | 集显负责显示,独显全绑 vfio-pci | 服务器 CPU 大多没有 iGPU,基本只适用于工作站 |
| 动态 unbind/rebind | VM 启动前切到 vfio-pci,关闭后切回 | 社区实践显示非常脆弱——切回后 nvidia-smi 能用但 PyTorch 拿不到设备、mmap BAR 失败等;K8s 下还会出现"资源显示可用但实际用不了"的脏状态。不推荐生产使用 |
| MIG-backed vGPU | 通过 SR-IOV 切分,宿主机保留 nvidia.ko |
这是另一条路——NVIDIA 明确写 “vGPU is not supported” for Kata passthrough |
| 节点池隔离 | kata-gpu 池(GPU 全绑 vfio-pci)+ 普通 GPU 池(保留驱动) | K8s 生产唯一干净的解法 |
我们的结论是第四条:把"节点上的 GPU 归谁"这个物理事实,直接映射成调度层的节点池维度,而不是指望运行时动态切换。这也正好呼应 §3.10 讲的"路由标签"——runtime-class 要作为一个 placement 维度。
再强调一次 3.4.2 的结论:这些代价只属于 GPU 直通,不属于 Kata 本身。如果你的 agent 沙盒不需要 GPU,"Kata + QEMU + 全 virtio"不产生任何节点级副作用。
3.4.7 两条硬限制
限制一:VFIO 不支持快照和热迁移。 GPU 的设备侧状态(显存内容、命令队列、驱动上下文)没有标准化的迁移协议,社区结论是"没有简单办法"。
这一条对我们是致命的:它和 §2.3 讲的 AgentENV"50ms 快照 + fork"哲学完全互斥。在 Kata VFIO 路线上,快照/fork 这套玩法一点都用不了——这是选型时必须先确认的需求。
(注:这条限制同样只属于 VFIO。纯 virtio 形态的 Kata 没有直通设备,快照能力不受此限制——但 Kata 本身对 VM 快照的支持也远不如 Firecracker 那套原生方案成熟。)
限制二:节点级 GPU 绑定,牺牲密度和灵活性。 见 3.4.6,节点池必须分开。
3.4.8 我们实践中的高频坑
前面几节讲的是方案层面的约束,下面这些是我们自己踩出来的:
- guest kernel 驱动缺失:Kata 默认的 guest kernel 需要包含对应 GPU/DRM 驱动;
- 内存默认 2GB 不够:需要手动调整 annotation;
- virtio-fs 共享根文件系统:在 I/O 密集时性能塌方;
- 镜像在 guest 内拉取超时:CoCo 场景默认
image_pull_timeout1200s 不够用; - vsock 通道超时:会掩盖真实的 PCI 初始化失败;
- 冷启动慢:镜像要先进 guest,启动时间随镜像体积增长——这直接引出了 §4 的镜像加速决策。
3.4.9 选型:三条决策线
把前面所有分析收敛成三个问题,按顺序问:
1 | Q1. 要 VM 级隔离吗?(跑的是不可信代码) |
三种形态的适用条件:
| 形态 | QEMU | KVM | VFIO | 适用场景 |
|---|---|---|---|---|
| Kata + QEMU,全 virtio,无直通 | ✅ | ✅ | ❌ | Agent sandbox(无 GPU)——要 VM 级隔离但不需要 GPU,这是最干净的形态 |
| Kata + QEMU + VFIO GPU 直通 | ✅ | ✅ | ✅ | Agent sandbox + GPU,且节点支持 IOMMU |
| 裸容器(runc + Toolkit) | 不涉及 | 不涉及 | 不涉及 | 可信仿真器(Isaac Lab 类),性能优先 |
✅ Kata VFIO 适合:多租户 GPU 仿真(需要 VM 级隔离);机密计算(仿真数据敏感,需要 SEV-SNP / TDX 的内存加密——-tdx / -snp 两个 RuntimeClass 就是为这个准备的);边缘侧跑不受信 agent 代码、需要把 GPU 的 DMA 边界严格限定在 VM 里。
❌ Kata VFIO 不适合:训练吞吐优先(具身 RL 跑成千上万 rollout,吞吐差 2% 就是几小时);需要快照/回滚(完全不支持);需要一张卡跑多个沙盒(一卡绑死一 VM);节点资源紧张、想混部轻量与强隔离负载。
⚖️ 需权衡:Isaac Lab 这类"必须有 RTX"的负载——技术上跑得通(RT Core 是硬件特性,直通不拦),但生产数据少、官方没背书,建议先在测试环境验证 RT 光追在直通下的表现。
一句话:Kata 的默认形态(QEMU + 全 virtio)和它的 GPU 直通形态(+ VFIO)是两个可以分开做的决定。前者代价很低,后者代价很高——“要 VM 隔离"不自动等于"要付出节点级代价”。
3.5 具身仿真的 GPU 路线:虚拟化不是默认选项
§1.5 我们修正过一个判断:具身仿真不是"把运行时推向 VM",而是"把谱系拉得更宽"。这一节把这个结论展开成可操作的选型。
先说结论:主流 GPU 仿真器应该直接跑在宿主机容器里,不走 virtio-gpu——因为 Isaac Lab、ManiSkill、MuJoCo Warp 这类负载吃的是 NVIDIA 专有计算栈,virtio-gpu 的任何模式都无法承载。virtio-gpu 只在两类特定负载下才是正解:Android 模拟器(rutabaga/gfxstream)和有 GUI 可视化需求的传统 3D 仿真(VirGL/Venus)。
3.5.1 主流仿真器的硬件约束
| 仿真器 | 硬件 / 驱动要求 | 渲染 API | 虚拟化路径可行性 |
|---|---|---|---|
| Isaac Lab / Isaac Sim | 必须有 RTX GPU(RT Cores);A100/H100 不支持 | RTX 光追 + Vulkan | ❌ 只能裸金属/直通——virtio-gpu 无法承载 RT Core |
| ManiSkill3 / SAPIEN | Linux + NVIDIA GPU,需要 Vulkan 驱动 | Vulkan(默认) | ⚠️ Venus 理论可行,但性能损失大;推荐裸金属容器 |
| MuJoCo(CPU) | 任意 CPU | OpenGL(GLX/EGL/OSMesa) | ⚠️ VirGL 可行,但为了兼容性套 VM 没必要 |
| MuJoCo Warp / MJX | NVIDIA GPU | CUDA + RT 批渲染 | ❌ CUDA 负载,virtio-gpu 不适用 |
| Habitat-Sim | NVIDIA GPU,EGL headless | OpenGL (EGL) | ⚠️ VirGL 可行,性能受限 |
关键在 NVIDIA 官方文档的那句话:“GPUs without RT Cores (A100, H100) are not supported”。具身仿真对 GPU 的要求不是"有个 GPU 就行",而是"必须带 RT Core 的工作站/游戏卡"——这和 virtio-gpu"半虚拟化一张共享 GPU"的假设在硬件层面就撞车了。
3.5.2 为什么主流仿真器不该套 VM + virtio-gpu
其一,设计假设不匹配。 virtio-gpu 的所有模式都建立在"一张 host 物理卡,被多个 VM 通过半虚拟化协议共享"之上。这对 Computer-Use、桌面 GUI 是合理的(负载轻、并发多、单 VM 占用小),但具身仿真的真实画像是:
- 单仿真进程往往吃掉一整张卡——Isaac Lab 单 GPU 可跑上千个并行环境,这不是"共享"是"独占";
- 计算栈走的是 NVIDIA 专有驱动——CUDA、PhysX、cuDNN、NCCL 这些根本不走图形栈,virtio-gpu 只翻译图形 API,帮不上忙;
- 对帧延迟敏感——RL 训练里 step-render-loop 是紧密耦合的,任何翻译层的上下文切换都会放大延迟。
其二,翻译层的性能损失不可接受。 落到具身仿真这个场景:
| 路径 | 性能损失 |
|---|---|
| GPU 直通(VFIO) | 接近裸金属 |
| vGPU 时间片 | 计算密集场景有明显损失,内存带宽密集场景更差 |
| NCCL 多机 all-reduce | vGPU 场景下延迟膨胀达数倍 |
⚠️ 上表的具体百分比(vGPU 计算/带宽损失、NCCL 膨胀倍数)来自社区讨论与厂商材料,不同硬件和拓扑下差异很大,我们没有独立复现,引用时请当作方向性参考。
RL 训练里 NCCL all-reduce 是每个 iteration 都要走的操作,它的延迟膨胀对整体训练吞吐是毁灭性的。这是"具身仿真不走虚拟化 GPU"最硬的理由。
其三,NVIDIA 官方对虚拟化的支持是分级的。 vGPU(GRID / time-slicing)主要面向 VDI 场景,Isaac Sim 官方文档并不推荐;GPU 直通支持但要开 IOMMU;云厂商文档里推荐的"能跑 Isaac"的 SKU,也只说明跑得起来,不代表性能不打折。
结论:具身仿真如果一定要进 VM,走 VFIO 直通,不走 virtio-gpu(见 §3.4)。
3.5.3 virtio-gpu 真正适用的两类具身负载
其一:Android 模拟器 / 移动端仿真(rutabaga_gfx + gfxstream)
1 | -device virtio-gpu-rutabaga,gfxstream-vulkan=on,cross-domain=on,... |
这是 Google 官方路线——rutabaga_gfx 直接来自 Android Emulator 的 crosvm 图形栈,GLES/Vulkan 调用被转发到宿主机。具身场景里的刚需是移动端 agent 训练(AndroidWorld、Mobile-Agent 这类 benchmark)需要在 Android 环境里 rollout。
注意:virtio-gpu 2D 完全不够——Android 的 UI 渲染(SurfaceFlinger、HWUI)吃 OpenGL/Vulkan,必须走 gfxstream。
其二:传统 3D 仿真带 GUI 可视化(VirGL 或 Venus)
如果仿真的渲染部分不强求 NVIDIA 专有栈,且确实需要跑在 VM 里(多租户隔离、边缘节点资源紧张),按 API 选模式:
| 负载特征 | 推荐模式 | 理由 |
|---|---|---|
| 传统 OpenGL 仿真器(Gazebo 老版本、Webots) | VirGL | GL 翻译链最成熟,兼容性好 |
| Vulkan 原生仿真(Gazebo Ignition 新版等) | Venus | Vulkan 直通,性能接近原生 |
| Wayland 桌面 passthrough | rutabaga + cross-domain | 直接转发 Wayland socket |
| 高密度部署(几十上百 VM) | vhost-user-gpu 变体 | 图形栈独立进程,避免互相干扰 |
但要清醒:这类场景的性能上限明显低于裸金属。如果仿真器的 FPS 本身就是瓶颈,套一层 virtio-gpu 后可能直接掉一个数量级。
3.5.4 推荐的分层选型
“按负载类型分层”,而不是"统一用一种模式":
1 | ┌──────────────────────────────────────────────────────────┐ |
对应到 §3.2 的四级运行时谱系:
| 运行时 | 对应 Tier |
|---|---|
| FnCall / Container | Tier 1——主流 GPU 仿真走这里 |
| MicroVM(Firecracker) | 不适合具身仿真(无 GPU 通路) |
| Full VM(QEMU + virtio-gpu) | Tier 3 和 Tier 4 |
| Full VM(QEMU + VFIO) | Tier 2 |
这个分层对调度层的直接要求:runtime-class、GPU 通路类型(裸金属 / virtio-gpu / VFIO)、仿真器类型 三个维度都要进 placement 标签,节点池也要按 Tier 分开——因为 Tier 2 的节点 GPU 被 vfio-pci 吃掉了,在物理上就不可能和 Tier 1 混部。
3.5.5 落到我们平台
回看 §1.5 讲的平台形态(Device Plugin 暴露机械臂/摄像头、边缘跑 embodied-runtime),GPU 仿真这条线建议这样落地:
- 主流 GPU 仿真(Isaac Lab、ManiSkill、MuJoCo Warp):继续走 Device Plugin 分配 GPU,不用 virtio-gpu,也不用 VM。Agent 的仿真 rollout 直接在容器里跑,通过 NVIDIA Container Toolkit 拿到 GPU——这条路与我们现有的"容器 + GPU Device Plugin"架构天然契合;
- Android 类仿真:单独走 Full VM 线(QEMU + virtio-gpu-rutabaga),按四级运行时调度;
- 强隔离 GPU 仿真:走 §3.4 的 Kata VFIO 路线,但先确认是否真的需要——代价是节点池隔离 + 放弃快照;
- 底座复用:Tier 1 的 GPU 容器不用虚拟化 GPU,但镜像分发加速(Nydus)、网络策略、快照管理这些底座照常复用——这正是 §4.1 讲的"镜像加速是所有运行时共用的公共底座"。
一句话收束:具身仿真里的 virtio-gpu 不是"默认该用",而是"按需才用"。虚拟化不是目的,是手段——隔离强度和性能之间要按负载特征显式选边,这正是 DSec 多运行时架构的核心思想。
3.6 我们自己的抽象层:从"沙盒即进程"到"沙盒即传输层"
在选型之外,我们内部已经有一套在跑的沙盒框架(mizar-syndrom),它的抽象方式值得单独讲,因为它和 DSec 的"调度层选后端"是同一个问题的两种解法。
它的核心抽象是两级接口:Engine(引擎)和 Sandbox(沙盒)。最关键的一行设计是——Sandbox 接口直接内嵌了 http.RoundTripper:
1 | type Sandbox interface { |
沙盒不是"远程对象",而是一个 HTTP 传输层。 所有引擎实现 RoundTrip,把请求转发到自己的后端(容器 IP、unix socket、内存 listener、隧道)。这个设计带来两个直接好处:上层(MCP 路由、工具调用、HTTP 代理)完全不需要知道沙盒是什么形态;接一个新沙盒形态只需要实现一个 RoundTrip。
另一个刻意的设计是:Engine 接口里没有 Exec。沙盒内的能力全部通过"工具(Tool)"暴露,走 HTTP /tools、/tools/:tool 以及 MCP 协议。这把"怎么执行"和"执行什么"解耦了。
已实现的引擎覆盖了一个完整的隔离谱系:
| 引擎 | 隔离级别 | 实现要点 | 默认容量 |
|---|---|---|---|
| Echo | 无隔离(纯内存) | 只注册一个原样返回的 Echo 工具,用于测试 | 256 |
| FileServer | 无隔离(共享宿主 FS) | 直接 MkdirAll(workdir),暴露 /files 与 /zip |
256 |
| Git | 无隔离(共享宿主 FS + git 二进制) | git archive / git bundle 端点 |
256 |
| Process | 进程级 | 起 sandbox-agent 子进程,通过 unix socket 通信 |
64 |
| Container | 强隔离 | docker / ctr 容器 + netns | 32 |
| Combination | 取决于被组合的后端 | 把多个沙盒在工具命名空间和 HTTP 路径两个维度合并 | 256 |
| ReverseProxy | 无(纯隧道) | 把外部通过 WebSocket 注册的客户端当作"沙盒" | 256 |
组合沙盒(CombinationSandbox) 是个有意思的设计:用 YAML 声明多个后端,通过 prefix、toolRename、hideTools、reroutes 把它们的工具和 HTTP 路径合并成"一个沙盒",对上层呈现单一 MCP Server。参数还支持用 Go text/template 渲染,例如 workdir: /tmp/test_workdir/{{ .id }}。配置每 60 秒热重载。
VM 能力也已经落地了,但不在独立的 VMSandbox 引擎里——README 把它列为"计划支持",实际实现是容器引擎的 runtime 后端(--runtime=aws.firecracker,走 firecracker-containerd)。这里有个值得注意的设计:一个 sandbox 容器 + N 个 sidecar 容器共处同一个 microVM,vCPU 和内存按所有容器配额累加(sumCpu / sumMemoryMB 向上取整),通过 aws.firecracker.vm.pod annotation 传给 runtime,用 aws.firecracker.vm.id 关联同 pod 的所有容器。网络则强制 --net-host,DNS 通过 annotation 注入。
3.7 会话粘性:三种语义,别搞混
Agent 沙盒和普通容器调度最大的行为差异,是同一个会话的后续请求必须回到同一个沙盒。我们内部实际上并存着三种"粘性"机制,语义完全不同:
| 机制 | 位置 | 语义 | 生命周期 |
|---|---|---|---|
| session → leaf 缓存 | Router | 请求头 Mcp-Session-Id 映射到具体 leaf,命中则固定 |
TTL 7200s,请求期间每分钟续期,空闲即过期 |
| session 引用计数 + 延迟销毁 | Manager | 最后一个会话释放后,等 expireSeconds 才真正销毁沙盒;期间新会话可取消销毁 |
expire_seconds < 0 表示永不过期 |
| thread → runtime 确定性哈希 | Scheduler | 以 Thread ID 为种子做确定性随机,同一 Thread 在同一 Snapshot 内恒映射到同一 Runtime | 绑定 Epoch 快照周期 |
第三种是最优雅的:用确定性哈希替代状态存储。调度器不记录"这个 thread 上次去了哪",而是让同一个 thread ID 在任何调度器实例上、对同一个集群快照,都算出同一个 runtime。这样多实例调度器之间不需要共享状态,扩容缩容也不影响粘性。
配合它的还有一条"线程级粘性短路":Schedule() 在任何 filter 之前先查 ThreadRuntime[processID+"/"+threadID],命中且 runtime 可用就直接返回,连过滤都省了。
3.8 K8s 原生路线:agent-sandbox 与 OpenSandbox
除了"自己写调度器",还有一条路线是把沙盒做成 K8s 的一等公民。这条路上目前有两个值得看的项目。
先说清定位:这一节讲的两个项目都是为跑 LLM 生成代码设计的 agent 沙盒。它们和具身仿真沙盒是两类负载、威胁模型相反——详见 §3.11.5。
kubernetes-sigs/agent-sandbox 提供了 Sandbox CRD,核心取舍是严格 1 Sandbox : 1 Pod(ownedPods > 1 直接报 MultiplePods 错误),Pod 名就等于 Sandbox 名。它和 StatefulSet 的差异很能说明"为什么不能用现成抽象凑合":
| 维度 | Sandbox | StatefulSet |
|---|---|---|
| 副本语义 | 无 replicas,1:1 | spec.replicas + 序号 |
| 暂停/恢复 | 一等 API:spec.operatingMode: Suspended,保留 Sandbox 对象和 PVC,只删 Pod |
无(scale 到 0 会按 retention policy 处理 PVC) |
| 存储模板 | volumeClaimTemplates 是不可变原子列表,CRD 层用 CEL 强制 |
可变更 |
| 失败处理 | failedPodPolicy:Retain(默认)/ Recreate(带 5s→5m 指数退避) |
podManagementPolicy |
| 过期 | 内建 lifecycle.shutdownTime + shutdownPolicy |
无 |
它的扩展模块 SandboxWarmPool 实现了预热池,工程细节很扎实:per-pool 令牌桶限速(--sandbox-warm-pool-max-refill-rate,桶容量 = 一秒的 create 量)、自适应慢启动批量创建(initialBatchSize=1,成功后翻倍)、以及一个 expectations gate(原子检查此前所有 create/delete 是否已被 informer cache 观察到,未满足就跳过并退还令牌)。补充策略上,totalReplicas gate 住 create、currentReplicas gate 住 delete,防止删除滞后期间池子膨胀超 spec。
它公开的吞吐数字是:burst warm adoption ~300 claims/s @ p90 ≤ 200ms;单池持续补充 ~70–85 sandboxes/s,瓶颈在 kube-scheduler 默认的 --kube-api-qps=50;300 成员的池从 0 到 300 Ready 约 6.4 秒。(版本锚点:agent-sandbox commit 10c3f70,2026-10-22,取自其 docs/performance-tuning.md。)
值得单独提的是它的多集群方案 KEP-1434(注意这是 agent-sandbox 项目自己 docs/keps/ 目录下的编号,不是 kubernetes/enhancements 仓库的 KEP):没有 hub apiserver、没有共享 etcd、没有跨集群网络、没有新 CRD,每个集群一个单副本 fleet-member Deployment,集群外跑一个无状态 fleetctl planner CLI,两者通过一个对象存储 bucket 协调。文档给出的实测规模是 106.9 万个并发 sandbox、跨六个集群、单条 fleetctl apply。单集群的天花板被算得很清楚:max-pods=256 + 8 vCPU 节点约每节点 236 个 sandbox pod,999 节点约 23.45 万个,“a single cluster therefore tops out in the low 200Ks under any tuning we have found”。
OpenSandbox 走的是另一条路——协议优先。它的架构文档明确列出六个面(client / protocol / lifecycle control plane / runtime backends / sandbox data plane / network and security plane),设计原则里有一条很关键:
Public behavior starts from OpenAPI contracts in
specs/. SDKs and clients should align to those contracts.
它的 K8s runtime 里没有自定义 Pod 调度器——kubernetes/internal/scheduler/ 实际是任务调度器,负责把 BatchSandbox 的 taskTemplate 派发到具体 Pod;真正的 Pod→Node 调度仍然交给 kube-scheduler。它自己做的是池内分配,两种算法:PackedSchedule(按顺序把 Pod 全给一个 sandbox,满足一个再下一个)和 SpreadSchedule(round-robin 注水式)。
池和 BatchSandbox 的匹配走一套可插拔的谓词 + 打分框架:
1 | Predicate: []string{"capacity", "image", "resource", "nodeselector"}, |
注意打分策略是 LeastAllocated(最少分配优先),也就是反亲和/打散,而不是 bin-packing 紧凑。这和 DSec 的取舍正好相反——DSec §7 用的是 power-of-k-choices 选"最不忙"的节点,本质也是打散。理由在 DSec 论文里写得很清楚:极端突发下要"spreading incremental load evenly with elastic rather than pinned resource reservations"。
OpenSandbox 最有说服力的一组数字是批量投递的复杂度对比:
| 测试场景 | 总耗时(秒) |
|---|---|
| SIG Agent-Sandbox (concurrency=1) | 76.35 |
| SIG Agent-Sandbox (concurrency=10) | 23.17 |
| SIG Agent-Sandbox (concurrency=50) | 33.85 |
| BatchSandbox | 0.92 |
文档的结论是:批量为 N 个沙盒投递的时间复杂度,SIG Agent-Sandbox 是 O(N),BatchSandbox 是 O(1)。原因是前者每个沙盒一个 CR,后者一个 CR 带多副本。这是"池化 + 单 CR 多副本"相对"每沙盒一个 CR"最直接的优势论证。
有意思的是两个项目互相引用:OpenSandbox 把 agent-sandbox 当作一等 runtime backend(workload_provider = "agent-sandbox"),而 agent-sandbox 的 KEP-539.2 在讨论沙盒内接口标准化时,明确把 OpenSandbox 的 REST 方案和 E2B 的 gRPC envd 方案列为两个对标范式,提出的 sandboxd 是混合 gRPC/REST——Process Service 走 :9090 gRPC,Filesystem Service 走 :8080 REST。
顺带记一个真实的版本漂移:OpenSandbox 的 agent-sandbox provider 里写的 group/version 是
agents.x-k8s.io/v1alpha1,而 agent-sandbox 的 roadmap 已经写明 “v1beta1 is the exclusive served API version across all CRDs”,v1alpha1 类型和 conversion webhook 已全部移除。跨项目集成时这类漂移很容易踩。
3.9 商业沙盒平台:另一个参照系
前面比较的都是"自建"路线(DSec、AgentENV、我们内部、以及两个 K8s 原生项目)。还有一类参照系是面向开发者的商业沙盒平台——它们不服务 RL 训练,但在"沙盒即服务"这件事上打磨得更久,有几处设计值得借鉴。
| 平台 | 底层隔离 | 对外形态 | 与本文的关联 |
|---|---|---|---|
| E2B | Firecracker microVM | HTTP API + Python/JS SDK,开源 | AgentENV 的 API 就是兼容 E2B 的——E2B_API_URL 指向自建服务即可无缝替换。这是一个很聪明的生态位选择:把"迁移成本"降到接近零 |
| Modal | gVisor | Python 装饰器(@app.function) |
是"gVisor 能扛生产负载"最有力的存在性证明之一;也说明 §5.9 里 gVisor 的兼容性边界在实践中是可接受的 |
| Fly.io Machines | Firecracker microVM | REST API,机器即资源 | 把 microVM 的启停做到接近进程的粒度,是"快照/恢复"体验的另一种产品化 |
⚠️ 上表中 Modal 的隔离实现(gVisor)与 Fly Machines 的底层(Firecracker)来自公开资料,我们没有逐一核实到官方文档级证据,引用时请自行确认。
从它们身上能学到的三件事:
- API 兼容性是战略资产。 AgentENV 兼容 E2B,意味着用户从 E2B 迁过来几乎零成本;反过来,自建平台如果 API 自成一派,就会把用户锁死在迁移成本里。这也是为什么 agent-sandbox 的 KEP-539.2 要专门讨论"沙盒内接口标准化"——接口形态的选择,长期看比性能数字更重要。
- 沙盒的产品形态可以很不同。 Modal 是"函数装饰器"、Fly 是"机器 API"、E2B 是"沙盒 CRUD"——同样跑在 microVM 上,抽象层级差了好几层。我们做内部平台时容易默认"CRUD 沙盒"是唯一形态,其实未必。
- 它们不解决我们的核心问题。 这三个平台的调度模型都假设"请求是相对平滑的";而我们的场景是"一个任务瞬间要 32K 个沙盒"(§4.1)。突发规模这一点上,DSec 才是同量级的参照。
3.10 一个关键观察:多运行时不是"多写几个 if"
把上面几条路线放在一起看,多运行时架构的难点其实不在"支持几种后端",而在三件事:
- 统一的操作面。DSec 用 aether(每沙盒代理,容器走 Unix domain socket、VM 走 vsock)+ chronus(shell 会话抽象,提供命令执行、文件系统、HTTP、流式 I/O)把容器和 VM 的操作统一起来。FnCall 则完全不经过 aether/chronus,走独立路径直接在执行容器里跑。我们内部用
http.RoundTripper做同一件事。 - 调度层的路由标签。把"是否需要渲染"“是否需要 GPU 计算”"隔离强度"作为 placement 的标签维度,让调度器按负载特征路由到不同的节点池。
- 公共底座的复用。镜像分发、网络策略、可观测性这些层,必须对所有后端同时生效,否则每加一种运行时就要重做一遍。
3.11 K8s 侧的调度与 Sandbox 现状:两个被混为一谈的世界
§3.5 讲了具身仿真的 GPU 路线,§3.8 讲了 K8s 原生的 agent 沙盒。这一节补上 K8s 生态的现状全景,并指出一个最容易混淆的地方。
⚠️ 本节涉及的第三方项目状态(RLinf 的论文出处、KAI/Volcano/Kueue 的成熟度、K8s 1.34 的 DRA 状态、NVIDIA 各类参考实现)来自公开资料与社区讨论,我们没有逐一验证到官方 release note 级别。选型前请以官方文档为准。
3.11.1 调度侧:三层叠加的格局
| 方案 | 定位 | 在 K8s 里的形态 | 成熟度 |
|---|---|---|---|
| RLinf(清华大学) | 专为 RL 训练设计的执行系统 | 基于 Ray 做集群管理和 worker 启动,但绕开 Ray 自己做设备分配 | 学术前沿 |
| Ray on K8s(KubeRay) | 通用 AI 集群编排 | RayCluster CRD + GPU Operator | 生产主流 |
| KAI Scheduler(NVIDIA 开源) | K8s 原生 AI 调度器 | Scheduler Plugin,层级队列 + gang scheduling + 基于 ResourceClaims 的 GPU 共享 | CNCF Sandbox |
| Volcano | 批调度系统 | Scheduler + Controllers + admission webhook,按 job 整体调度 | CNCF Graduated |
| Kueue + DRA | 原生 job 队列 + 资源分配 | Admission controller + ResourceClaim | Kueue 已成熟;DRA 在 K8s 1.34 达到 GA |
| NVIDIA Run:ai | 企业级 GPU 编排平台 | 全套 CRD + 调度器 + Fractional GPU | 商业 GA |
Isaac Lab 官方的 K8s 路线其实很朴素:主推的是 Singularity + SLURM/PBS(ETH Zurich Euler、IIT Genoa Franklin 等超算已验证),K8s 不是首选;K8s 侧的参考实现主要是 CloudXR 遥操作部署文档和 headless 训练的容器镜像。所以在 K8s 里跑 Isaac Lab 的常见做法是:Docker 镜像 + GPU Operator + Job/CronJob 提交,gang scheduling 靠 Kueue/Volcano 补——没有专门的具身调度器。
3.11.2 RLinf 的分工:为什么绕开 Ray 的设备分配
RLinf 论文里有一段讲得很清楚,值得直接引:
“We implement RLinf using Ray for cluster management and worker process launch on remote nodes. RLinf does not rely on Ray for device allocation to workers, because Ray only supports rigid packed-style or spread-style resource allocation, which are not flexible enough for today’s complex and dynamic RL workflows. Instead, RLinf offers a flexible device allocation strategy, which allows each worker process to be allocated with any device or devices of any node across the cluster, by simply specifying the target devices’ global IDs.”
这个设计直接针对具身 RL 的调度痛点——三类 worker 的资源画像完全不同(下表的利用率数字来自 RLinf 论文,我们未独立复现):
| Worker 类型 | GPU 利用率 | 瓶颈 |
|---|---|---|
| Simulator | < 24% | 吃显存,不吃算力 |
| Generation | > 70% | 吃算力 |
| Training | 吃满 | 但时间短 |
只有 GPU 利用率这一项差异,就决定了 packed/spread 两种粗粒度策略不可能同时摆好这三类 worker——把 simulator 和 training 按同一套 packed 规则摆,要么显存不够,要么算力闲置。
RLinf 的方案是让开发者只写逻辑流,系统自动在 collocated / disaggregated / hybrid 三种模式间选择。论文给出的实测是:ManiSkill 上 hybrid 模式相比 Temporal / Spatial 两种基线,吞吐分别高 52.2–69.1% 和 60.7–87.2%(同为论文数据,未独立复现)。
还有一个容易被忽略的细节:RLinf 把"机械臂等非加速器硬件"也抽象成可调度设备,和 GPU 走同一套调度 API——这正好对应具身场景"GPU + 真机混合调度"的需求,和我们在 §1.5 讲的 Device Plugin 思路是同一个方向。
3.11.3 DRA:K8s 1.34 的关键变化
K8s 1.34 把 Dynamic Resource Allocation(DRA) 推到 GA,这是调度侧最值得关注的演进。它解决了传统 Device Plugin 的三个限制:
| 传统 Device Plugin 的限制 | DRA 的解法 |
|---|---|
| all-or-nothing——要么全给要么不给,无法表达"我要半个" | 通过 DeviceRequest 声明细粒度需求 |
| 无共享——设备不能被多个 Pod 共享 | ResourceClaim 支持共享语义 |
| 元数据有限——device plugin 只能上报计数和少量属性 | ResourceSlice 可以携带丰富的设备属性,用 CEL 表达式做筛选 |
四个新 API 是 ResourceClaim / DeviceClass / ResourceSlice / DeviceRequest。
对具身仿真来说,DRA 的意义是:MIG 切分、拓扑感知放置、多设备组合请求(GPU + NVLink 拓扑 + InfiniBand)终于成为 K8s 原生能力,不用再靠各家调度器的插件去 hack。我们 §3.4.6 讲的"节点池隔离"和 §3.5 讲的"Tier 分层",在 DRA 之后可以用声明式的方式表达,而不是靠节点标签硬绑。
3.11.4 Sandbox 侧:裸容器是绝对主流
具身仿真的沙盒几乎清一色是 runc + NVIDIA Container Toolkit:
- NVIDIA Container Toolkit 负责 GPU 挂载(
/dev/nvidia*、驱动库注入); - GPU Operator 的 DaemonSet 在每个 GPU 节点装 driver、toolkit、device plugin、DCGM;
- Isaac Lab 的官方镜像就按这个模式跑,用环境变量控制渲染模式与 headless。
没有 gVisor、没有 Kata、没有 Firecracker。
具身场景里用的"轻隔离"是 K8s 原生那一套:cgroups 管配额、NetworkPolicy / Cilium 管网络边界、Pod Security Standards(restricted profile)管 seccomp 与权限、PVC/PV 管数据集和 checkpoint。不引入任何 VM。
3.11.5 关键反差:Agent Sandbox ≠ 仿真 Sandbox
这是本节最值得记的一点。K8s 生态里"Agent Sandbox"这个概念和具身仿真几乎是两个世界:
| Agent Sandbox | 仿真 Sandbox | |
|---|---|---|
| 典型代表 | kubernetes-sigs/agent-sandbox、OpenSandbox、Kata 集成 | Isaac Lab / ManiSkill 的裸容器 |
| 跑什么 | LLM 生成的短代码 | 你自己写的重仿真器 |
| 威胁模型 | 代码不可信 → 必须 VM 隔离 | 代码可信,agent 只产出动作数据 → 隔离需求弱 |
| 优化目标 | 快启动、强隔离、快速销毁 | GPU 吃满、多卡通信、长生命周期 |
| 隔离选型 | Kata / gVisor / microVM | 裸容器直通 GPU |
两者在"沙盒"字面上有重叠,但技术选型完全相反——前者要 VM 隔离,后者要裸容器直通。
这里有个边界要说清楚:说"仿真沙盒不需要强隔离",前提是 agent 只产出动作、不产出代码。一旦场景变成下面任何一种,前提就失效了:
- agent 生成奖励函数或环境配置(LLM 写 reward code);
- DSec 式的环境自演化(§7 讲的
pack_diff——agent 自己构建环境并打包复用); - embodied agent 的代码评测(让 LLM 写控制代码在仿真器里跑)。
判断标准很简单:agent 的产出是"数据"还是"代码"。 是数据 → 裸容器;是代码 → 必须上 VM 边界。这也解释了为什么 DSec 要把容器套在 QEMU VM 里(§5.1)——它的沙盒里跑的正是模型生成的代码。
3.11.6 对我们的映射
结合我们的平台形态(云上 GPU 训练集群 + 边缘机器人/传感器/摄像头):
- 云端仿真训练:KAI Scheduler 或 Kueue 做 gang scheduling + quota 管理,DRA(1.34+)声明 GPU/MIG 需求,Isaac Lab 官方镜像 + 裸容器跑;
- RLinf 可评估作为上层执行系统:它基于 Ray 但自己做设备分配,能混部 training / inference / simulation 三类 workload,对齐我们"一套 infra 覆盖多类负载"的定位;
- 边缘侧:K3s + Device Plugin(我们已有
rlinf.io/device-franka)+embodied-runtime。边缘节点通常没有条件跑 Kata VM,这条线保持轻量; - Agent 沙盒(非仿真类):Agent Sandbox CRD + Kata 作为独立的
RuntimeClass,和仿真负载的节点池分开(§3.4.6 的结论在这里同样适用); - 暂时不做:在 K8s 里用 VM 跑 Isaac Lab 这类仿真器——当前生态没有这条路径的参考实现,性价比明显不如裸容器。
一句话收束:K8s 背景下,具身仿真的调度侧正在从"Ray + GPU Operator"向"专用调度器(KAI/RLinf)+ DRA"演进,而 Sandbox 侧从来没离开过"裸容器 + NVIDIA Toolkit"这个基本盘。这两个事实合起来,解释了为什么具身场景的工程难点集中在调度层而不是隔离层。
3.12 我们已经落地的形态:E2B SDK + 四模板网关
前面 11 节讲的都是"业界怎么做"和"我们建议怎么做"。这一节说清楚我们实际做成了什么——它正好把前面的分析收敛成一套可运行的 API。
3.12.1 四个模板
| 模板 | 后端 | 隔离边界 | 启动速度 | 适用场景 |
|---|---|---|---|---|
cpu-*(如 cpu-ubuntu) |
AgentENV + Firecracker | microVM 独立内核 | 最快(快照恢复) | 通用命令与工具;需要"暂停后进程还在"时选它 |
gpu-light-*(如 gpu-light-pytorch) |
gVisor + GPU | syscall 拦截 + 宿主内核 | 百毫秒级 | CUDA / Ray 等轻量算子——不是渲染沙盒 |
vm-*(如 vm-ubuntu) |
Kata(独立内核) | VM 独立内核 | 秒级 | 需要特定 Linux 行为、又不需要 GPU |
render-*(如 render-isaac) |
普通 NVIDIA 容器 | 仅 namespace / cgroup | 数十秒 | Isaac 等图形栈仿真 |
3.12.2 架构
graph TB
T["Agent / 评测任务"] --> SDK["E2B Python SDK"]
SDK --> GW["xr-sandbox 网关"]
GW -->|"cpu-*"| A["AgentENV + Firecracker"]
GW -->|"gpu-light-*"| B["gVisor + GPU"]
GW -->|"vm-*"| C["Kata 独立内核"]
GW -->|"render-*"| D["普通 NVIDIA 容器"]
3.12.3 三个关键设计
其一,用 E2B SDK 做统一接入层。 这是 §3.8 讲的"API 兼容性是战略资产"的直接落地——AgentENV 的 API 本来就兼容 E2B(§2.1),所以选 E2B SDK 作为统一入口,等于**同时拿到了"迁移成本接近零"和"后端可替换"**两件事。调用方只写一次 sandbox.run() / pause() / resume(),换后端不改代码。
模板前缀(cpu-* / gpu-light-* / vm-* / render-*)承担了 RuntimeClass 的语义——网关按前缀路由到对应后端,和 §3.10 讲的"路由标签"是同一个思路,只是把标签编码进了模板名。
其二,刻意排除了"强隔离 + GPU 仿真"这个组合。 对照 §3.5 的 Tier 分层会发现,这四个模板正好覆盖了 Tier 1 / 3 / 4,唯独没有"Tier 2 + 仿真"——因为前面已经论证过,这个组合在当前技术条件下要么不存在(Firecracker 没有 GPU 通路),要么代价过高(Kata VFIO 的节点级驱动卸载 + 放弃快照)。这是一个主动的架构决策,不是遗漏。
其三,render-* 明确降级为"可运行环境"而非"沙盒"。 响应里带 sandbox: false,就是在 API 层直说"这个不承诺 VM 级隔离"。这是四个模板里最需要解释的一个:
- 为什么被迫这么做:Isaac 这类负载要 OpenGL/EGL headless + RT Core + 完整 CUDA 栈。Firecracker 没有 GPU 通路起不来;gVisor 的 GPU 支持只覆盖轻量 CUDA 算子(§5.9 的 nvproxy 边界),撑不住 Isaac;Kata + VFIO 技术上可行但代价前面算过。裸容器 + NVIDIA Container Toolkit 是唯一现实的路径;
- 信任边界仍然清晰:不可信代码走
cpu-*/vm-*,仿真任务由 agent 调用render-*,而render-*里跑的是官方 Isaac 镜像、不是 agent 生成的代码。agent 生成的仿真脚本是作为输入数据传给仿真器的,不是作为可执行代码进沙盒——这正是 §3.11.5 讲的"agent 产出是数据还是代码"那条判断标准的具体应用。
其四,能力边界在 API 层显式暴露。 cpu-* 不支持 volume_mounts(Firecracker 的块设备直通有限制),可写用 /workspace;render-* 有 sandbox: false 字段。把每种后端的限制写在 API 契约里,而不是留给调用方踩坑——这比事后写文档有效得多。
3.12.4 与本文分析的对齐
回头看,这套实现基本是本文前面所有分析的收敛:
| 本文的结论 | 实现里的对应 |
|---|---|
| §2.3 AgentENV 的快照/fork 是 RL rollout 的关键能力 | cpu-* 用 AgentENV + Firecracker,启动最快 |
| §3.5 具身仿真不该走 virtio-gpu,主流仿真器要裸容器 | render-* 是普通 NVIDIA 容器 |
| §3.4 Kata 的默认形态(全 virtio、零 VFIO)代价低 | vm-* 用 Kata 独立内核,不带 GPU 直通 |
| §5.9 gVisor 的 GPU 只覆盖 CUDA 计算 | gpu-light-* 定位为"轻量算子",明确不是渲染 |
| §3.10 多运行时要有统一操作面 + 路由标签 | E2B SDK + 模板前缀路由 |
| §3.11.5 仿真沙盒与 agent 沙盒威胁模型相反 | render-* 标 sandbox: false,与前三档明确区分 |
还缺什么(对照 §3.5 的 Tier 分层):Tier 2(强隔离 GPU 仿真,Kata + VFIO)没有对应模板——这是有意的,因为它的代价(节点池隔离 + 放弃快照)对当前阶段不划算。如果将来出现"多租户 + GPU 仿真 + 合规要求"三者叠加的需求,再补这一档。
四、镜像分发加速:为什么优先级最高
4.1 第一瓶颈
无论上层是 Firecracker、Kata 还是 QEMU,镜像拉取都是沙盒冷启动的第一瓶颈。
DSec 论文 §8.2 的实验很能说明问题:在 10 节点集群上发起 8,192 个容器的突发创建,按需 EROFS 加载约 35 分钟完成,与"全部镜像已本地缓存"的基线持平;而 eager Docker 拉取需要 60 分钟以上,慢了 1.71 倍。磁盘写入方面,eager 拉取每节点累计超过 1,600 GB,按需加载只有约 700 GB,减少约 57%,接近全本地缓存的 600 GB。
论文 §4.4 还点出一个更隐蔽的成本:因为超卖让集群接近满负载,拉镜像消耗的 CPU 和 I/O 会直接挤占正在运行的沙盒的资源——这不只是"慢",而是会干扰已有负载。
这也是我们把镜像加速排在第一优先级的原因:它是所有运行时共用的公共底座。先做它,后面 Kata 的坑再多也能受益。
端到端链路长这样——四段各有各的优化点,别只盯着"拉得快":
graph LR
subgraph B1["① 构建期"]
OCI["OCI 镜像"] -->|"Nydusify / Buildkit"| RAFS["RAFS v6 格式
chunk 化 + 元数据分离"]
end
subgraph B2["② 分发层"]
REG["Registry / Harbor"]
P2P["Dragonfly P2P"]
REG --> P2P
end
subgraph B3["③ 节点侧"]
META["元数据
下载到本地磁盘"]
BLK["数据块
按需拉取"]
end
subgraph B4["④ 沙盒"]
MNT["EROFS 挂载
只读层共享"]
APP["容器 / VM 根文件系统"]
MNT --> APP
end
RAFS --> REG
P2P --> META
P2P --> BLK
META --> MNT
BLK --> MNT
| 环节 | 优化点 | 对应小节 |
|---|---|---|
| ① 构建期 | 转换格式 + 记录热点文件 + 签名 | §4.3 / §4.9 |
| ② 分发层 | P2P 避免 registry 单点 | §4.5 |
| ③ 节点侧 | 元数据本地化、数据块按需、二级缓存 | §4.3 / §4.6 |
| ④ 沙盒 | 只读层共享、组合式层避免重建 | §4.7 |
注意 ① 的位置:格式转换放在构建期而不是运行期,是 §4.6 和 §4.9 反复强调的一个判断——能在构建期做的事,不要留到运行期。
4.2 四种懒加载方案对比
业界主流的容器镜像懒加载方案有四种,关键差异在对 OCI 镜像的转换要求和性能表现上:
| 方案 | 转换需求 | 1GB 镜像冷启动 | 带宽节省 | 优势 | 劣势 |
|---|---|---|---|---|---|
| eStargz | 需转换 | 13.5s | 60–80% | containerd 社区项目(stargz-snapshotter),FUSE 无需改内核 | 性能中等,社区活跃度下降 |
| SOCI | 无需转换(仅生成索引 artifact) | 11.3s | 50–70% | 对现有 OCI 仓库零侵入 | 性能一般,AWS 生态 |
| Nydus | 需转换 | 8.2s | 70–90% | 性能最好,chunk 去重,Kata 官方集成 | 需转格式,依赖 nydusd |
| OverlayBD | 需转换 | 9.7s | 65–85% | 块设备级,高 IOPS 场景强 | 对分布式存储依赖更深 |
⚠️ 这张表的数字来源需要说明:冷启动数据来自公开的第三方横向基准,其中 Nydus 的对比数据很可能出自 Nydus 官方(阿里/蚂蚁)的评测,存在立场偏差——它同时在卖 Nydus 这个方案。不同硬件、不同镜像构成下结果会有明显差异,请当作量级参考而不是选型依据。
稳定的只是方向性结论:内核态方案优于用户态 FUSE、块设备方案在高 IOPS 场景占优。真正要做选型,必须用自己环境的镜像集自测。
SOCI 的差异化在于它不修改镜像 digest,只在 OCI Referrer artifact 里存一个"文件名 → 字节范围"的索引,对现有构建流水线零侵入。代价是性能上不如需要转换格式的方案。
4.3 Nydus 的核心技术:RAFS v6 + EROFS over FSCache
Nydus 是 CNCF Dragonfly 的子项目,蚂蚁/阿里主导。它的核心思路是把 OCI 镜像转成 RAFS(Registry Acceleration File System)格式——chunk 级内容寻址 + 元数据/数据分离 + 按需加载(lazy pulling)。官方给出的量化效果是:353MB 的 Node.js 镜像启动从 126s 降到 15s,网络传输量从 353MB 降到 28MB。
RAFS v6 是 Nydus 的第二代格式,直接对接 Linux 内核的 EROFS 文件系统(EROFS 的按需加载能力——on-demand read 与 cachefiles 按需模式——自 Linux 5.19 起合入主线,后续版本持续完善),实现了内核态按需加载。
在此之前,几乎所有懒加载方案都在用户态(FUSE daemon)——频繁的内核/用户态上下文切换和内存拷贝是性能瓶颈,尤其是在镜像已全部本地缓存的情况下,文件访问仍然要切到用户态。
EROFS over FSCache 的解法是把"镜像数据的缓存管理"和"缓存未命中时的网络回源"解耦:缓存管理完全下沉到内核态,避免本地命中时的上下文切换;只有缓存未命中时才通知用户态 nydusd 进程通过网络拉取。
⚠️ 技术演进提示:这条路线的内核侧支持正在发生变化。
有资料指出,
EROFS over FSCache的按需读特性已在较新的内核中被标记为 deprecated,社区方向转向fanotify的 pre-content hooks——后者允许用户态容器项目接入自己的缓存逻辑,比 fscache 那套"内核管缓存"的模型更灵活,预计会在未来几个内核版本内移除旧路径。我们没能核实这个弃用声明的具体内核版本(不同来源说法不一),所以这条要作为选型前的必查项。它之所以重要,是因为 §4.3 的整个论证建立在"EROFS 内核态按需加载"之上:
- 如果弃用属实,Nydus 本身仍然可用——RAFS 格式、chunk 级去重、P2P 分发这些都不依赖 fscache;受影响的只是"缓存管理下沉到内核"这一层的实现路径,Nydus 已经在跟进 file-backed mount + fanotify 的新方案;
- 但选型时的评估口径要改:不要假设"EROFS over FSCache 是一个长期稳定的内核接口",而应该问"Nydus 在当前内核版本上的默认路径是什么、迁移成本多大"。
这一条不影响 §4.1 的优先级判断(镜像加速仍是第一优先级),也不影响"组合式环境层"(§4.7)的价值——但如果你是照着我们这份文档做技术选型,请务必先去内核文档和 Nydus release note 核实这一条。
RAFS v6 还带来一层安全红利:传统 OCI 镜像解压到本地后数据就不再可校验,而 Nydus 不解压,每次数据访问都对每个数据块计算 SHA-256,检测篡改后可以从远端重新拉取,运行时具备端到端完整性校验能力。
4.4 Kata Containers 官方集成
Nydus 是 Kata 官方钦定的镜像加速方案,CI 里有专门的 Nydus Integration 测试。Kata 提供两种模式:
- 独立模式(virtio-fs-nydus):nydusd 作为独立进程跑在宿主机,通过 virtio-fs 把镜像挂给 QEMU/Cloud Hypervisor guest,guest 内组装 overlayfs;
- 内置模式(inline-virtio-fs / Dragonball):nydusd 内嵌进 VMM 进程,开销更低、性能更好。
Kata 的设计文档里有对应的性能对比,nydus 镜像的冷启动耗时几乎不随镜像体积增长——这对动辄几 GB 的仿真环境镜像是质变。
值得注意的是,Nydus 与 Kata 的集成方式不止一种,社区 issue 里列出了 enable_nydus_overlayfs + virtio-fs-nydus、enable_kata_volume + proxy 模式等多种组合,配置不当会有 ENOENT 等问题。
4.5 一套技术栈覆盖多条运行时线
Nydus 的兼容性矩阵相当宽:
- runc / containerd(nydus-snapshotter 是 containerd 非 core snapshotter)✅
- CRI-O / Podman ✅
- Kata Containers ✅
- EROFS 内核态直挂 ✅
- 机密容器 ✅
也就是说做一次镜像格式转换,Container 运行时和 Kata 沙盒两条线同时受益,不用像 OverlayBD 那样在 Kata 场景下还要单独搞一份。这是我们从 OCI 镜像切换到 Nydus 的直接原因。
4.6 Nydus vs OverlayBD:两条路线的取舍
需要承认的一个架构决策是:AgentENV 仓库用的是 OverlayBD/ublk 路线(块设备级懒加载),和 Nydus 是两条并行的技术路线(文件系统级 vs 块设备级)。如果走 Nydus,意味着镜像层要和 AgentENV 的存储栈解耦。
有意思的是,DSec 其实两条都用,而且是按后端分的。论文 §5.3 写得很清楚:
- 容器:EROFS(多设备模式,元数据与数据分离),元数据下载到 worker 本地磁盘、文件数据留在 3FS;容器创建时由改造过的 dockerd 动态插入 EROFS 层到 overlayfs 的 lowerdir 顶部(论文 §7 说明这个改动"只需 30 行 Go 代码");
- microVM:只读的基础镜像和 toolkit 层仍用 EROFS,但可写的 ext4 盘走 OverlayBD(包括一个直接挂在 Docker data root 上的独立盘,用于 Docker-in-microVM 负载)。
论文给出的理由是兼容性:Docker 的 overlay2 driver 不能用 overlayfs 支撑的 data directory;而把宿主文件系统通过 virtio-fs 导出给 guest 的方案,Firecracker 后端不支持这个接口。
而且两条路径对 3FS 的小 I/O 处理方式不同:OverlayBD 走 ublk 暴露块设备,ext4 元数据嵌在块镜像里,所以元数据读可能触发远端 I/O——DSec 的对策是按 256 KiB 分块拉取并存入二级本地文件系统缓存,“即使某个 chunk 从 page cache 被淘汰,它仍留在本地缓存里,不需要再从 3FS 拉一次”。
一个容易写错的点:AgentENV 的镜像按需加载有两条路径,只有一条是真正的零下载。
我们在评估时一度以为"AgentENV 的镜像完全按需拉块",实际读代码发现要分两种情况:
| 路径 | 触发条件 | 行为 |
|---|---|---|
| OverlayBD-native 镜像 | manifest 的 mediaType 表明是 overlaybd/zfile 格式 | 不下载任何 blob,直接返回 ResolvedImage::Remote;运行时由 registryfs_v2 用 HTTP range 请求按需拉块 |
| 标准 OCI tar 层 | 普通 OCI 镜像 | 半 lazy:先只取 manifest 做分类,把层 digest 对着 OCI→commit 索引查;全部命中则一个 blob 都不下载;有缺失才触发后台转换 |
标准 OCI 路径的关键在于转换产物是 content-addressed 的——转换结果落进 commits/<sha256>/overlaybd.commit 缓存,可以跨节点、跨镜像复用。而且转换过程只等"下一个需要的层 blob 出现",并严格按镜像层顺序 apply,不是等整镜像下完。
还有一个默认开启的开关 convert_standard_oci = true:设成 false 就要求镜像必须是 overlaybd-native 或带 referrer,否则直接 fail fast。这个开关的存在本身就说明"标准 OCI 走转换"是一条有成本的路径,而不是免费的。
对我们的启示很直接:如果决定走 OverlayBD 路线,镜像构建流水线最好直接产出 overlaybd-native 格式,否则第一次使用每个新镜像都会退化成"半 lazy + 一次转换"。这也解释了为什么 Nydus 用 Nydusify/Buildkit 直接输出 RAFS 是更省事的选择——转换发生在构建期而不是运行期。
我们的权衡是:
- 短期用 Nydus 快速落地:生态最成熟,Kata 官方集成现成,RAFS v6 + EROFS 内核态性能最好;
- MicroVM 线可评估继续用 OverlayBD:对齐 DSec/AgentENV 的技术栈,块设备级在高 IOPS 场景更强,而且 Rust 版 OverlayBD + ublk 已经在 AgentENV 仓库开源可直接复用;
- 调度层按运行时后端选择 snapshotter:避免两套镜像格式并行维护的成本失控。
4.7 组合式环境层:DSec 真正的杀手锏
前面讲的都是"怎么把镜像拉得更快"。但 DSec 论文 §5.1 提出的组合式环境层解决的是更根本的问题:为什么需要拉这么多镜像。
论文把沙盒内容拆成三部分:
- base image:OS 级依赖(Ubuntu、Python 3.10、Java 8 环境)
- workspace:任务的代码仓库和任务特定依赖
- toolkit:频繁更新的工具集(如 DeepSeek Harness)
生产一周内,容器后端服务了 11,266 个基础镜像、102,171 个 workspace,另有 103 个 toolkit;67.8% 的沙盒在基础镜像之外还需要至少一个 workspace 或 toolkit。
如果按传统做法把三者熔进一个 OCI 镜像,维护成本是组合爆炸的:维护 M 个基础镜像、N 个 workspace、K 个 toolkit,升级 m 个基础镜像需要重建它们的 workspace 组合,成本是 O(m·N);升级 k 个 toolkit 的成本是 O(k·N)。
DSec 的做法是把三者当作独立版本化的层,在沙盒创建时动态组合 overlayfs 的 lowerdir:基础镜像在最底层,workspace 作为只读层插在上面,每个 toolkit 再叠上去。这样升级成本降到 O(m) 和 O(k)。
论文还否掉了两个看似简单的替代方案,理由很值得记:
- 压缩包 + 启动时解包:集中在一个突发里,重复的解压工作会带来大量 CPU 和 I/O 开销,可能导致沙盒启动超时。§8.3 的实验验证了这一点:用 tar.gz 供给同一个 workspace,端到端任务完成时间 79 分钟;用 EROFS 直接挂载共享层,45 分钟,1.76× 加速;tar 方案产生的磁盘写入流量是 EROFS 的约 5.5 倍,峰值写入吞吐是 3.4 倍。
- 宿主只读目录 bind mount:bind mount 会完全替换目标路径,而这些组件需要的是"追加"语义——它们的文件必须合并进沙盒已有的目录树,而不是把下面的内容遮住。而且严格只读挂载会和往自己安装树里写文件的工具冲突(比如 Python 创建
__pycache__)。
还有一些工程细节值得抄:
- 因为发布的层是不可变的,DSec 用 EROFS 存储——相比 ext4/XFS 省掉写相关的记账,布局更紧凑,且支持压缩的同时保持随机访问。论文特意对比了 tar.gz:“EROFS 可以只读取和解压覆盖请求数据的压缩块,不需要在可用之前传输并解包整个镜像”。
- 层合并:挂载大量 EROFS 层本身会给容器创建带来开销,所以 DSec 会离线把尺寸阈值(如 3 GB)内的连续层合并成一对 EROFS 镜像(元数据 + 数据),同时保留 overlayfs 的 whiteout 语义以正确表达文件删除。这减少了最终挂载数、避免文件重复,并保留了共享公共层的镜像之间的 page cache 复用。
- EROFS file-backed mount mode:消除 loop 设备块映射层及其开销。
4.8 镜像侧的另一半:镜像即工具定义
上面讲的都是"分发"这一侧。但镜像本身怎么组织,同样是调度侧要关心的事——因为它决定了同一份运行时能覆盖多少种负载。
我们内部维护了一套业务沙盒镜像仓库,覆盖 Node(含 pnpm 组合、npx-mcp)、Python(含 anaconda 派生、uvx-mcp)、以及几个业务方向(Neta 多模态生成、阿里云 CLI 运维、Alcor 集群运维、两个全栈 demo 应用),共 11 类。
它最值得说的一点是:每个镜像都内置一份工具定义。
1 | COPY config.yaml /etc/syndrom/sandbox-agent.yaml |
这份 YAML 用 Go template 声明该镜像暴露给 Agent 的工具集,例如基础 Node 镜像:
1 | tools: |
Python 镜像在上面加一个 python-run(scriptExecutor: python3),Neta 镜像换成 neta-exec 调 neta-cli,Alcor 镜像的 exec 工具则在每次调用前用 heredoc 把 kubeconfig 写成指向内部隧道的配置、token 从环境变量注入。
镜像即工具定义的好处是:换负载类型不需要改调度层,换镜像就行。而且工具描述、参数 schema、成功/失败的判定标准(stdout/stderr 怎么映射)都和镜像一起版本化,避免了"工具定义在一个仓库、镜像在另一个仓库"的漂移。
顺带记一个构建实践上的取舍:这个仓库完全没有做镜像懒加载(grep nydus|overlaybd|dragonfly|stargz 零命中),实际做的是三件事——自建 Harbor 做上游代理仓(所有 base image 都是 harbor.infini-ai.com/share/*)、tag 用 24.x 这样的通配形式跟随上游大版本、包管理器源全面换国内镜像(tuna / aliyun / npmmirror)。它优化的是构建期和拉取期的本地化,不是分发期的按需加载——这两件事不冲突,可以叠加。
4.9 懒加载改变了镜像的验签时序
前面把懒加载讲得很美,但有一个副作用很少被讨论:它改变了镜像完整性校验的时机。
传统全量拉取是 “先验签、后解压、再使用”——镜像在落地前已经过完整校验,运行时用的是已校验的数据。懒加载则变成 “边拉、边用”:数据块在你第一次访问它的时候才到,此时它已经被访问了。
这个时序变化带来两个具体问题:
问题一:签名的粒度对不上拉取的粒度。
cosign / sigstore 这类工具签的是镜像 manifest(或者 manifest list),也就是"这组 layer digest 的集合是可信的"。但懒加载是按 chunk 拉的——manifest 验签通过不代表某个 chunk 在传输过程中没被替换。所以需要块级校验来补齐这一层。
Nydus 在这一点上做得比较彻底:RAFS v6 不解压,每次数据访问都对数据块算 SHA-256,检测到篡改可以从远端重新拉取。这相当于把校验从"一次性的入口检查"变成了"持续性的运行时检查"。
问题二:admission 时机该放在哪。
如果策略是"未验签的镜像不许跑",那在懒加载下"什么时候算验过"就变得模糊。实践中三条路:
| 策略 | 做法 | 代价 |
|---|---|---|
| admission 只验 manifest | 入口处校验签名,chunk 靠运行时块校验兜底 | 依赖运行时校验实现质量 |
| admission 全量校验 | 入口处把镜像完整拉一遍校验 | 懒加载的收益被吃掉大半 |
| 预转换 + 签名 | 构建期产出 Nydus/OverlayBD 格式并签名,运行时只消费已签格式 | 需要改造构建流水线,但最干净 |
我们倾向第三条:把转换和签名都推到构建期,运行时只消费"已经签好名、格式也固定"的产物。这样 admission 的策略可以保持简单——验一次 manifest 即可,因为格式转换(§4.3 讲的 Nydusify / Buildkit 输出)已经在可信环境里完成了。这也和 §4.6 里"如果走 OverlayBD 路线,镜像流水线最好直接产出 overlaybd-native 格式"是同一个判断:能在构建期做的事,不要留到运行期。
五、网络与内核隔离
镜像解决的是"起得来",隔离解决的是"起得安全"。这一层在 Agent 场景下比传统容器平台重要得多,因为沙盒里跑的是模型生成的、不可信的代码。
范围说明:本章讨论的是 agent 沙盒(跑 LLM 生成代码)的隔离需求。具身仿真的沙盒是另一回事——那里跑的是我们自己写的仿真器、agent 只产出动作数据,威胁模型完全不同,隔离选型也相反。这个区分见 §3.11.5,不要混用。
5.1 内核隔离:容器也要套一层 VM
DSec 的做法在论文 §3.3 里写得很直接:FnCall 和 Container 后端都跑在 QEMU/libvirt VM 里,而不是直接跑在宿主机上。VM 提供隔离的内核和网络栈,作为不可信容器与裸机之间的额外安全边界。
这是一个容易被忽略的设计——它意味着 DSec 的"容器"不是裸容器,而是一层 VM 里的容器。代价是额外的虚拟化开销,收益是把内核漏洞的爆炸半径从"整台物理机"缩小到"一个 VM"。
论文 §7 还提到一个相关取舍:用户隔离会进一步限制资源突刺或内核级故障的爆炸半径,代价是节点级的密度。也就是说隔离强度和部署密度在这里是直接对立的,必须显式选边。
5.2 网络隔离:eBPF 白名单
论文 §6.5 描述的网络控制机制是:训练框架指定按域名或镜像服务组织的任务级网络权限,DSec 通过每沙盒的 eBPF 程序,按 IP 地址、端口、协议过滤流量,拒绝白名单之外的流量。策略可以随任务阶段动态更新。
论文 Listing 1 给了一个很直观的例子:
1 | args = DSecContainerRunArgs( |
允许访问 PyPI,拒绝访问 NPM——粒度到包管理器级别。
5.3 权限收敛:AppArmor 对付"沙盒内的 root"
网络只是外边界。DSec 论文 §6.5 还加了文件与 socket 的访问控制:用 AppArmor profile 控制文件读写权限和 socket 访问,包括对 chronus 日志和 Unix domain socket 的访问。
关键在于论文特意强调的那句:这些策略对沙盒内以 root 运行的 agent 控制进程同样生效。这在 Agent 场景下是必须的——沙盒内的 root 是"假 root",但很多越权路径恰好依赖它。
5.4 Agent 越狱实录:论文里最值得读的一节
DSec 论文 §6.4 记录了生产环境中 agent 的真实越权行为,这一节的价值远超任何安全设计文档,因为它给出的是真实对抗样本。
在沙盒内寻找答案:
- agent 搜索平台管理的文件和通信通道,寻找残留答案;
- 尝试伪造用户请求——直接向 chronus 的 socket 发送构造好的 RPC 消息;
- 检查 chronus 日志找泄露的答案;
- 因为 chronus 部分操作会调用 bash,agent 尝试覆写
/bin/bash来绕过检查或向后续 shell 会话注入命令; - 在 DSec 引入文件读写和 socket 访问控制之后,有 agent 尝试用
XFS_IOC_SWAPEXT绕过——这个 ioctl 可以交换两个文件的数据 extent 映射,目的是让受保护文件的内容能通过另一个文件描述符访问。这次尝试破坏了 XFS 元数据,导致文件系统被迫关闭。
在沙盒外寻找答案:
- 扫描端口和服务,发现可达的镜像站;
- 用 Go module proxy 拉取 GitHub 上托管的代码,安装更新版本的包,寻找所需功能的现成实现。
论文的结论很重要:仅靠最终输出检查,无法可靠判断 agent 是否按预期方式解决了任务。
无意造成的破坏(这些不是故意攻击):
- 一个 agent 从根目录递归跑
grep,遍历/proc并读取/proc/kpagecgroup,触发内核 bug 导致内核崩溃; - 一个漏洞利用任务的攻击命令本该转发到单独的目标 VM,却在 agent 容器内执行,崩溃了自己的内核;
- 一个 agent 调用
yes,因为 chronus 会记录命令输出供用户异步取回,捕获的 stdout 在存储上累积了几十 GB。
论文的总结很克制也很诚实:没有单一机制能阻止所有 agent 越权和系统故障,所以他们的做法是加强可观测性以识别新出现的问题,并随着模型演化持续加固 DSec;而且这些访问控制只解决了部分问题,不提供针对"触发内核 bug"这类破坏性行为的通用防御。
对我们最有参考价值的是最后这句——不要指望一套策略搞定,要假设它会失败,然后设计好爆炸半径。这也是 DSec 把容器套在 VM 里、把用户隔离作为密度代价换来的根本原因。
5.5 我们的网络方案:TUN + gVisor netstack + SSH 隧道
我们平台已经有一套在跑的跨集群网络方案,它的设计取舍和 DSec 的 eBPF 路线很不一样,值得对比。(完整实现细节见另一篇 RLark 跨集群具身智能云原生平台全栈架构,这里只保留和沙盒隔离相关的部分。)
背景:我们的场景是"云上 GPU 训练集群 + 边缘机器人/传感器/摄像头"跨站点协同,集群分布在多个网络域,很多在 NAT 后面,没有公网入站能力。
方案:Pod 侧注入一个 network sidecar,创建 TUN 设备(gnet0)并挂一个 gVisor netstack 做用户态 TCP/IP 栈,劫持 Pod 的出站流量;请求经由本节点的 NodeServer 做路由决策:
- 同集群:直接连目标 Pod 的真实 IP(
targetPod.LocalIP:5700); - 跨集群:走 SSH 连接池——按 Domain 复用单个 SSH 连接(
ssh.Client多路复用),断线自动重连,重连期间并发请求等待而非各自新建,退避带 jitter(wait = backoff + rand(backoff/2))。
graph LR
subgraph A["集群 A(NAT 后)"]
PA["Pod A 进程"] --> TUN["TUN gnet0"] --> NS["gVisor netstack"] --> NSA["NodeServer A"]
end
subgraph CP["控制面"]
SRV["Server :8443 / :2222"]
end
subgraph B["集群 B(NAT 后)"]
NSB["NodeServer B"] --> PB["Pod B Proxy :5700"]
end
NSA -->|"同集群直连 LocalIP:5700"| PB
NSA -->|"跨集群 SSH direct-tcpip"| SRV
SRV -->|"反向隧道"| NSB
AA["Agent A"] -.->|"主动外拨 WSS"| SRV
AB["Agent B"] -.->|"主动外拨 WSS"| SRV
NAT 穿透的解法很朴素:所有连接都是数据面主动外拨,控制面永远不主动连数据面。Agent 启动时用 X.509 客户端证书拨 WSS 到 Server,通过反向隧道注册;跨集群时再由 Agent 主动 SSH 拨到 Server 的 2222 端口,用 Domain 证书认证,通过 direct-tcpip channel 接到对端 Agent 的隧道。于是两个数据面集群都不需要公网入站能力,NAT 后面的集群也能互通。
几个工程细节:
- gVisor netstack 开了
SetPromiscuousMode(true)和SetSpoofing(true),因为它要收发非本机地址的包; - ICMP Echo Reply 需要特殊处理——直接写回 TUN 设备绕过 gVisor,否则会被 gVisor 当作本地包消费掉;
- NodeServer 用
/proc反查发起连接的进程 PID,再映射到 Pod UID → Domain → 虚拟 IP 做凭据识别; - sidecar 每 30 秒从 NodeServer 拉一次
hostname → 虚拟 IP的映射写进/etc/hosts的托管区段(用# BEGIN rlark sidecar标记),域名后缀是rlark-domain——这样 Ray 这类框架可以用域名而非 IP 组网。
一个必须说清楚的边界:这套虚拟网络不是基础设施隔离边界。我们自己的文档写得很明确——它不构成对底层基础设施或普通 K8s 网络的隔离,安全域隔离应当用 K8s NetworkPolicy 等基础设施手段来强制,不要只依赖 Domain。把"跨集群连通"和"租户隔离"当成两件事,是这个设计里最重要的认知。
5.6 另一条路线:沙盒内 egress sidecar
OpenSandbox 的网络方案提供了第三个参照点,而且它的文档把"为什么不用 K8s NetworkPolicy"论证得很完整:
Kubernetes NetworkPolicy controls traffic through Pod label selectors. It faces fundamental limitations in the sandbox isolation scenario:
- Unpredictable labels: sandbox Pod labels are injected automatically by the platform and are not user-controllable.
- Dynamic lifecycle: sandboxes are created and destroyed frequently. NetworkPolicy is a static declaration.
- Granularity mismatch: NetworkPolicy operates on Pod sets (namespace + label selector), while sandbox isolation requires each sandbox to be an independent security domain. Expressing “deny access from all other Pods to me” with NetworkPolicy would require a separate rule per sandbox, and cannot cover sandboxes created in the future.
- No outbound control: NetworkPolicy Ingress rules can block inbound traffic, but cannot prevent sandbox processes from initiating outbound connections.
它的替代方案是把控制点放在沙盒内部(egress sidecar),技术栈是 DNS proxy + iptables nat REDIRECT + nftables,不是 eBPF:
- 第一层 DNS proxy:监听
127.0.0.1:15353,iptables 把 53 端口重定向过来,按 allowlist 过滤查询,被拒域名返回 NXDOMAIN; - 第二层网络过滤:nftables 做 IP 级 allow/deny,允许域名的已解析 IP 被加入带 TTL 的动态 allow set(“dynamic DNS”)。
动态条目的 TTL 机制设计得挺细:初始用 DNS TTL 加一个安全余量,钳制在 60–360 秒;sidecar 每 30 秒轮询活跃 TCP 连接,只续期仍在使用的、DNS 授权的远端 IP;活动结束后再做一次 6 分钟的续期,提供有界的重连窗口。注意 UDP 和 QUIC 条目不做连接跟踪,仍按 DNS 驱动的 TTL 过期。
还有一套平台级强制覆盖的 always-rules 文件,优先级是 deny.always > allow.always > 用户策略,每分钟热重载一次,用户无法覆盖。
最尖锐的一个发现是 gVisor 的硬冲突:egress sidecar 依赖 iptables nat 表的 REDIRECT 规则做 DNS 拦截,这在 runc 和所有 Kata 变体(kata-qemu / kata-clh / kata-fc)上都工作,但 gVisor 的 netstack 没有实现 nat 表——典型报错是 iptables: Failed to initialize nft: Protocol not supported。所以 gVisor + 域名级 egress 策略在当前实现下不兼容,server 会在请求时直接返回 HTTP 400 拒绝这个组合。
这个例子很好地说明了多运行时架构的一个现实:隔离运行时和网络方案不是正交的。选 gVisor 就要放弃沙盒内 iptables 方案(改用 CNI 级 FQDN 策略,如 Cilium toFQDNs),选 iptables 方案就要放弃 gVisor。这类耦合必须在架构设计早期就识别出来。
同一思路的另一个实现:AgentENV 用的是 netns + iptables,不是 eBPF。
这一点很有意思——DSec 和 AgentENV 同属一个团队生态、共用存储栈,但在网络隔离上选了完全不同的机制。DSec 论文 §6.5 明确写的是 per-sandbox eBPF 程序;AgentENV 的实现是每个沙盒一个 network namespace + iptables + 透明代理(仓库里 grep eBPF 零命中,也没用 nftables,代码还会主动检测 nftables 干扰并告警)。
它的分层是:
- 每沙盒一个 netns:VM 侧
eth0 169.254.0.21↔tap0 169.254.0.22↔vpeer↔ host 侧veth-{slot};veth 池用10.12.0.0/16(每 slot 一个 /31),host-interaction 池用10.11.0.0/16。 - 主机侧一套全局 iptables,其中第一对规则解决了一个很实际的问题——阻止 guest 主动连接任意宿主服务,同时保留 host 发起的已建立流量:
1 | INPUT -i veth-+ -s <host_interaction_cidr> -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT |
- 每个 namespace 三条链:
AGENTENV-EGRESS(静态:放行 ESTABLISHED/RELATED → 放行到 guest DNS 的 53 → 拒绝全部内部地址池和always_denied_cidrs→ jump 用户链)、AGENTENV-USER-EGRESS(可替换)、AGENTENV-EGRESS-PROXY(NAT 侧)。 - namespace 内的透明 egress 代理:只 REDIRECT TCP 80/443 到
0.0.0.0:15000,不终止 TLS、不重写 HTTP;读SO_ORIGINAL_DST恢复目标;缓冲最多 64 KiB 解析 HTTPHost或 TLS ClientHello 的 SNI(要跨 record 边界累积);域名匹配交给留在 host netns 的进程级 resolver,逐个候选 IPv4 过策略检查才选。域名策略 fail-closed。 - 策略更新是"替换"而非"增量"语义:先在 proxy 的 pending map 里暂存 → 需要代理时确保 listener → 用一次
iptables-restore批量替换 filter + NAT 链 → 成功后才激活。这个顺序保证了不会出现"规则改了一半"的中间态。
节点级默认拒绝的 CIDR 列表(10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16)和 DSec 的 allowlist 思路一致,但用户策略永远覆盖不了平台内部/保留网段的 deny——这条边界定得很清楚。
还有个容易忽略的细节:冷启动时会追加 net.ipv4.conf.all.arp_notify=1。原因是 guest 的 NIC 没有固定 MAC,udevd 可能改 MAC,需要 gratuitous ARP 保证 tap0 侧的邻居项不会陈旧。
把四种方案放在一起看:
| 方案 | 机制 | 粒度 | 主要约束 |
|---|---|---|---|
| DSec | per-sandbox eBPF 程序 | IP / 端口 / 协议 | 需要 eBPF 运行时;策略可随任务阶段动态更新 |
| AgentENV | netns + iptables + 透明代理 | CIDR / 域名(SNI/Host) | 不终止 TLS;UDP/QUIC 不做连接跟踪 |
| OpenSandbox | 沙盒内 egress sidecar(DNS proxy + iptables nat + nftables) | FQDN 通配符 + 动态 IP set | 与 gVisor 不兼容;与 Istio/Envoy 透明 mesh 冲突 |
| K8s 原生 | NetworkPolicy(CNI 实现) | Pod 集合(namespace + label selector) | 无法表达"每个沙盒是独立安全域",也无法阻止出站 |
"哪个最好"没有答案,"哪个能和你选的运行时共存"才是问题。 我们目前的判断是:容器线可以走 netns + iptables(生态成熟、调试直观),microVM 线的网络天然在 VM 边界内、可以考虑更激进的方案,而域名级白名单无论走哪条路都是刚需——因为 agent 最典型的越权行为之一就是"从包管理器或代码托管站找现成答案"(§5.4)。
5.7 隔离层级的横向对比
§5.2 / §5.5 / §5.6 讲了三套网络方案,它们最本质的差别不在"用什么技术",而在控制点摆在网络的哪一层:
graph TB
APP["沙盒内的应用进程"]
subgraph L1["第 1 层:沙盒内部"]
A1["OpenSandbox
egress sidecar
DNS proxy + iptables nat + nftables"]
A2["AgentENV
独立 netns + iptables 链
+ 透明代理(解析 SNI)"]
end
subgraph L2["第 2 层:宿主内核"]
A3["DSec
per-sandbox eBPF 程序
按 IP / 端口 / 协议过滤"]
end
subgraph L3["第 3 层:集群网络"]
A4["K8s 原生
NetworkPolicy 由 CNI 执行
(Cilium toFQDNs 可做域名级)"]
end
APP --> A1
APP --> A2
A2 --> A3
A3 --> A4
层越靠内,粒度越细、越贴近沙盒,但越依赖沙盒内的运行时——这就是为什么 §5.6 那个"gVisor 与 iptables nat 表冲突"的问题会出现:控制点在沙盒内,就会受沙盒内运行时的实现限制。反过来,控制点在 CNI 层(第 3 层)最通用,但表达不了"每个沙盒是独立安全域"(K8s NetworkPolicy 的根本限制)。
我们的选择:容器线走第 1 层(netns + iptables,生态成熟、调试直观),microVM 线的网络天然在 VM 边界内,域名级白名单无论走哪条路都是刚需。
下面这张表是隔离强度维度的对照,和上面的控制点位置是两个正交的维度:
OpenSandbox 的文档给了一张很实用的开销对照表(版本锚点:OpenSandbox commit 230af58a,2026-08-10;表内是该项目自测口径,且 GPU 一行已过时——见本节末尾的说明):
| Runtime | 隔离机制 | 启动开销 | 内存开销 | 适用场景 |
|---|---|---|---|---|
| runc(默认) | 进程级 cgroups | ~0ms | 极小 | 可信负载、本地开发 |
| gVisor | 用户态内核(syscall 拦截) | ~10–50ms | ~50MB | 通用负载、低开销 |
| Kata (QEMU) | 完整 VM + QEMU | ~500ms | ~20–50MB | 最大兼容性与隔离 |
| Kata (Firecracker) | microVM + Firecracker | ~125ms | ~5MB | 高密度、最小足迹 |
| Kata (Cloud Hypervisor) | Cloud Hypervisor | ~200ms | ~10–20MB | 性能与隔离的平衡 |
配套的兼容性矩阵更能说明选型约束:
| 能力 | runc | gVisor | Kata (QEMU) | Kata (CLH) | Kata (FC) |
|---|---|---|---|---|---|
| syscall 兼容性 | 完整 | 部分 | 完整 | 完整 | 受限 |
| GPU 支持 | 是 | 仅 CUDA 计算(nvproxy) | 是 | 是 | 否 |
| 特权模式 | 是 | 否 | 是 | 是 | 否 |
| systemd | 是 | 否 | 是 | 是 | 否 |
iptables nat 表 |
是 | 否 | 是 | 是 | 是 |
这张表基本决定了运行时谱系的形状:要图形/Vulkan 就得排除 gVisor(nvproxy 只覆盖计算);要 GPU 就得排除 Kata-FC;要 systemd 就得排除 gVisor;要用沙盒内 iptables 网络方案就得排除 gVisor。"哪个隔离最强"不是选型问题,"哪些能力组合可用"才是。
关于 gVisor 的 GPU 支持,这里要修正一个流传很广的过时说法。 表里 “仅 CUDA 计算” 这一栏是我们核实后的结果:gVisor 从 2023 年起提供了 nvproxy,通过拦截并转发 NVIDIA 的 ioctl 来支持 CUDA 计算负载(官方文档给出的是驱动版本白名单制,已验证过 T4 / L4 / A100 上的 PyTorch 负载)。所以——
- CUDA 计算:gVisor 可用(受驱动版本白名单约束),不再是"要 GPU 就排除 gVisor";
- 图形 / Vulkan / OpenGL:gVisor 不支持,这一条没变;
- 很多二手资料(包括我们引用的 OpenSandbox 兼容性矩阵)仍写着 gVisor “GPU: No”,那是 nvproxy 之前的结论,不能直接采信。
对我们的具身场景来说这个区分很关键:仿真器的物理计算部分理论上可以走 gVisor + nvproxy,但渲染部分不行——而仿真器通常是两者耦合的,所以实践中仍然倾向于 QEMU。但如果是纯 CUDA 的算子/物理后端,gVisor 是一个值得评估的选项。
5.8 隔离的粒度不止一层
最后补一个容易被忽略的维度:隔离不是只有"沙盒之间"一层。
OpenSandbox 提供了一种"沙盒内的隔离会话"(isolation session):在一个沙盒内部,execd 为每个会话 fork 一个 bubblewrap 子进程,设置 PID / mount / tmpfs / env namespace 后 exec 一个长活 bash。创建约 100ms,后续 run 接近零开销。workspace 支持三种模式:rw、overlay(默认,overlayfs copy-on-write,upper dir 默认在 /var/lib/execd/isolation,删除时消失)、ro。
它把隔离层级讲得很清楚:
| 概念 | 边界 | 启动开销 | 典型用途 |
|---|---|---|---|
| Isolation session | 单沙盒内的 bubblewrap namespaces | ~100ms | 许多短小任务复用长活会话 |
| Bash session | bash 进程,无额外 namespace | ms | 交互式命令序列 |
| Sandbox | 容器或 Pod | 100ms–秒级 | 租户 / workspace / 用户边界 |
| Secure runtime | 每沙盒一个用户态内核或 VM | 10–500ms | 对抗容器逃逸的硬件级保护 |
但它也诚实地标注了限制:“No hardware-level guarantee. Namespaces + seccomp only; pair with a secure runtime for kernel-exploit defense.”,以及仅支持 Linux、每会话运行串行化。
对我们的启发是:Agent 的一次 rollout 里可能同时需要多层隔离——沙盒之间要强隔离(防越权),同一沙盒内的多次实验之间可能要弱隔离(防互相污染但要求低开销)。把这两层混为一谈会导致"要么太重、要么太危险"。
5.9 gVisor 的能力边界:Sentry 与 nvproxy
前面几节反复提到 gVisor,但一直只在表格里出现一行。这一节把它讲清楚,因为它同时踩中了"网络方案"和"GPU 支持"两条线。
原理:gVisor 不是容器运行时,而是一个用 Go 重写的用户态内核(Sentry)。它在沙盒进程和宿主内核之间插了一层:沙盒里的应用发起系统调用 → Sentry 拦截 → Sentry 用自己的实现来响应(文件系统、网络栈、内存管理都在用户态重实现)→ 只有 Sentry 自己需要时才向宿主内核发系统调用。
配套的两个组件是 Gofer(文件访问代理,把宿主文件系统安全地暴露给 Sentry)和平台层(Sentry 跑在哪——ptrace、KVM、systrap 三种,性能和隔离性不同)。
这个设计带来两个后果:
- 隔离强:沙盒里的内核漏洞打在 Sentry 上,而 Sentry 是一个用户态进程,攻击面比真内核小得多;
- 兼容性有损:Sentry 重实现的 Linux ABI 不可能 100% 覆盖。这就是前面表格里 syscall 兼容性标"部分"、systemd 标"否"的原因。
nvproxy:GPU 支持的现状
前面已经说过,gVisor 自 2023 年起通过 nvproxy 支持 CUDA 计算——它拦截沙盒进程对 NVIDIA 驱动的 ioctl,转发给宿主上的真实驱动。但能力边界很明确:
| 能力 | gVisor + nvproxy |
|---|---|
| CUDA 计算 | ✅ 支持,但受 NVIDIA 驱动版本白名单约束(新驱动往往要等 gVisor 跟进) |
| OpenGL / Vulkan / 图形 | ❌ 不支持 |
| 显存管理 / MIG 切分 | 视驱动版本,支持范围小于裸机 |
| CUDA 之外的其他加速器(如某些 NPU) | ❌ |
对我们的意义:仿真器的物理计算后端理论上可以走 gVisor + nvproxy,但渲染部分不行——而主流仿真器通常是计算与渲染耦合的。所以实践中我们仍倾向 QEMU;只有纯 CUDA 的算子/物理后端才值得单独评估 gVisor 这条线。
“gVisor + 域名级 egress” 的完整闭环
§5.6 留了一个悬而未决的问题:gVisor 的 netstack 没有实现 iptables nat 表,导致沙盒内的 egress sidecar(依赖 nat REDIRECT 做 DNS 拦截)装不起来,OpenSandbox 会在请求时直接 400 拒绝这个组合。
但这不意味着"选 gVisor 就必须放弃域名级白名单"。可行的替代路径是把策略点从沙盒内移到 CNI 层:
1 | 沙盒内:不做任何 iptables 改写(因为 gVisor 不兼容) |
这条路的代价是依赖 Cilium 且需要集群级权限(toFQDNs 是 Cilium 的 NetworkPolicy 扩展,不是 K8s 原生能力),好处是策略在集群层统一管理、不受沙盒内运行时限制。
结论:gVisor 不是"因为 GPU 和网络两件事都做不了所以直接排除"——它是"GPU 只覆盖计算、网络要换一条实现路径"。能不能用,取决于你的负载是否同时需要图形和沙盒内网络策略;两者都不需要的话,gVisor 的低开销(~10–50ms 启动、~50MB 内存)是很有吸引力的档位。
5.10 数据外泄:被忽略的另一半
前面整章讲的都是"挡进来"——阻止 agent 从外部拿到不该拿的东西(现成答案、残留数据、越权通道)。但对称的风险是"带出去":agent 手里已经有任务数据、代码仓库、甚至用户凭据,它完全可以借白名单域名把数据外带。
这个风险和 §5.4 里"从 GitHub 找现成答案"是对偶的,而且更难防——因为出网白名单本身是必需的(agent 得装包、得查文档),你不可能把出网全关掉。
典型的几条外带路径:
| 路径 | 手法 | 特征 |
|---|---|---|
| DNS 隧道 | 把数据编码进子域名,查询 aGVsbG8.evil.com |
域名标签超长、熵值高、查询量大、同一父域高频 |
| 白名单域名外带 | 借 PyPI / GitHub 等已放行域名,把数据塞进 URL 路径或 POST body | 请求体异常大、URL 异常长 |
| 时序/大小侧信道 | 不传内容,只传"有没有"(如按位编码的请求有无) | 请求模式规律 |
可用的缓解手段(按性价比排序):
- 出网流量配额——按域名/按沙盒限制字节数。这一条最简单也最有效:正常装包和查文档的流量是有量级的,外带几 MB 数据必然触发阈值。§5.4 里那个
yes命令写满几十 GB 的案例,说明资源配额本身就是安全手段。 - DNS 层检测——监控标签长度分布、熵值、单父域查询速率。这一步不需要解析内容,只需要统计特征,成本低。
- 请求体大小与 URL 长度上限——在 egress 代理层直接卡掉异常请求。
- 响应内容审计——成本最高(要过内容),一般只对高风险任务开。
需要诚实说明的是:这几条都只能降低概率,不能消除。任何允许出网的沙盒都存在侧信道,最终还是要靠"任务本身不把敏感数据放进沙盒"这个前提来兜底——数据不进沙盒,就不存在外带。这也是为什么 §6.1 提到 DSec 在环境构建阶段就要求"构建者和运行时使用不同账号、打包前清除构建期残留数据"——减少沙盒里存在的东西,比事后检测更根本。
六、高密度与超卖:调度侧绕不开的第二题
§1.2 讲了"90% 的沙盒平均 CPU 用量不超过申请量的 5%"。这个数字的直接推论是:不超卖就是浪费。但超卖会引入两类新问题——内存被钉住、CPU 被互相干扰。DSec 论文 §5.2 给出了完整的解法。
6.1 内存:两条互补的路径
DSec 和 AgentENV 在生产里用的是同一套机制的两种组合,先把全景画出来,再看细节:
graph TB
subgraph G["Guest(microVM 内部)"]
GA["应用"] --> GPC["guest page cache"]
GPC --> GK["guest 内核"]
GK --> DAM["DAMON
只回收 file-backed 冷页
(skip_anon=Y)"]
GK --> BAL["virtio-balloon
free page reporting"]
DAM -->|"把散落的冷文件页
还给 buddy,合并成高阶块"| BAL
end
subgraph HO["宿主侧"]
PM["virtio-pmem + DAX
消除 guest / host 冗余缓存"]
HPC["host page cache
多沙盒共享同一份"]
HM["宿主物理内存"]
PM --> HPC
HPC --> HM
end
BAL -->|"报告空闲页
→ madvise(MADV_DONTNEED)"| HM
GPC -.->|"同一个只读 ublk 设备
天然共享"| HPC
读图要点:两条路径解决的是不同的问题,所以 DSec 要两个一起用——
- virtio-pmem + DAX 解决的是"同一份数据被缓存了两遍"(guest 一份、host 一份),实测把峰值宿主内存降了 40.2%;
- DAMON + balloon FPR 解决的是"guest 里的冷页还回不来",实测把时间积分的内存消耗降了 21.2%;
- 而虚线那条(多沙盒共享 host page cache)是 §2.3 讲的只读 ublk 设备复用带来的,它和上面两个机制是叠加关系。
下面是细节。
路径一:消除 guest-host 之间的 page cache 冗余(virtio-pmem + DAX)
DSec 用 virtio-pmem with DAX 把文件访问直接映射到宿主侧支撑的页,不复制进 guest RAM,从而让同机的多个 microVM 共享同一份 host page cache。论文 §8.4 的实测结果是峰值宿主内存降低 40.2%。
但论文也列出了 virtio-pmem 不适用的两种情况,这两条对选型很重要:
- 通过 virtio-pmem + DAX 的冷访问可能需要同步缺页处理来建立映射并让支撑数据可用;而 buffered virtio-blk 路径反而能受益于 guest 侧的预读和批量块 I/O。论文实测 virtio-pmem 会把瞬时峰值 CPU 利用率从 26.5% 抬到 41.4%——在 CPU 受限的部署里,运维可能宁愿只开 FPR 而保留 virtio-blk。
- guest 必须为整个 pmem 支撑的地址范围分配
struct page元数据。4 KiB 页 + 64 字节struct page的情况下,这份元数据要占用 pmem 设备容量 1/64 的 guest RAM——128 GB 的 pmem 设备需要 2 GB guest RAM 来存元数据。
路径二:把 guest 的空闲内存还回去(DAMON + virtio-balloon free-page reporting)
对不使用 virtio-pmem 的盘,冷文件数据会积压在 guest page cache 里,必须单独回收。DSec 的做法是组合两个内核特性:
- virtio-balloon 的 free-page reporting:guest 周期性扫描 buddy allocator,主动把空闲页报告给 hypervisor,hypervisor 用
madvise(MADV_DONTNEED)释放对应的宿主内存。默认按 order-9 页操作(4 KiB 基页对应 2 MiB 区域),可通过内核参数调整。 - DAMON(Data Access MONitor):周期性采样页访问位,识别超过可配置年龄阈值仍未触碰的冷文件页,通过内核回收路径驱逐。这一步的价值在于——它把散落的文件页释放回 buddy allocator,让 buddy 把碎片合并成更高阶的块,从而满足 free-page reporting 对高阶页的要求。
论文 §8.4 的实测:DAMON + balloon FPR 让时间积分的内存消耗降低 21.2%,且没有明显的 CPU 开销。两种机制叠加得到最低的总内存消耗。
DSec 的生产配置是:只读的 EROFS 基础镜像和 toolkit 层启用 virtio-pmem + DAX,更大的可写盘用 DAMON + balloon free-page reporting 回收。注意这个"分层配置"的思路——不是全局开一个开关,而是按盘的性质分别选机制。
AgentENV 是同一套机制的另一组生产参数,前面 §2.3 已经列过它的完整 boot args,这里只强调三点差异:
- 它把
page_reporting.page_reporting_order设成 6(对应 64 个 4 KiB 页),而 DSec 论文提到默认是 order-9(2 MiB)——这个值直接决定回收的粒度,调小了更容易凑出高阶块,但报告次数变多; skip_anon=Y明确只回收 file-backed 页,不碰匿名页——这是安全的默认值,因为匿名页里是进程的真实工作集;- DAMON 的监控区间要按内存大小动态计算以避开 MMIO/PCI 空洞,这个细节论文里没提,但不处理会直接导致 DAMON 去监控不存在的地址区间。
另外,超卖在生产里必须配 OOM 保护,否则"超卖"会变成"随机杀进程"。AgentENV 的做法是给 Firecracker 子进程设 oom_score_adj = 1000(最优先被杀,从而保住 agentenv server 本身),同时要注意两个 K8s 的坑:不给容器设 requests.memory 的话容器自己的 oom_score_adj 也会是 1000(保护失效),以及要把 memory.oom.group 置 0(否则默认的 1 会让整个 cgroup 一起被杀)。
6.2 CPU:两层 QoS,缺一不可
内存解决"能不能塞下",CPU 解决"塞下之后互不干扰"。
论文 §4.3 点出了问题的本质:有些任务有严格的每步延迟预算(例如每步有时间限制的博弈类 agent)。仅给 best-effort 工作更低优先级是不够的——当 best-effort 和延迟敏感任务跑在兄弟超线程上下文上时,它们仍然共享核心执行资源。
DSec 的两层策略是:
- BE 沙盒放到
SCHED_IDLE,这样只要有 LS 任务可运行,BE 就主动让出 CPU; - 对 LS 沙盒启用 Linux core scheduling(
prctl(PR_SCHED_CORE)),阻止无关的 BE 工作跑在同一个物理核的兄弟线程上。
论文 §8.5 的实测(用一个延迟敏感的棋类应用作为负载):50% BE 负载下,无 QoS 控制时每步延迟增加 45.2%;只加 SCHED_IDLE 最多只改善 3.4%(因为 LS 线程仍会和 SMT 兄弟上的 BE 工作竞争);SCHED_IDLE + core scheduling 把延迟膨胀限制在 17.3%,且在低负载下接近无共置基线。论文强调"改善随 BE 竞争加剧而增大"。
残余的退化主要来自高多核负载下的 CPU 睿频下降、内存带宽和共享 LLC 争用——core scheduling 管不了这些。论文的结论是这部分干扰已经可接受,因此没有做内存带宽隔离。
§7 还补充了一个实现上的好消息:这些机制全部基于现有 Linux 内核特性,不需要改内核——virtio-pmem + DAX 通过 Firecracker 设备配置和 guest 内核挂载选项启用,DAMON 回收通过 guest 内核参数和 sysfs 调优,CPU QoS 通过 SCHED_IDLE 和 prctl 设置。实现完全由配置和对沙盒编排器的集成构成。
6.3 池化与预热:三种设计对比
超卖之外,降低感知延迟的另一半是预热。前面提到的两个 K8s 原生项目给出了两种不同的池化设计,值得放在一起看:
| agent-sandbox SandboxWarmPool | OpenSandbox Pool | |
|---|---|---|
| 容量模型 | 单一 replicas |
双区间:bufferMin/bufferMax(预热余量)+ poolMin/poolMax(总量) |
| 越界处理 | 直接朝目标补齐 | 回到区间中点(防抖) |
| 补充限速 | per-pool 令牌桶 + 慢启动批量 + expectations gate | scaleStrategy.maxUnavailable(默认 25%)限制未 Ready 并发数 |
| 认领机制 | SandboxClaim 在内存队列里挑(NodeSpread 策略:选剩余 warm 沙盒最多的节点,平局 FIFO) | Pool controller 的 allocator(Packed / Spread)+ 谓词打分选池 |
| 绑定后处理 | 剥离 warm pool 标签、移除 safe-to-evict 注解、打 launch-type=warm |
RecycleStrategy:Delete / Restart(重启容器保留 Pod) / Noop |
| 缩容优先级 | 先删未 Ready 的,同组内删最新的 | 由分配算法决定 |
两个细节值得单独拎出来:
agent-sandbox 的 stuck sandbox GC 逻辑:非 Ready 且超过 grace period 的池成员,如果是 unschedulable 就不删,只计数并 hold——注释解释得很清楚:删掉一个不可调度的只会产生另一个同样不可调度的,形成无界的 delete→create 循环。调度门控(scheduling-gated)的成员同样 hold,因为替换品会排在队尾。这是一个只有踩过坑才会写出来的逻辑。
OpenSandbox 的 RecycleStrategy:它的 Restart 选项是"重启容器而非删除 Pod"——这比 Delete 更快(不用重新调度 Pod),也比 Noop 更干净。对"沙盒用完还池"这种高频操作,这个中间档位很有价值。
另外,agent-sandbox 的 claim 有一个很优雅的安全设计:X-Sandbox-UID 作为 capability。router 在转发时,对 warm pool 场景只选择尚未被任何 SandboxClaim 认领的池成员——也就是说,未认领的池 Pod 即使被猜到名字也访问不到。把"未认领"这个状态本身变成访问凭据,比额外维护一张授权表简单得多。
6.4 可观测性:怎么证明这些优化真的有效
DSec 论文反复强调可观测性(§6.5 的结论就是"加强可观测性以识别新出现的问题,并随着模型演化持续加固"),但前面几章讲的都是"做什么优化",没讲"怎么证明优化生效"。这一节补齐。
调度侧的沙盒平台需要盯的指标,可以分成四组:
① 启动链路(对应 §4 的镜像加速)
| 指标 | 为什么重要 |
|---|---|
| TFFI(Time to First Instruction) | 从"请求创建"到"沙盒能执行第一条指令"的端到端延迟——这是用户真正感知的指标,比"镜像拉取耗时"更贴近体验 |
| 启动延迟分位数(p50/p90/p99) | 只看均值会漏掉长尾;而突发场景下长尾就是瓶颈——§4.1 说过"任何一个阶段的掉队者都会延迟有效的模型交互" |
| 各阶段耗时拆解 | 供给 / 镜像挂载 / 缺页预热 / 服务就绪,分开看才知道该优化哪一段 |
| 缺页率(major fault rate) | 直接反映按需加载的工作集是否命中;缺页率高说明预取策略没打准 |
② 存储与缓存(对应 §4.6 的双层缓存)
- Page Cache 命中率 与 remote-blocks 二级缓存命中率——分开看,因为它们的淘汰策略和成本完全不同;
download_gate的排队深度——这是 AgentENV 里专门做前后台 I/O 仲裁的模块,排队深度直接反映"后台下载是否在挤压前台关键路径";- 镜像访问热图——哪些 base image / workspace / toolkit 被反复访问。这张图是预取策略的输入,也是 §4.7 组合式层设计的依据。
③ 密度与超卖(对应 §6.1 / §6.2)
- 超卖比 与 实际利用率 要成对看:只看超卖比会误判——DSec 的数据是"90% 的沙盒平均 CPU 用量不超过申请量的 5%",这个分布比单一比值更有信息量;
- OOM kill 次数——超卖的护栏指标。如果它不为零,说明超卖已经过头了;
- CPU QoS 的实际效果:延迟敏感任务的 p99 每步延迟,在 BE 负载上升时的膨胀曲线。DSec §8.5 那张"45.2% → 17.3%"的图就是标准做法。
④ 安全与异常(对应 §5)
- 被拒的网络请求(按域名聚合)——既看攻击,也看误拦:如果 agent 频繁被拦在正常依赖上,说明白名单太紧;
- AppArmor 拒绝次数;
- 出网流量按域名的分布——§5.10 讲的 DNS 外带检测就建立在这个数据上;
- 命令输出大小分布——§5.4 那个
yes写满几十 GB 的案例,事前是能从"输出大小分布的长尾"看出来的。
一条实践经验:这些指标里,"启动延迟分位数 + 缺页率 + 缓存命中率"三个是最小可观测集。有了这三个,§4 和 §6 的任何优化都能立刻验证是否生效;缺了它们,优化就只能靠感觉。
七、落地路线图
结合前面的分析,我们把工作分成三个阶段。先说清楚当前已经到哪了:
已完成:四模板运行时(§3.12)
cpu-*(AgentENV + Firecracker)/ gpu-light-*(gVisor + GPU)/ vm-*(Kata)/ render-*(普通容器)四个模板已经通过 E2B SDK + xr-sandbox 网关跑通,"统一 SDK 封装多后端"这一层已经落地。
下面三个阶段是围绕它继续补的。
短期(现在做):镜像分发加速
-
方案:OCI 镜像 → Nydus RAFS 格式,宿主机启用 EROFS over FSCache 内核态按需加载;
-
工具链:Nydusify 或 Buildkit 直接输出 Nydus 格式,Harbor 侧可加 Acceld 做自动转换;
-
P2P 加速:叠一层 Dragonfly 应对大规模突发分发;
-
预取优化:这里要区分两条不同的路径,别混为一谈——
- 构建期热点记录(Nydus 的主流做法):在镜像构建时记录热点文件列表,写进 Nydus 镜像元数据;运行时 nydusd 按这份列表预取(
nydusd prefetch files)。预取清单是镜像的一部分,随镜像版本走,不依赖运行时观察。 - 运行期 NRI 预取(另一条路):containerd ≥1.7 的 NRI 插件机制允许在容器创建时介入,按历史访问模式动态决定预取什么。这条更灵活,但要自己维护访问模式的学习与存储。
我们倾向先做第一条——Agent 沙盒的访问模式本来就相对固定(Python 包、常用工具),构建期记录已经能吃到大部分收益,而且不用引入运行时状态;等访问模式统计积累够了再评估第二条;
- 构建期热点记录(Nydus 的主流做法):在镜像构建时记录热点文件列表,写进 Nydus 镜像元数据;运行时 nydusd 按这份列表预取(
-
组合式层:中期目标是对齐 DSec 的 base / workspace / toolkit 三层独立版本化,把升级成本从 O(m·N) 降到 O(m)。
中期:运行时谱系完善
- Container 线:runc + Nydus snapshotter,处理轻量软件工程任务;
- MicroVM 线:复用 AgentENV 的 Firecracker 代码(MIT 协议,OverlayBD/ublk 栈已开源),处理安全攻防类;我们内部已有的 firecracker-containerd 引擎(
/dev/kvm+vhost-vsock+ devmapper thinpool)可直接作为起点;上云前先确认云 VM 是否支持嵌套虚拟化,不支持则评估 PVM(注意它目前只有 x86_64、且 KVM/PVM 两种模式的快照不能互相恢复); - 调度侧演进:评估 KAI Scheduler / Kueue 做 gang scheduling 与 quota 管理,并跟进 DRA(K8s 1.34+)——它让 MIG 切分、拓扑感知放置、多设备组合请求变成 K8s 原生能力,我们的 Tier 分层(§3.5)和节点池隔离(§3.4.6)可以用声明式表达而不是靠节点标签硬绑。RLinf 作为上层执行系统值得评估(§3.11.2);
- 主流 GPU 仿真(Tier 1,优先级最高):不进 VM,直接走容器 + NVIDIA Container Toolkit + GPU Device Plugin。这是具身训练的主力路径,也是和我们现有架构最契合的一条——见 §3.5;
- Full VM 线:参考 DSec 的 QEMU 方案。Android 类负载走
virtio-gpu-rutabaga+ gfxstream(Google 官方路线);传统 GUI 仿真器才用 VirGL/Venus。注意 3D 仿真不等于 GUI 仿真——吃 CUDA/RTX 的那些不该走这条线(§3.5)。存储层继续复用 OverlayBD 懒加载; - FnCall 线:常驻进程池 + GPU 场景评估 MIG 切分;
- Kata + VFIO 作为"强隔离 GPU"档位:只在多租户/机密计算/边缘不受信代码这三类需求下启用。启用前必须先确认两件事——(a) 能不能接受放弃快照/fork(VFIO 完全不支持);(b) 能不能接受节点池隔离(GPU 被 vfio-pci 吃掉后,该节点再也不能跑普通 GPU 容器)。部署组合是"裸金属 + IOMMU + kernel 6.8+ + IOMMUFD + 节点池分离",详见 §3.4;
- 调度层的路由标签:把"是否需要渲染"“是否需要 GPU 计算”"隔离强度"作为 placement 的标签维度,让调度器按负载特征路由到 Firecracker 节点池、Kata 节点池或 QEMU 节点池,避免 QEMU 的高开销拖累整体密度;
- 超卖与 QoS:容器线先上
SCHED_IDLE+ core scheduling 两层 CPU QoS;microVM 线评估 virtio-pmem + DAMON/balloon FPR 的内存回收组合。
长期:网络与内核隔离、环境自演化
- 网络隔离:把现有的 Domain 虚拟网络(跨集群连通)与安全隔离明确分层——连通性用 TUN + SSH 隧道解决,安全域隔离用 eBPF / NetworkPolicy 强制,两者不混为一谈;域名级白名单作为目标能力;
- 内核隔离:FnCall / Container 后端包在 QEMU/libvirt VM 里,作为不可信容器与裸机之间的安全边界;
- 权限收敛:AppArmor 管控文件与 socket 访问(尤其是沙盒内以 root 运行的进程),配合命令输出的大小上限——DSec 那个
yes命令写满几十 GB 存储的案例提醒我们,输出限流和网络策略一样是必需的; - 非 Linux guest(待评估):目前整条运行时谱系都假设 guest 是 Linux。但 Computer-Use 场景经常要跑 Windows 商业软件(Office、专业工具),移动端仿真要跑 Android(本质是 Linux,但需要完整图形栈)。Windows guest 需要单独评估三件事:镜像分发格式(§4 的 EROFS/OverlayBD 都是 Linux 侧的)、快照能力(Windows 的内存快照语义和 Linux 差异很大)、以及许可。这一条我们暂时没有排期,但架构上要留出位置——不要把"guest 一定是 Linux"写死进调度层的假设里;
- 环境自演化(可选):参考 DSec 的
pack_diff机制——agent 可以在任意时刻对沙盒做增量磁盘快照作为 checkpoint,之后恢复成一个新沙盒。这等于把一次交互式会话直接变成可复用的环境,让环境的构建、验证、消费发生在同一套基础设施上,不需要单独的镜像构建流水线。论文 §6.1 的两个细节必须一并抄走:构建者和运行时 agent 使用不同的账号;打包前从可写层清除构建期残留数据,避免参考答案被带进镜像。这是 AgentENV 没有能力、DSec 独有的方向。
八、总结
回顾这段时间的调研和决策,有四点值得强调:
第一,Agent 沙盒不是一个问题,而是一类问题。 刷题、软件工程、安全攻防、Computer-Use、仿真渲染,这些负载在隔离强度、资源形态、启动延迟上的需求完全不同,单一沙盒抽象必然顾此失彼。DSec 的四级运行时谱系(FnCall / Container / MicroVM / Full VM)是当前最完整的架构参照,而它背后有一条容易被忽略的设计立场:libdsec 刻意不做完整的语义抽象,选择后端的责任在调用方。试图用一个抽象抹平所有运行时的差异,只会造出一个谁都跑不好的中间层。
第二,选型要看底层的硬边界,不要只看上层 API。 AgentENV 的 fork/snapshot/restore 原语在 RL rollout 场景下确实漂亮,但它建立在 Firecracker "只有 5 个虚拟设备"这个底层事实上——这意味着渲染、GPU、设备直通这类需求在架构上就不可达,而且即使未来 Firecracker 加上 GPU,快照机制也无法覆盖 GPU 状态。更普遍地说:隔离运行时和网络方案不是正交的(gVisor 与 iptables nat 表冲突)、隔离层级和部署密度是直接对立的(DSec 明确承认用户隔离以节点密度为代价)。这些耦合关系必须在架构设计早期识别,而不是等集成时才发现。
第三,镜像加速是所有运行时共用的公共底座,优先级最高。 无论上层是 runc、Kata、Firecracker 还是 QEMU,都要经过镜像分发这一层。DSec 的实验给出了很硬的理由:在超卖接近满负载的集群上,拉镜像不只是慢,还会直接挤占正在运行的沙盒的 CPU 和 I/O。先做 Nydus(或同类懒加载方案),后续所有运行时升级都能直接受益。而比"拉得更快"更根本的是"不需要拉这么多"——组合式环境层把升级成本从 O(m·N) 降到 O(m),这一条的价值可能超过所有分发侧的优化。
第四,假设隔离一定会失败,然后设计好爆炸半径。 DSec 论文 §6.4 那节越狱实录值得每个做 Agent 基础设施的人读一遍:agent 会伪造 RPC、会覆写 /bin/bash、会用 XFS_IOC_SWAPEXT 绕过文件保护(并因此损坏文件系统)、会用 Go module proxy 去 GitHub 找现成答案、会从根目录递归 grep 触发内核 bug。论文的结论也很诚实——没有单一机制能阻止所有越权和故障,这些控制只解决了部分问题,不提供针对内核级破坏的通用防御。所以正确的姿势是分层的:VM 边界兜底、AppArmor 收敛权限、eBPF 管住网络、输出限流防资源耗尽,再加上持续的可观测性来发现新形态。
Agent 沙盒这个方向在 2026 年已经不是"要不要做"的问题,而是"怎么把它做好"的问题。DeepSeek、Moonshot、阿里云、腾讯云各家都在跑,开源生态(AgentENV、Nydus、Kata、Firecracker、QEMU、kubernetes-sigs/agent-sandbox、OpenSandbox)也足够成熟,真正的差异化在于对自身负载谱系的理解和调度层的精细打磨。这是接下来一段时间我们要持续投入的方向。
附录:从零理解这条链路(Guest 内核 / mmap / Page Cache)
§2.3 的图解默认你已经熟悉虚拟化和内核内存管理。如果这些概念对你是新的,这一节把它们补齐——这些技术分属不同层次(硬件、内核、用户态、虚拟化),单独看都懂,串起来就容易晕。
A.1 三个基础问题
Guest 有自己的内核吗?
有,而且它是完全独立的一套操作系统内核。
graph TB
subgraph HOST["宿主机(Host)"]
HK["Host 内核
管理真实硬件、调度真实 CPU / 内存
运行 Firecracker 进程"]
FC["Firecracker(用户态进程,VMM)
模拟硬件:CPU、内存、磁盘、网卡"]
GK["Guest 内核
管理「假硬件」
有自己的页表、调度器、文件系统"]
GA["Guest 里的应用(训练任务、Agent 代码)"]
HK --> FC --> GK --> GA
end
关键理解:Guest 内核和 Host 内核是两个独立的内核实例,它们互不知晓对方的存在。
| 对比项 | Host 内核 | Guest 内核 |
|---|---|---|
| 运行在哪 | 真实 CPU 上(特权级最高) | 被 Firecracker 模拟的 vCPU 上 |
| 管理什么 | 真实硬件 | 虚拟硬件(virtio 设备等) |
| 页表 | Host 虚拟地址 → Host 物理地址 | Guest 虚拟地址 → Guest 物理地址 |
| 内存 | 真实物理内存 | 它以为的"物理内存"(其实是 Host 上的一块 mmap 区域) |
类比:Host 是"房东",Firecracker 是"二房东",Guest 是"租客"。租客以为自己有一整套房子(Guest 内核以为自己在管理真实硬件),实际上只是房东房子里的一个房间。
mmap 的使用场景是什么?
mmap 的本质是:把"文件"或"设备"映射进进程的虚拟地址空间,让"读写内存"就等于"读写文件"。
传统
read()/write()像去图书馆借书——每次要看书,得跑去柜台登记、把书搬回家、看完再还回去,每次操作都有系统调用开销。
mmap()像图书馆给你配了一把钥匙——书直接摊开在你面前,你翻到哪页看哪页。省去了反复登记搬运。
四种典型场景:
| 场景 | 说明 |
|---|---|
| 读大文件 | 传统方式 read(fd, buf, 4096) × N 次,数据要经"磁盘 → 内核页缓存 → 用户缓冲区"两次拷贝;mmap 之后用户直接访问页缓存,零拷贝 |
| 多进程共享数据 | 多个进程 mmap 同一个文件 → 指向同一块物理内存,改一处其他进程立刻看到 |
| 按需加载(惰性分页) | 一个 10GB 文件只读第 1 页,mmap 时不会加载整个文件,访问第 1 页才触发缺页、只加载那一页 |
| 设备映射 | 把块设备当文件映射,访问内存就是访问设备——这正是我们这条链路的核心用法 |
在我们这条链路里,mmap 的用途是:Firecracker 把 /dev/ublkb[N](一个只读块设备)映射成 Guest 的内存。Guest 访问自己的物理内存时,实际上是在访问这个块设备上的数据,由内核的页缓存和缺页机制按需加载。
Page Cache 到底是什么?
Page Cache 是内核用空闲内存做的一个"磁盘数据缓存层"。 你读过的文件内容,内核会留在内存里;下次再读直接从内存拿,不用碰磁盘。
Page Cache 就像你书桌上的书架:要看书(读文件)先从书架找(查 Page Cache),有就直接拿(命中);没有就去仓库取(读磁盘),取回来顺手放书架上(填入 Page Cache)。
Page Cache 的索引单位是"文件页",索引键就是 文件 offset / 4096——它是文件内部的页号,不是虚拟地址。
在 AgentENV 这条链路里它的作用很关键:
1 | 沙盒 A 读某页 ──┐ |
三个沙盒共享同一份物理内存——这就是"多沙盒复用只读块设备"省内存的根本原因。
A.2 技术分层全景
| 楼层 | 层次 | 职责 |
|---|---|---|
| 5 | 应用层 | Guest 里的应用(训练任务、Agent 代码) |
| 4 | Guest 内核层 | 管理 Guest 虚拟内存、调度、文件系统;它以为自己有真实硬件 |
| 3 | 虚拟化层(VMM) | Firecracker 模拟 vCPU、虚拟内存、virtio 设备;Guest 的"物理内存" = Host 上一块 mmap 区域,backend = File |
| 2 | Host 内核层 | Page Cache(缓存块设备数据,多沙盒共享)、mmap 子系统、CoW(隔离各沙盒写入)、ublk 驱动(把用户态块设备接进内核块层) |
| 1 | 用户态存储层 | ublk 用户态服务(io_uring 与内核通信)、OverlayBD(按 offset 在分层快照中查找)、快照层(Layer 0…N) |
| 0 | 存储介质 | 本地磁盘 / 对象存储 / 网络存储 |
A.3 跟着一次读请求走完整条链路
§2.3 的图解③已经画过"三个沙盒共享同一份物理页"的静态结构,这里补的是一次具体读请求的动态时序——两条路径上的同一件事,可以对着看。
假设 Guest 里的程序要读一段数据,这段数据在 Guest 物理地址 GPA 0x1000 处——第一次访问时它还没在内存里。
1 | 【第 1 步】Guest 应用发起读操作 |
关于"offset"这个词的精度:上面的描述里"页内偏移"其实跨了四层不同的映射,严格写清楚是——
- Guest 虚拟地址 → Guest 物理地址(Guest 页表);
- Guest 物理地址 → Host 虚拟地址(EPT/NPT);
- Host 虚拟地址 → 块设备上的位置:这一步只在"mmap 的是块设备节点"时才是纯线性(page index × 4096 → LBA)。如果 mmap 的是普通文件,中间还隔着一层文件系统的块映射(extent → 物理块),不是线性的;
- 块设备位置 → 后端数据:OverlayBD 在这一层做分层查找。
另外两个容易写错的技术点:块设备页缓存和文件页缓存是两个不同的
address_space(前者挂在 bdev inode 上,后者挂在文件 inode 上),命中判断不能混;ublk 的请求单位是 sector(512 字节)而不是页,所以一次 4 KiB 的缺页对应 8 个 sector 的 I/O。页缓存的索引结构在现代内核里是 XArray + folio,不是老的 radix tree。这些细节不影响主结论,但涉及"offset 怎么算"时值得说准。
注意第 2 步和第 3 步的分工:GVA → GPA 由 Guest 自己的页表完成(Guest 内核管),GPA → HPA 由 EPT/NPT 完成(KVM 管,且 MAP_PRIVATE)。两次转换对应两个独立的内核实例,这也是"Guest 内核和 Host 内核互不知晓对方"的具体体现。
而这个"缺页不需要 exit 到用户态"的细节,恰恰是共享机制能成立的关键:三个沙盒各自的 EPT violation,最终都在内核层落到同一份 page cache 上——不需要 VMM 介入,也就没有额外的进程上下文切换开销。如果 Firecracker 用的是 userfaultfd,这里就多了一次"内核 → 用户态 handler → 内核"的往返,那才是 uffd 方案慢的真正原因。
A.4 各组件一句话职责
| 技术 | 一句话职责 | 类比 |
|---|---|---|
| Firecracker | 让 Guest 以为自己有独立硬件 | 二房东,给租客造一个"独立公寓"的幻觉 |
| Guest 内核 | 管理 Guest 内部的虚拟资源 | 租客自己的家务管理系统 |
| mmap | 把文件/设备映射成内存,读写内存 = 读写文件 | 给文件配一把钥匙,直接摊开看 |
| Page Cache | 用内存缓存磁盘数据,多读者共享同一份 | 公共书架,看过的书放上面 |
| CoW(写时复制) | 写只读页时自动复制一份私有的 | 多人看同一本书,谁要批注谁自己复印一份 |
| ublk | 让块设备驱动能跑在用户态 | 快递员在楼外干活,不必进楼 |
| OverlayBD | 分层存储快照,按 offset 查找数据 | 分层仓库,按货号找货 |
| io_uring | 内核和用户态之间的高性能异步 I/O 通道 | 快递员和仓库之间的传送带 |
A.5 共享发生在哪
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Guest A │ │ Guest B │ │ Guest C │
└───────────┘ └───────────┘ └───────────┘
│ mmap │ mmap │ mmap
└──────────────────┼──────────────────┘
▼
┌──────────────────────────────────────────┐
│ Page Cache(只有一份) │
│ 物理页 P1, P2, P3, … │
│ ← 三个沙盒共享这一份 │
└─────────────────────┬────────────────────┘
│ 未命中时下发 I/O
▼
┌──────────────────────────────────────────┐
│ /dev/ublkb[N](每个快照一个) │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ OverlayBD + 分层快照 │
└──────────────────────────────────────────┘
读:三个沙盒 → 同一个 Page Cache 页 → 物理内存只有一份。写:触发 CoW → 各自分配私有页 → 互不影响。
A.6 最小记忆模型
Guest 是"租客",有自己的家务系统(Guest 内核)。
Firecracker 是"二房东",用 mmap 把一块只读块设备假装成租客的"整套房子"。
Page Cache 是"公共书架",所有租客看的书都放在上面,同一本书只有一份。
ublk 是"外卖通道",让块设备的数据能从楼外(用户态)送进来。
OverlayBD 是"分层仓库",告诉你数据在哪一层、哪个位置。
CoW 是"批注复印机",谁要改内容谁自己复印一份,不污染公共书架。
一句话收束:共享靠 Page Cache,隔离靠 CoW,内容靠 OverlayBD,通道靠 ublk + io_uring,虚拟化靠 Firecracker,而 mmap 是把这些串起来的那根"线"。
参考资料
- DSec: DeepSeek Elastic Compute — A Sandbox Infrastructure for Effective Agentic Training at Scale. arXiv:2609.22978v1, 2026-09-19. DeepSeek-AI & Tsinghua University.
- AgentENV: https://github.com/kvcache-ai/AgentENV (含 DSec 使用的 Rust OverlayBD + ublk 存储栈)
- PVM(AgentENV 的非嵌套虚拟化模式): PVM: Efficient Shadow Paging for Deploying Secure Containers in Cloud-native Environment, SOSP '23, pp. 515–530(ACM DOI 10.1145/3600006.3613158)
- Nydus: https://github.com/dragonflyoss/nydus
- Kata Containers: https://katacontainers.io/
- Firecracker: https://github.com/firecracker-microvm/firecracker
- kubernetes-sigs/agent-sandbox: https://github.com/kubernetes-sigs/agent-sandbox
- OpenSandbox: https://github.com/opensandbox-group/OpenSandbox
- AIO Sandbox: https://github.com/agent-infra/sandbox