As Crônicas do Gocracker: uma microVM em Go, de hack de fim de semana a sandbox de produção

Eu amo o Firecracker. Eu colocaria um adesivinho do Firecracker no meu laptop se a AWS distribuísse um. A ideia — bootar uma VM Linux de verdade em milissegundos, descascar todo dispositivo que ninguém precisa, trancar os syscalls, e dar o dia por encerrado — é uma das ideias de engenharia de sistemas mais elegantes da última década. Ela transformou “container, mas de verdade isolado” de meme em produto.

Então, naturalmente, num fim de semana, decidi substituí-lo.

Não porque ele seja ruim. Mas porque toda vez que eu queria rodar ubuntu:22.04 como uma microVM, havia seis passos manuais entre mim e um prompt: baixar a imagem, extrair o rootfs, construir um disco ext4, gerar um initrd, escrever uma config JSON de quarenta linhas, criar um dispositivo TAP, brigar com o iptables, aí então chamar a API. O Firecracker é um especialista em Rust. Ele boota VMs lindamente e assume que o resto é problema seu. Esse é o design certo para a AWS, onde cada passo é mais um serviço especialista. Para uma pessoa rodando coisas num laptop, é uma catástrofe de abas.

Eu queria um generalista. gocracker run --image ubuntu:22.04, e queria que ele já tivesse feito tudo isso na hora em que eu terminasse de apertar Enter. Então escrevi um. Em Go. Porque a linguagem onde “baterias inclusas” é a cultura é o lugar óbvio para construir uma microVM com baterias inclusas.

Se o Firecracker é CGI — uma interface crua e principista que qualquer componente esperto pode dirigir — então o gocracker é FastCGI: a mesma interface com um processo confortável e de longa duração envolto nela, que assume que você é um desenvolvedor num laptop e gostaria de tocar o seu dia.

text

    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.

O KVM — a Kernel-based Virtual Machine — é um dispositivo de caractere, /dev/kvm, que expõe a virtualização de hardware como ioctls. É só isso. Abra um arquivo, chame ioctls nele, e uma CPU começa a rodar código em seu nome dentro de uma sandbox de hardware.

Gosto de descrever os ioctls do KVM como a linguagem assembly das VMs. Baixo nível, ortogonais, cada um faz exatamente uma coisa, e você mesmo tem que compô-los em algo útil. Não há scheduler, não há modelo de dispositivos. Há um pequeno alfabeto de primitivas — criar a VM, criar a vCPU, mapear memória, rodar, obter registradores, definir registradores — e é você quem transforma isso num computador.

O coração de todo VMM já escrito é um loop. Você chama run na vCPU. A thread do host bloqueia. O guest roda. Eventualmente o guest faz algo que precisa da atenção do host — toca um registrador MMIO, atinge uma porta de I/O, recebe uma interrupção, desliga — e o KVM devolve o controle. Esse retorno se chama VMexit. Todo o resto — dispositivos, boot loaders, engines de snapshot — é açúcar em volta desse loop.

text

            HOST                              GUEST
    +---------------------+            +-------------------+
    | ioctl(KVM_RUN)      | ---------> | guest instructions|
    |                     |            |                   |
    |                     | <--------- | VMexit            |
    | dispatch(exit_reason)            | (MMIO / IO / IRQ) |
    |   handle_mmio()     |            |                   |
    |   handle_io()       |            |                   |
    |   inject_irq()      | ---------> | resume            |
    +---------------------+            +-------------------+

Todo o binding com o KVM mora num único arquivo, e o número central tem exatamente cinco dígitos hexadecimais:

go

// internal/kvm/kvm.go
kvmRun = 0xAE80

Quando o userspace chama ioctl(vcpu_fd, 0xAE80, 0), o kernel transfere o controle para a CPU do guest. A thread do host bloqueia. O guest roda. Esse é o coração de todo VMM já escrito.

Aqui está a alegria de escrever um VMM em Go, e a coisa que fez o fim de semana parecer um fim de semana. Uma CPU virtual é um file descriptor que bloqueia até o guest sair. Se você quer duas vCPUs, você quer duas threads de host. Se quer dezesseis, quer dezesseis threads.

Go tem uma palavra para “uma coisa que parece uma thread que bloqueia num syscall.” A nota de rodapé irritante é que o scheduler do Go normalmente move goroutines entre threads do SO sempre que tem vontade, e o KVM realmente não gosta disso. A correção tem três palavras:

