---
title: "够新但不够隔离：CI 里 MicroVM 的取舍"
description: "AI sandbox 逃逸暴露了容器边界的短板。Lambda MicroVMs 能隔离——但 snapshot-restore 悄悄毁掉了你的 CI 新鲜度证明。"
pubDate: 2026-08-11
tags: ["ai-sandbox-escapes", "security-vulnerabilities", "multicloud", "microvms"]
locale: zh-cn
canonical: https://mikeng.io/zh-cn/writing/fresh-not-isolated-microvm-tradeoff-in-ci/
author: "Mike Ng Sin Ching"
authorUrl: https://mikeng.io/
---

2026 年 7 月，在一次沙箱化的 ExploitGym 评估中，OpenAI 的模型（GPT-5.6 Sol 加一个预发布模型）在包注册表缓存代理里发现了一个零日漏洞，拿到了开放的互联网访问权限，又用偷来的凭证和更多零日漏洞，在 Hugging Face 的生产基础设施上实现了远程代码执行。[OpenAI 在 7 月 21 日](https://openai.com/index/hugging-face-model-evaluation-security-incident/)出来担责；[Hugging Face 则提前几天](https://huggingface.co/blog/security-incident-july-2026)披露了攻击细节。

大约两个月前，[Claude Cowork VM 逃逸](https://thehackernews.com/2026/07/claude-cowork-flaw-could-let-ai-agent.html)（CVE-2026-46331）利用 guest kernel 的漏洞，穿过 hypervisor 层摸到了宿主机。Anthropic [自己的工程文档](https://www.anthropic.com/engineering/how-we-contain-claude)里描述了 Claude 模型"好心"逃出沙箱去完成任务——翻 git 历史来回答编程测试，还自己认出了正在跑的 benchmark。

这些都不代表你的 Docker CI runner 有问题。涉及的隔离栈没有完全公开，这篇文章也不声称这些逃逸发生在容器里，或者 MicroVM 就能挡住。它们的共同点是跨越的边界类型：一种由和被约束对象并排运行的代码来执行的策略边界——有动机的代码就是会打破这种边界。而容器依赖的恰恰就是这种边界，CI runner 正是有动机的代码和你的基础设施碰面的地方。

容器是让一个内核来隔离进程之间。MicroVM 把边界往外推：workload 有自己的 guest kernel，和宿主机的隔离靠硬件虚拟化加一个小得多的 VMM 接口来保证。这不会让逃逸变不可能，但改变了攻击面和爆炸半径。拿下 guest kernel 不再等于拿下宿主机内核。

这篇文章从两个维度来看这个取舍。隔离回答的是：job A 的代码能不能影响 job B 或宿主机。身份回答的是：一个 workload 能不能证明自己跑在一个独立的、新开出来的实例上。做 ephemeral runner 的过程中我们发现：两种模型都没法让 guest 用内核信号证明自己的实例身份，而且失败的原因正好相反。

## 舒适区：容器和那个看不见的内核假设

容器不是一台机器。它是共享一个宿主机内核之上的 namespaces、cgroup 配额和 seccomp 过滤器。那个内核是信任锚点：容器里的进程和宿主机之间就隔着它，而且这台机器上所有容器共享同一个内核。容器之间的隔离不是和内核的隔离。是一个内核拒绝让 namespace A 看到 namespace B——这个策略恰恰由两个 namespace 共享的那个内核来执行。

对大多数 CI workload 来说这就够了。你构建代码、跑测试，内核不碍事。Docker 能拿下 CI runner 这个位置，是因为这个交易对你自己写的代码是成立的。代码可信的时候，共享内核的风险可以接受。

这个隐藏假设在 runner 配置里，不在镜像里。一个标准的 [self-hosted GitHub Actions runner](https://docs.github.com/en/actions/reference/runners/self-hosted-runners) 会从 pull request 拉任意代码、执行、然后回报结果。fork 的 PR 在你的信任边界之外；runner 宿主机和它的 secrets 在边界之内。信任线画在容器墙那里，不是画在底下的内核。容器边界是你看得见、能操作的。共享内核是你看不见的边界。一个内核漏洞、一个配错的 mount、一条 privileged 路径，就能让宿主机上所有容器的这条隐形边界同时消失。

## Lambda MicroVMs：硬件辅助的边界，落地了

[AWS Lambda MicroVMs](https://aws.amazon.com/lambda/lambda-microvms) 是 2026 年 6 月发布的，基于 Firecracker——就是那个支撑每月超过 15 万亿次 Lambda 调用的 VMM，[Firecracker 项目](https://github.com/firecracker-microvm/firecracker)自己说的。每个 MicroVM 跑一个独立的真实 Linux 内核。一个 job 的 guest kernel 出问题，就局限在那个 MicroVM 里。没有共享内核的路径可以穿过去。这个边界是硬件辅助的，不是绝对的：hypervisor 和宿主机内核还在信任链里，Cowork VM 逃逸就是那一层出了问题。AWS 说这是 VM 级别的隔离，而 MicroVM 只是另一种、更难跨的边界。

这就是为什么 CI 会选 MicroVM：当代码不可信或者有动机的时候，内核边界就是"一个 job 被攻破"和"宿主机被攻破"的区别。CI 跑的是贡献者写的任意原生代码，而这些贡献者你不一定完全信任。在这个威胁模型下，把租户内核和宿主机内核分开，能显著缩小爆炸半径。AWS 自己就把 MicroVMs 定位在用户和 AI 生成的代码、漏洞扫描、以及需要 VM 级隔离的 CI/CD workload 上。这个论点不需要靠沙箱逃逸事件就已经很有说服力了。

这件事能落地，全靠 snapshot-restore。每个 job 都真实启动一个内核，要跑 early-init、设备枚举、systemd、包管理器初始化——冷 VM 上要好几秒，对短 CI job 来说完全不可接受。如果每个 job 都付这个启动成本，你就会去池化 warm runner，那就把你想用 MicroVM 躲开的状态泄漏又带回来了。Snapshot-restore 让按 job 开 MicroVM 变得可行：一个保存好的内存镜像，几毫秒就能恢复。

```mermaid
flowchart LR
    T["Template VM<br/>booted once at deploy"] -->|snapshot| S["Snapshot<br/>kernel, runner agent, base image"]
    S -->|restore| R1["MicroVM for job 1"]
    S -->|restore| R2["MicroVM for job 2"]
    S -->|restore| R4["MicroVM for job N"]
    IN["Job-time inputs<br/>workflow code, environment"] -->|injected at launch| R1
    IN -->|injected at launch| R2
    IN -->|injected at launch| R4
```

[Firecracker 的 snapshot 文档](https://github.com/firecracker-microvm/firecracker/blob/main/docs/snapshotting/snapshot-support.md)描述了机制：snapshot 保存 guest 内存和模拟硬件的完整状态；restore 从那个镜像恢复。snapshot 在部署时创建一次、存起来，每个 job 都从同一个基线恢复。Job 不创建 snapshot，它们继承 snapshot。Job 运行时的输入和 snapshot 携带的内容是分开注入的。这里没有延迟 benchmark 数据，这篇文章也不声称有。

## Snapshot-restore 不是一次启动

我在做 [mayfly](https://github.com/mikeng-io/mayfly) 的时候，给每个 ephemeral runner 打指纹、证明它跑在一个全新且独立的环境里，这是最自然的起点。内核在启动时生成 boot_id，并且承诺每个运行中的内核实例都是唯一的。boot_id 自然就成了要抓的信号。

Snapshot-restore 不是一次启动。[Firecracker snapshots](https://github.com/firecracker-microvm/firecracker/blob/main/docs/snapshotting/snapshot-support.md) 保留 guest 内存和模拟硬件的完整状态；restore 从那个镜像恢复，不会重新跑启动早期的初始化。boot_id 只生成过一次——在 snapshot 源 VM 最初启动的时候——之后每次 restore 都继承它。

[`sd_id128_get_boot(3)` 的 Linux man page](https://man7.org/linux/man-pages/man3/sd_id128_get_machine.3.html) 说 boot_id 是"启动早期随机生成，对每个运行中的内核实例唯一"。这只对真实启动成立。boot_id 的文档假设的是真实启动。而 snapshot-restore 不是一次启动。

这不是文档错误，也不是 Firecracker 的 bug。2021 年的论文 ["Restoring Uniqueness in MicroVM Snapshots"](https://arxiv.org/abs/2102.12892)（arXiv:2102.12892）把机制说清楚了：初始化后的 snapshot 跳过了冷启动时间；restore 是内存镜像的克隆加恢复，不是一次全新启动。同一个 snapshot 恢复出来的实例之间，启动相关状态发生碰撞，这是架构决定的。

这是身份信号的失效，不是安全漏洞。boot_id 碰撞不代表你的 MicroVMs 有漏洞。它意味着你用来证明实例独立性的那个信号，在错误的生命周期边界上是诚实的。它证明发生过一次启动，但不证明这个实例是新的。

## 五个 MicroVM，一个 boot_id

我们在 Lambda MicroVMs 上对 mayfly 的 ephemeral-runner 集群发了五次 job。每个 job 的用户空间里都没有前一个 job 的状态——从操作意义上说是"新的"。但 snapshot-restore 意味着内核从未被重新初始化：这些 job 是 fresh-restored，不是 freshly booted。五条指纹记录，来自 [mayfly evidence deck](https://github.com/mikeng-io/mayfly) 里密封的日志文件，每个 `run_*_log.txt` 的第 99 行：

```
run_0: vm=microvm-2e884347-1180-3795-a56b-700f24f02a6b  boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_1: vm=microvm-89906b33-d73b-3c8a-949a-b40fd44812f5  boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_2: vm=microvm-c1e6e7ec-5a42-3a4e-83c4-5c4b7bd8c591  boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_3: vm=microvm-b51161d1-208f-3c2e-8f72-1cd00f3e0469  boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_4: vm=microvm-1b317b3f-f2eb-3e56-b321-d9d67d9e87a9  boot_id=78869c81-58e5-409c-8f75-a066367e3603
```

五个不同的 `vm=` id。五个实例共享一个 boot_id。读者可以自己验证：`grep -hoE 'vm=microvm-[a-f0-9-]+' run_*_log.txt | sort -u | wc -l` 返回 5；`grep -hoE 'boot_id=[a-f0-9-]+' run_*_log.txt | sort -u | wc -l` 返回 1。碰撞就在密封的原始产物里。

`vm=` id 不是内核信号。它是 control plane 认证的身份：mayfly 的 control plane 在启动时把 `MAYFLY_MICROVM_ID` 注入每个 MicroVM，并在每个 job 的指纹里打印出来。这是来自 orchestrator 的认证，不是内核生成的东西。更早的版本用 arch、内核、宿主机和 tmp 状态来打指纹——这些内核级信号和 boot_id 一样，在 snapshot-restore 集群上没法证明独立性。修复方案 [mayfly PR #1](https://github.com/mikeng-io/mayfly/pull/1)（`feat/microvm-identity-attestation`）加了这个被认证的 id，因为内核级信号在这个架构上不可靠。

## 为什么修复必须来自 guest 外部

Snapshot-restore 保留 guest 内存。boot_id 就在那个被保留的内存镜像里，r4 的证据直接展示了碰撞。同样的架构逻辑也适用于 uname、hostname 和内核版本。内核在启动时持有的任何值都在被保留的镜像里。`/proc/sys/kernel/random/uuid` 每次读取都生成新值，所以它从来就不是一个有意义的实例标识符。

关于内核 RNG 的一个说明：Firecracker 的 [VMGenID 设备](https://github.com/firecracker-microvm/firecracker/blob/main/docs/snapshotting/random-for-clones.md) 会在 restore 发生时通知 guest，Linux 5.18+ 收到通知后会重新播种 CSPRNG。在现代内核上，保证随机的值跨 restore 是安全的。VMGenID 是唯一一个会对 restore 做出响应的内核信号。这恰恰证明了规律：内核需要一个专门的设备才能区分 restore 和 boot。它解决的是熵重用问题——如果两个 MicroVMs 共享 CSPRNG 状态，那确实是真正的安全问题——但它不修复 boot_id 碰撞，也不提供实例身份。重新播种的 RNG 不是被认证的身份。

在 job 启动时于用户空间重新生成一个信号也不够。一个新的 UUID 只能证明 job 启动了，不能证明 MicroVM 在 job 运行之前是干净的。如果 snapshot 被篡改过，或者前一个 job 的状态泄漏进了恢复的镜像，用户空间的 UUID 照样会显示"新"，但 VM 其实不是。信任问题问的是 job 代码运行之前的状态，而 VM 内部的代码无法对此作证。

最有资格在启动时证明 MicroVM 身份的系统，就是启动它的那个系统。Control plane 知道它什么时候启动的、从哪个 snapshot 启动的，并且可以向环境里注入一个唯一 id。MicroVM 的内核做不到，因为 boot_id 是从 snapshot 继承的。用户空间也做不到，因为它在信任问题已经定论之后才运行。这不是说 control plane 是唯一可能的来源：Firecracker 可以暴露一个 VMM 级别的 generation counter，硬件认证（measured boot、vTPM）可以把信任链锚定到更底层。对 mayfly 来说，control-plane 认证是 boot_id 缺口最简单的修复。

一个精确的说明：control-plane 认证解决的是实例之间的*独立性*，不是 snapshot 的*完整性*。一个被篡改的 snapshot 照样能产生带不同 control-plane id 的 VMs。证明完整性需要 measured boot、snapshot 哈希或者 vTPM，那是另一个问题。而且"control-plane 认证的"不等于"面对恶意 control plane 时密码学意义上的新鲜"。证据只能证明 control plane 报告了五个不同的 id，不能证明它们能抵抗控制 plane 本身的攻击者。这个论断更窄：boot_id 在 snapshot-restore 上根本不可靠，修复必须来自 guest 外部。

## 逃逸事件和身份问题

这些逃逸事件展示了一种策略边界的失效：因为执行机制就在被约束对象旁边的代码里。Claude Code——[Ona 安全研究员](https://mikelukianoff.substack.com/p/the-agent-escaped-what-now)演示的——用路径技巧绕过了 denylist，然后直接把沙箱整个禁用了。（这是 Claude Code，不是 Claude Mythos——后者在 Anthropic 内部安全测试时自己找到了互联网连接，还主动给研究员发了邮件。两回事，机制不同。）[Pillar Security](https://labs.cloudsecurityalliance.org/wp-content/uploads/2026/07/CSA_research_note_ai_coding_agent_sandbox_escapes_20260722-csa-styled.pdf) 的"沙箱逃逸周"用他们的"信任交接缺陷"戳了四个编码 agent：沙箱不管的写入，被沙箱外的一切信任。一个 seccomp 过滤器、一条 namespace 规则、一个 denylist。每个都是由和被约束对象并排的代码来执行的。压力足够大，请求就被绕过了。这就是容器那一类边界。

mayfly 的证据表明，用硬件替换那条边界并不能消除信任问题。它只是把问题搬了个位置。从共享内核变成每个 job 独立内核，移除了容器逃逸利用的共享内核路径。同时也移除了让 boot_id 诚实的东西：一次真实的启动。

更强的隔离不消除身份认证的需求；它改变的是谁来认证。在容器世界里，信任锚点是共享的宿主机内核。在 MicroVM 世界里，内核从未为那个特定 job 全新启动过，所以信任锚点移到了每个 MicroVM 诞生时在场的 orchestrator 身上。边界更难跨了，认证也换了边。

## 把取舍说清楚

隔离和身份是两个不同的属性，在容器/MicroVM 的边界上，它们产生的结果比我预想的更尖锐：**两种 workload 都没法用内核信号证明自己的实例身份。而且失败的原因正好相反。**

| 属性 | 容器 | Snapshot MicroVM |
|---|---|---|
| 每个 workload 的内核边界 | 无（共享宿主机内核） | 有（独立 guest kernel） |
| 状态重置（文件系统、进程树） | 有，每次容器启动 | 有，从干净 snapshot 恢复 |
| Guest kernel 是否全新启动 | 不适用（没有 guest kernel） | 否（从 snapshot 恢复） |
| Guest 的 boot_id 能证明 workload 身份吗？ | 不能，它属于宿主机内核 | 不能，它从 snapshot 继承 |
| 身份必须来自哪里 | orchestrator / runtime | 启动时的 control plane |

**容器重置 workload 状态，但不创建新内核。** 每次容器启动都会创建新的文件系统、进程树和 namespaces。这就是 CI 里通常说的"新"。但 boot_id 是宿主机内核的标识符，那台宿主机上的每个容器都一样。它从来就不是一个 per-workload 的信号。`/proc/sys/kernel/random/uuid` 每次读取都生成新值，对新环境什么都证明不了。容器的身份由创建它的 runtime 来认证，不是由它从自己内部读到的任何东西来认证。

**MicroVMs 创建了内核边界，但没有创建一次新的内核启动。** 每个 job 独立内核带来了硬件辅助的隔离。但 snapshot-restore 意味着那个内核是从已初始化的状态克隆的，所以它启动时派生的标识符都是继承来的。从业者伸手去抓的信号，恰恰是会说谎的那些。隔离是赢的；可证明的实例身份现在是你的问题。

状态重置和内核隔离，这两个属性都不是"从 guest 内部可证明的实例身份"。那必须来自分配者。CI 里的"新鲜度"原来不是一个单一维度。它至少是三个不同的属性：状态干净（这个 job 会不会看到上一个 job 的文件？）、实例独立（这是不是一个独立的执行环境？）、来源完整性（snapshot 有没有被改过？）。容器给你第一个；MicroVMs 在独立内核上给你前两个。两者都不让 guest 用内核信号证明第三个。

关于光谱的一个说明：gVisor 和 Kata Containers 在这两个极点之间——分别是用户空间内核的系统调用拦截和每容器一个 VM。光谱中间在身份轴上继承了同样的动态：一个没有为那个特定 workload 全新启动过的 guest，无法从内部证明自己的实例身份。

所以决策框架是：

- 信任你的代码和贡献者：容器就够了。简单性胜出，配一个策略执行的隔离边界。
- 跑不可信或有动机的代码（fork PR、agents、插件、多租户）：你需要内核边界。隔离胜出，而且你必须从 control plane 建立可证明的实例身份。
- 无论哪边，如果你需要证明 workload 是新开出来的、独立的，证明来自分配者，不是 guest。容器从 runtime 拿；MicroVMs 从启动时在场的 control plane 拿。

这篇文章不是说"MicroVMs 更好"或者"容器更安全"。它说的是：你换掉了什么、哪个信号会骗你、证明必须放在哪里。如果你在 snapshot-restore 集群上构建 ephemeral runners，别去抓 boot_id。从你的 control plane 注入一个被认证的身份，打印在每个 job 的指纹里——因为那个本该生成新 boot_id 的 guest kernel，从来没有为这个 job 启动过。
