# Gocracker 编年史：用 Go 写的 microVM，从周末玩具到生产级沙箱


## Firecracker 很棒。但我还是又写了一个。

我喜欢 Firecracker。要是 AWS 出周边，我会在笔记本上贴一个小小的 Firecracker 贴纸。它的理念——在毫秒级启动一台真正的 Linux 虚拟机，剥掉所有没人需要的设备，锁死系统调用，然后收工——是过去十年里最优雅的系统工程理念之一。它把"容器，但真正隔离"从一个梗变成了一个产品。

于是很自然地，某个周末，我决定取代它。

不是因为它不好。而是因为每次我想把 `ubuntu:22.04` 当作 microVM 来跑时，在我和一个 shell 提示符之间隔着六个手动步骤：拉取镜像、解出 rootfs、构建 ext4 磁盘、生成 initrd、写一份四十行的 JSON 配置、创建 TAP 设备、和 iptables 搏斗、*然后*才调用 API。Firecracker 是个 Rust 专才。它把虚拟机启动得无比漂亮，并假定剩下的都是你的事。对 AWS 来说这是正确的设计，那里每一步都是另一个专才服务。但对于在笔记本上跑东西的人来说，那是一场标签页的灾难。

我想要一个通才。`gocracker run --image ubuntu:22.04`，而且我希望在我按完回车的那一刻，它已经把上面那些都做完了。所以我写了一个。用 Go。因为"自带电池"是其文化的那门语言，正是构建一个自带电池的 microVM 的显而易见之选。

如果说 Firecracker 是 CGI——一个朴素而有原则的接口，任何聪明的组件都能驱动它——那么 gocracker 就是 FastCGI：同样的接口，外面裹着一个长期运行、舒适的进程，它假定你是个在笔记本上干活的开发者，想把今天的活儿干完接着过日子。

```
    FIRECRACKER                gocracker
    (Rust specialist)          (Go generalist)

    just a VMM                 VMM + OCI + initrd + TAP + Compose
    you bring:                 you bring:
      - rootfs                   - one command
      - kernel                   - one kernel
      - initrd
      - tap + NAT
      - JSON config
      - 40 lines of bash

    beautiful.                 I am lazy and I like it.
```

## KVM 到底是什么

KVM——Kernel-based Virtual Machine——是一个字符设备 `/dev/kvm`，它把硬件虚拟化以 ioctl 的形式暴露出来。就这么回事。打开一个文件，对它调 ioctl，于是一颗 CPU 就在硬件沙箱里替你开始运行代码。

我喜欢把 KVM 的 ioctl 形容为虚拟机的汇编语言。底层、正交，每一个都只干一件事，而你必须自己把它们组合成有用的东西。没有调度器，没有设备模型。只有一小套原语字母表——创建虚拟机、创建 vCPU、映射内存、运行、读寄存器、写寄存器——而把这些变成一台计算机的人是你。

每一个写过的 VMM 的核心都是一个循环。你在 vCPU 上调用 run。宿主线程阻塞。guest 运行。最终 guest 做了某件需要宿主注意的事——碰了一个 MMIO 寄存器、击中了一个 I/O 端口、收到一个中断、关机——于是 KVM 把控制权交还。这种返回叫 VMexit。其他一切——设备、引导加载器、快照引擎——都是这个循环周围的糖。

```
            HOST                              GUEST
    +---------------------+            +-------------------+
    | ioctl(KVM_RUN)      | ---------> | guest instructions|
    |                     |            |                   |
    |                     | <--------- | VMexit            |
    | dispatch(exit_reason)            | (MMIO / IO / IRQ) |
    |   handle_mmio()     |            |                   |
    |   handle_io()       |            |                   |
    |   inject_irq()      | ---------> | resume            |
    +---------------------+            +-------------------+
```

整个与 KVM 的绑定就住在一个文件里，而那个核心数字恰好是五位十六进制：

```go
// internal/kvm/kvm.go
kvmRun = 0xAE80
```

当用户态调用 `ioctl(vcpu_fd, 0xAE80, 0)` 时，内核把控制权交给 guest 的 CPU。宿主线程阻塞。guest 运行。这就是每一个写过的 VMM 的核心。

## 每个 vCPU 一个 goroutine 就是整个模型

这就是用 Go 写 VMM 的乐趣所在，也是让那个周末真正感觉像个周末的东西。一颗虚拟 CPU 是一个会阻塞直到 guest 退出的文件描述符。如果你想要两个 vCPU，你就要两个宿主线程。如果你想要十六个，你就要十六个线程。