go

runtime.LockOSThread()

É isso. É essa a história do multiprocessamento. Cada vCPU ganha uma goroutine. Cada goroutine trava sua thread e roda o loop de saída. O runtime cuida do resto. Nenhum thread pool para escrever. Nem mesmo uma primitiva de sincronização — os channels na sua caixa de ferramentas já funcionam.

text

            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     |
    |       }                                          |
    |   }                                              |
    +--------------------------------------------------+

Quando um guest de verdade boota, esse loop roda milhões de vezes por segundo. A maioria das saídas é rápida — um notify de fila virtio, uma escrita na UART, um tick de timer — e o loop retorna para dentro do KVM_RUN antes que alguém perceba. A arte é tornar cada caso do switch barato.

No fim do fim de semana, eu tinha um prompt verde, uma VM rodando, e um cold boot abaixo de 400 ms. Eu colocaria Rust no meu adesivo de laptop também. Mas o Go me deu goroutines que fizeram de “uma thread por vCPU” uma feature de três palavras, um servidor de API HTTP em talvez duzentas linhas, config JSON que simplesmente funciona, e o ecossistema de bibliotecas OCI mais maduro de qualquer linguagem. Compilar para ARM64 leva duas variáveis de ambiente. As brigas que tive com Go foram reais, mas limitadas; a conveniência se acumulou ao longo de meses.

A VM bootou. Um comando. A vida estava linda. E então a mesma propriedade de goroutines-baratas que tornou o primeiro fim de semana prazeroso começou a cobrar juros.

Cada bug começava como um sintoma diferente e batia num beco sem saída no mesmo lugar: duas goroutines discordando sobre quem era dono de um pedaço de estado do kernel. O jailer ficava deixando os sapatos na porta — bind mounts sobrevivendo a uma sandbox que crashou, envenenando a próxima VM. Um close cru num file descriptor vsock compartilhado acabou sendo uma operação de refcount, não uma mensagem de protocolo; o host bloqueava para sempre até o usuário apertar uma tecla de puro desespero, o que desbloqueava uma goroutine de fundo, que soltava a última referência, que finalmente enviava o pacote de shutdown. Um panic durante a limpeza deixava o terminal em modo raw. Cada release do runtime do Go queria mais um syscall que o filtro seccomp não conhecia, e “Bad system call” virou a trilha sonora da semana de release.

Quinze goroutines num sobretudo continuam sendo quinze goroutines. A lição foi menos sobre qualquer bug específico e mais sobre o formato de todos eles: numa microVM, o kernel do host mantém estado sobre a sua VM, e se você não limpá-lo no caminho feliz, ninguém vai limpá-lo no caminho triste. Depois da quinta corrida em três dias, a correção estrutural não foi “seja mais cuidadoso” — eu já estava sendo cuidadoso — mas sim ligar -race no CI e escrever testes que deliberadamente corriam produtor e consumidor para fazer as condições de corrida aparecerem de forma reproduzível. O detector de corrida é a configuração mais importante que você pode ligar num projeto Go. Ele é de dez a cem vezes mais lento, e vale cada ciclo.

Quando o CI parou de oscilar, eu finalmente tinha uma VM em que confiava o suficiente para medir.

Por cerca de duas semanas, achei que o gocracker tinha um problema de 2× contra o Firecracker. O campo duration que eu estava imprimindo ia de ~30 ms sem o jailer para ~55 ms com ele. Duas vezes pior. O fork-exec era o vilão. O jailer era o vilão. Sete PUTs REST eram o vilão.

Olhe para o relógio de parede, porém. Cerca de 860 ms vs cerca de 880 ms. Cerca de vinte milissegundos de diferença, sobre uma baseline de 860 ms. Isso não é 2×; é cerca de 2%, e 2% é ruído. O “2×” estava inteiramente num campo duration que media coisas diferentes nos dois caminhos de código. O caminho in-process media o vmm.New cru. O caminho do worker media o fork-exec, o setup do jailer, o chroot, sete PUTs REST, mais o vmm.New. Ambos paravam o relógio antes do kernel do guest ter impresso um único byte. Nenhum dos números media o tempo até um guest útil. Nenhum era comparável ao outro.

