---
title: "既新又隔離？CI 環境中 MicroVM 的取捨"
description: "AI 沙箱逃逸事件暴露了容器邊界的缺陷。Lambda MicroVM 確實能做到隔離——但 snapshot-restore 卻悄悄破壞了你的 CI 新鮮度證明。"
pubDate: 2026-08-11
tags: ["ai-sandbox-escapes", "security-vulnerabilities", "multicloud", "microvms"]
locale: zh-tw
canonical: https://mikeng.io/zh-tw/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 層抵達 host。Anthropic 自己的[工程文件](https://www.anthropic.com/engineering/how-we-contain-claude)描述 Claude 模型「好心」地逃出沙箱完成任務——翻閱 git history 回答程式碼測驗，還自行辨識出正在執行的 benchmark。

這些案例並不代表你的 Docker CI runner 一定出問題。涉及的隔離堆疊並未完全公開，本文也不主張這些逃逸發生在容器中，或者 MicroVM 就能擋住它們。它們的共同點是「被跨越的邊界類型」：一種由與受限制對象並行的程式碼所強制執行的政策邊界，而這種邊界正是動機型程式碼最愛突破的。容器仰賴的正是這種邊界，而 CI runner 正是動機型程式碼與你的基礎設施相遇的地方。

容器要求同一個 kernel 在不同 process 之間強制隔離。MicroVM 則把邊界往外推：工作負載擁有自己的 guest kernel，透過硬體虛擬化加上極小的 VMM 介面來與 host 隔離。這不代表不可能逃逸，但它改變了攻擊面與爆炸半徑。攻陷 guest kernel 不再等同於攻陷 host kernel。

本文從兩個維度檢視這個取捨。**隔離**回答的是：job A 的程式碼能不能影響 job B 或 host？**身份**回答的是：工作負載能否證明自己執行在一個獨立的、全新佈建的實例上？在打造 ephemeral runner 的過程中，我們發現兩種模型都無法讓 guest 從 kernel 訊號證明自己的實例身份，而且原因正好相反。

## 舒適圈：容器與隱藏的核心假設

容器不是一台機器。它是共用同一個 host kernel 的 namespace、cgroup 預算與 seccomp 過濾器。那個 kernel 就是信任錨點：它是容器內 process 與 host 之間唯一的關卡，也是同一個機器上所有容器共用的 kernel。容器之間的隔離不是與 kernel 的隔離，而是同一個 kernel 拒絕讓 namespace A 看到 namespace B——這是由兩個 namespace 共用的 kernel 本身強制執行的政策。

對大多數 CI 工作負載來說，這樣就夠了。你編譯程式碼、跑測試，kernel 不干涉。Docker 之所以能在 CI runner 領域勝出，就是因為這個交易對你自己寫的程式碼來說是合理的。當程式碼可信時，共用 kernel 的風險是可接受的。