Go 有一个词来描述"一个看起来像线程、会阻塞在系统调用上的东西"。烦人的脚注是：Go 的调度器通常会想什么时候就什么时候把 goroutine 在 OS 线程之间挪来挪去，而 KVM *真的*不喜欢这样。修复办法是三个词：

```go
runtime.LockOSThread()
```

就这样。多进程处理的故事就是这样。每个 vCPU 拿到一个 goroutine。每个 goroutine 锁住自己的线程，跑那个退出循环。运行时负责其余的。不用写线程池。甚至连一个同步原语都不用——你工具箱里的 channel 就已经好使了。

```
            per-vCPU goroutine (LockOSThread)
    +--------------------------------------------------+
    |                                                  |
    |   for {                                          |
    |       err := ioctl(vcpu_fd, KVM_RUN, 0)          |
    |       if err == EINTR || err == EAGAIN {         |
    |           continue          // transient; resume |
    |       }                                          |
    |       switch run.exit_reason {                   |
    |       case IO:        handleIO(run.io)           |
    |       case MMIO:      handleMMIO(run.mmio)       |
    |       case SHUTDOWN:  return                     |
    |       case INTR:      continue                   |
    |       case HLT:       waitForIRQ(); continue     |
    |       }                                          |
    |   }                                              |
    +--------------------------------------------------+
```

当一个真正的 guest 启动时，这个循环每秒运行数百万次。大多数退出都很快——一次 virtio 队列通知、一次 UART 写、一次定时器滴答——循环在任何人察觉之前就返回到了 `KVM_RUN` 里。艺术在于让 switch 的每一个 case 都廉价。

到周末结束时，我有了一个绿色的提示符、一台运行中的虚拟机，以及一个低于 400 ms 的冷启动。我也会在笔记本贴纸上贴 Rust。但 Go 给了我 goroutine，让"每个 vCPU 一个线程"变成一个三词功能、一个大约两百行的 HTTP API 服务器、开箱即用的 JSON 配置，以及任何语言中最成熟的 OCI 库生态。交叉编译到 ARM64 只需两个环境变量。我和 Go 的搏斗是真实的，但有界；而那份便利在数月里复利累积。

## 接下来的三周

虚拟机启动了。一条命令。生活很美好。然后，正是那个让第一个周末愉快的廉价-goroutine 特性，开始收利息了。

每个 bug 都以不同的症状开始，却在同一个地方走进死胡同：两个 goroutine 对谁拥有某块内核状态各执一词。jailer 总把鞋留在门口——bind mount 在崩溃的沙箱之后存活下来，毒害下一台虚拟机。一个对共享 vsock 文件描述符的裸 `close`，结果是一次引用计数操作，而非一条协议消息;宿主永远阻塞，直到用户出于纯粹的绝望按下一个键，这唤醒了一个后台 goroutine，它放掉了最后一个引用，最终才发出关机数据包。清理过程中的一次 panic 让终端停在 raw 模式。Go 运行时的每个发布版都想多要一个 seccomp 过滤器不认识的系统调用，于是"Bad system call"成了发布周的主题曲。

十五个 goroutine 套着一件风衣，那也还是十五个 goroutine。教训与其说是关于某一个 bug，不如说是关于它们全体的形状：在 microVM 里，宿主内核保留着关于你虚拟机的状态，如果你不在顺利路径上清理它，没人会在不顺利路径上替你清理。在三天内的第五次竞态之后，结构性的修复不是"更小心点"——我本来就已经很小心了——而是在 CI 里打开 `-race`，并写一些故意让生产者和消费者赛跑的测试，让竞态条件可复现地暴露出来。竞态检测器是你能在一个 Go 项目上打开的最重要的一个设置。它慢十到一百倍，但每一个时钟周期都值。

当 CI 不再抖动后，我终于有了一台可信到足以拿来测量的虚拟机。

## 我盯错了盒子

有大约两周，我以为 gocracker 对比 Firecracker 有个 2× 的问题。我打印的那个 `duration` 字段，从不带 jailer 的 ~30 ms 涨到了带 jailer 的 ~55 ms。慢两倍。fork-exec 是反派。jailer 是反派。七个 REST PUT 是反派。