Dividir a medição em quatro fases honestas — orquestração, setup do VMM, start, primeira saída do guest — fez o 2× evaporar:

go

// 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

O jailer custou aproximadamente 30 ms na orquestração, em cima de um boot de kernel do guest de ~300 ms que ambos os caminhos compartilhavam. Um imposto honesto de ~10% na orquestração, não uma penalidade de 2×.

text

   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."

E então outra coisa caiu por terra: cerca de trezentos daqueles milissegundos eram o Linux bootando dentro da VM. Se eu quisesse uma VM mais rápida, o meu código não era o problema. O meu kernel era.

Depurar performance é auditar contas de despesa. Você encara as linhas de item. Continua encarando até achar a que diz “almoço de negócios $480” e o restaurante acaba sendo um Costco. A pegadinha nunca está onde você espera.

Eu bifurquei o kernel do guest em dois perfis: um genérico, que envio por padrão, e um “mínimo” que arranca qualquer coisa que uma VM com virtio e nada mais jamais precisará. ACPI NUMA foi. Hibernação foi. O subsistema USB inteiro foi. Gerenciamento de energia, profiling, SCSI, dispositivos loop, XFS, NFS — tudo embora. Virtio ficou. ext4 ficou. kvm-clock ficou. O kernel encolheu cerca de 12% e o boot caiu um bom pedaço só por rodar menos initcalls.

Então veio a pequena mudança que importou mais do que qualquer outra. Adicionei um parâmetro à linha de comando do kernel: loglevel=4. Ele diz ao kernel “só imprima avisos e acima no console; todo o resto ainda vai para o ring buffer, então você pode ver via dmesg.” A maior parte da saída do boot parou de ir para a UART emulada.

Acontece que uma UART virtualizada é cara por byte. Cada byte que o kernel escreve no console serial é uma saída MMIO para o userspace, que é uma troca de contexto, que são alguns microssegundos de tempo desperdiçado. Multiplique por alguns milhares de bytes em tempo de boot, e o boot era dominado por impressão. Silenciar o console tirou cerca de 130 ms do boot.

Uma linha.

As vitórias menores seguiram o mesmo tema: pare de brigar com o kernel, e peça que ele faça o seu trabalho. Cachear uma sonda de discard cuja resposta só depende do filesystem do host, não do guest. Rotear interrupções x86 através de eventfd + IRQFD em vez de um ioctl por asserção, do mesmo jeito que o backend ARM64 já fazia. Desligar o garbage collector do Go no subprocesso VMM de vida curta:

go

// cmd/gocracker-vmm/main.go
import "runtime/debug"

func init() {
    debug.SetGCPercent(-1) // short-lived process; let the OS reap memory
}

O processo não precisa de um GC; ele alegremente roda até o fim, e o SO recolhe a sua memória. Alguns milissegundos aqui, alguns milissegundos ali. Não é genial, individualmente. É cumulativo.

Empilhando as vitórias como um gráfico de barras na escala aproximada das medições reais:

text

 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*.

Depois de tudo isso: cold boot na faixa de 150–170 ms. Cerca de 45 ms atrás do Firecracker, vindo de muito mais. Um gap Go-vs-Rust mensurável em milissegundos, sobre um boot dominado por um kernel Linux estrangeiro que não controlo. Esse é o lugar certo onde acabar. Se alguém te disser que a microVM dele está algumas dezenas de milissegundos atrás do Firecracker, você acena educadamente; se disser que está 2× atrás, você tem perguntas.

O restore de snapshot costumava ser o caminho rápido. Um restore de 80 ms em cima de um cold boot de 400 ms é um erro de arredondamento. Um restore de 80 ms em cima de um cold boot de 170 ms é metade do seu orçamento.

O restore antigo fazia a coisa óbvia: alocar um mmap anônimo fresco de 128 MiB para a RAM do guest, ler o arquivo de snapshot inteiro para um byte slice de Go, e fazer memcpy de tudo para a posição. Os passos um a três levavam cerca de 80 ms, exatamente como você esperaria se já tiver feito memcpy de 128 MiB a cada requisição.

Então a pergunta: e se eu simplesmente não copiasse?

O Linux tem uma flag chamada MAP_PRIVATE. Quando você faz mmap de um arquivo com ela, o kernel não faz nenhum I/O real de antemão. Ele monta uma entrada de tabela de páginas que diz “se o userspace tocar nesta página, faça fault no kernel, leia do arquivo, mapeie. Se o userspace escrever na página, faça fault, copy-on-write para uma página anônima privada, e redirecione o mapeamento para a cópia.” O arquivo em si nunca é modificado.

