← 返回
IT技术

Agent 要跑自己写的代码,沙箱选型得换一套威胁模型

✍️ zhirenhun 📅 2026/11/2 👁 5 阅读 ⏱ 104 分钟
Agent 要跑自己写的代码,沙箱选型得换一套威胁模型

你的 Agent 刚写了四十行 Python,想跑起来。你得决定在哪儿跑。

于是你去找指导,找到的是一架梯子。裸进程、容器、加固容器、用户态内核,最顶上还有 microVM。每一级的解释都围绕“有多难逃出去”,言下之意是预算允许就尽量往上爬。可这个排序背后,并没有任何关于模型生成代码实际运行行为的观测分布。

本文要测的正是这个分布,共五个实验:三个在一台固定配置的主机上,程序语料在一个一次性 VM 上,暂停/恢复基准测试则针对托管产品。各自的局限见下文 Scope 一节。

第 1、2 节测量失败分布,并据此推导需求。第 3 节用一组失败用例测试五个隔离层级,第 4 节沿三个维度解读得到的矩阵,最后落在一个配置无法弥合的缺口上。第 5 节为一个按此分布构建的执行层定价,第 6 节是决策框架:你该待在哪一级,以及如何盘问那些向你推销上一级的人。

TL;DR

引入的威胁模型

逃逸难度阶梯只回答了威胁模型的一部分,对更大的那部分几乎没涉及。它针对的是不可信的用户代码——作者是想要逃出去的人:提交的竞赛作品、多租户构建运行器、浏览器标签页。它的组织性假设是:对手有意图、有耐心、有计划。

你那四十行代码的作者这些都没有,它根本没有意图。但攻击者仍能借它之手,把指令埋进你的 Agent 被要求读取的数据里——这正是硬件边界仍留在需求清单上的原因。

威胁模型是一个分布,而非最坏情况

你要防御的是各类失败构成的概率分布。最坏情况告诉你事情能坏到什么程度,分布则告诉你该把资源花在哪里。

当主体和尾部需要不同的防御时,两者会严重脱节。如果常见故障靠配置就能挡住,罕见故障才需要硬件边界,那么你买了边界却跳过配置,结果就是:几乎对所有会发生的故障都毫无防护,而对几乎不会发生的故障却防护到位。

沙箱几乎一生都在空转

第二个差异在于频率,而非内容。传统沙箱假设偶尔使用:提交来了,在盒子里跑一次,然后把盒子扔掉。没人会问盒子每秒花多少钱。

Agent 循环看起来正好相反。模型写代码,沙箱运行,输出回到上下文,模型再写更多,沙箱于是成了高频组件,其启动延迟每一轮都落在用户面前。

我们追踪了四个模型谱系、23 个多步任务中的 92 个 Agent 会话,频率这一半比我们预想的要弱。全部 92 个会话中,会话执行生成代码的中位数是4 次,最忙的也只有 10 次。如果你打算让沙箱在每项任务的多次执行中摊销成本,这些追踪数据并不支持。

我们的测试框架把每个会话限制在 12 步,而步骤包含模型调用,所以几十次执行在结构上就不可能,追踪数据也无法告诉你预算更长的循环会怎样。92 个会话中,62 个完成了任务,11 个触到上限,19 个提前停止,其中 16 个从未产出可运行的代码块。

另一半数据也基本站得住脚。会话时长的中位数中有94.5%处于空闲状态,最不空闲的会话也有78.1%的时间在空转。这个中位数覆盖的是至少执行过一次代码的76个会话,比上面执行次数统计所依据的92个会话范围更窄;按全部92个会话算,空闲占比是95.6%,但从未运行过代码的会话按定义就是100%空闲,说明不了什么问题。实际耗时都花在了哪里:

所有被追踪的Agent会话实际耗时分布汇总。等待模型占95.4%,服务商重试占2.3%,执行生成的代码占2.3%,harness开销四舍五入后为零。这是汇总后的整体拆分,而非典型会话的情况,所以与上面94.5%和95.6%的中位数对不上。

92个会话中有26个遇到服务商拥塞,其中11个属于执行过代码的那76个会话。我们把这种拥塞与思考时间分开记账,但只按合并区间中的分摊份额计入(见第3节);把退避等待从实际耗时中剔除后,76个会话的中位数也只从94.5%变成94.3%。

可见Agent循环里的沙箱基本是个空转的组件,而真正没人细究的开销,恰恰发生在它空转的时候。

范围

我们用良性提示词从四个模型系列生成了192个程序,每个都在插桩环境下运行了两次。我们让一组15个失败程序在五种隔离层级、两种网络策略下各运行十次,共得到1,500行数据。我们按三种方式、覆盖全部五个层级,计算了让环境跨越空闲间隙持续存活的成本。我们端到端追踪了92个Agent会话。我们还将暂停与恢复机制与DigitalOcean MicroVMs做了基准对比。harness、语料库和行级结果都已发布在GitHub上,本文每个数字都可以对照其背后的数据行核验:Sandboxing-LLM-Generated-Code-Needs-a-Different-Threat-Model

有三条限制贯穿始终。整个测试集里没有任何一项尝试内核漏洞利用或容器逃逸,所以本文无法衡量逃逸抵抗力——而这恰恰是分级体系的核心;因此,文中所有关于“各层级难以区分”的说法,都要结合这一点来理解。生成语料中的每条提示词在构造上都是良性的,所以零恶意率并不能说明对抗性提示词下的生成情况,本文也不把它当作相关证据。另外,每个被追踪的会话都是自主运行的,因此这里测到的空闲时间都不是在等人;如果循环中有真人参与,空闲只会更多、不会更少,而更长的间隔会让空闲计时器回收的时长超过驻留表所示。

第 1 节:失败分布,或者说模型生成的代码实际会做什么

随便问一个搭建过 Agent 沙箱的人他们在防什么,你听到的都会是死循环。模型写下 while True,容器占满一个核,账单就来了。

于是我们去实测了一番。

我们用 48 条良性提示词,跨四个模型系列生成了 192 个程序,覆盖数据处理、文件操作、抓取、长时间计算以及安装并运行这几类任务。每个程序都在插桩下运行两次,分别置于两种环境,下文按两种叫法区分:裸跑第一遍,在 PEP 668 下的原版 Ubuntu 解释器;装备第二遍,预装常用包的 virtualenv。共 384 次执行。没有对抗性内容,没有越狱,没有红队提示词。

在进入主流程的装备轮运行中,失控计算出现在 2.2% 的样本里。

模型生成的代码最常犯的错误,而且差距悬殊,是安装东西。需要说明的是,这个比例取决于我们的语料构成:四分之一的提示词要求安装,而每一次安装尝试都出自这些提示词。下表中的比例仅来自配备环境的那一轮,n=192 个程序,每个运行一次:

失败类别(配备环境,n=192;178 个进入 main) 比例 分母
无隔离失败 78.1% 已执行
F3:尝试在运行时安装软件包 12.5% 已执行
F4:写入工作目录之外 12.0% 已执行
F3:假定某个依赖存在 6.8% 已执行
F1:资源耗尽 2.2% 进入 main
F5:输出洪泛 1.1% 进入 main
F2:意外外联 0 进入 main
F4:程序逻辑触达文件系统 0 进入 main
F5:进程存活时间超过父进程 0 进入 main
任何恶意行为 0 已执行

第六类 F6 没有单独一行,因为良性提示词不会产生它:它属于注入类尾部情况,即攻击者的指令通过模型被要求处理的数据传入,导致代码窃取数据或植入持久化。若有发生,它会出现在“任何恶意行为”那一行。

沙箱要求通常针对的三种失败模式从未出现:进入 main 的 178 次配备环境运行中为零,136 次裸环境运行中也为零,相应的探测均在同一主机上验证过。大家惯常引入的威胁模型描述的是可能发生的事,它高估了戏剧性的那些,却低估了占主导的乏味那一个。

依赖路径才是分布的主体

近五分之一的运行触及了依赖系统——配备环境那一轮的 192 次运行中有 37 次,这还是个下限,因为它的软件包是预装的;裸环境那一轮更高,如下所示。这可以拆成两种失败,它们在日志里看起来相似,实则截然不同:假定某个依赖存在,以及在运行时安装依赖。只有 5 次是未经提示、在提示词从未要求安装的类别中假定依赖;其余 32 次来自安装类提示词,其中包括 13 次假定依赖中的 8 次。

假设依赖已存在是最安静的一类,在装备齐全的环境里占 6.8%,192 次运行中有 13 次。模型写下 import pandas,环境里没有 pandas,程序在第 3 行就挂了。把预装包全部移除,同样的习惯代价大得多:在裸环境里,74 个以状态码 1 退出的程序中有 40 个正是这种情况,其中 22 个卡在 pandas,6 个卡在 numpy,5 个卡在 BeautifulSoup,其余是长尾。烦人,但不危险。在裸环境中,近 30% 的模型生成程序(192 个中有 56 个,包括假设包已存在的,以及安装被拒后留下同样缺失导入的)根本走不到自己的逻辑,所以你沙箱的资源限制对它们从未生效。