不过，看看*墙钟时间*。大约 860 ms 对大约 880 ms。在一个 860 ms 的基线上差大约二十毫秒。那不是 2×;那大约是 2%，而 2% 是噪声。那个"2×"完全出在一个在两条代码路径上度量了不同东西的 `duration` 字段里。进程内路径度量的是原始的 `vmm.New`。worker 路径度量的是 fork-exec、jailer 设置、chroot、七个 REST PUT，*外加* `vmm.New`。两者都在 guest 内核打印出哪怕一个字节之前就停了表。两个数字都没度量到"到达可用 guest"的时间。两者彼此也无法比较。

把度量拆成四个诚实的阶段——编排、VMM 设置、启动、guest 首次输出——让那个 2× 蒸发了：

```go
// pkg/vmm/timings.go
//
// BootTimings is the per-phase breakdown of how long it took to
// bring a microVM to life.
//
//   - Orchestration:    host-side work *before* the guest kernel starts
//   - VMMSetup:         time inside vmm.New() — KVM_CREATE_VM, memory...
//   - Start:            KVM_RUN starts on the vCPU goroutines
//   - GuestFirstOutput: first byte the guest prints on the UART
```

jailer 在编排上大约花了 30 ms，叠加在一个两条路径共享的 ~300 ms guest 内核启动之上。一个诚实的 ~10% 编排税，而非 2× 的惩罚。

```
   before the breakdown                   after the breakdown
   (one misleading number)            (four honest numbers)

   +-----------------------+         +--------------------+
   | duration = ~55ms      |         | orchestration ~30ms|
   | (in runViaWorker this |         | vmm_setup      ~8ms|
   |  includes jailer,     |         | start          ~2ms|
   |  fork, a few REST     |         | guest_first  ~320ms|
   |  PUTs, then vmm.New,  |         | total        ~360ms|
   |  then start)          |         +--------------------+
   +-----------------------+            |
                                        v
        "2x slower"                 "I was staring
                                     at the wrong box."
```

然后又掉出来一件事：那些毫秒里大约有三百是 *Linux 在虚拟机里启动*。如果我想要一台更快的虚拟机，我的代码不是问题。我的内核才是。

调试性能就像审计报销账单。你盯着一行行条目。你一直盯着，直到找到那行写着"商务午餐 480 美元"的，而那家餐厅原来是 Costco。坑从来不在你以为的地方。

## 内核才是问题

我把 guest 内核分叉成两套配置：一套通用的，我默认发布;还有一套"最小化"的，它扯掉了一台只有 virtio、别无他物的虚拟机永远用不到的任何东西。ACPI NUMA 走了。休眠走了。整个 USB 子系统走了。电源管理、profiling、SCSI、loop 设备、XFS、NFS——全没了。virtio 留下了。ext4 留下了。kvm-clock 留下了。内核缩小了约 12%，而启动仅靠少跑些 initcall 就降了一截。

然后来了那个比其他任何改动都更重要的小改动。我给内核命令行加了一个参数：`loglevel=4`。它告诉内核"只把警告及以上打印到控制台;其余的仍然进 ring buffer，这样你可以通过 dmesg 看到。"大部分启动输出不再发往那个被模拟的 UART。

事实证明，一个虚拟化的 UART 按字节算是昂贵的。内核写入串口控制台的每一个字节都是一次进入用户态的 MMIO 退出，那是一次上下文切换，是几微秒被浪费的时间。乘以几千个启动期字节，启动就被*打印*主导了。让控制台安静下来给启动减掉了大约 130 ms。

一行。

那些更小的胜利遵循同样的主题：别和内核搏斗，而是让它干自己的活。缓存一个其答案只取决于宿主文件系统、与 guest 无关的 discard 探测。让 x86 中断走 `eventfd + IRQFD`，而不是每次断言一个 ioctl，正如 ARM64 后端早就在做的那样。在短命的 VMM 子进程里关掉 Go 的垃圾回收器：

```go
// cmd/gocracker-vmm/main.go
import "runtime/debug"

func init() {
    debug.SetGCPercent(-1) // short-lived process; let the OS reap memory
}
```

这个进程不需要 GC;它会高高兴兴地一直运行到结束，然后由 OS 回收它的内存。这里几毫秒，那里几毫秒。单看都不聪明。累加起来才有意义。

把这些胜利按真实测量的大致比例堆成一张柱状图：

