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

现代开发中有一个大家都默许的体面假象,因为它很方便:我们假装 Docker 容器既快又轻量。如果你的参照系是 2005 年裸机的配置过程,那确实如此。但随着我的工具需要越来越频繁——而且越来越动态地——隔离工作负载,我开始感到一种拖累:在我和我真正想要的东西之间,横亘着一个庞大的外部引擎。

我想要的是某种在人体工程学上感觉像在 Node.js 中调用 child_process.spawn() 的东西,但它能提供一台完整虚拟机的硬件级隔离。不是共享命名空间的容器。我想要一个真正的内核——或者至少是一个微内核——在隔离环境中运行。最重要的是,我希望暂停和恢复足够快,快到 VM 内部的 HTTP API 能够响应客户端,并让人感觉这只是普通的网络延迟。没有"解冻"的延迟。

node-vmm 就是这么来的。而正如这类事情通常的走向一样,这个想法一直很简单,直到我一头撞上了原生 hypervisor 的现实。

几乎每一个需要跨平台虚拟化的项目都会本能地求助于 QEMU。它就是瑞士军刀。但 QEMU 很庞大。从 Node 中封装 QEMU 本可以在一周内解决跨平台问题——却会彻底葬送延迟和轻量的目标。

所以我换了一种做法:直接与每个操作系统的原生 hypervisor API 对话,用 C++ 通过 N-API 绑定到 Node。结果是一套被拆分为三个完全独立世界的架构,它们在最底层不共享任何代码:

  1. Linux:直接对 KVM 发起 ioctl 调用。一个带着某种粗犷优雅的文件——native/kvm/backend.cc
  2. Windows:Windows Hypervisor Platform(WHP)。这一个是真的折磨人。WHP 丢给你一个赤裸的虚拟 CPU,然后说祝你好运自己去组装主板,于是我不得不从零开始模拟 APIC、定时器和 UART 端口。
  3. macOS / Apple Silicon:Hypervisor.framework(HVF)。熬了好几个深夜之后,我意识到最干净的路径不是假装成 x86,而是使用 ARM64 机器配置(基于 virt)来保持一切快速且原生。

把这三者隐藏在 TypeScript 中一个单一的 interface NativeRunConfig 之后并不简单。Virtio 的 MMIO 中断布局是事情变得棘手的地方。在 KVM 上,设备的内存步长很干净——0x1000。在 Windows 上,我不得不把它们更紧凑地塞在 0x200,以避免与 ACPI 表重叠。最终这个抽象层稳住了,任何引入这个库的开发者永远都不必去想这些。

每个现代工具都需要运行镜像。显而易见的做法是桥接到本地 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,而不会打断流程。

1 到 3 秒的冷启动时间还可以接受。我真正的执念是让被暂停的进程在一个网络请求的延迟内恢复——亚 100 毫秒。

VM 暂停/恢复的传统做法是冻结 CPU,把 RAM 和中断状态序列化到磁盘,唤醒时再恢复。对我的需求而言,这慢了好几个数量级。另一个选项是在 Node.js 主线程和管理 hypervisor 的 Worker 线程之间传递消息。问题在于:Node 在事件循环之上的消息传递桥会引入延迟抖动和停顿。

灵感来自思考现代游戏引擎是如何渲染的:SharedArrayBuffer 与 Atomics 的结合。

我实现了一个小巧的结构化缓冲区,带有用于 CONTROL_COMMANDCONTROL_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 跟踪在追求的目标,我已经把它接入了代码的基础结构中。但到目前为止,我已经得到了我当初想要构建的东西:一台真正虚拟机毫不妥协的隔离,被隐藏在某种看起来、运行起来、消亡起来都像我终端里又一个进程那样轻松的东西里。

相关内容