3355 字
17 分钟
给 AI Agent 选沙箱:七个角度、一条隔离阶梯和两条主线

本文是「沙箱底层机制」系列第 1 篇(总览)。后续各篇会展开:缺页中断与 CoW 家族、逃逸案例复盘、K8s 声明式模型、Wasm/eBPF/GPU 三个编外话题。

Agent 要执行 LLM 生成的代码,这件事本身就是一个新的威胁模型——传统容器安全体系假设”代码是自己人写的,只是可能有 bug”,而 agent 场景下代码是模型现场生成的,你既不能审计也不能预测。于是”选一个什么样的沙箱”成了 AI Infra 绕不开的问题。

前段时间我做了一轮比较深的调研,从 syscall、namespace 这些地基一路挖到 microVM 的缺页处理,最后沉淀成一份二十来章的文档。这篇把主干提炼出来——具体参数会过时,提问的方式和底层机制不会。

不要停留在参数对比:从七个角度提问#

沙箱选型最容易犯的错是直接对着参数表比较:“冷启动 60ms vs 150ms”、“支不支持 snapshot”。参数会随版本变,而且厂商自述的数字基本都没有第三方基准。更有效的方式是从七个角度逐个追问:

  1. 隔离——攻击者拿到任意代码执行后,离宿主机内核还有几层?
  2. 启动——冷启动路径上哪些步骤能被 snapshot 掉?恢复时内存怎么来?
  3. 资源——CPU/内存超卖时,是软性节流还是硬分区?GPU 能不能挂?
  4. 存储——warm pool 和持久卷能不能共存?状态往哪放?
  5. 调度——能不能进 K8s?还是要专用节点池自成一体?
  6. 网络——出口流量能不能做到域名级控制?
  7. 镜像——分层共享怎么做?发一次版要传多少字节?

每个角度上不同引擎给出的答案差异巨大,而且角度之间存在相互制约——后面会看到”持久卷和 warm pool 的矛盾”(存储 vs 启动)、“强隔离和 K8s 原生的矛盾”(隔离 vs 调度)。选型的实质是决定在哪几个角度上让步。

隔离:四种哲学,不是四个档次#

隔离值得第一个展开,因为它常被简化成”容器不安全、microVM 安全”,但底下其实是四种完全不同的哲学:

沙箱隔离的四种哲学

  • 容器(runc):所有沙箱共享宿主机内核,namespace 划视野、cgroup 限额度、seccomp 砍 syscall。哲学是”发出来我拦一部分”。真实的逃逸案例分三类由浅入深:配置性逃逸(挂了 docker.sock、给了 --privileged、误容忍危险 capability)、运行时组件漏洞(runc 的 CVE-2019-5736,靠时序竞争改写宿主机上的 runc 二进制)、内核漏洞(Dirty COW、Dirty Pipe)——前两类靠配置纪律能防,第三类共享内核就躲不掉。
  • gVisor:用户态实现一个”内核替身”(Sentry),guest 的 syscall 先被它接住,绝大多数不落到真内核。哲学是”发出来我替你处理”。对内核漏洞这一类有真实优势(不共享宿主页缓存,Dirty COW 这种打不到它),代价是兼容性和性能税。
  • microVM(Firecracker/Kata):硬件虚拟化加一道 EPT 边界,guest 有自己的完整内核,宿主内核的洞和它基本无关。哲学是”内核之上再加一道硬件墙”。代价在别的角度付:/dev/kvm 依赖、GPU 缺口、没有热迁移。
  • Wasm:指令集里压根没有发起 syscall 的指令,能力靠 WASI 显式授权(预先开好目录句柄才能碰文件)。哲学是”能力从未存在过”。连 ROP 攻击都从结构上防死了——调用栈和线性内存物理隔离,返回地址是代码碰不到的。代价是生态:负载得能编译成 Wasm。

看懂”哲学不同”而不是”档次不同”,很多争论就消解了。比如 eBPF 的”验证后信任”(静态证明安全再放进内核跑,靠禁止无界循环绕开停机问题)和 microVM 的”隔离后限制”是正交的思路,不存在谁取代谁。

启动:snapshot 恢复省掉的是 boot,不是分配#