```
 standard kernel:
   [==orch==][vmm][======guest_first_output: ~305ms======]   ~390ms
    ~70ms    ~15  ~305
                                              this is Linux booting.

 minimal kernel:
   [==orch==][vmm][=====guest_first_output: ~280ms=====]     ~365ms (-25)
                                              fewer init calls.

 minimal + loglevel=4:
   [==orch==][vmm][guest_first_output: ~170ms]                ~250ms (-115)
                                              80% of the cost
                                              was *printing*.
```

这一切之后：冷启动落在 150–170 ms 区间。比 Firecracker 慢大约 45 ms，而此前慢得多得多。一个 Go-对-Rust 的差距，以毫秒计，而这个启动被一个我无法控制的外来 Linux 内核所主导。这是个该停下来的好地方。如果有人告诉你他们的 microVM 比 Firecracker 慢几十毫秒，你礼貌地点头;如果他们告诉你慢 2×，你就有问题要问了。

## 冷启动小了之后，热路径就尴尬了

快照恢复曾经是那条快路径。在 400 ms 的冷启动之上做一次 80 ms 的恢复，是个舍入误差。在 170 ms 的冷启动之上做一次 80 ms 的恢复，是你预算的一半。

旧的恢复干的是显而易见的事：给 guest RAM 分配一块全新的 128 MiB 匿名 mmap，把整个快照文件读进一个 Go 字节切片，再把整块 memcpy 到位。第一步到第三步大约花 80 ms，如果你曾经在每个请求上 memcpy 过 128 MiB，这正是你会预料到的。

然后是那个问题：要是我干脆不拷贝呢？

Linux 有个标志叫 `MAP_PRIVATE`。当你用它 mmap 一个文件时，内核事先不做任何实际的 I/O。它建立一个页表项，意思是"如果用户态碰这个页，缺页进内核，从文件里读出来，映射进去。如果用户态*写*这个页，缺页，写时复制到一个私有的匿名页，并把映射重定向到那个副本。"文件本身从不被修改。

那个 Netflix 类比是我一再回想的。Netflix 不会先把整部电影下载到你的设备上再开始播放。它立刻开始播放，并在你观看时逐分钟地获取。如果你快进略过一些部分，那些部分永远不会被下载。你为观看的分钟付费，而非为选定的电影付费。`MAP_PRIVATE` 就是 guest RAM 的这种模式。

新路径把快照直接 mmap 进 guest 的内存区域：

```go
mem, _ := unix.Mmap(int(f.Fd()), 0, int(memSize),
    unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE)
_ = unix.Madvise(mem, unix.MADV_HUGEPAGE)
```

guest 从不碰的页永远不会被加载。它读但不写的页保持与 page cache 共享。它写的页进入私有的 COW 副本，而快照文件保持干净。

```
  BEFORE: eager copy
  +----------------+   +----------------+    +----------------+
  |  mem.bin file  |-->|   os.ReadFile  |--->| copy(ram, mem) |
  |    128 MiB     |   |  read 128 MiB  |    |  128 MiB memcpy|
  +----------------+   +----------------+    +----------------+
                                                      |
                                                      v
                                           ~80ms before this point

  AFTER: lazy mmap (MAP_PRIVATE)
  +----------------+   +----------------------------+
  |  mem.bin file  |<--| mmap(fd, PRIVATE)          |
  |    128 MiB     |   | sets up page table only    |
  +----------------+   +----------------------------+
                                   |
                                   v
                        guest touches page N
                                   |
                                   v
                        minor fault (-> page cache)
                        kernel maps the page on the fly
                                   |
                                   v
                         ~20ms to "running"
```

底层完整的缺页舞步看起来是这样的：

```
  guest vCPU                  host kernel (KVM + mm)        snapshot
  +---------+                                                +-------+
  | read P  |---(EPT miss)--->| PTE not-present, PRIVATE     | on    |
  |         |                 | -> minor fault                | disk  |
  |         |                 | -> page cache lookup          |       |
  |         |                 |    (or read from disk)    <---+       |
  |         |                 | -> install PTE readable       |       |
  |         |<-----(resume)---|                               |       |
  +---------+                                                 +-------+

  later, guest writes page P:
  +---------+                                                 +-------+
  | write P |---(EPT miss)--->| PTE readable only             |       |
  |         |                 | -> COW fault                  |       |
  |         |                 | -> alloc anon page            |       |
  |         |                 | -> copy from page cache       |       |
  |         |                 | -> install PTE writable       |       |
  |         |                 |    (snapshot unchanged!)      |       |
  |         |<-----(resume)---|                               |       |
  +---------+                                                 +-------+
```

