node-vmm:感觉像创建进程一样简单的 VM 隔离

1 关于容器的那个令人安心的谎言
现代开发中有一个大家都默许的体面假象,因为它很方便:我们假装 Docker 容器既快又轻量。如果你的参照系是 2005 年裸机的配置过程,那确实如此。但随着我的工具需要越来越频繁——而且越来越动态地——隔离工作负载,我开始感到一种拖累:在我和我真正想要的东西之间,横亘着一个庞大的外部引擎。
我想要的是某种在人体工程学上感觉像在 Node.js 中调用 child_process.spawn() 的东西,但它能提供一台完整虚拟机的硬件级隔离。不是共享命名空间的容器。我想要一个真正的内核——或者至少是一个微内核——在隔离环境中运行。最重要的是,我希望暂停和恢复足够快,快到 VM 内部的 HTTP API 能够响应客户端,并让人感觉这只是普通的网络延迟。没有"解冻"的延迟。
node-vmm 就是这么来的。而正如这类事情通常的走向一样,这个想法一直很简单,直到我一头撞上了原生 hypervisor 的现实。
2 用艰难的方式绕过 QEMU
几乎每一个需要跨平台虚拟化的项目都会本能地求助于 QEMU。它就是瑞士军刀。但 QEMU 很庞大。从 Node 中封装 QEMU 本可以在一周内解决跨平台问题——却会彻底葬送延迟和轻量的目标。
所以我换了一种做法:直接与每个操作系统的原生 hypervisor API 对话,用 C++ 通过 N-API 绑定到 Node。结果是一套被拆分为三个完全独立世界的架构,它们在最底层不共享任何代码:
- Linux:直接对 KVM 发起
ioctl调用。一个带着某种粗犷优雅的文件——native/kvm/backend.cc。 - Windows:Windows Hypervisor Platform(WHP)。这一个是真的折磨人。WHP 丢给你一个赤裸的虚拟 CPU,然后说祝你好运自己去组装主板,于是我不得不从零开始模拟 APIC、定时器和 UART 端口。
- macOS / Apple Silicon:Hypervisor.framework(HVF)。熬了好几个深夜之后,我意识到最干净的路径不是假装成 x86,而是使用 ARM64 机器配置(基于 virt)来保持一切快速且原生。
把这三者隐藏在 TypeScript 中一个单一的 interface NativeRunConfig 之后并不简单。Virtio 的 MMIO 中断布局是事情变得棘手的地方。在 KVM 上,设备的内存步长很干净——0x1000。在 Windows 上,我不得不把它们更紧凑地塞在 0x200,以避免与 ACPI 表重叠。最终这个抽象层稳住了,任何引入这个库的开发者永远都不必去想这些。
3 解雇中间人:没有 Docker Engine 的 OCI
每个现代工具都需要运行镜像。显而易见的做法是桥接到本地 Docker socket。这违反了我不使用重量级引擎的规则。
所以我写了 oci.ts——一个用 TypeScript 实现的完整 OCI(Open Container Initiative)registry 客户端。它解析 manifest,协商 token,逐层拉取 tar.gz blob,直接把它们注入到一个 VM 可以即时挂载的 ext4 rootfs 中。通过在本地解码镜像并在不接触 dockerd 的情况下挂载它来引导 node:22-alpine,从实践层面改变了"即时"的含义。在那些默认不安装 mkfs.ext4 的架构上,我们会优雅地回退到 WSL2 或 Homebrew,而不会打断流程。
4 顿悟时刻:作为违背物理定律之黏合剂的 SharedArrayBuffer
1 到 3 秒的冷启动时间还可以接受。我真正的执念是让被暂停的进程在一个网络请求的延迟内恢复——亚 100 毫秒。
VM 暂停/恢复的传统做法是冻结 CPU,把 RAM 和中断状态序列化到磁盘,唤醒时再恢复。对我的需求而言,这慢了好几个数量级。另一个选项是在 Node.js 主线程和管理 hypervisor 的 Worker 线程之间传递消息。问题在于:Node 在事件循环之上的消息传递桥会引入延迟抖动和停顿。
灵感来自思考现代游戏引擎是如何渲染的:SharedArrayBuffer 与 Atomics 的结合。
我实现了一个小巧的结构化缓冲区,带有用于 CONTROL_COMMAND、CONTROL_STATE 和控制台的槽位——TypeScript 主线程和 C++ Worker 线程都能原子地读取它,无需昂贵的锁,也无需通过 V8 进行任何消息序列化。
当我想暂停一台 VM 时,TS 线程会原子地往缓冲区里写入一个 1。KVM/WHP Worker 在它某次微观的 VM-exit 期间,检查那个共享字节,然后直接停止 vCPU 的执行——而不拆除机器的内存基础设施。VM 并不在磁盘上。它仍然在 hypervisor 中活着,只是睡着了,不消耗任何周期。
里面的 Fastify 或 Express 服务器会恢复并在 5 到 50 毫秒内解析一个挂起的 GET /。与商业 hypervisor 的做法相比,这是个不起眼的小把戏。但对于启动隔离环境而言,它带来的人体工程学收益一点也不小。
这个项目还没完成。从冷快照完整恢复 RAM 仍是我借助 dirty-page 跟踪在追求的目标,我已经把它接入了代码的基础结构中。但到目前为止,我已经得到了我当初想要构建的东西:一台真正虚拟机毫不妥协的隔离,被隐藏在某种看起来、运行起来、消亡起来都像我终端里又一个进程那样轻松的东西里。