node-vmm: La ilusión de un proceso, el aislamiento de una máquina virtual

Hay una mentira piadosa en la industria del desarrollo moderno que todos aceptamos porque es conveniente: fingimos que levantar un contenedor de Docker es rápido y ligero. Y lo es, si lo comparas con aprovisionar hardware desnudo en 2005. Pero a medida que mis herramientas necesitaban aislar cargas de trabajo cada vez con más frecuencia (y de forma más dinámica), empecé a sentir la fricción de depender de un motor externo gigante que actúa como intermediario.

Yo quería algo que se sintiera ergonómicamente como llamar a un child_process.spawn() en Node.js, pero que me entregara el aislamiento de hardware de una máquina virtual completa. Nada de contenedores con namespaces compartidos; quería un kernel propio (o al menos un micro-kernel) ejecutándose aislado. Y más importante aún: quería un “pause y resume” que se ejecutara tan rápido que una API HTTP dentro de la VM pudiera responder al cliente sintiéndose como si sólo hubiera habido latencia de red, sin tiempos de “descongelamiento”.

Aquí nació node-vmm. Y como suele ocurrir con estas cosas, la idea era sencilla hasta que me encontré de frente con la realidad de los hipervisores nativos.

Casi cualquier proyecto que necesita virtualización cruzada acude a QEMU instintivamente. Es la navaja suiza. Pero QEMU es grande. Envolver QEMU desde Node hubiera resuelto el problema de la compatibilidad multiplataforma en una semana, pero habría matado el objetivo de la latencia y la ligereza.

Decidí construirlo interactuando directamente con las APIs de los hipervisores nativos de cada sistema operativo, usando C++ atado directamente a Node a través de N-API. El resultado fue una arquitectura dividida en tres mundos completamente distintos que no comparten ni una sola línea de código en su capa inferior:

  1. Linux: Comunicación directa mediante llamadas ioctl a KVM. Es un solo archivo de una belleza cruda (native/kvm/backend.cc).
  2. Windows: The Windows Hypervisor Platform (WHP). Aquí el dolor fue real. Requirió emular desde cero dispositivos como el APIC, timers, y puertos UART porque WHP te da un procesador virtual desnudo y te dice “buena suerte ensamblando la placa base”.
  3. macOS / Apple Silicon: El Hypervisor.framework (HVF). Pasé noches enteras dándome cuenta de que la forma más limpia aquí no era fingir ser un x86, sino usar una silueta de máquina ARM64 (basada en virt) para mantener todo veloz y nativo.

Poder abstraer estas bestias debajo de un simple interface NativeRunConfig en TypeScript no fue trivial. Hubo momentos donde el diseño de las interrupciones Mmio (Memory-Mapped I/O) para Virtio no cuadraba. En KVM la zancada de memoria (stride) para los dispositivos era limpia (0x1000). En Windows necesité empacarlos más ajustados (0x200) para hacerlos funcionar sin solapar las tablas de ACPI. Al final, logramos que la abstracción ocultara el abismo arquitectónico a cualquier desarrollador que importe la librería.

Toda herramienta moderna necesita ejecutar imágenes. El impulso natural era hacer un puente que hablara con el socket local de Docker. Pero eso rompía mi regla de no depender de motores pesados.

El resultado es oci.ts, un cliente de registros OCI (Open Container Initiative) escrito enteramente en TypeScript. Descifra los manifiestos, negocia los tokens y descarga los blobs tar.gz capa por capa, inyectándolos directamente en un rootfs ext4 que la VM puede montar al vuelo. Poder iniciar node:22-alpine arrancando un rootfs con la imagen decodificada localmente, sin la sobrecarga del demonio dockerd, cambia completamente el paradigma de inmediatez. En las arquitecturas donde mkfs.ext4 no está presente por defecto, nos apoyamos en WSL2 o Homebrew, degradando con gracia pero sin romper el flujo.

Lograr tiempos de boot fríos de 1-3 segundos estaba bien. Pero mi verdadera obsesión era hacer que procesos pausados se reanudaran en latencias de petición de red (sub-100 ms).

Normalmente, el “pause/resume” en máquinas virtuales se logra congelando la CPU, serializando la RAM e interrupciones en el disco, y restaurándolas al despertar. Eso es lentísimo para mi propósito. Mi otra opción era mandar mensajes entre el hilo principal de Node JS y el Worker thread que manejaba el hipervisor. ¿El problema? El puente Message Passing de Node en el event loop introduce parpadeos (jitter) de latencia y bloqueos.

La iluminación llegó recordando cómo renderizan los videojuegos modernos: SharedArrayBuffer combinado con Atomics. Implementé un búfer diminuto estructurado (con slots para CONTROL_COMMAND, CONTROL_STATE y la consola) que el hilo principal (TypeScript) y el hilo Worker (C++) pueden consultar de manera atómica sin locks costosos ni serialización de mensajes a través del V8.

Cuando quiero pausar una VM, el hilo de TS escribe un 1 de forma atómica en el buffer. El Worker de KVM/WHP, en uno de sus microscópicos VM-exits, revisa ese byte de memoria compartida y simplemente detiene la ejecución del vCPU sin tirar abajo la infraestructura de memoria de la máquina. LaVM no está en el disco, sigue viva en el hipervisor pero “dormida” sin gastar ciclos.

Los servidores Fastify o Express reanudan resolviendo un GET / pendiente en apenas 5 a 50 milisegundos. Es un truco modesto comparado con un hypervisor comercial, pero el impacto ergonómico que tiene para levantar ambientes aislados es descomunal.

El proyecto aún no está terminado —el restore de RAM complejo (snapshot frío) es algo que sigo persiguiendo utilizando dirty-page tracking, que he dejado preparado en los cimientos del código—. Pero hasta ahora, he conseguido exactamente lo que buscaba: la firmeza inquebrantable de una máquina virtual real escondida en el bolsillo de algo que luce, opera y muere tan fácilmente como un proceso más de mi terminal.

Related Content