运行时安装依赖才是真正要紧的一类,占 12.5% 的运行,并且打开了供应链攻击面。这里套用下文对出网流量同样的折扣:全部 24 次尝试都来自安装类提示词,是该类 12 条提示词生成的 48 个程序中的一半,分布在 12 条中的 10 条上。这个频率是我们造成的,不是模型。模型选择的是方式:运行时访问公共仓库、从错误信息里照搬 --break-system-packages 覆盖参数,以及影响范围取决于它碰巧写了哪种调用形式。这些发现经折扣后依然成立。

危险在于这个包可能根本不存在。Spracklen 等人(USENIX Security 2025)跨 16 个模型生成了 576,000 个代码样本,发现 223 万次推荐包提及中,有 440,445 次(19.7%)指向 PyPI 或 npm 中不存在的包。商业模型幻觉率 5.2%,开源模型 21.7%,共涉及 205,474 个不同的虚构包名。

持久性把这个问题从 bug 变成了攻击面。同一批作者把 500 条触发幻觉的提示词各重跑十次。43% 的幻觉包名在十次运行中每次都出现,58% 出现不止一次。能复现的幻觉,攻击者就能枚举、注册并守株待兔,这就是“slopsquatting”。

于是模型编出一个看似合理的包名,明天再编一次,而你 Agent 的沙箱乖乖地对公共仓库执行 pip install。这里面没有任何恶意,所以针对恶意代码作者的防御措施会漏掉它,结果仍然是攻击者控制的代码在你的边界内执行。

当我们设下一道防线后发生了什么

裸跑在 PEP 668 下直接失败——这道 externally-managed-environment 防护会拒绝在虚拟环境之外执行 pip install。二十四个程序尝试安装,二十三个被拒。

有一个没被拒。它执行同样的 sys.executable -m pip install,读取报错,然后重试:

result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
    # Retry for externally-managed environments
    result = subprocess.run(cmd + ["--break-system-packages"], ...)

随后它访问了 PyPI,下载了一个 wheel 并解包到 /tmp。在那一轮全部 192 个程序中,它是唯一写到工作目录之外的程序,共涉及 7 个外部路径。一个程序,串起整条链路:假定某个依赖存在,在运行时安装它,绕过宿主机的打包策略,连上公网,逃出自己的工作目录。全程没有任何攻击行为。那个覆盖标志就印在模型当时正在读的报错信息里。

那 23 次拒绝是被宿主机打包策略拦下的,而非被隔离机制拦下的。一旦 Agent 的工具在 virtualenv 里运行,PEP 668 就立刻失效——这既是常见做法,也正是装备了工具链的那一轮所做的。安装尝试次数仍是 24 次;真正生效的安装从 1 次变成了 20 次。

第二个程序写的是 os.system("pip install rapidfuzz"),而不是调用 sys.executable -m pip,于是裸 pip 经由一个不含 venv bin 的 PATH 解析,找到了系统解释器,被拒绝。只要 PATH 稍有不同,同一行代码就会污染宿主机。同样的意图,同样的包,影响范围却天差地别,全看模型碰巧写了哪种调用形式。

文件系统触达大多并非表面看起来那样

装备工具链的那一轮共 192 个程序,其中 12% 写到了工作目录之外。这听起来像是文件系统隔离出了问题,但大多并不是。

那 23 个程序每一个都跟安装有关:pip 把 wheel 解包到 /tmp 和 site-packages。它们合计记录了 5,768 个工作目录之外的路径,其中一个程序在安装 nltk 等七个包时,就产生了 1,390 个。

那 40 个文件操作程序,全部工作就是遍历一个杂乱的工作区、重命名重复文件、处理非 UTF-8 文件名,它们写到目录之外的次数是零。全部 40 个数据处理程序同样如此。

文件系统触达的来源 程序数 写到工作目录之外
安装并运行 48 23
普通文件与数据处理 80 0
抓取与长时间计算 64 0

把这些统统概括成“12% 的模型代码写到了沙箱之外”,虽然属实,却极具误导性。在这个语料库里,文件系统触达是安装软件包带来的后果。禁止运行时安装,大部分文件系统暴露也随之消失;允许安装,那么生成代码里再怎么讲究路径规范也无济于事。

测量结果为零的失效模式

有三种情况没有出现,各自原因不同。

意外外联(F2):零。第一轮有23个程序、第二轮有49个程序发起了对外连接,每一个都在执行被要求做的事。第二轮中,26个是给定URL的抓取任务,23个是拉取软件包的安装任务;第一轮中,22个是抓取程序,最后一个是唯一突破PEP 668限制的安装。在良性语料中,外联是被请求的外联。

由程序逻辑触发的文件系统访问(F4-program):零,原因同上。

进程存活时间超过父进程(F5-immortal):零,两轮均如此。由良性提示词生成的程序,没有任何一个派生出能存活下来的东西。

不死进程这一类几乎出现在每一份沙箱需求清单上,而在这里一次都没发生。零观测不能证明它不可能发生,只能说明常规Agent工作没有产生它。被提示词注入的Agent通过双重fork留下信标是显而易见的手法,所以从对抗角度看它确实可达,属于需要评估规模的尾部风险,而不是不加审视就继承下来的假设。

尾部风险真实存在,而且形态不同

这一切都不意味着你可以撤掉边界。

对抗场景很少以用户发来恶意提示词的形式出现。它出现的方式是嵌入在Agent被要求处理的数据中的指令:一个网页、一份PDF、一个CSV单元格、它被要求总结的仓库里的一条代码注释。恶意意图的作者和代码的作者是不同的人,模型夹在中间,成了混淆代理。

被注入操纵的代码很难抓,因为直到最后一步之前,它看起来和良性语料一模一样。一个程序读取工作目录、生成摘要、再把它发到某个地方,这三件平常事按某个顺序组合起来,没人会觉得可疑。第3节的测试组里包含三个这样的程序,因为是手写的——良性提示词不会产生它们:一个窃取预置的金丝雀凭证,一个把捆绑的工作集外传,一个在边界之外放置惰性启动钩子。

普通提示词产生的是事故;攻击者产生的是注入。只针对后者的沙箱昂贵、缓慢,而且对看到的东西几乎全都判断错误。只针对前者的沙箱便宜、快速,但在真正要紧的那一次会带来灾难性后果。

这项研究无法告诉你的是两者之间的比例,原因在Scope一节已经说明:良性语料本身就有局限,这里没有任何数据能说明真实输入路径中携带注入尝试的频率有多高。这个数字取决于你的产品,而不是我们的追踪数据,任何声称比例是98/2的人都是在编造。即便没有这个比例,不对称性依然成立,而且这是成本和防御形态上的不对称,不是频率上的不对称:一次意外只会浪费一次执行,设一个标志位就能拦住;而一次注入会让你损失边界范围内的一切,只有边界本身才能拦住。两种防御无法互相替代,所以即便你测不出比例,也仍然不能只买其中一种。

第2节:推导出的需求,即隔离必须为这种分布保证什么

以下需求的权重依据是第1节实际测得的数据,而不是想象中令人恐惧的东西。每项需求都列出了它涵盖的类别;无法追溯到某个类别的需求不该出现在这份清单上。

1. 每次执行的硬性上限,在运行时之下强制执行

涵盖:F1(资源耗尽)、F5(输出洪泛)

CPU、实际耗时、内存、进程数、磁盘和输出字节:全部六项,全部按每次执行计,全部由生成代码无法触及的机制强制执行。解释器内部的超时只是一句建议,一个阻塞在C扩展里或执行fork的程序会活得比它更久。

输出字节也归入此项,尽管洪泛只占配备通过运行的1.1%,因为这种故障不是局部性的:一个打印400 MB的程序不仅填满磁盘,还会填满模型的下一个上下文窗口,这正是需求7的问题从需求1的门走了进来。

资源耗尽只占到达main的配备通过运行的2.2%,但上限仍然排在清单第一位:它是清单上最便宜的一项,而且那种情况是无界的,其他情况只是糟糕而已。

2. 默认拒绝出站流量,依赖安装作为策略而非能力

涵盖:F3(依赖破坏)、F2(意外出站),以及F4的大部分后果

这其实是两件事,人们常把它们写成一条要求。

默认拒绝出站流量说起来容易,做起来容易出错。真正要防的失败情形是:沙箱连上了互联网,而根本没人决定它可以连。第 1 节在良性代码中没有发现任何未经请求的出站流量,但每个抓取任务都需要网络,每次安装也需要网络,而一个没有网络的 Agent 沙箱不过是个计算器。

把依赖安装纳入策略管控是更难的一半。既然被推荐的包名中有 19.7% 指向并不存在的包,且 43% 的幻觉在每次尝试中都会重现,那么对着公共仓库执行 pip install 就等于一个未经审查的代码执行原语。应当提前从固定的包集合中解析依赖,并由你自己掌控的源提供:连不上仓库的安装,自然装不了攻击者昨天注册的包。

不要把平台自带的包管理防护当成这道控制。第 1 节就见到一个模型从报错信息里读出一个参数,绕过了 PEP 668。

3. 默认临时化,持久化须显式且范围收窄

覆盖:F4(文件系统可达范围)、跨运行污染