那里的每一步都是 Linux 对任何文件支持的 mmap 早就在做的事。没有一行缺页处理需要去实现。只要别和内核搏斗，让它干自己的活就好。

在一个 128 MiB 的 Alpine 快照上，恢复从 ~80 ms 降到大约 20 ms。快照恢复一下子比冷启动快了好几倍。（一个重要的告诫：当虚拟机正基于某个快照文件运行时，别删那个文件。问我是怎么知道的。）

## 为虚拟机做 Mise en place

午餐时间走进一家像样的餐厅。点 steak frites。它六分钟内就上桌了。光是那块牛排，往宽松了估，就是六分钟的烹制。薯条要十二分钟。荷兰酱要十五分钟。厨房是怎么在六分钟内做到的？

Mise en place（备料就位）。土豆在你到达之前就已半熟并沥干。荷兰酱已乳化并保温待用。订单一打到出菜口，盘子就从保温柜里端了出来。厨房在*你下单之后*唯一做的事，就是最后一道煎封。

一个 warm pool 就是为虚拟机做的 mise en place。公开基准测试上最快的竞争对手沙箱供应商大约在 100 ms——这正是你会预料到的：靠让一台虚拟机已经在运行、已暂停、等着有人说开始，从而彻底跳过恢复。如果领跑者靠预先备菜取胜，那就别再优化炉灶了。

这个 warm pool 变成了三个设计决策，每一个都是与一个假想的糟糕日子争论的结果。

第一，`Acquire` 是非阻塞的。pool API 的诱惑是让 `Acquire` 阻塞，直到有 worker 可用。那种"总是给用户一个 worker"感觉很安全。它不安全。如果池子空了，说明已经出了岔子，而让用户等一次全新的恢复，严格地说比落到那条本来就好使的冷启动路径更糟。池子是尽力而为的。一次未命中绝不能让用户比基线更慢。

第二，释放一个 worker 会*杀掉*它。每个池化库最终都想把一个 worker 回收回池子里。在多租户的世界里，刚处理完一个请求的 worker，碰过上一个租户要求的任何东西。把它交给下一个租户是一个租户隔离的漏洞，而至今没人利用过它这个事实，并不是一个安全论据。每次 `Acquire` 返回的都是一个从未服务过请求的进程。补充在后台进行，所以下一个调用者什么都不用付。池子总在流动。从不复用。

第三，补充是异步的、有上限的、且竞态安全的。针对同一个模板的一阵补充请求不应踩踏成十个并行的 spawn;一个与关停赛跑的补充 spawn 应当自己清理干净;而时钟必须可注入，好让陈旧性测试是确定性的。这些都不聪明。它们只是那些等池子第一次在生产中运行时、你会后悔当初没加的不变量。

把缓存和池子都接好的完整流程：

```
  request arrives
         |
         v
  warmcache.Lookup(key)
         |
         +-- miss --> cold boot (~250ms)  <-- baseline
         |
  hit, snapshotDir=S
         |
         v
  pool.Acquire(key, S)
         |
         +-- empty --> restore_direct (~20ms)   <-- still better
         |
  got a warm worker
         |
         v
  worker.Resume (~3ms)  <-- fastest path
         |
         v
  serve request
         |
         v
  pool.Release(w)  --> worker.Close()
         |
         v
  EnsureRefill in background
         |
         v
  spawn replacement (~20ms off the hot path)
```

池子的 API 表面有意做得很小：

```go
// pkg/warmpool/pool.go
type Worker interface {
    ID() string
    Close() error
}

func (p *Pool) Acquire(key, snapshotDir string) (Worker, bool, error)
func (p *Pool) Release(w Worker)
func (p *Pool) EnsureRefill(key, snapshotDir string)
```

在热路径上：那个热 worker 已经把 guest RAM 映射好、vCPU 状态加载好、虚拟机暂停好。`Acquire` 返回。一个单独的 resume ioctl 把它从暂停翻转到运行。三毫秒之后，guest 已经打过招呼了。Mise en place。

## 九个沙箱白白烧着一颗 CPU

九个沙箱在运行，已暂停，空闲。没有流量。没有 exec 会话。没有 HTTP。坐在一个 warm pool 内的 shell 提示符上，等着有人让它们干活。`top` 显示宿主占着一颗核的 46%。

百分之四十六，就为了让九个空闲的 Linux guest 活着。大约每个空闲虚拟机占一颗核的百分之五。一台物理的空闲 Linux 机器在现代硬件上大约用一颗核的 0.1%。一个被恰当虚拟化的空闲 guest 应当*更便宜*，而不是贵五十倍。