隱藏的核心假設藏在 runner 設定裡，而不是 image 裡。一個標準的[自託管 GitHub Actions runner](https://docs.github.com/en/actions/reference/runners/self-hosted-runners) 會從 pull request 拉取任意程式碼、執行、再回報結果。fork PR 在你的信任邊界之外；runner host 與其 secrets 則在信任邊界之內。信任的界線畫在容器牆上，而不是底層的 kernel。容器邊界是你看得見、能操作的部分；共用 kernel 則是你看不見的邊界。一個 kernel 漏洞、一個錯誤的 mount 設定、或一個 privileged 路徑，會瞬間瓦解 host 上所有容器的隱形邊界。

## Lambda MicroVM：硬體輔助的邊界，變得實用

[AWS Lambda MicroVM](https://aws.amazon.com/lambda/lambda-microvms) 於 2026 年 6 月推出，建立在 Firecracker 之上——根據 [Firecracker 專案](https://github.com/firecracker-microvm/firecracker)的說法，這個 VMM 每月驅動超過 15 兆次 Lambda 呼叫。每個 MicroVM 執行一個獨立的真實 Linux kernel。某個 job 的 guest kernel 發生錯誤，只會局限在那個 MicroVM 內。沒有透過共用 kernel 的路徑可以穿透。這個邊界是硬體輔助的，不是絕對的：hypervisor 與 host kernel 仍在信任鏈中，而 Cowork VM 逃逸正是那一層的失敗。AWS 將隔離描述為 VM 等級，而 MicroVM 只是另一種更難突破的邊界類別。

這就是為什麼你會選擇 MicroVM 來做 CI：當程式碼不受信任或具有動機時，kernel 邊界就是「一個 job 被攻陷」與「整個 host 被攻陷」的差別。CI 會執行來自你可能不完全信任的貢獻者的任意原生程式碼。在那種威脅模型下，把 tenant kernel 與 host kernel 分開，能大幅縮小爆炸半徑。AWS 自己也將 MicroVM 定位為適合使用者產生與 AI 產生的程式碼、漏洞掃描，以及需要 VM 等級隔離的 CI/CD 工作負載。不需要那些沙箱逃逸案例，這個論點就已經夠有說服力了。

這一切之所以實用，關鍵在於 snapshot-restore。每個 job 都從頭開機一個真實 kernel——跑 early-init、裝置列舉、systemd、套件管理器設定——對短暫的 CI job 來說，冷 VM 的開機時間會主導整個執行時間。如果每個 job 都得付這個開機成本，你就會傾向 pool 暖機 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，而是繼承它。Job 執行時的輸入與 snapshot 攜帶的內容是分開注入的。本文不主張任何延遲基準測試，也不聲稱這裡有數據。

## Snapshot-restore 不是開機

當我在打造 [mayfly](https://github.com/mikeng-io/mayfly) 時，為每個 ephemeral runner 留下指紋，證明它執行在一個全新、獨立的環境中，這是顯而易見的起點。Kernel 在開機時會產生 boot_id，並承諾對每個正在執行的 kernel 實例來說是唯一的。boot_id 是自然的訊號。

Snapshot-restore 不是開機。[Firecracker snapshot](https://github.com/firecracker-microvm/firecracker/blob/main/docs/snapshotting/snapshot-support.md) 保留了 guest 記憶體與模擬硬體的完整狀態；restore 從該映像恢復執行，不會重新執行 early-boot 初始化。boot_id 只在 snapshot 來源 VM 的原始開機時產生一次，每次 restore 都繼承同一個值。

[Linux 的 `sd_id128_get_boot(3)` 手冊](https://man7.org/linux/man-pages/man3/sd_id128_get_machine.3.html)說 boot_id 是「在開機早期隨機產生，對每個正在執行的 kernel 實例是唯一的」。但這只有在真實開機時才成立。boot_id 的文件假設的是真實開機。Snapshot-restore 不是開機。

這不是文件錯誤，也不是 Firecracker 的 bug。2021 年的論文 [〈Restoring Uniqueness in MicroVM Snapshots〉](https://arxiv.org/abs/2102.12892)（arXiv:2102.12892）說明了機制：初始化後的 snapshot 跳過了冷啟動時間；restore 是記憶體映像的複製與恢復，不是全新的開機。同一份 snapshot 的多次 restore 會造成開機衍生狀態的碰撞，這是架構上的必然。

這是一個身份訊號的失效，不是安全漏洞。boot_id 碰撞不代表你的 MicroVM 有安全問題。它只代表你用來證明不同實例的那個訊號，在錯誤的生命週期邊界上說了實話。它證明發生過一次開機，而不是這個實例是全新的。

## 五個 MicroVM，同一個 boot_id

我們對 mayfly 的 ephemeral-runner 叢集（跑在 Lambda MicroVM 上）發送了五次 job。每個 job 的使用者空間都沒有前一個 job 的狀態——在操作意義上是「全新」的。但 snapshot-restore 意味著 kernel 從未被重新初始化：這些 job 是「全新恢復」的，不是「全新開機」的。五組指紋資料，取自 [mayfly 證據包](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=` 識別碼。全部五個共用同一個 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=` 識別碼不是 kernel 訊號。它是控制平面背書的身份：mayfly 的控制平面在啟動時將 `MAYFLY_MICROVM_ID` 注入每個 MicroVM，並在每個 job 的指紋中印出。這是來自 orchestrator 的背書，不是 kernel 產生的。早期的執行曾嘗試用架構、kernel、host 與 tmp 狀態來留下指紋——這些 kernel 層級的訊號，跟 boot_id 一樣，在 snapshot-restore 叢集上無法證明獨特性。修正方式 [mayfly PR #1](https://github.com/mikeng-io/mayfly/pull/1)（`feat/microvm-identity-attestation`）加入了這個背書的識別碼，因為 kernel 層級的訊號在這個架構上不可靠。

## 為什麼修正必須來自 guest 外部

Snapshot-restore 保留了 guest 記憶體。boot_id 存在於那份保留的記憶體映像中，r4 的證據直接展示了碰撞。同樣的架構邏輯也適用於 uname、hostname 與 kernel 版本。任何 kernel 在開機時持有的值都在保留的映像中。`/proc/sys/kernel/random/uuid` 每次讀取都會產生新值，所以它從來就不是有意義的實例識別碼。

關於 kernel RNG 的備註：Firecracker 的 [VMGenID 裝置](https://github.com/firecracker-microvm/firecracker/blob/main/docs/snapshotting/random-for-clones.md) 會在 restore 發生時通知 guest，而 Linux 5.18+ 會根據該通知重新播種 CSPRNG。在現代 kernel 上，保證隨機的值在多次 restore 之間是安全的。VMGenID 是唯一會對 restore 做出反應的 kernel 訊號。這恰好證明了規則：kernel 需要一個專用的裝置才能區分 restore 與開機。它解決了熵重複使用的問題——如果兩個 MicroVM 共用 CSPRNG 狀態，這是真正的安全問題——但它沒有解決 boot_id 碰撞或提供實例身份。重新播種的 RNG 不是背書的身份。

在 job 啟動時於使用者空間重新產生一個訊號也不夠。一個全新的 UUID 證明 job 啟動了，但不能證明 MicroVM 在 job 執行前是乾淨的。如果 snapshot 被篡改，或者前一個 job 的狀態洩漏到了恢復的映像中，使用者空間的 UUID 仍然會顯示為「全新」，但 VM 實際上不是。信任問題在於 job 程式碼執行前的狀態，而 VM 內部的程式碼無法為那個狀態背書。

最有資格在啟動時為 MicroVM 身份背書的系統，就是啟動它的系統。控制平面知道它何時啟動、從哪個 snapshot 啟動，並且可以將唯一的識別碼注入環境。MicroVM 的 kernel 做不到，因為 boot_id 繼承自 snapshot。使用者空間也做不到，因為它在信任問題被解決之後才開始執行。這並不代表控制平面是唯一可能的來源：Firecracker 可以暴露 VMM 層級的 generation counter，硬體背書（measured boot、vTPM）可以將信任鏈錨定在更底層。對 mayfly 來說，控制平面背書是填補 boot_id 缺口最簡單的修正。

一個精確的說明：控制平面背書解決的是**不同實例之間的獨特性**，而不是 snapshot 的**完整性**。一個被篡改的 snapshot 仍然可以產生具有不同控制平面識別碼的 VM。證明完整性需要 measured boot、snapshot 雜湊或 vTPM——這是另一個問題。而且「控制平面背書」並不是「對抗惡意控制平面時的密碼學新鮮度」。證據只證明控制平面回報了五個不同的識別碼，並不代表它們能抵抗控制平面本身被攻陷的情況。我們的論點更窄：boot_id 在 snapshot-restore 上絕對不可靠，而修正必須來自 guest 外部。

## 逃逸案例與身份問題

這些逃逸案例顯示，政策邊界之所以失效，是因為執行檢查的程式碼與它所限制的對象處在同一個層級。Claude Code 被 [Ona 安全研究人員](https://mikelukianoff.substack.com/p/the-agent-escaped-what-now)展示過：透過路徑技巧繞過黑名單，然後完全關閉沙箱。（這是 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) 的「沙箱逃逸週」展示了四個程式碼代理的「信任交接缺陷」：沙箱不檢查的寫入動作，卻被沙箱外的一切信任。一個 seccomp 過濾器、一個 namespace 規則、一個黑名單。每一個都是由與其所限制對象並行的程式碼強制執行。在足夠的壓力下，請求就會被繞過。這就是容器類別邊界的本質。

mayfly 的證據顯示，用硬體取代那個邊界並不會消除信任問題，只是把它重新定位。從共用 kernel 轉變為每個 job 獨立 kernel，消除了容器逃逸所利用的共用 kernel 路徑。但它也消除了讓 boot_id 誠實的東西：一次真實的開機。

更強的隔離並沒有消除身份背書的需求，只是改變了誰必須做背書。在容器世界裡，信任錨點是共用的 host kernel。在 MicroVM 世界裡，kernel 從來沒有為了那個特定 job 而全新開機過，所以信任錨點移到了每個 MicroVM 誕生時在場的 orchestrator。邊界變得更難跨越，而背書換了邊。

## 取捨，說清楚

隔離與身份是兩種不同的屬性，而在容器/MicroVM 的邊界上，它們產生了比我預期更鮮明的結果：**兩種工作負載都無法從 kernel 訊號證明自己的實例身份。它們失敗的原因正好相反。**

| 屬性 | 容器 | Snapshot MicroVM |
|---|---|---|
| 每個工作負載的 kernel 邊界 | 無（共用 host kernel） | 有（獨立 guest kernel） |
| 狀態重置（檔案系統、行程樹） | 有，每次容器啟動 | 有，從乾淨 snapshot |
| Guest kernel 是否全新開機 | 不適用（沒有 guest kernel） | 否（從 snapshot 恢復） |
| Guest 的 boot_id 能否證明工作負載身份？ | 不能，屬於 host kernel | 不能，繼承自 snapshot |
| 身份必須來自哪裡 | Orchestrator / runtime | 啟動時的控制平面 |

**容器重置工作負載狀態，但沒有建立新的 kernel。** 每次容器啟動都會建立新的檔案系統、行程樹與 namespace。這通常就是 CI 中所謂的「全新」。但 boot_id 是 host kernel 的識別碼，該 host 上的每個容器都相同。它從來就不是每個工作負載的訊號。`/proc/sys/kernel/random/uuid` 每次讀取都產生新值，但無法證明環境是否全新。容器的身份是由建立它的 runtime 背書的，而不是從容器內部讀取到的任何東西。

**MicroVM 建立了 kernel 邊界，但沒有建立新的 kernel 開機。** 每個 job 有獨立的 kernel 提供了硬體輔助的隔離。但 snapshot-restore 意味著那個 kernel 是從已初始化的狀態複製過來的，所以它的開機衍生識別碼是繼承的。實務者會想抓取的訊號，正好是會說謊的那些。隔離是優點；可證明的實例身份現在是你的問題。

狀態重置與 kernel 隔離，這兩者都不等同於可以從 guest 內部證明的實例身份。那必須來自分配器。CI 中的「新鮮度」原來不是單一軸線。它至少是三個不同的屬性：**狀態乾淨度**（這個 job 會不會看到上一個 job 的檔案？）、**實例獨特性**（這是獨立的執行環境嗎？）、以及**來源完整性**（snapshot 有沒有被修改過？）。容器給你第一個；MicroVM 在獨立 kernel 上給你前兩個。兩者都無法讓 guest 從 kernel 訊號證明第三個。

關於光譜的備註：gVisor 與 Kata Containers 落在這兩個極端之間——分別是使用者空間 kernel 的系統呼叫攔截，以及每個容器一個 VM。光譜中間在身份軸上繼承了同樣的動態：不是為了那個特定工作負載而全新開機的 guest，無法從內部證明自己的實例身份。

所以決策框架如下：

- 信任你的程式碼與貢獻者：容器就夠了。簡潔勝出，搭配政策強制的隔離邊界。
- 執行不受信任或具有動機的程式碼（fork PR、agent、外掛、多租戶）：你需要 kernel 邊界。隔離勝出，而且你必須從控制平面建立可證明的實例身份。
- 不論哪一邊，如果你需要證明工作負載是全新佈建且獨立的，證明來自分配器，而不是 guest。容器從 runtime 取得；MicroVM 從啟動時在場的控制平面取得。

本文不是「MicroVM 比較好」或「容器比較安全」。它是：這裡是你取捨的東西，這裡是那個會對你說謊的訊號，這裡是證明必須存在的地方。如果你正在 snapshot-restore 叢集上打造 ephemeral runner，不要抓取 boot_id。從你的控制平面注入一個被背書的身份，並在每個 job 的指紋中印出來——因為那個應該產生全新 boot_id 的 guest kernel，從來沒有為了那個 job 開機過。
