node-vmm: isolamento de VM que parece criar um processo

Existe uma ficção educada no desenvolvimento moderno que todo mundo aceita porque é conveniente: fingimos que os containers Docker são rápidos e leves. E são, se o seu ponto de referência for provisionar bare metal em 2005. Mas à medida que minhas ferramentas precisavam isolar cargas de trabalho com mais frequência — e de forma mais dinâmica — comecei a sentir o peso de depender de um motor externo gigante posicionado entre mim e aquilo que eu realmente queria.

O que eu queria era algo que parecesse ergonomicamente como chamar child_process.spawn() no Node.js, mas que entregasse o isolamento de hardware de uma máquina virtual completa. Sem containers com namespaces compartilhados. Eu queria um kernel de verdade — ou pelo menos um micro-kernel — rodando isolado. E, mais do que tudo, eu queria que pausar e retomar fosse rápido o suficiente para que uma API HTTP dentro da VM pudesse responder a um cliente e parecer mera latência de rede. Sem atrasos de “descongelamento”.

Foi daí que veio o node-vmm. E, como essas coisas costumam acontecer, a ideia era simples até o momento em que eu bati de cara com a realidade dos hypervisors nativos.

Quase todo projeto que precisa de virtualização multiplataforma recorre ao QEMU por instinto. Ele é o canivete suíço. Mas o QEMU é grande. Envolver o QEMU a partir do Node teria resolvido o problema multiplataforma em uma semana — e matado completamente os objetivos de latência e peso.

Então, em vez disso, eu o construí conversando diretamente com a API do hypervisor nativo de cada sistema operacional, usando C++ vinculado ao Node via N-API. O resultado é uma arquitetura dividida em três mundos completamente separados que não compartilham nenhum código na camada mais baixa:

  1. Linux: chamadas ioctl diretas ao KVM. Um arquivo com uma certa elegância bruta — native/kvm/backend.cc.
  2. Windows: a Windows Hypervisor Platform (WHP). Essa foi genuinamente dolorosa. A WHP te entrega uma CPU virtual nua e diz boa sorte para montar a placa-mãe, então tive que emular APIC, timers e portas UART do zero.
  3. macOS / Apple Silicon: Hypervisor.framework (HVF). Depois de várias madrugadas, percebi que o caminho mais limpo não era fingir ser x86, mas usar um perfil de máquina ARM64 (baseado em virt) para manter tudo rápido e nativo.

Esconder os três por trás de uma única interface NativeRunConfig em TypeScript não foi trivial. O layout de interrupções MMIO para o Virtio foi onde as coisas ficaram confusas. No KVM, o passo de memória para dispositivos é limpo — 0x1000. No Windows, tive que compactá-los de forma mais apertada em 0x200 para evitar sobreposição com tabelas ACPI. No fim, a abstração se sustentou, e qualquer desenvolvedor que importe a biblioteca nunca precisa pensar em nada disso.

Toda ferramenta moderna precisa rodar imagens. O movimento óbvio era criar uma ponte com o socket local do Docker. Isso quebrava minha regra de não usar motores pesados.

Então escrevi o oci.ts — um cliente completo de registry OCI (Open Container Initiative) em TypeScript. Ele faz o parsing de manifests, negocia tokens e baixa blobs tar.gz camada por camada, injetando-os diretamente em um rootfs ext4 que a VM pode montar na hora. Inicializar node:22-alpine decodificando a imagem localmente e montando-a sem tocar no dockerd muda o que significa “instantâneo” na prática. Em arquiteturas onde o mkfs.ext4 não vem instalado por padrão, recorremos ao WSL2 ou ao Homebrew de forma graciosa, sem quebrar o fluxo.

Tempos de cold boot de 1 a 3 segundos eram aceitáveis. Minha obsessão real era fazer com que processos pausados retomassem dentro da latência de uma requisição de rede — sub-100 ms.

A abordagem tradicional para pausar/retomar uma VM é congelar a CPU, serializar a RAM e o estado de interrupções em disco e restaurar ao acordar. Isso é lento demais por ordens de magnitude para o que eu precisava. A outra opção era passagem de mensagens entre a thread principal do Node.js e a thread Worker que gerencia o hypervisor. O problema: a ponte de passagem de mensagens do Node sobre o event loop introduz jitter de latência e travamentos.

A sacada veio ao pensar em como os motores de jogos modernos renderizam: SharedArrayBuffer combinado com Atomics.

Implementei um pequeno buffer estruturado com slots para CONTROL_COMMAND, CONTROL_STATE e o console — algo que tanto a thread principal em TypeScript quanto a thread Worker em C++ conseguem ler atomicamente sem locks caros ou qualquer serialização de mensagens através do V8.

Quando quero pausar uma VM, a thread TS escreve atomicamente um 1 no buffer. O Worker do KVM/WHP, durante um de seus microscópicos VM-exits, verifica aquele byte compartilhado e simplesmente interrompe a execução da vCPU — sem derrubar a infraestrutura de memória da máquina. A VM não está em disco. Ela continua viva no hypervisor, apenas adormecida, sem queimar ciclos.

Servidores Fastify ou Express lá dentro retomam e resolvem um GET / pendente em 5 a 50 milissegundos. É um truque modesto comparado ao que um hypervisor comercial faz. O ganho ergonômico para subir ambientes isolados é tudo, menos modesto.

O projeto não está terminado. A restauração completa da RAM a partir de um snapshot frio ainda é algo que persigo com rastreamento de dirty-pages, que já deixei conectado nas fundações do código. Mas, até agora, consegui exatamente o que me propus a construir: o isolamento intransigente de uma máquina virtual de verdade, escondido dentro de algo que parece, opera e morre com a mesma facilidade de mais um processo no meu terminal.

Related Content