每次执行都从同一状态开始,结束后不留痕迹;任何必须保留的东西都要显式命名、限定范围并有意挂载。第 1 节中的文件系统可达范围绝大多数是安装带来的副产品,因此,如果沙箱把 site-packages 跨执行保留下来,上一次运行装了什么,就会原样带到下一次。

临时化还让其他要求变得可审计。如果每次运行都从干净状态开始,那么残留的产物就是有东西越过了边界的证据。如果运行之间不断累积,那就什么都证明不了。

4. 不携带环境凭据

覆盖:F6(注入的尾部风险)

执行环境中不应持有任务并不需要的任何密钥。没有云元数据端点,没有继承的环境变量,没有挂载的凭据文件,也没有哪个服务商 SDK 悄悄解析出默认配置。

意外发生的概率分布并不足以支撑这一条,所以只能从尾部风险的角度来论证。良性的模型代码不会去找凭据。被注入操纵的代码会去找,因为这正是它的目的;而“沙箱泄露了一个金丝雀令牌”与“沙箱泄露了生产密钥”之间的距离,早在任何代码运行之前就由一项配置决策决定了。

改用中间凭证:从边界之外签发、限定范围、短期有效且可审计的句柄,而不是密钥本身。

5. 按注入尾部规模配置的真实硬件边界

涵盖:蓄意绕过边界的尝试,这类尝试横跨所有类别,而本文没有任何测量覆盖它

抗逃逸适用于全部六个类别,而不是与它们并列。硬件边界换来的是抵御蓄意逃逸尝试的能力,这里指由注入操纵的代码去探测它与其他租户共享的内核。

事故分布中没有任何一项需要硬件虚拟化。上限约束住失控循环,出站策略约束住不想要的安装,挂载命名空间约束住文件系统可达范围。应当按尾部来配置边界规模,问一问:你的爆炸半径里有多少是别人的数据,你的输入路径中注入有多大概率。多租户执行且密钥在可达范围内是一种答案;单租户、只循环处理自己的文件则是另一种答案。

Scope的第一条限制在这里最为致命:没有任何测量能说明买硬件边界究竟是为了什么。

6. 空闲成本低,启动足够快

涵盖:无直接涵盖。这是一项经济性要求,并约束其余所有要求。

空闲成本低是硬性要求。在中位会话 94.5% 的时间里,环境要么空转烧钱,要么根本不存在、需要重建,要么与其他会话共享以同时避免前两者。阶梯的每一级都逼你做出这个选择,第 4 节按层级为这三种方案定价。一种只能常开或常关的隔离技术,回答的是这个工作负载根本没问的问题。

快速启动确实重要,但没宣传的那么大。沙箱位于任务关键路径上,它的启动时间落在用户等待的每一次执行上。但在全部 92 条追踪会话中,每会话执行次数中位数为 4,这笔成本只付四次,而不是几十次。对照第 3 节的启动数据,加固容器在四次执行中每次 171 ms,合计 0.7 秒;即便是实测最重的一级——microVM 的 1.8 秒,每会话也才约 7 秒。在这个频率下,加固容器的占比接近舍入误差,远不是频率框架所暗示的主导成本。

7. 向模型提供忠实且有边界的反馈

涵盖:F5(输出洪泛),以及每次重试的正确性

沙箱的输出就是模型的下一次输入,因此反馈路径属于执行语义的一部分,而非日志层面的问题。

“忠实”意味着模型能看到真实的退出状态、真实的 stderr,以及任务停止的原因。“被杀掉”和“被拒绝”是两种不同的事实。只被告知“被杀掉”的模型会原样重试;而被告知“超出了进程限制”的模型则能换一种做法。

“有边界”意味着反馈本身不能成为故障源。截断要从中间截:输出的头部承载程序发现的内容,尾部承载 traceback,因此只截尾部的粗暴做法会把所有错误都藏在一次成功的输出转储后面。

这份清单的侧重

需求 1、2、3 和 7 针对的是分布中实测的主体部分,而这四项都属于配置问题。它们都不需要更强的边界,只需要一个配置正确的边界,成本低得多。下一节会检验这份清单的一部分:需求 2 只测网络策略开或关,需求 7 完全不测。需求 4 和 5 针对的是尾部,它们能向上覆盖多少取决于你的输入路径和爆炸半径。

第 3 节:实验——针对各隔离层级的故障测试组

第 2 节的清单只有在你能够说清哪个层级交付了哪些条目时才有价值。

五个隔离层级,也是大多数团队在阶梯上选择的区间:

层级 说明
T1 带资源限制的裸子进程
T2 默认 Docker
T3 加固版 Docker(seccomp 配置、丢弃 capabilities、只读根文件系统、PID 与内存上限)
T4 gVisor,用户态内核
T5 自建的 Firecracker microVM

六个失败类别共涉及十五个程序:F1 类六个,F3、F4、F6 类各三个,F5 类两个,F2 类一个。这些数字加起来是十八而非十五,因为有三个程序带有复合标签,同时计入两个类别:两个既破坏系统包又在自身目录之外写入,一个既耗尽资源又刷屏输出。每个程序在每个层级各运行十次,覆盖两种网络策略。每种策略 750 行,共 1,500 行。microVM 层级的前 300 行不在其中:一个测试框架的 bug 吞掉了它的环境变量,因此该层级重新运行,被丢弃的那一轮单独发布。

程序来自两个渠道。七个是采集而来:来自第 1 节语料库的真实输出,并非为制造失败而编写。八个是合成的,手工编写,而且不只是为了那些零命中的类别,因为其中两个属于 F1、三个属于 F6。只包含良性提示词恰好产生的那些内容的一组测试,根本无法触及尾部,而有些效应需要刻意激发才能以有用的强度测量到。

主机环境固定:一个内核、一个 Docker 版本、一个 gVisor 构建、一个 Firecracker 构建,全部记录在案。运行顺序按记录的种子随机化,每个单元开始前都等待主机回到负载区间,上限 90 秒。1,500 个单元中有一个在达到上限后仍然启动,即 deny 策略下 T2 的 F6-03-persistence,主机负载为 7.92;其余所有单元启动时负载均低于 4。测量窗口内的 CPU steal time 为2,282 个样本平均 0.00%、最高 1%,因此主机并未悄悄与邻居共享核心。

预先承诺

在读取任何一个汇总数据之前,我们写下了如果结果与论点相悖我们会怎么说,并承诺照说。

那份文件点名了具体结果。如果加固容器在事故类别上与 microVM 无法区分,那么文章的阶梯就把加固容器上移,举证责任转移到任何建议更多防护的人身上。如果 microVM 的启动开销可以忽略,低于约 250 ms,那么频率经济性论证就失去力量,文章会如实说明。它还点名了要避免的失败模式:报告一个通缩性结果,然后悄悄把建议又收回来。

第一个触发条件发生了,第 4 节就是据此写的。第二个没有:microVM 的 p95 启动时间为 1,831 ms,远非 250 ms,因此就启动开销而言,频率论证经受住了自己的检验。削弱它的是另一项完全不同的测量,即上文报告的会话轨迹,它发现中位会话中只有四次执行,而非数十次。

核心结果图

隔离矩阵,运行时配置了一条通往宿主机本地收集器的可用路由:

在可用出站路由下,按故障类别与层级划分的隔离情况。 数值为程序完全隔离的百分比,每项运行十次,行标签上标注了每个类别包含的程序数。

F2 只有一个程序,因此该行是单一二元结果换算成的百分比;F5 有两个;只有 F1 拥有六个程序,其百分比才足以描述一个分布。请将这些数据理解为该网格中特定程序的逐类结果,而非故障类别的整体比率。

单元格汇总十次运行,采用最差结果优先规则;自身结果不一致的单元格以拆分形式报告,而非按多数投票处理:一种策略下 75 个中有 4 个,另一种策略下有 7 个。

六个类别中有五个从默认 Docker 到硬件虚拟化 microVM 始终持平。 只有 F1 发生变化,而且方向是错的。

持平不等于已隔离,而且这一点是双向的。默认 Docker 在 F3 和 F4 上已经达到上限,再往上也无济于事,因为更高的层级本就不需要。从 T2 往上,F2、F5 和 F6 持平在低于上限的水平:往上爬没用,原地不动也没用,它们需要的是配置变更。在 F1 和 F5 上,百分比掩盖了真实的改善,因为完全隔离的门槛很高。在 T2 时,F1 的六个程序全部未隔离;到 T3 时,两个已隔离,另外四个为部分隔离;F5 呈现相同形态,T2 时的一个未隔离在 T3 变为一个部分隔离。加固将这两个类别上的彻底失败转化为有界失败,但未达到此处统计的阈值。

为什么有两种网络策略,以及哪一种才算数

我们把整个网格跑了两遍:一遍拒绝出站,一遍配置可用路由通往回环收集器,后者记录每次命中,并标记任何携带预置金丝雀的请求。

在拒绝策略下,F2 和 F6 在 T1 以上的每一层级都显示 100% 隔离。这只是在复述配置,而非测量层级,因为程序无法通过一个从未挂接的接口外泄数据。而在路由可用的条件下,F2 在每一层级都降为零,F6 在 T1 以上每一层级降至 33.3%,在 T1 处为零。