A analogia da Netflix é aquela à qual sempre volto. A Netflix não baixa o filme inteiro para o seu dispositivo primeiro para depois começar a tocar. Ela começa a tocar imediatamente e busca cada minuto enquanto você assiste. Se você pular partes adiante, essas partes nunca são baixadas. Você paga por minuto assistido, não por filme selecionado. O MAP_PRIVATE é esse padrão para a RAM do guest.

O novo caminho faz mmap do snapshot diretamente na região de memória do guest:

go

mem, _ := unix.Mmap(int(f.Fd()), 0, int(memSize),
    unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE)
_ = unix.Madvise(mem, unix.MADV_HUGEPAGE)

Páginas que o guest nunca toca nunca são carregadas. Páginas que ele lê mas não escreve permanecem compartilhadas com o page cache. Páginas que ele escreve vão para cópias COW privadas, e o arquivo de snapshot permanece limpo.

text

  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"

A dança completa de page-fault por baixo dos panos é assim:

text

  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)---|                               |       |
  +---------+                                                 +-------+

Cada passo ali é o que o Linux já faz para qualquer mmap respaldado por arquivo. Nem uma única linha de tratamento de page-fault precisou ser implementada. Apenas pare de brigar com o kernel e peça que ele faça o seu trabalho.

Num snapshot Alpine de 128 MiB, o restore caiu de ~80 ms para cerca de 20 ms. O snapshot-resume ficou de repente várias vezes mais rápido que o cold boot. (Uma ressalva importante: não delete o arquivo de snapshot enquanto VMs estiverem rodando a partir dele. Pergunte como eu sei.)

Entre num restaurante decente na hora do almoço. Peça o steak frites. Ele chega em seis minutos. Só o bife já é um cozimento de seis minutos, estimativa generosa. As batatas levam doze. O bearnaise precisa de quinze. Como a cozinha conseguiu em seis minutos?

Mise en place. As batatas estão pré-cozidas e escorridas antes de você chegar. O bearnaise está emulsionado e em espera. O prato saiu do aquecedor no momento em que o pedido chegou ao passe. A única coisa que a cozinha faz depois do seu pedido é a selagem final.

Um warm pool é mise en place para VMs. O provedor de sandbox concorrente mais rápido no benchmark público ficava em torno de 100 ms — exatamente o que você esperaria de pular o restore inteiramente por ter uma VM já rodando, pausada, esperando alguém dizer vai. Se o líder ganha pré-cozinhando, pare de otimizar o fogão.

O warm pool virou três decisões de design, cada uma resultado de uma discussão com um dia ruim hipotético.

Primeiro, Acquire é não-bloqueante. A tentação com APIs de pool é fazer o Acquire bloquear até que um worker esteja disponível. Esse “sempre dê um worker ao usuário” parece seguro. Não é. Se o pool está vazio, algo já deu errado, e fazer o usuário esperar por um restore fresco é estritamente pior do que cair para o caminho de cold-boot que já funciona. O pool é best-effort. Um miss nunca deve deixar o usuário mais lento que a baseline.

Segundo, liberar um worker o mata. Toda biblioteca de pooling eventualmente quer reciclar um worker de volta para o pool. Num mundo multi-tenant, o worker que acabou de tratar uma requisição tocou o que quer que o último tenant tenha pedido. Entregá-lo ao próximo tenant é um buraco de isolamento entre tenants, e o fato de ninguém tê-lo explorado ainda não é um argumento de segurança. Todo Acquire retorna um processo que nunca serviu uma requisição. O refill acontece em segundo plano, então o próximo chamador não paga nada. O pool está sempre em movimento. Nunca reutilizado.

Terceiro, o refill é assíncrono, limitado e seguro contra corridas. Uma rajada de requisições de refill para o mesmo template não deve debandar em dez spawns paralelos; um spawn de refill que corre com o shutdown deve se limpar sozinho; e um relógio deve ser injetável para que os testes de obsolescência sejam determinísticos. Nenhum disso é genial. São apenas os invariantes que você se arrepende de ter deixado de fora na primeira vez que o pool roda em produção.

O fluxo inteiro com o cache e o pool conectados:

text

  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)

A superfície de API do pool é pequena de propósito:

go

// 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)

No caminho quente: o worker quente já tem a RAM do guest mapeada, o estado da vCPU carregado, a VM pausada. Acquire retorna. Um único ioctl de resume o vira de pausado para rodando. Três milissegundos depois, o guest já disse olá. Mise en place.

Nove sandboxes estavam rodando, pausadas, ociosas. Sem tráfego. Sem sessões de exec. Sem HTTP. Sentadas num prompt de shell dentro de um warm pool, esperando alguém pedir que trabalhassem. O top mostrava o host em 46% de um core.

Quarenta e seis por cento para manter nove guests Linux ociosos vivos. Cerca de cinco por cento de um core por VM ociosa. Uma máquina Linux física ociosa usa em torno de 0,1% de um core em hardware moderno. Um guest ocioso devidamente virtualizado deveria ser mais barato, não cinquenta vezes mais caro.

Algo estava muito errado.

A maneira limpa de ver o que uma thread de vCPU está fazendo é amostrá-la. Alguns segundos de perf devolveram um stack trace que era inequívoco: entra no KVM, sai quase imediatamente, dorme por um milissegundo, volta para dentro. Repetidamente, mil vezes por segundo, em cada thread de vCPU, em paralelo. Nove threads fazendo isso ao mesmo tempo eram exatamente os 370% de um core que o host estava reportando.

A causa era um hedge. Lá no fundo do loop da vCPU, a saída HLT estava sendo “tratada” com um sleep de um milissegundo:

go

case KVM_EXIT_HLT:
    // Guest is idle. Don't spin; give it a breather.
    time.Sleep(time.Millisecond)

O sleep era razoável num mundo onde o VMM é dono do controlador de interrupções no userspace. O gocracker não é. O gocracker usa o IRQCHIP in-kernel — o default certo para quase todo workload — onde o KVM deveria segurar a thread dentro do ioctl até a próxima interrupção disparar, sem nenhuma saída. O sleep era código morto que sobreviveu a uma mudança de design que ninguém questionou.

A correção foi uma deleção:

go

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.

Na próxima iteração do loop, o código chama o KVM de novo, e o KVM — porque é dono do IRQCHIP e sabe que não há interrupção a caminho — bloqueia a thread dentro do kernel por todo o tempo em que o guest permanecer ocioso.

Mesmo teste de nove sandboxes ociosas. top: 7%. Não sete por cento por VM. Sete por cento para a frota inteira. De ~370% para ~7% deletando uma linha. Cinquenta vezes menos.

O padrão geral vale a pena nomear. O código que você adicionou “só por garantia” é frequentemente o código mais digno de ser deletado, porque ninguém o questiona. As partes de um sistema com que você briga são revisadas até a morte. As partes de que ninguém reclama apodrecem em paz. Quando a podridão finalmente te custa, ela te custa cinquenta vezes mais do que qualquer coisa em que você já tenha pensado.

Quando o tempo de Acquire até a primeira instrução do guest virou três milissegundos, fiquei orgulhoso disso por cerca de uma semana. Então tentei construir algo com aquilo.

O que eu queria era a coisa que todo mundo quer hoje em dia: uma API REST onde um cliente diz “me dê uma sandbox Python 3.12 com numpy e pandas, deixe eu rodar código nela”, uma sandbox aparece, três segundos depois ele recebe um stdout de volta, e segue com a vida. Um gocracker run cru não consegue fazer nada disso. Ele boota uma VM. É só isso. Se uma microVM é um bloco de motor, o que eu precisava era do resto do carro.

A primeira decisão foi a mais importante: manter o gocracker exatamente o que ele era, e construir a camada gerenciada como uma coisa separada. O gocracker permanece o VMM de baixo nível, o cache de snapshots, e o warm-pool-de-workers. Ele fala bytes e ioctls. Não tem opiniões sobre clientes ou templates. sandboxd é um novo daemon que fica acima dele e é dono de templates, leases, pools e tokens de preview. O SDK só fala com o sandboxd. O sandboxd só fala com o gocracker por um socket unix. O round-trip extra é uma feature, não um bug.