冷启动慢,慢在从”分配资源”到”guest 内核 boot、init 进程、运行时就绪”这一长串。snapshot 的思路是把”boot 完成后的状态”整个存下来,恢复时跳过 boot/init——省掉的是引导过程,不是资源分配本身

恢复时内存怎么来,有两条路,密度收益完全不同:

  • File backend:guest 内存直接 mmap 映射 snapshot 文件,多个沙箱未改动的页真正共享宿主机物理内存——这是高密度部署的核心来源。
  • UFFD(userfaultfd):缺页时由用户态进程接管、按需填页。它真正的卖点是”用户态接管”带来的灵活性(可以从网络、从压缩存储拉页),而不是”懒加载”这三个字本身;每个沙箱的页是各自私有的,只共享磁盘 page cache,密度收益比 File backend 弱一档。

这里还有个反复出现的底层套路:复用缺页中断做正经事。fork 的 CoW 靠它(写时才复制,refcount 从 2 掉回 1)、UFFD 懒加载靠它、Wasm 的边界检查也靠它(guard page + 硬件缺页替代软件边界判断)。一个机制在三个场景被挪用,后面还会提到。

资源:软节流与硬分区,以及 GPU 这个缺口#

容器的资源限制走 cgroup,本质是记账式软隔离;microVM 是启动时划走一块的硬分区。这个差异在超卖时暴露:

  • CPU 是可压缩资源:超了就节流(throttle),表现为变慢,不死。
  • 内存是不可压缩资源:超了就 OOM Kill,表现为猝死。严格说内存本可以往 swap 压,但 K8s 场景通常禁用 swap,这条退路被堵死,才只剩 OOM 一条路。

动态调整能力这几年在收敛:K8s 的 In-Place Resize 已 GA(不重建 Pod 改配额),microVM 侧 virtio-mem 甚至能超过启动时的内存规格热扩。但 GPU 是个例外——内核并不直接管理 GPU(显存由 GPU 驱动自己管),所以它没法像 CPU/内存那样精细切分,共享只有两条路:时间片(软件层,只切算力不切显存,共享显存池有被挤爆的风险)或 MIG(硬件层真隔离,但每份算力打折)。而 Firecracker 这条路线目前压根挂不了 GPU,这对”沙箱里跑训练/推理”的场景是硬约束。

存储:warm pool 与持久卷的固有矛盾#

warm pool 的逻辑是”预先启动一批就绪沙箱,请求来了直接领走”;持久卷的逻辑是”这个沙箱挂着属于某个用户的状态”。矛盾在于:挂了卷的沙箱是”有主”的,没法预先生产——K8s 的 Pod spec 在创建时就钉死了挂载关系,不能等领走时再热挂一块卷进去。这不是网络协议问题,是 Pod 不可变性的设计约束。

所以持久化基本三条路:sidecar/daemon 把状态异步搬走、靠 snapshot 把整机状态存下来、或者干脆上 durable execution 框架把状态外置。哪条都不免费,选型时要想清楚你的 agent 到底需不需要 POSIX 语义的持久盘——很多场景一个对象存储接口就够了。

调度:microVM 为什么挑节点#

microVM 需要 /dev/kvm 做硬件加速——没有它虚拟化退化为纯软件翻译,开销从”百分之几”变成”几十倍”,毫秒级冷启动无从谈起。这意味着 microVM 引擎进 K8s 集群必须圈一块专用节点池(往往还要特权容器、宿主内核版本要求、本地有状态盘)。

另外两个常被忽略的运维约束:Firecracker 没有 live migration,节点下线时实例只能销毁重建;快照是 host-bound 的(绑定宿主机的 CPU 特性和内存布局),不能随便搬到异构节点上恢复。这些都不是性能参数,但决定了你的运维模型。

网络:域名级出口控制是引擎之外的仗#

agent 沙箱的网络需求很具体:“允许访问 pypi 和 GitHub,禁止其他”。麻烦在于 K8s NetworkPolicy 只有 L3/L4,表达不了域名。生产上真正能落地的是三条路:强制 CONNECT 代理、SNI 嗅探代理、或 Cilium 的 toFQDNs。