事先的预承诺确定了:若两种变体结果不一致,以哪一种作为核心结果。两者的分歧几乎到了两种变体所能达到的最大程度,因此上表采用的是可用路由矩阵。

层级阶梯的代价

在禁止外联的网格上测量启动耗时,以沙箱内探针发出的请求到首字节的时间为准,每档 N=150:

档位 p50 p95
T1 裸子进程 14 ms 16 ms
T2 默认 Docker 172 ms 186 ms
T3 加固 Docker 171 ms 189 ms
T4 gVisor 224 ms 255 ms
T5 Firecracker microVM 1,779 ms 1,831 ms

加固没有带来可测量的启动开销:T3 的 p50 与 T2 相差不到 1 毫秒,而这套 flag 组合在整个网格中提供了最好的 F1 隔离度。这里说的只是启动延迟,仅此而已,并不代表这套 flag 组合在运维上没有代价。gVisor 大约多出 50 ms。microVM 是 T2 p95 的 9.8 倍。这三项结论在实际工作路径下同样成立:同一批档位在 p50 下的启动耗时分别为 14、210、209、260 和 1,787 ms,加固依然零成本,gVisor 依然多出约 50 ms,microVM 依然慢一个数量级左右,这里是 T2 p95 的 7.8 倍、p50 的 8.5 倍。

空闲列

没有实测的空闲数据,第 4 节的差距分析就只是断言,所以我们跑了第二轮网格:175 个单元格,275.6 分钟,零 harness 错误。一个 Agent 会话,两段工作,中间隔一段空闲,时长分别为 1、10 和 30 分钟。在空闲期间,我们按档位测量了三种方案:保留环境、销毁后重建、把池化环境交给下一个会话。重建时均清空了页缓存。

空闲时长从何而来

给空闲定价只是问题的一半。另一半是真实空闲有多长、多久出现一次,这正是那 92 个带追踪的会话要回答的。它们的 23 个任务横跨四类:数据整理、日志分析、文本处理和报告生成。

harness 为每个阶段打时间戳:模型调用、代码执行、provider 重试,以及一个 harness 余项,让各部分占比之和为 1,而不是把未计入的时间藏起来。空闲指一次执行结束到下一次执行开始之间的间隔,首次执行之前的引导段和最后一次执行之后的收尾段不计入,单独报告。

有一个注意事项会影响标题结论:provider 退避本身没有单独的时间边界。一次模型调用及其重试共享同一个起止时间,只有时长从上报的等待时间中拆分出来,因此 provider_retry 只是从合并区间中分摊出的份额,并非独立观测到的阶段。更早版本的测试框架完全没有拆分它,结果把 provider 状态不佳的时段记成了 Agent 思考时间。

我们测到的所有间隔:

92 个已追踪 Agent 会话中的空闲间隔,n=296。中位数 13.8 秒,p90 为 118 秒,最长 508 秒。296 个间隔中只有 5 个达到第 5 节最终依赖的 5 分钟空闲超时。这里及下文每个会话的数字都基于至少执行过一次代码的 76 个会话计算,因为从未运行代码的会话没有间隔可分析:这些会话平均每个有 3.9 个间隔,中位会话中最长间隔为 25.3 秒。

大多数间隔很短:296 个中有 155 个低于 15 秒,占 52.4%。但大部分空闲时间落在长尾里,因为这 155 个短间隔只占总量的 9.7%。按时长加权的间隔中位数是126 秒,也就是说一半空闲时间位于超过约两分钟的间隔中。间隔大多很短,所以无论每个间隔做什么,都必须足够便宜,能每 14 秒触发一次而用户无感:这支持快速恢复。但空闲时间落在长尾里,长间隔很容易吸收一段短暂等待,所以短驻留仍能覆盖大部分空闲。第 5 节会给出代价。

我们撤回了一项测量,而撤回的指标同样能说明方法问题。按 T1 比值计算的每次执行开销被放弃了,因为 T1 基线本身受 runner 30 秒墙钟截断,导致两种网络策略对同一批程序给出截然不同的结论:以 41 毫秒为基线,固定容器启动变成 7 倍;以 30,000 毫秒为基线,同一次启动只有 1.01 倍。启动开销改由成本论证承担。

需求 7 未测量:feedback_ok 在网格全部 1,500 行中均为 true,每种网络策略下各 750 行,这是我们测试框架的属性,而非层级差异,因此它留在检查清单上作为义务,背后没有测量支撑。

第 4 节:解读矩阵、三个轴与间隔

矩阵要说明三件事。两件重新排列阶梯,第三件根本不在阶梯上。

维度 1:事故遏制靠的是配置,而且裸子进程之上的每一层都具备

遏制 F3 和 F4 的是命名空间,而默认 Docker 就带命名空间。加固、用户态内核和硬件虚拟化在这一项上并不能带来更多收益。

F3 这一类的名字承诺得比实测结果多。这三个程序安装的都是合法软件包,没有一个运行攻击者控制的包代码。这一行说明的是命名空间能遏制安装过程造成的混乱——被改乱的 site-packages 目录或被覆盖的版本——而不是恶意依赖。恶意包在你的命名空间里执行是另一个问题,正是第 1 节幻觉发现所指的那个问题,本表格中没有任何一项测量它。

唯一拉开差距的是 F1,资源耗尽,而且往上爬并不加分。加固 Docker 的遏制率为 33.3%,而 gVisor 和 microVM 均为 16.7%。差距来自一个程序:它在上面两层都评为 partial,gVisor 是因为缺少诊断,microVM 则是因为 8 次运行中该层始终没有结束。

机制在表格各行中清晰可见。在 T3 的 64-PID 上限下,fork 风暴成功派生 63 次,第 64 次被拒绝:它自己的 stderr 写着“fork refused after 63 spawns”,在 314 到 507 毫秒内退出,评分标准判定为已遏制。

T4 拦住了同一个程序,却什么也没告诉你。它在 643 到 1,241 毫秒后以状态码 2 退出,远未触及运行器 30 秒的时限,输出为零字节,也没有任何耗尽信号。该行的遏制备注写道:“在产生任何输出之前就被停止,没有任何东西存活下来,但该层没有报告可归因的上限:已遏制但无诊断(退出码 2)。”评分标准在 T4 全部 20 行 fork 风暴记录中都将其评为 partial,依据的是针对不报告可归因上限的运行时的规则:gVisor 自行管理资源,从不设置加固 Docker 会设置的 OOMKilled 标志,因此 docker inspect 无从归因这次停止。yes 等于声称知道是哪个上限起了作用;unsupported 则会丢弃一行遏制明显发生过的记录。

只有 T5 这一层被限流。它的运行时长在 22.7 到 105.0 秒之间,最终撞上自身的内存上限;在每种网络策略下,10 次运行中有 6 次以退出状态 137 结束,其余 4 次则被 runner 的 105 秒 VM 预算终止——该预算由 60 秒启动时间、30 秒实际运行时间和 15 秒余量构成,远超其他层级所参照的 30 秒实际运行时间。按最差优先规则,它的单元格被标记为部分通过。T4 的单元格数值相同,却覆盖了全部 20 行;T5 的只覆盖少数行,大多数运行都干净地止步于可归因的上限。

拒绝式上限在诊断和速度上胜出,在遏制上并不占优。默认 Docker 之上的三个层级都控制住了 fork 风暴,T4 自己的遏制记录也说明了这一点。T4 做不到的是指出究竟是哪个上限拦住了它,而 T5 做不到的是让 20 次运行中的 8 次正常结束。

这个结果一方面关乎 T5 的配置,另一方面关乎 T4 的运行时。我们的 T5 确实施加了 PID 上限,但它是通过 ulimit 在客户机侧实现的,而非通过 cgroup,这是我们的测试框架做出的选择;硬件虚拟化本身并不妨碍实现一个会明确拒绝的 PID 上限。T3 与 T4 的对比更干净:T4 就是 T3 加上 --runtime=runsc,别无其他,因此同样 64 的 PID 上限在一种运行时下产生明确的拒绝,在另一种运行时下却是无声的停止。限制如何执行,比它周围边界的强度更重要,而 PID 上限不过是一个标志位。

而真实提示词产出的程序,恰恰是这套阶梯处理得最差的:在工作路径下,采集到的程序在其“程序—层级—运行”行中只有 34% 被遏制,而合成程序为 44%,因为采集集集中在 F1,而类命名空间对 F1 毫无作用。这描述的是本网格中的程序,而非按频率加权的估计,因为偏向 F1 是我们的选择造成的。

我们原本担心的尴尬恰恰相反:以为未被遏制的单元格会是我们自己手写的最坏情况。结果反了过来。在这套测试集中,普通良性提示词产出的代码,正是这套阶梯遏制得最差的代码。

轴 2:各层级之间的差异,并非多数阶梯所宣称的那样

向上攀爬换来的是抗逃逸能力,这一点无人质疑。本实验能说明的范围更窄:遏制住实测分布的并不是抗逃逸能力。五行结果持平,再加上第六行——在加固容器之上继续攀爬,换来的诊断更少而非遏制更多——意味着对于本测试集产生的普通效应而言,层级几乎无关紧要,配置才几乎决定一切。