有什么东西大错特错了。

看清一个 vCPU 线程在干什么的干净办法是采样它。几秒钟的 `perf` 回了一个毫不含糊的栈轨迹：进入 KVM，几乎立刻退出，睡一毫秒，再进去。一遍又一遍，每秒一千次，在每个 vCPU 线程上，并行地。九个线程同时这么干，正好就是宿主报告的那颗核的 370%。

起因是一个对冲。在 vCPU 循环深处，`HLT` 退出被一个一毫秒的 sleep "处理"了：

```go
case KVM_EXIT_HLT:
    // Guest is idle. Don't spin; give it a breather.
    time.Sleep(time.Millisecond)
```

在一个 VMM 于用户态拥有中断控制器的世界里，这个 sleep 是合理的。gocracker 不是那样。gocracker 用的是内核内 IRQCHIP——对几乎每一种工作负载来说都是正确的默认——在那里 KVM 本应把线程一直保持在 ioctl *内部*，直到下一个中断触发，根本没有退出。那个 sleep 是死代码，它在一次没人质疑的设计变更中幸存了下来。

修复是一次删除：

```go
case KVM_EXIT_HLT:
    // No-op. In-kernel IRQCHIP already blocks the vCPU until the
    // next interrupt. There is no productive work for userspace here.
```

在下一次循环迭代里，代码再次调入 KVM，而 KVM——因为它拥有 IRQCHIP 并且知道没有中断将至——会把线程阻塞在内核内，只要 guest 保持空闲就一直阻塞。

同样的九空闲沙箱测试。`top`：7%。不是每台虚拟机百分之七。是整个机群百分之七。从 ~370% 到 ~7%，靠删掉一行。少了五十倍。

这个普遍的模式值得起个名字。你"只为安全起见"加的代码，往往是最值得删掉的代码，因为没人质疑它。一个系统里你与之搏斗的部分，会被评审到死。没人抱怨的部分，得以安然腐烂。当腐烂最终让你付出代价时，它让你付出的，是你曾经想过的任何东西的五十倍。

## 一台快的 microVM 是一套工具箱，不是一个产品

当从 `Acquire` 到 guest 第一条指令变成三毫秒时，我为此自豪了大约一周。然后我试着用它*构建*点什么。

我想要的是如今人人都想要的东西：一个 REST API，客户说"给我一个带 `numpy` 和 `pandas` 的 Python 3.12 沙箱，让我在里面跑代码"，一个沙箱出现了，三秒后他拿回一份 stdout，然后接着过日子。一个裸的 `gocracker run` 做不到这些中的任何一件。它启动一台虚拟机。仅此而已。如果说 microVM 是一个发动机缸体，那我需要的是这辆车的其余部分。

第一个决策是最重要的：让 gocracker 严格保持它原本的样子，而把那个托管层构建成一个独立的东西。gocracker 仍是底层 VMM、快照缓存，以及那个 worker 的 warm pool。它说的是字节和 ioctl。它对客户或模板没有任何意见。*sandboxd* 是一个坐在它之上的新守护进程，它拥有模板、租约、池子和预览令牌。SDK 只和 sandboxd 说话。sandboxd 只通过一个 unix socket 和 gocracker 说话。那个额外的往返是一个特性，不是一个缺陷。

我学到这个拆分的价值，靠的是先*不*把它拆干净，然后花三个小时调试一个竞态条件，它之所以存在，纯粹是因为两层在共享一个它们根本没理由共享的指针。跨越一个进程边界逼你去协商。共享一个指针让你能作弊。边界就是安全带。

这个拆分是一个干净的三层流程：

```
  SDK (Python / Go / TS)
         |
         |  HTTP over unix socket
         v
  sandboxd            <-- managed-runtime daemon
         |             (templates, leases, pools, preview tokens)
         |  HTTP over unix socket
         v
  gocracker serve     <-- low-level VMM orchestrator
         |             (KVM ioctls, snapshots, warm pool of workers)
         |  KVM ioctls + vsock
         v
  guest VM
         |
         +-- toolbox agent (listens on a vsock port inside the guest)
```