Aprendi o valor dessa divisão primeiro não a fazendo de forma limpa, e gastando três horas depurando uma condição de corrida que só existia porque duas camadas estavam compartilhando um ponteiro que não tinham por que compartilhar. Cruzar uma fronteira de processo te força a negociar. Compartilhar um ponteiro te deixa trapacear. A fronteira é o cinto de segurança.

A divisão é um fluxo limpo de três camadas:

text

  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)

Três saltos. Dois daemons. O SDK nunca fala com o gocracker diretamente — ele não sabe que o gocracker existe. O sandboxd é a única superfície de API pública; tudo a jusante é um detalhe de implementação. Cruzar uma fronteira de processo num socket unix é barato (sub-milissegundo para payloads JSON pequenos), e a separabilidade se paga na primeira vez que você quer reiniciar o sandboxd sem matar uma centena de VMs vivas.

Templates são a outra ideia de carga. Um cliente não quer “uma VM Linux.” Ele quer o ambiente que usa para o seu agente de IA — uma imagem base específica, alguns pacotes apt, alguns pacotes pip, um diretório de trabalho, algumas variáveis de ambiente. Um template captura essa mistura, mais o snapshot resultante de bootar a spec uma vez e deixá-la chegar a um estado estável. Dois templates com specs idênticas compartilham um snapshot. Um segundo create com a mesma spec é um no-op.

go

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
}

Isso parece óbvio até você imaginar o ciclo de vida de um SaaS de verdade: a maioria dos creates de template é retry idempotente. Um deploy roda de novo. Um job de CI ressubmete. Um SDK preguiçosamente garante-que-existe antes de um create. Se cada um deles custasse um docker build fresco, você estaria entregando um produto de $40/mês sobre $400/mês de infra. A identidade endereçável por conteúdo em cada camada se acumula: o warm cache era endereçável por conteúdo, os templates são endereçáveis por conteúdo em cima dele, e as sandboxes são baratas porque os templates são baratos.

Eu estava rodando um teste de carga contra um sandboxd recém-reconstruído. Nada sofisticado — criar uma sandbox, executar echo hi, deletar a sandbox, num loop apertado. O pool estava configurado para três hot-ready e três paused-ready para um único template. Todo create deveria ser essencialmente instantâneo, porque o pool deveria manter seis sandboxes aquecidas vivas e eu só precisava de uma de cada vez.

Funcionou por cerca de noventa segundos.

Então todo create começou a falhar. Não devagar. Não com backpressure. Cada um deles, com variações de “runtime returned 404: unknown vm.” O endpoint de status do pool reportava três hot-ready, duas paused-ready, zero leased. Um pool perfeitamente saudável, segundo ele mesmo. As VMs estavam mortas havia minutos.

Foi uma terça-feira divertida.

A primeira versão do reconciler confiava no seu próprio registro em memória. Ela contava entradas marcadas warm_ready, comparava a contagem com MinHot, e concluía: saudável, nenhuma ação necessária. Nada no reconciler estava olhando. Uma VM warm-ready morria silenciosamente — panic de vCPU, OOM-kill, travamento do guest, typo no fstab jogando o systemd no modo de rescue, qualquer um deles — e o sandboxd continuava contando-a como viva. Os leases subsequentes falhavam na hora do attach com 404s, o handler de lease marcava a entrada como “broken” e caía para o cold boot, mas as entradas quebradas persistiam na memória como warm_leased até uma goroutine de cleanup separada recolhê-las. Enquanto isso, o pool continuava alegando cinco quentes, o reconciler continuava sem tomar decisões, e cada requisição fazia cold boot.

A cascata não foi espetacular. Sem alarmes de incêndio. Sem pager. O sistema estava silenciosamente se degradando ao modo de pior caso, um 404 de cada vez, enquanto diligentemente reportava verde.

text

  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

A degradação silenciosa é o pior modo. A falha barulhenta deixa você acionar um alerta sobre ela. A falha silenciosa significa que o gráfico parece verde enquanto os clientes vão embora.

A correção foi estrutural e pequena. O reconciler agora faz três coisas em ordem, e a ordem é de carga:

go

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)
}

Primeiro, sondar cada sandbox quente que o manager acha que possui e recolher qualquer coisa que o runtime não conheça mais — “inconclusivo” conta como morto, porque um pool de VMs talvez-vivas é pior que um pool com um buraco. Segundo, contar a partir do inventário agora honesto. Terceiro, aparar o excesso e repor até o mínimo. Antes da correção: cinco sandboxes fantasma, cada requisição um cold boot, o pool alegremente reportando saudável. Depois: creates instantâneos de novo.