阶梯的每一级都不是网络策略,而网络策略恰恰是阶梯常被归功的一项。在路由可用的情况下,每一层都产生完全相同的结果:

策略 collector 命中 canary 泄漏
出站拒绝 T1:30,其余各层:0 T1:10,其余各层:0
路由可用 T1–T5 每层均为 30 T1–T5 每层均为 10

每一级只决定某个网络接口是否存在,没有一级决定程序能用它做什么。买一个 microVM、接上一块可用的网卡,你得到的出站控制和一个裸子进程一样多,也就是零。

唯一的例外是 T1:两种策略下它都会泄漏,因为裸子进程共享宿主机的网络命名空间,也是唯一无法被剥夺接口的一层。边界的贡献在于能够不给接口,而不在于管住接口。

还有一个更小的结果,与“拒绝还是限流”的道理相通。在 T1,三个依赖损伤程序在出站拒绝下的单程序中位数分别为 1.43、2.06 和 5.27 秒,该层中位数为 2.2 秒,最慢的单次运行 5.6 秒。而在路由可用时,配置完全相同,三十次运行全部撞满 30 秒上限——因为一种情况下被阻断的域名解析会快速失败,另一种情况下则被静默丢弃并不断重试,直到撞墙。T2、T3、T4 以更慢的方式呈现同样的反转:出站拒绝下单程序中位数为 8.4 到 18.1 秒,路由可用时则全部撞墙。T5 打破了这个规律:单程序中位数为 2.9 到 11.1 秒,两种策略下都从未撞墙,尽管它的路由是通的,也和其他层一样在泄漏——30 次 collector 命中、10 次 canary 泄漏。同样的策略用在全部五层上,结果仍按层分化,说明这种卡顿是静默丢弃的解析请求与各层网络栈、域名解析之间相互作用的结果。变化的只有运行时间,别的都没变:隔离效果不变,泄漏计数完全相同。快速失败的解析大约只消耗 2 秒,而静默丢弃的解析会耗尽全部上限。所以,如何阻断网络,决定了程序会把多少实际耗时上限耗在一个永远解析不出的域名上。

轴 3:会话经济学,不在任何人的阶梯上

Agent 会话是一段段工作被等待隔开,所以等待期间你对环境做的任何处理,实际上决定了几乎整个会话。第二张网格给三个选项标了价。

先别急。空闲成本是内存,不是 CPU:25 次 keep-alive 运行中,唯一在间隔期仍持有沙箱的那几次,空闲 CPU 峰值也只有 0.003 vCPU。它真正占住、且不会归还的是:

层级 空闲时占用
T1 / T2 / T3 ~74 MB
T4 gVisor 323 MB
T5 Firecracker 392 MB

这其实是两组,不是五个台阶。T1、T2、T3 彼此相差不到 1 MB,因为它们是同一个 Python 进程,只是外面套了不同的内核记账;更重的层级则要花 4.4 倍和 5.3 倍,因为边界总得由某种东西构成:gVisor 的用户态内核,或 microVM 的客户机内核。

重建它。拆掉再冷启动要 11.9 到 24.7 秒,而时间花在哪里,决定了这场争论的走向:

各层级重建时间中位数,每组 n=10。柱子上方的灰色百分比是依赖安装所占份额。深色段归因于层级本身,T1 为 664 ms,T2 为 1,336,T3 为 1,378,T4 为 2,388,T5 为 2,871。

每个层级的重建时间里,依赖安装都占 82% 到 95%,所以在两次突发之间拆掉环境,大部分钱都花在重装那十七个 wheel 上——而你刚销毁的环境本来就有这些。每根柱子都是独立的中位数,所以各分量相加未必精确等于总数;也别拿总数给 T1、T2、T3 排名,因为 n=10 时它们的区间有重叠,真正区分它们的是深色的层级归因段。

有个显而易见的反驳:把 wheel 烤进基础镜像,安装那一列基本就消失了,成本转移到镜像拉取上——而这张图没测镜像拉取。但较弱的结论依然成立。无论重建花多少,几乎都不是花在边界上。

有一行数据与这套阶梯逻辑自相矛盾。gVisor 安装同样的 wheel 要 22.3 秒,而 microVM 只要 12.8,接近裸机速度。pip install 是成千上万次小文件创建、写入和 stat,gVisor 的用户态内核要逐一拦截并重新实现,而 microVM 只在启动时付一次代价,之后就以硬件速度跑真正的内核。凡是工作以文件元数据为主的地方,大概都会这样——不过这只是从一次操作推出来的,不是第二次实测。

共享它。把池化环境交给下一个会话,带重置只要 0.23 到 6.71 毫秒,不带重置 0.29 到 6.92 毫秒,而重建要 12 到 25 秒。人们这么做,原因就在这里。

不做重置时,一切都会串:会话 A 的内存状态、它的文件以及它派生的进程,在每一层级上 10 次运行中 10 次都对会话 B 可见。采用协作式重置后,内存和文件清理得干干净净,各处均为 10 次中 0 次,但派生的进程在每一层级上都是 10 次中 10 次串过去,连 microVM 也不例外。

从裸子进程到硬件虚拟化,结果完全一致,这是结构性的。协作式重置是运行在环境内部的代码:它能删除自己知道的名称、能看见的文件,却无法回收上一个会话派生后又遗弃的子进程。买更强的层级、跨会话做池化,等于在外围买到了隔离,却在交接时又还了回去。

精确命名这个缺口

把三项按层级放在一起,中间隔十分钟:

层级 保持(n=1) 重建(n=10) 带重置的池化(n=10)
T1 736 MB·min 13,898 ms 0.23 ms,进程串过去
T2 744 MB·min 14,621 ms 0.56 ms,进程串过去
T3 743 MB·min 11,878 ms 0.53 ms,进程串过去
T4 3,257 MB·min 24,696 ms 0.91 ms,进程串过去
T5 3,918 MB·min 15,634 ms 6.71 ms,进程串过去

我们没有把这些合并成一个分数:兆字节·分钟、毫秒和一个泄漏的进程,不借助某人的价格和某人的风险承受度,是无法换算的。

每一行都是同样的形状。阶梯上没有哪个层级能同时提供便宜的闲置、完好的状态和不破的按会话边界。只能三选二:

空闲开销的分布并不均匀。87.4% 的空闲时间位于执行之间的间隙,而非首次执行之前或最后一次执行之后,所以间隙才是成本所在,而第 3 节描述的形状需要一种短触发、快回收的机制。第 5 节将分别对这两部分定价。

这对建议意味着什么

五个持平的行、一个反转的行、任何层级都没有出口管控,再加上随阶梯攀升的空闲开销,使得逃逸抵抗力成为继续升级唯一还能买到的东西,因此硬件边界的理由仅剩注入尾部这一项。第 6 节将逐级分析。

配置无法弥合这一差距。三难困境在每一层都真实存在,加固容器也不例外:你无法靠加标志来逃避为空闲内存付费,也没有任何 seccomp 配置能回收池化前驱留下的孤儿进程。

第 5 节:为这种分布构建的执行层是什么样

第 4 节以一个配置无法弥合的差距收尾。这个差距的形状本身就暗示了答案。

模式

四个要素,每一个都对应第 4 节测量过的内容。暂停、恢复和检查点正是弥合差距的关键,而它们都不属于某一特定层级:检查点与恢复存在于 gVisor 的 runsc checkpoint 中,存在于 docker checkpoint 背后的 CRIU 中,也存在于你自己运行的 Firecracker 快照中。它们底下的硬件边界是那个条件性要素,放在最后说。

会话空闲时暂停;恢复时状态完好。没有任务运行时,环境停止消耗资源;恢复时,内存、文件和进程都保持原样。这一招直接针对三难困境:它拿走了重建的空闲成本,保留了保持的连续性,又不需要池化的交接。

对预热后的状态做检查点。环境装好运行时和依赖后做一次快照,之后启动新会话时直接恢复该快照。第 4 节量化过这笔收益:依赖安装占每次重建的 82% 到 95%。它没有堵住的是幻觉包这个攻击面,因为检查点消除了你在运行时安装的需要,却没有消除模型尝试安装的能力,只有出口策略能拦住这一点。

会话结束时销毁。这是需求 3 的短暂性,也是对池化泄漏最干净的答案——在池化方案里,协作式重置会让上一会话的孤儿进程在每个层级都十次十次地继续运行。销毁不是协作式的:环境直接不复存在,连同它承载的一切。

按会话做硬件隔离,前提是需求 5 适用于你。每个会话一个边界,条件性正如第 4 节所述:取决于你的爆炸半径是否承载他人的数据,或者你的输入路径是否可被注入触及。如果两者都不成立,加固容器仍是默认选择,上面三个要素依然是你需要的。

实例演示

DigitalOcean MicroVMs目前处于公开预览阶段,是这一模式的产品化形态。它的每个要素都能对应到上文推导出的某一项,这也是它值得写进本文的唯一理由:

模式要素 产品文档的说法
按会话隔离硬件 它“在 Firecracker 上启动,拥有独立内核和真正的硬件虚拟化边界,而非共享边界”
空闲时暂停,带状态恢复 “在你设定的空闲时长后”暂停,“在毫秒级内恢复,并从上次中断处继续,内存、文件和运行中的进程都保持原样”
对预热状态做检查点 “环境搭建只做一次,做好检查点,之后的新会话都从该检查点启动”
会话结束时销毁 环境上提供了删除调用;按会话销毁取决于你如何使用它,并非产品强制的生命周期