三跳。两个守护进程。SDK 从不直接和 gocracker 说话——它根本不知道 gocracker 的存在。sandboxd 是唯一的公开 API 表面;下游的一切都是实现细节。在一个 unix socket 上跨越一个进程边界很廉价（小 JSON 负载是亚毫秒级），而这种可分离性在你第一次想要重启 sandboxd 却不杀掉上百台活着的虚拟机时，就把自己的成本赚回来了。

模板是另一个承重的理念。一个客户想要的不是"一台 Linux 虚拟机"。他想要的是他给自己的 AI agent 用的那个环境——一个特定的基础镜像、一些 apt 包、一些 pip 包、一个工作目录、一些环境变量。一个模板捕获这个组合，*外加*把这份规格启动一次、让它达到稳态后所产生的那个快照。两个规格相同的模板共享一个快照。用相同规格再做一次 create 是一个 no-op。

```go
type Template struct {
    ID           string
    Name         string
    SnapshotDir  string
    SpecHash     string  // canonical fingerprint of image, kernel, mem, env...
    ContextHash  string  // build-context tarball when using a Dockerfile
    WarmPolicy   WarmPolicy
}
```

这听起来显而易见，直到你想象一个真实 SaaS 的生命周期：大多数模板 create 是幂等重试。一次部署重跑。一个 CI job 重新提交。一个 SDK 在 create 之前惰性地确保-存在。如果这些里的每一个都要花一次全新的 `docker build`，你就会在每月 400 美元的基础设施上交付一个每月 40 美元的产品。每一层的内容寻址身份会复利累积：warm 缓存是内容寻址的，模板在它之上是内容寻址的，而沙箱之所以廉价，是因为模板廉价。

## 五个热就绪。一个都没有。

我在对一个刚重建好的 sandboxd 跑负载测试。没什么花哨的——创建一个沙箱、exec `echo hi`、删除沙箱，在一个紧凑的循环里。池子为单个模板配置了三个 hot-ready 和三个 paused-ready。每次 create 都应当基本上是瞬时的，因为池子应当保持六个预热的沙箱活着，而我每次只需要一个。

它工作了大约九十秒。

然后每次 create 都开始失败。不是慢慢地。不是带着背压。每一个，带着"runtime returned 404: unknown vm"的各种变体。池子状态端点报告三个 hot-ready、两个 paused-ready、零个 leased。一个完美健康的池子，按它自己的说法。那些虚拟机已经死了好几分钟了。

那是个有趣的周二。

reconciler 的第一个版本信任它自己的内存内记录。它数标记为 `warm_ready` 的条目，把数目和 `MinHot` 比较，然后得出结论：健康，无需行动。reconciler 里没有任何东西在*看*。一台 warm-ready 虚拟机悄无声息地死了——vCPU panic、OOM-kill、guest 卡死、fstab 打错字把 systemd 扔进救援模式，随便哪种——而 sandboxd 继续把它数成活的。随后的租约在 attach 时带着 404 失败，租约处理器把那个条目标记为"broken"并落到冷启动，但那些坏掉的条目作为 `warm_leased` 滞留在内存里，直到一个独立的清理 goroutine 把它们回收掉。与此同时，池子继续宣称五个热，reconciler 继续不做任何决策，而每一个请求都在冷启动。

这场连锁反应并不壮观。没有火警。没有 pager。系统正悄无声息地把自己降级到最坏情况模式，一次一个 404，同时尽职地报告绿色。

```
  sandboxd's view              actual runtime state
  +-----------------+          +----------------------+
  | warm_ready: 3   |          | VM #1: dead          |
  | warm_ready: 2   |          | VM #2: dead          |
  | leased:     0   |          | VM #3: alive but oom |
  | total:      5   |          | VM #4: missing       |
  +-----------------+          | VM #5: missing       |
         ^                     +----------------------+
         |                                  |
         | "healthy, no action"             | lease attempt -> 404
         |                                  |
         |                                  v
   reconciler tick               lease handler marks broken,
   counts in-memory state        falls through to cold boot
   compares with MinHot                     |
   does nothing                             v
                                 user sees 2-second cold boot
                                 every single request
```

悄无声息的降级是最坏的模式。响亮的失败让你能为它设报警。无声的失败意味着图表看起来是绿的，而客户正在离开。

修复是结构性的，而且很小。reconciler 现在按顺序做三件事，而这个顺序是承重的：

```go
func (m *Manager) reconcileTemplate(tpl *Template) {
    m.reapDead(tpl)              // probe runtime, drop ghosts
    inv := m.inventoryFor(tpl.ID) // count from honest state
    m.pruneExcess(tpl, inv)
    m.replenishUpToMin(tpl, inv)
}
```