Já bati nesse exato bug antes. Suspeito que você também. Toda vez ele veste uma roupa ligeiramente diferente — um controller do Kubernetes que confia no status de pod em cache em vez do kubelet, um connection pool que marca um backend como saudável porque a última resposta foi 200 enquanto o socket recebeu FIN há trinta segundos, um service registry cuja thread de heartbeat não tem relação com a thread de trabalho, então o serviço pode estar em deadlock e ainda pingando, um navegador que cacheia um registro DNS além da realidade. O erro subjacente é o mesmo toda vez: confiar numa representação em memória do mundo, através de uma fronteira de processo, sem sondar. O estado em memória e a realidade fora do processo sempre divergem. A pergunta não é se você vai notar; é quando, e que dano visível ao usuário se acumula nesse meio-tempo.

Mais duas guardas entraram na mesma época. Um backoff por template para que um único template quebrado — digamos, um cujo snapshot está sutilmente corrompido — não consiga sozinho manter o reconciler grudado fazendo spawn de VMs que falham a cada tick, faminteando templates mais saudáveis. E um orçamento global de trabalho de spawn em voo por todo o host, porque dez templates cada um querendo repor três VMs ao mesmo tempo são trinta spawns paralelos, o que é o bastante para tornar cada spawn mais lento do que precisa, o que aperta os timeouts, o que cascateia. Limites por template não bastam. O número de coisas que podem dar errado simultaneamente através de N templates cresce mais rápido do que o limite por template o contém.

Olhando para trás no arco inteiro, algumas coisas se destacam o suficiente para valer a pena carregar adiante.

O estado do kernel do host sobrevive ao seu processo. Limpe-o na inicialização tanto quanto no shutdown. close(fd) é uma operação de refcount, não uma mensagem de protocolo — se você precisa que o peer saiba que você foi embora, você tem que de fato dizer. Todo caminho de saída precisa de um restore terminal, porque defer é uma sugestão que sinais e trips de seccomp ignoram. O detector de corrida no CI é inegociável para qualquer projeto Go que mantenha estado entre goroutines.

O seu maior custo quase certamente não é a coisa que você escreveu. O Linux bootando dentro da VM eram três quartos de um cold start de quatrocentos milissegundos. Nada do que eu escrevi importou até eu ir lá e encolher aquilo. Uma UART virtualizada é cara por byte; silenciar o log do kernel no caminho do console foi a maior vitória de performance do projeto. O MAP_PRIVATE é dinheiro grátis para o restore de snapshot. O garbage collector do Go é um imposto que você pode escolher não pagar em subprocessos de vida curta.

Confie no kernel mais do que nos seus instintos. O IRQCHIP in-kernel já resolve o estacionamento de vCPU ociosa. O sleep defensivo em cima era trabalho negativo. Código defensivo é um detector de mentiras para premissas que mudaram desde então: revisite os hedges quando o sistema subjacente mudar. E às vezes a maior vitória é uma deleção.

Quando um warm pool sobe acima de uma microVM, as regras mudam. O pool é best-effort — um miss nunca deve deixar o usuário mais lento que a baseline. Mate os workers no release; nunca dê a um tenant um processo que tocou os dados de outro tenant. Loops de reconciler devem observar antes de agir, porque a única coisa mais perigosa que um cache errado é um cache que o sistema parou de questionar. E inconclusivo é sempre morto num pool — o custo de tratar uma sandbox talvez-viva como morta é um cold boot; o custo de tratar uma morta como viva é a falha de lease que o seu cliente vê.

Nenhuma dessas vitórias é individualmente genial. Cada uma é algo que outra pessoa descobriu anos atrás — mmap, copy-on-write, isolamento por tenant, eventfd mais IRQFD, mise en place como conceito, confiar no scheduler do IRQCHIP in-kernel. Nada inventado aqui. O que aconteceu foi parar de brigar com cada um deles, um de cada vez. É assim que cold starts de ~3 ms visíveis ao usuário acontecem. Você os conquista camada por camada. Não há uma única mudança heroica. Há uma pilha de mudanças pequenas e honestas, cada uma das quais torna a próxima mais barata de escrever.

As partes interessantes da máquina estão prontas. O que resta é mantê-las honestas.

Related Content