访问通过“一个经过身份验证的 HTTPS 端点,因此只有携带有效 DigitalOcean API token 的请求才能到达”,这就把控制环境的凭证放在了环境之外。这不算要求 4:经过身份验证的入站端点决定谁能访问 MicroVM,而要求 4 关心的是你往它内部放哪些密钥,这仍由你决定。下文“诚实的边界”一节还有一点需要说明。

上面的推导漏掉了一个原语。再往上一层,Managed Agents(同样处于公开预览)在 Harness Runtime 中记录了“暂停/恢复/fork 语义”,Harness Runtime 是 Managed Agents 内部的一项服务。对运行中的环境做 fork,看起来就像池化,而且交接问题从机制上就不存在了,因为子环境从父环境的状态启动,而不是接手兄弟会话的残留。但三难困境是三条腿同时成立,而 fork 对空闲成本只字未提。我们没有实测,所以这里读的是一行文档,而非测量结果;MicroVMs 的文档也没有说明下一层是否提供 fork。

两层之间的选择在文档里划得很清楚。MicroVMs 提供“虚拟机及其生命周期——从镜像创建、暂停与恢复、做检查点、在其中运行命令,并通过其端点访问”,而 Managed Agents 在 MicroVM 之上加了一层 Agent 控制平面,通过 Harness Runtime 提供文件传输、harness 升级、跨运行的会话状态和工具代理。如果执行层是你的产品而非你的依赖,你该选下面那一层。

暂停值多少

定价是公开的,所以这部分成本可以算清楚。计算费用只在运行期间产生,按每 vCPU 小时 $0.022、每 GiB 小时 $0.0085,按秒计费。存储费用为每 GiB 月 $0.05,因此暂停的 MicroVM 只需为磁盘付费;出站流量为每 GiB $0.01。

用一个具体数字让这些单位变得直观。我们做基准测试的那台 1 vCPU、2 GiB 的 MicroVM,每小时费用为 $0.022 + 2 × $0.0085 = $0.039。让一台机器保持开启,经历一次中位数追踪会话 70.3 秒,计算费用是 $0.00076。下面各项决策都只是这个量级数字的零头,只有乘以你自己的会话数(不是我们的)才会变成真金白银。

有一个前提约束着下面所有内容。我们的测试框架从未让沙箱保持开启。它每次执行都新建一个容器,用完即弃,属于“重建”场景而非“保持”场景。因此,这些追踪数据衡量的是会话的节奏,而下面的推演是把这种节奏投射到一个我们并未实际运行的持久化环境上。间隔是实测的;账单是建模推算的。

你可以显式暂停。使用 doctl compute microvm pause,或 POST /v2/microvms//pause。你的编排器已经收集了本次执行的输出,因此它知道工作停止的时刻,可以在同一代码路径上执行暂停。

或者让自动暂停来完成。MicroVM 在“空闲超时期间未收到任何流量”时会自动暂停,默认超时为 5 分钟,且无法关闭。

这个兜底方案与当前负载严重不匹配。在驻留时间 D 下,长度为 L 的空闲间隔可回收 max(0, L − D),因为环境在整个等待期间持续运行并计费,所以短于驻留时间的间隔什么都回收不了,而我们的中位间隔是 13.8 秒。超时机制的成本如下,统计范围为至少执行过一次代码的 76 个会话;需注意文档写明上限为 4 小时、完全没有下限,因此 15 秒那一行是相对于一个我们无法确认的最小值做的保守估计:

驻留时间 长到足以暂停的间隔占比 回收的空闲占比 计费占实际时长比例 曾暂停过的会话数 中位会话的账单比例
显式暂停 (下文实测) — 95.0% 7.2% 76 / 76 —
15 秒 (可能低于最小值) 47.6% 71.4% 30.3% 53 / 76 81.7%
30 秒 (文档中最短的示例) 26.0% 59.1% 42.3% 31 / 76 100%
60 秒 17.2% 43.8% 57.3% 25 / 76 100%
120 秒 10.1% 25.0% 75.6% 19 / 76 100%
300 秒 (文档默认值) 1.7% 5.0% 95.1% 4 / 76 100%

表中“回收的空闲占比”和“计费占实际时长比例”两列随驻留时间变化的曲线。 虚线是显式暂停、环境从会话开始就保持运行的情况,计费比例为 7.2%。其下方的点线是同样的暂停方式,但环境在首次执行时才创建,计费比例为 5.3%。无论哪种,都低于所有可选的超时方案。

若沿用默认值不动,这个负载要支付相当于环境全程保持运行费用的 95.1%,而 76 个会话中只有 4 个曾暂停过。 这说明的是五分钟超时与一个只有 5/296 个间隔达到五分钟的分布不匹配,而非暂停-恢复机制本身的问题。由于大部分空闲时间落在第 3 节的长尾里,15 秒驻留仍能回收 71.4% 的空闲,而 300 秒驻留只能回收 5.0%。

汇总所有会话来看,30 秒驻留的计费占实际时长的 42.3%,是实打实的节省。但在该驻留下,中位会话的计费比例为 100%,一次都不会暂停,因为它最长的间隔只有 25.3 秒。节省来自少数长间隔会话,所以大规模集群的总账单会趋近于汇总值,而任何单个会话大多省不下什么。

所以真正关键的数字是显式暂停和恢复的代价,因为恢复落在关键路径上的频率几乎和代码运行一样高。我们在 MKC1 中用 MicroVM 做了测量,配置为 1 vCPU、2 GiB:恢复重复 10 次,每次先注入状态,通过 API 暂停,然后恢复,并从内部探测一次以确认可以执行;另外做了 12 次冷创建作为对照。

p50 p95 范围
恢复 → 可执行 1,380 ms 1,972 ms 1,212–2,279 ms
其中 API 调用 688 ms — —
冷创建 → 可执行 16,134 ms 17,205 ms 15,264–17,536 ms
其中安装 numpy 和 pandas 6,759 ms — —

恢复比从头构建环境快 11.7 倍,而冷创建耗时 16.1 秒,落在第 4 节 11.9 到 24.7 秒的重建区间内,尽管这两个总耗时由不同的工作构成。十次运行中,恢复都保留了内存、文件和运行中的进程;验证方式是读回一个随机 token,该 token 只存在于暂停前启动的某个进程的 RAM 中。整个模式就建立在这个结论之上。

把 1,380 ms 放回追踪到的节奏中,经济账就反过来了。与默认计时器的 95.1% 做同类比较,每次执行后暂停的计费为实际耗时的 7.2%,并回收 95.0% 的空闲时间。你为每个间隙付出 1.38 秒延迟,以避免为 13.8 秒的间隙计费。按与重建数字相同的基础来算:每个会话约 3.9 个间隙,这会给会话增加约 5.4 秒,而在 T3 下,每个间隙重建一次的策略会增加约 46 秒。还有一个反对意见尚未解决。如果你的编排器能在模型调用仍在进行时发出恢复,那 5.4 秒会与等待重叠,而不是叠加;我们的测试框架只在模型返回后才恢复,因此重叠的情况尚未测试,不能算作结果。

在暂停之上再加一条纪律:在第一次执行时才惰性创建环境,而不是让环境从会话打开那一刻就存在,账单会降到实际耗时的 5.3%,回收 97.0% 的空闲时间。这两个数字之间 2.0% 的空闲时间就是提前量:第一次执行之前的那段时间,还没有任何东西运行过,因此没有可暂停的对象。这是计时器唯一做得更好的地方,因为显式暂停会为整个提前量计费,而长度为 D 的超时在自行暂停前只计费 min(提前量, D)。

有两项结果与产品自身的说法相矛盾。公告称恢复发生在“毫秒级”;但仅API调用就要688毫秒,而一个能运行你代码的MicroVM需要1.4秒,这才是用户实际等待的时间。依赖安装耗时6.8秒,仅相当于冷启动MicroVM的42%,而非第4节针对我们自建microVM测得的82%,因为创建MicroVM本身就要花约9.4秒,之后才轮到处理依赖。这两个比例要谨慎看待:它们对应两次不同的安装——MicroVM基准测试中是公共索引里的两个包,而第4节重建时用的是本地wheelhouse里十七个锁定wheel,并加了--no-index。这个对比说明的是方向,而非经过测量的等价关系,但核心结论依然成立:冷MicroVM的大部分时间花在VM本身而非包上,所以检查点能省掉的那部分,在这里比第4节重建的任何层级都小,包括我们自己的microVM。

模型有两个假设是有据可查而非图方便的:算力“按秒计费”,并且“创建、暂停、恢复或处于暂停状态的时间不计入算力费用”。即便如此,把暂停调用本身视为瞬时,仍然是对它有利的估计。