第一，探测 manager 认为自己拥有的每一个热沙箱，回收掉运行时不再认识的任何东西——"无法确定"算作死亡，因为一个由也许-活着的虚拟机组成的池子，比一个有窟窿的池子更糟。第二，从如今诚实的库存里计数。第三，修剪多余的并补充到最小值。修复之前：五个幽灵沙箱，每个请求一次冷启动，池子欢快地报告健康。之后：又是瞬时的 create 了。

我以前撞过这个一模一样的 bug。我猜你也撞过。每次它都穿着略有不同的行头——一个信任缓存的 pod 状态而非 kubelet 的 Kubernetes 控制器、一个因为最后一次响应是 200 就把后端标记为健康的连接池（而那个 socket 三十秒前就已经被 FIN 掉了）、一个心跳线程与工作线程毫无关系的服务注册表（于是服务可以死锁却还在 ping）、一个把 DNS 记录缓存到超出现实的浏览器。底层的错误每次都一样：信任一个关于世界的内存内表示，跨越一个进程边界，却不去探测。内存内状态和进程外的现实总会漂移。问题不是你会不会注意到;而是*什么时候*，以及在这中间积累了多少用户可见的损害。

大约在同一时间又加了两道护栏。一个按模板的退避，好让一个单独损坏的模板——比如，一个快照微妙地损坏了的——不至于单枪匹马地把 reconciler 钉死在每个 tick 都 spawn 失败的虚拟机上，从而饿死那些更健康的模板。还有一个跨整个宿主的全局在途 spawn 工作预算，因为十个模板各自想同时补充三台虚拟机就是三十个并行 spawn，这足以让每个 spawn 都比它需要的更慢，这让超时更紧，这又会级联。按模板的上限不够。能在 N 个模板间同时出错的事情的数量，增长得比按模板的上限约束它的速度更快。

## 这套地基教会了我什么

回望整段弧线，有几件事突出到值得带着往前走。

宿主内核状态比你的进程活得更久。在启动时和关停时一样要清理它。`close(fd)` 是一次引用计数操作，不是一条协议消息——如果你需要对端知道你走了，你就得真的说出来。每条退出路径都需要一个终端复原，因为 defer 是一个会被信号和 seccomp 触发所忽略的建议。CI 里的竞态检测器，对任何在 goroutine 间持有状态的 Go 项目来说，是不容商量的。

你最大的成本几乎肯定不是你写的那个东西。Linux 在虚拟机里启动占了一个四百毫秒冷启动的四分之三。在我去把它缩小之前，我写的任何东西都无关紧要。一个虚拟化的 UART 按字节算昂贵;让内核日志在控制台路径上安静下来，是这个项目里最大的单项性能胜利。`MAP_PRIVATE` 对快照恢复是白捡的钱。Go 垃圾回收器是一笔税，在短命的子进程里你可以选择不交。

信任内核胜过信任你的直觉。内核内 IRQCHIP 早已解决了空闲 vCPU 的驻泊。在它之上那个防御性的 sleep 是负功。防御性代码是一个测谎仪，专测那些此后已经改变了的假设：当底层系统变动时，去重新审视那些对冲。而有时最大的胜利是一次删除。

一旦一个 warm pool 上升到一台 microVM 之上，规则就变了。池子是尽力而为的——一次未命中绝不能让用户比基线更慢。在释放时杀掉 worker;绝不把一个碰过另一个租户数据的进程给某个租户。reconciler 循环必须先观察后行动，因为唯一比一个错误的缓存更危险的，是一个系统已经停止质疑的缓存。而在一个池子里，无法确定永远算死亡——把一个也许-活着的沙箱当作死亡的代价是一次冷启动;把一个死的当作活的代价，是你的客户看到的那次租约失败。

这些胜利里没有一个是单独看就聪明的。每一个都是别人多年前就想明白了的东西——mmap、写时复制、按租户隔离、eventfd 加 IRQFD、作为一个概念的 mise en place、信任内核内 IRQCHIP 调度器。这里没有任何发明。发生的事情是，停止和它们每一个搏斗，一次一个。这就是 ~3 ms 的用户可见冷启动是怎么发生的。你一层一层地把它挣来。没有单一的英雄式改动。有的是一摞小而诚实的改动，每一个都让下一个写起来更便宜。

这台机器有趣的部分已经做完了。剩下的，是让它们保持诚实。