更有意思的是各引擎在这一层的差异其实不影响结局:gVisor 用自研用户态协议栈(netstack)在自己层里拦,microVM 走 virtio-net + TAP 把数据包全部交由宿主机处理、hypervisor 层零过滤——但无论哪种,域名级 egress 控制最终都收敛到宿主机层执行。也就是说这个能力和选哪个引擎基本正交,不应期望由引擎层解决。顺带一个陷阱:基于 DNS 解析结果做放行是有 TOCTOU 窗口的(DNS rebinding——校验时解析到合法 IP,连接时已经换了),设计防护时要意识到”检查”和”使用”之间隔着时间。

镜像:分层共享的三种做法#

容器世界的 OCI 镜像是内容寻址的分层结构,基础层天然跨沙箱共享,发版只传增量。到了 microVM 世界这个便宜没了:Firecracker 没有原生分层,只认 raw 块设备,想要分层得靠 guest 内 overlayfs 或宿主机端 dm-thin/reflink 自己拼。QEMU 系的 qcow2 倒是原生块级分层——它的簇表和内存页表在结构上同构,backing file 和 fork 的”共享只读源”同构,又是同一个 CoW 套路在磁盘层重放

文件共享方案里,virtio-fs(FUSE + DAX,宿主机页缓存直接映射进 guest)性能远好于协议交互冗长的 virtio-9p;而 Kata 的实际架构是”guest 系统镜像走 virtio-blk、容器内容走 virtio-fs”两条独立路径——调研前我以为的”qcow2 vs virtio-fs 二选一”根本是个伪命题。

深入之后,是两条反复出现的主线#

调研做到后半段,我发现二十来章的内容其实在反复回答两个问题:

主线一:隔离/信任问题,在不同粒度上重复出现。 内存页的 CoW、磁盘簇的 qcow2、整文件的 OverlayFS、整内核的容器/gVisor/microVM 阶梯、内核内代码的 eBPF 验证器、字节码层的 Wasm——每一层都在问同一个问题:“这份资源能不能共享?出事了信任边界画在哪?”

主线二:管理/编排问题,从”改一次”走向”持续对账”。 内存气球是改一次,cgroup 是记账,K8s 的 reconcile loop 是持续对账——声明期望状态,controller 无限循环地把现实拉向期望。理解了这条主线,CRD/Operator 就不神秘了:CRD 是教 K8s 认识一个新名词,controller 是让这个名词动起来的动词。连”沙箱超时要先 snapshot 再 terminate”这种应用层设计,都是两条主线的交点——快照捕获状态(主线一),controller 持续监听触发(主线二)。

带着这两条主线再看任何新引擎的文档,很快就能定位它每个设计在回答哪个老问题。

结论:引擎在商品化,价值在 facade#

把公开的几个主流开源引擎放到这七个角度上逐一评估之后(Firecracker 系的 E2B、RustVMM 系的 CubeSandbox、K8s Operator 路线的 OpenSandbox),我的结论是:

引擎本身在快速商品化。 冷启动都在卷毫秒级,snapshot 都在做,SDK 接口在向事实标准收敛(E2B 兼容成了新引擎的标配卖点)。单看引擎能力,差距在缩小。

真正拉开差距的是运营模型。 microVM 路线普遍刻意不用 K8s——特权、宿主内核依赖、本地有状态盘、毫秒级调度,正是 K8s 的盲区,强行整合是与其设计相悖的;容器路线 K8s 原生、零新增基建,但隔离强度和持久化有天花板。选引擎实质上是在选运营模型:要不要为它立专用节点池?团队愿不愿意运维第二套编排系统?

所以架构上的正解是把投资集中在 facade 层:对上暴露统一的沙箱 API(隔离强度、要不要持久卷做成路由参数),对下把引擎当可替换的 provider。引擎会换代,接缝是长期资产——这和上一篇 AI Coding 工作流 里”边界测试套件是长期资产”是同一个道理:易变的部分后面,永远站着一个值得投资的稳定接口


最后说一句方法:这轮调研全程是和 AI 结对完成的——每个角度先让它做源码级调研,再起独立的子代理做对抗式核验(专门找第一轮结论的漏洞),错的当场纠正再回填文档。比自己啃源码快得多,比直接问 AI 可靠得多。这套方法本身,值得单独成文。