每个驻留行都会在首次执行前计入前置时间、在最后一次执行后计入尾随时间,两者各自以驻留时长为上限,因为交给定时器的环境在整个会话期间都存在,无论是否已有东西运行过。显式暂停会全额计入前置时间,但尾随时间计为零,因为每次执行后都暂停的编排器在最后一次执行后也会暂停。5.3%那一行则连前置时间也去掉了。尾随时间占空闲时间的10.6%,前置时间占2.0%,这就是为什么从定时器基准切换到暂停基准,收益远大于从7.2%降到5.3%这一步。而且这不是提供商拥塞造成的:把每次HTTP 529退避从其所在的间隔中扣除后,默认驻留结果从回收5.0%变为4.8%。

有一个未解决的问题可能改变这一切。MicroVM在“收不到流量”后会暂停。我们的测试框架把Agent放在外面,所以间隔期间没有入站流量。如果你的Agent运行在内部并从那里调用模型,那么它自身出站连接的响应是否算作流量,目前并不清楚。两种答案都别扭:如果算,Agent思考时MicroVM就永远不会暂停;如果不算,它可能在调用中途带着打开的socket暂停。这对上面的Harness Runtime影响最大,因为它最可能把harness跑在VM内部。这一点在此尚未测试。只要做一次会话——Agent在MicroVM内部,空闲超过配置的超时时间,同时有一个出站模型调用正在进行——就能定论。

所以机制本身是有效的,只是默认配置把大部分能力浪费掉了。暂停是你主动执行的动作,而不是被动等待的结果。在收集输出的代码路径里调用它,再把空闲超时设为兜底,覆盖你遗漏的路径——超时值按你自己的实际间隔来定,别让它停在五分钟。另外如前所述,这个原语并不新鲜:新鲜的是它背后有按秒计费的 API。

诚实的边界

配置层仍然归你管,只有一个例外。事故遏制属于配置层面的能力,从默认 Docker 往上都有,底层边界更好并不能废除这一点。无论用哪种原语,上限、输出封顶和凭据代理仍然是构建者的职责。

出网是例外,而且它来自产品本身而非层级:默认是带共享 NAT 网关的公网,但“VPC 网络里的 MicroVM 没有互联网访问,除非该 VPC 网络配有 NAT 网关”,和其他设置一样在创建时一次性选定。这就是把需求 2 的默认拒绝交到你手上,作为一个平台设置。而带 microvm:access 范围的 API token“能访问团队里的每一个 MicroVM”,无法限定到单个实例:硬件边界按会话隔离,控制面凭据却不是。

隔离的经济性解决不了论证层面的失败。一个因为错误理由删掉正确文件的程序,被完美地隔离了,但依然是错的。

检查点不是应对重建成本的唯一答案。第 4 节那个 82% 到 95% 的数字描述的是一种设计:会话启动时从本地 wheelhouse 安装十七个 wheel。把依赖烤进镜像也行,成本从 pip install 转移到镜像拉取,依赖集较小时这是明显的赢法,还不需要检查点。成本会以镜像大小的形式重新出现,而镜像仓库正在直接攻这个问题:DigitalOcean 的 Container Registry 支持启用 SOCI 的镜像,其中 Seekable OCI 索引让容器“按需流式加载镜像层,几秒内启动,而无需先下载整个镜像”。这是容器层面的另一个答案,不涉及 microVM。

检查点方案能站得住脚,靠的是两点。其一,懒加载需要运行时支持 SOCI,因为普通 Docker 会“把它当普通镜像拉取并运行”,所以这是运行时的特性,而非镜像仓库的特性。其二,检查点恢复的是内存已预热的运行中进程,而固化镜像仍要启动解释器并导入库。我们实测了热内存这一侧:恢复耗时 1,380 ms,而 MicroVM 在触及任何依赖之前创建就要约 9.4 秒——这才是公平的基线,因为固化镜像同样不必付出安装成本。若拿完整的 16.1 秒冷启动来比,检查点会显得比实际更好;诚实的差距约为 7 倍。我们没有实测懒加载,也从未真正运行检查点恢复策略本身。

我们的 Firecracker 不是他们的 Firecracker。 T5 是我们自行构建并配置的 Firecracker v1.17.0。DigitalOcean 未公布 MicroVM 所用的版本或配置,因此 T5 的数据不能代表该产品的表现,而且底层规格也不同:最小的 MicroVM 是 1 vCPU 配 2 GiB,而我们的 T5 跑在 512 MiB 上。

第 6 节:决策框架

实测之后的阶梯、你属于哪一级,以及如何评估任何自称更高一级的方案。

配置底线:裸子进程之上的每一级都适用

