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

1 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.2 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 的绑定就住在一个文件里,而那个核心数字恰好是五位十六进制:
// internal/kvm/kvm.go
kvmRun = 0xAE80当用户态调用 ioctl(vcpu_fd, 0xAE80, 0) 时,内核把控制权交给 guest 的 CPU。宿主线程阻塞。guest 运行。这就是每一个写过的 VMM 的核心。
3 每个 vCPU 一个 goroutine 就是整个模型
这就是用 Go 写 VMM 的乐趣所在,也是让那个周末真正感觉像个周末的东西。一颗虚拟 CPU 是一个会阻塞直到 guest 退出的文件描述符。如果你想要两个 vCPU,你就要两个宿主线程。如果你想要十六个,你就要十六个线程。
Go 有一个词来描述"一个看起来像线程、会阻塞在系统调用上的东西"。烦人的脚注是:Go 的调度器通常会想什么时候就什么时候把 goroutine 在 OS 线程之间挪来挪去,而 KVM 真的不喜欢这样。修复办法是三个词:
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 的搏斗是真实的,但有界;而那份便利在数月里复利累积。
4 接下来的三周
虚拟机启动了。一条命令。生活很美好。然后,正是那个让第一个周末愉快的廉价-goroutine 特性,开始收利息了。
每个 bug 都以不同的症状开始,却在同一个地方走进死胡同:两个 goroutine 对谁拥有某块内核状态各执一词。jailer 总把鞋留在门口——bind mount 在崩溃的沙箱之后存活下来,毒害下一台虚拟机。一个对共享 vsock 文件描述符的裸 close,结果是一次引用计数操作,而非一条协议消息;宿主永远阻塞,直到用户出于纯粹的绝望按下一个键,这唤醒了一个后台 goroutine,它放掉了最后一个引用,最终才发出关机数据包。清理过程中的一次 panic 让终端停在 raw 模式。Go 运行时的每个发布版都想多要一个 seccomp 过滤器不认识的系统调用,于是"Bad system call"成了发布周的主题曲。
十五个 goroutine 套着一件风衣,那也还是十五个 goroutine。教训与其说是关于某一个 bug,不如说是关于它们全体的形状:在 microVM 里,宿主内核保留着关于你虚拟机的状态,如果你不在顺利路径上清理它,没人会在不顺利路径上替你清理。在三天内的第五次竞态之后,结构性的修复不是"更小心点"——我本来就已经很小心了——而是在 CI 里打开 -race,并写一些故意让生产者和消费者赛跑的测试,让竞态条件可复现地暴露出来。竞态检测器是你能在一个 Go 项目上打开的最重要的一个设置。它慢十到一百倍,但每一个时钟周期都值。
当 CI 不再抖动后,我终于有了一台可信到足以拿来测量的虚拟机。
5 我盯错了盒子
有大约两周,我以为 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× 蒸发了:
// 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。坑从来不在你以为的地方。
6 内核才是问题
我把 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 的垃圾回收器:
// 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×,你就有问题要问了。
7 冷启动小了之后,热路径就尴尬了
快照恢复曾经是那条快路径。在 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 的内存区域:
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。快照恢复一下子比冷启动快了好几倍。(一个重要的告诫:当虚拟机正基于某个快照文件运行时,别删那个文件。问我是怎么知道的。)
8 为虚拟机做 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 表面有意做得很小:
// 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。
9 九个沙箱白白烧着一颗 CPU
九个沙箱在运行,已暂停,空闲。没有流量。没有 exec 会话。没有 HTTP。坐在一个 warm pool 内的 shell 提示符上,等着有人让它们干活。top 显示宿主占着一颗核的 46%。
百分之四十六,就为了让九个空闲的 Linux guest 活着。大约每个空闲虚拟机占一颗核的百分之五。一台物理的空闲 Linux 机器在现代硬件上大约用一颗核的 0.1%。一个被恰当虚拟化的空闲 guest 应当更便宜,而不是贵五十倍。
有什么东西大错特错了。
看清一个 vCPU 线程在干什么的干净办法是采样它。几秒钟的 perf 回了一个毫不含糊的栈轨迹:进入 KVM,几乎立刻退出,睡一毫秒,再进去。一遍又一遍,每秒一千次,在每个 vCPU 线程上,并行地。九个线程同时这么干,正好就是宿主报告的那颗核的 370%。
起因是一个对冲。在 vCPU 循环深处,HLT 退出被一个一毫秒的 sleep “处理"了:
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 是死代码,它在一次没人质疑的设计变更中幸存了下来。
修复是一次删除:
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%,靠删掉一行。少了五十倍。
这个普遍的模式值得起个名字。你"只为安全起见"加的代码,往往是最值得删掉的代码,因为没人质疑它。一个系统里你与之搏斗的部分,会被评审到死。没人抱怨的部分,得以安然腐烂。当腐烂最终让你付出代价时,它让你付出的,是你曾经想过的任何东西的五十倍。
10 一台快的 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。
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 缓存是内容寻址的,模板在它之上是内容寻址的,而沙箱之所以廉价,是因为模板廉价。
11 五个热就绪。一个都没有。
我在对一个刚重建好的 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 现在按顺序做三件事,而这个顺序是承重的:
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 个模板间同时出错的事情的数量,增长得比按模板的上限约束它的速度更快。
12 这套地基教会了我什么
回望整段弧线,有几件事突出到值得带着往前走。
宿主内核状态比你的进程活得更久。在启动时和关停时一样要清理它。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 的用户可见冷启动是怎么发生的。你一层一层地把它挣来。没有单一的英雄式改动。有的是一摞小而诚实的改动,每一个都让下一个写起来更便宜。
这台机器有趣的部分已经做完了。剩下的,是让它们保持诚实。