意外遏制靠的是配置,而非层级,所以这条底线不是毕业后就能越过的起点。它是你抵御实测分布主体的全部防线。

  1. 六个维度上的每次执行上限,涵盖 CPU、墙钟时间、内存、进程数、磁盘和输出字节,且在进程之外强制执行。要分清容器强制了什么、你的 runner 又必须强制什么:我们的 T3 通过 Docker 参数获得了内存、PID 和 CPU 上限,但墙钟时间和输出字节由 harness 控制,磁盘上限只覆盖 /tmp 下的 tmpfs,而可写工作目录是一个没有大小限制的 bind mount。逐一核查你的平台强制了这六项中的哪几项,别想当然地以为加固层级已经全部覆盖。
  2. 默认拒绝出站流量,并配一份你亲手编写的允许列表。
  3. 不在运行时安装依赖。 提前解析好,从你掌控的位置提供。
  4. 临时环境,持久化要明确命名且范围收窄。
  • 不依赖环境凭证。通过中间层发放限定范围的操作句柄,绝不直接挂载密钥。
  • 反馈有界且忠实,从中间截断,并区分“被拒绝”与“被杀死”。
  • 如果微虚拟机的 PID 上限像我们这样在客户机侧施加,那么面对 fork 风暴时,它的表现会比一个从外部强制限制、并能明确指出限制对象的加固容器更差。

    阶梯层级

    裸子进程:绝不可用

    没有任何工作负载适合这种方案。上一级方案在 p50 下约需 158 ms,从 14 ms 变成 172 ms,而这 158 ms 换来的是完整的意外隔离能力。它在除 F5 外的每一类上都得了零分隔离率;F5 上拿到 50%,是因为该类两个程序中的一个——一个脱离的守护进程——在全部十次运行中都被隔离,而输出洪泛程序一次都没被隔离。它也是唯一在两种网络策略下都泄漏金丝雀的层级,因为它共享宿主机的网络命名空间,无法拒绝某条路由。

    加固容器:单租户 Agent 循环的默认选择

    当你的循环是单租户、爆炸半径仅限于自己的数据、输入路径的注入面有界时,就选它。这些条件确实在起作用,所以要逐项核实,而不是想当然。

    它在六类中的五类上与更重的层级持平,并在唯一能拉开差距的那一类上领先:我们给它设置的 PID 上限会拒绝 fork 并明确报告,而 gVisor 在相同上限下阻止了同一程序却不报告,微虚拟机的内存上限则要花几十秒才生效,我们的运行器在 20 次运行中有 8 次先结束了。它的启动开销为 p50 171 ms,默认 Docker 是 172 ms;空闲间隙中它占用的内存最少,74 MB,对比 323 MB 和 392 MB。

    用户态内核:从这批数据看最难站住脚的层级

    当你需要第二道内核边界又无法运行嵌套虚拟化时,就选它。我们的测试组找不到它的其他用途:在资源耗尽场景下反馈比下一级更差——它阻止了 fork 风暴,却没有报告任何可归因于该上限的停止;其他方面完全相同;空闲时占用 323 MB;在主导每次重建的依赖安装上慢得多——这一条是从单次操作推断的,而非第二次测量。

    Scope 的第一条限制在此适用,所以上述内容应读作“我们没找到意外隔离或经济性方面的理由”,而不是“它无法抵御逃逸”。

    自运行微虚拟机:多租户、密钥在范围内、合规

    当你的爆炸半径涉及他人数据、凭据可从执行路径触达,或审计方有要求时,就该选这一档。第4节之后,理由就只剩注入尾部这一条了。

    成本是可量化的:p95 启动耗时 1,831 ms,是默认 Docker 的 9.8 倍,空闲间隙期间仍占用 392 MB。但另一方面,它安装依赖基本是裸速,所以在主导重建的这项操作上,比用户态内核更便宜。

    运维成本也要算进去,因为你现在自己跑虚拟化层:客户机镜像、内核、init、设备配置,以及随之而来的各种故障模式。我们自己的 microVM 档位就曾因为一个后端接受环境变量却静默地从未下发,丢了 300 行测量数据——这让该档位在两个类别上看起来完全隔离,因为它没有金丝雀可泄漏,也无处可泄漏。

    托管 microVM 原语:当虚拟化层属于无差别工作时

    边界相同,只是由别人来运维,再加上第5节讲的会话经济学机制。要决策的是运维虚拟化层该不该由你的团队来做,而不是隔离强度——名义上它和下一档是同一技术,只是运维方的版本和配置未公开。如果沙箱就是你的产品,这一档让你无需成为 hypervisor 运维者也能把它做出来。

    托管 Agent:当整个执行栈都属于无差别工作时

    最高一档。如果你不是在构建执行层,只是需要让 Agent 代码在某个地方安全运行,那么下面整架梯子都是无差别工作。

    排除它之前,先确认两件事。这一档通常支持自定义镜像,所以“托管”未必意味着“用他们的工具链”。另外,定价模式可能不是下面几档用的按秒计费——这点很重要,因为固定费率让空闲时间免费而非便宜,会颠覆驱动第4节的那套计算。

    三个问题帮你定位

    你的输入路径是否可被注入触达?网页、用户文档、第三方仓库,或任何攻击者能影响的内容。如果是,需求4和5的优先级上升,硬件边界就成了真正要考虑的因素。如果你的 Agent 只接触你掌控的数据,配置底线几乎承担了全部权重。

    你的爆炸半径是共享的吗?如果执行路径能触及其他租户的数据或凭证,硬件边界就不再是可商榷的判断,而是一条底线。

    你的循环执行频率有多高,等待时间有多长?如果每个会话只执行几次,需求6中“快速启动”那一半几乎可以忽略不计,只有到几十次时才会在用户的等待时间中占据可见比例。而“低成本空闲”那一半与频率无关:即使按我们追踪的全部92个会话的中位数4次执行来算,开销也主要发生在两次突发之间的空档里。

    还有一个问题决定你该选哪一级microVM:运维虚拟化是你的本职工作吗?如果你能说清楚为什么该由你的团队负责guest镜像和内核配置,那就自建。

    评估供应商,包括DigitalOcean

    把第 2 节的清单拿去问任何一个卖你“一级台阶”的人。这些问题的答案通常不会出现在营销页面上,而每一个问题都有一个足以终结对话的答案。

    要问的 会直接出局的回答
    六道天花板里我能设哪几道,每一道触发时会发生什么? “这个我们帮你处理”,却列不出具体是哪几道。追问一句:触到上限是直接拒绝还是限流——我们发现这个差别比多两级隔离还值钱。
    出站流量是否默认拒绝,允许清单是不是我自己维护? 默认放行,或者给一份你无法查看的允许清单。
    我能自带已经解析好的依赖集吗? 官方文档给出的路径是在运行时对公共仓库执行 pip install。
    两次执行之间、两个会话之间,哪些东西会被销毁? 这里含糊其辞,就是在变相回避资源复用的问题。
    复用是协作式的还是强制式的? 这一条要追问得最狠。协作式重置把内存和文件清得干干净净,却让上一个会话的进程继续跑着——10 次里 10 次如此,而且每一档都这样,连 microVM 也不例外。重置脚本给出的保证,比它所在的那道边界所暗示的要弱。
    空闲间隙期间是否计费,恢复延迟是多少? 答不上来,或者恢复比它所替代的 11.9–24.7 秒重建还慢。供参考:MicroVM 从恢复到可执行实测为 1,380 毫秒。
    我能按需暂停,还是只能等超时? 没有显式暂停,或者创建后停留时长不可更改。在一个中位间隙为 13.8 秒的工作负载里,按 5 分钟停留时长计费只能省下 5% 的空闲;如果从收集输出的那条代码路径上暂停,能省 95%;如果同时在首次执行时就把环境创建好,能省 97%。
    执行失败时,模型能看到什么? 只有退出状态码。沙箱的输出就是模型的下一个输入。
    隔离策略是否版本化,策略变更时我的自定义镜像会怎样? “这是托管的”,没有任何版本信息可查。边界归厂商管,说改就改:2026 年 10 月,DigitalOcean 开始要求 Harness Runtime 中新建的 Bring-Your-Own-Template 镜像必须基于“提供更强隔离”的推荐基础镜像,旧基础镜像仍可用于现有模板和重建,但其模板支持计划在未来版本中移除。记得问清楚弃用窗口有多长。
    我能看到沙箱做了什么吗? 没有执行级别的遥测。有隔离却没有可观测性,意味着你是从账单上才知道出了 fork 风暴。

    这里面有好几条在清单上挂了好多年,至今仍然没人问。

    关于本主题的常见问题

    1. AI 代码沙箱究竟在防御什么?

    并不是你想象中的那种作者。逃逸难度阶梯是为不可信的用户代码设计的,那里的作者是一个想逃出去的人,有意图、有耐心、有计划,而写出你这段代码的模型一样都没有。你真正要防的威胁分布分成截然不同的两半:主体部分是意外,即生成的代码去引用一个没安装的包、写到目录之外,或者陷入死循环;尾部部分是注入,即攻击者的指令通过模型被要求读取的数据传入。主体部分靠一次性配置就能管住;尾部部分才需要更强的边界。只买边界、跳过配置,那么几乎会发生的事你全都暴露,而几乎不会发生的事你反倒防住了。

    2. 容器够用吗,还是需要 microVM?

    配置正确的容器,足以应对单租户、只处理自己数据的循环。意外那一半靠命名空间、单次执行上限和出站策略就能管住,这些加固过的容器都有,所以再往上爬只是增加了你本来就有的隔离能力。往上爬换来的是抗逃逸能力,也就是抵御代码刻意探测与其他租户共享的内核,当你的爆炸半径里包含别人的数据、当执行路径能触达凭据、或者审计方要求时,这笔钱才值得花。microVM 的启动延迟大约高一个数量级,按每个会话几次执行来算,也就是几秒而非几分钟。请把抗逃逸当作往上爬的理由,而不是这里测出来的东西:我们的测试组没有尝试任何内核漏洞利用,也没有尝试容器逃逸。

    3. 为 Agent 生成的代码搭建沙箱,应该先配置什么?

    每次执行都对 CPU、实际耗时、内存、进程数、磁盘和输出字节数设上限,每一项都由生成代码无法触及的机制强制执行——解释器内部的超时形同虚设。接着是默认拒绝的出站访问,配一份你亲手写定的允许列表,因为真正要命的故障是沙箱在无人批准的情况下连上了互联网。再就是临时环境、不携带任何环境凭证,依赖提前解析好,而不是运行时才安装。宁可让限制拒绝某个操作并说明原因,也不要让它静默杀掉进程,因为沙箱的输出就是模型的下一次输入:只被告知“被杀掉”的模型会重试同样的操作,而被告知“超出进程数限制”的模型则能换个做法。输出要从中间截断:头部保留程序查到的结果,尾部保留 traceback。

    4. 生成代码要求安装的包,应该怎么处理?

    提前解析好,从你控制的某个位置提供一份固定版本的包集合,再靠默认拒绝的出站访问让这个决定落到实处。运行时去公共仓库安装,等于一个未经审查的代码执行原语,原因在于那个包可能根本不存在:Spracklen 等人(USENIX Security 2025)发现,223 万条推荐包提及中有 19.7% 指向 PyPI 或 npm 上并不存在的包。这些名字还会反复出现,正是这一点把 bug 变成了攻击面。同一批作者把 500 条会触发幻觉的提示词各重跑十次,发现 43% 的幻觉包名每次运行都会重现,可复现程度足以让攻击者注册一个名字然后守株待兔。别把平台自带的打包防护当成这道控制,因为一旦 Agent 的工具跑在 virtualenv 里,这类防护就不再起作用。

    5. 提示词注入会怎样改变沙箱设计?

    它改变了作者是谁。指令藏在网页、PDF、电子表格单元格或仓库评论里,而Agent接到的任务是总结这些内容,于是意图和代码有了不同的作者,模型夹在中间成了糊涂的传话人。注入操纵的代码很难被发现,因为直到最后一步它都像普通工作:读取工作目录、生成摘要、发布到某处,这三个动作平平无奇,顺序也没人会多看一眼。这正是那两条尾部要求的用途:环境里任何地方都不放常驻凭证,并按你的输入路径设定硬件边界。真实输入路径携带注入尝试的频率,取决于你的产品,而不是任何人的基准测试;像98/2这样言之凿凿的拆分是编出来的。

    6. 空闲的Agent沙箱要花多少钱,怎么才能不再为它付费?

    空闲才是昂贵的维度,隔离强度不是,因为Agent会话是短促的执行被漫长等待隔开:追踪到的运行过代码的会话,空闲时间中位数占其总时长的94.5%,而且这些会话全是自主运行,没有人在环里拉长等待。面对这些等待,你有三个选项,阶梯上哪一级都逃不掉:保留环境,为毫无用处的内存付费;销毁环境,在关键路径上付出几秒重建时间;或者跨会话池化,在交接时付出每会话边界的代价。池化最值得仔细审视:在环境内部执行的重置能清掉内存和文件,却收不掉上一个会话遗弃的进程。要从收集输出的那条代码路径显式暂停,然后在会话结束时销毁。

    结论

    隔离阶梯兜售的那个维度,在这里恰恰最不重要,而对代价最大的那个维度只字未提。

    所以从阶梯下面开始。先设置六项配置底线控制,因为它们能挡住我们测到的那个分布;然后拿第6节的三个放置问题去问你自己的循环,拿它的厂商对照表去问卖给你上一级的人。其中两个问题要追问到底:某个限制是直接拒绝还是限流,以及你是能按需暂停还是只能等超时。运行过代码的会话,空闲时间中位数占其总时长的94.5%,所以五分钟的默认值会让你为会话中几乎全部的空转时间付费,而解决办法只是在编排器已经在跑的一条代码路径上加一次API调用。

    来源与参考

    ——

    广告 广告
    🧑‍💻

    zhirenhun

    一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。