Las Crónicas de Gocracker: Una microVM en Go, de Hack de Fin de Semana a Sandbox de Producción

1 Firecracker es genial. Escribí uno de todos modos.
Amo Firecracker. Le pondría un sticker pequeñito a mi laptop si AWS lo vendiera. La idea — arrancar una VM Linux real en milisegundos, quitar todo dispositivo que nadie necesita, blindar las syscalls, y listo — es una de las ideas más elegantes de ingeniería de sistemas de la última década. Convirtió “contenedor pero de verdad aislado” de meme a producto.
Así que naturalmente, un fin de semana, decidí reemplazarla.
No porque sea mala. Porque cada vez que quería correr ubuntu:22.04 como microVM había seis pasos manuales entre yo y un prompt: bajarme la imagen, extraer el rootfs, armar un disco ext4, generar un initrd, escribir un JSON de cuarenta líneas, crear un TAP, pelearme con iptables, y entonces llamar a la API. Firecracker es un especialista de Rust. Arranca VMs maravillosamente y asume que el resto es tu problema. Ese es el diseño correcto para AWS, donde cada paso es otro servicio especialista. Para alguien corriendo cosas en una laptop, es una catástrofe de pestañas.
Yo quería un generalista. gocracker run --image ubuntu:22.04, y quería que ya hubiera hecho todo lo anterior antes de que yo terminara de presionar Enter. Así que escribí uno. En Go. Porque el lenguaje donde “baterías incluidas” es la cultura es el lugar obvio para construir una microVM con baterías incluidas.
Si Firecracker es CGI — una interfaz desnuda y principista que cualquier componente inteligente puede manejar — entonces gocracker es FastCGI: la misma interfaz con un proceso cómodo y de larga vida envuelto alrededor que asume que eres un desarrollador en una laptop y quieres avanzar con lo tuyo.
FIRECRACKER gocracker
(especialista en Rust) (generalista en Go)
solo un VMM VMM + OCI + initrd + TAP + Compose
tú traes: tú traes:
- rootfs - un comando
- kernel - un kernel
- initrd
- tap + NAT
- JSON config
- 40 líneas de bash
hermoso. soy flojo y me gusta así.2 Qué es KVM en realidad
KVM — la Kernel-based Virtual Machine — es un dispositivo de caracteres, /dev/kvm, que expone la virtualización por hardware como ioctls. Eso es todo. Abres un archivo, llamas ioctls sobre él, y un CPU empieza a correr código por ti dentro de un sandbox de hardware.
Me gusta describir los ioctls de KVM como el ensamblador de las VMs. Bajo nivel, ortogonal, cada uno hace exactamente una cosa, y tú eres quien los compone en algo útil. No hay scheduler, no hay modelo de dispositivos. Hay un alfabeto pequeño de primitivas — crear la VM, crear el vCPU, mapear memoria, correr, obtener registros, asignar registros — y tú eres quien las convierte en una computadora.
El corazón de cualquier VMM es un loop. Llamas run sobre el vCPU. El hilo del host se bloquea. El guest corre. Eventualmente el guest hace algo que necesita la atención del host — toca un registro MMIO, golpea un puerto de I/O, recibe una interrupción, se apaga — y KVM devuelve el control. Ese retorno se llama VMexit. Todo lo demás — dispositivos, bootloaders, motores de snapshots — es azúcar alrededor de este loop.
HOST GUEST
+---------------------+ +-------------------+
| ioctl(KVM_RUN) | ---------> | instr. del guest |
| | | |
| | <--------- | VMexit |
| dispatch(exit_reason) | (MMIO / IO / IRQ) |
| handle_mmio() | | |
| handle_io() | | |
| inject_irq() | ---------> | resume |
+---------------------+ +-------------------+Todo el binding hacia KVM vive en un solo archivo, y el número central es exactamente cinco dígitos hex:
// internal/kvm/kvm.go
kvmRun = 0xAE80Cuando userspace llama ioctl(vcpu_fd, 0xAE80, 0), el kernel le transfiere el control al CPU del guest. El hilo del host se bloquea. El guest corre. Ese es el corazón de cualquier VMM escrito jamás.
3 Una goroutine por vCPU es todo el modelo
Aquí está la alegría de escribir un VMM en Go, y la cosa que hizo que el fin de semana se sintiera como un fin de semana. Un CPU virtual es un file descriptor que se bloquea hasta que el guest sale. Si quieres dos vCPUs, quieres dos hilos del host. Si quieres dieciséis, quieres dieciséis hilos.
Go tiene una palabra para “una cosa que parece un hilo y se bloquea en un syscall”. La salvedad molesta es que el scheduler de Go normalmente mueve goroutines entre hilos del SO cuando le da la gana, y a KVM eso de verdad no le gusta. La solución son tres palabras:
runtime.LockOSThread()Eso es todo. Esa es la historia de multiproceso. Cada vCPU recibe una goroutine. Cada goroutine bloquea su hilo y corre el loop de exits. El runtime se encarga del resto. No hay pool de hilos que escribir. Ni siquiera una primitiva de sincronización — los canales que ya tienes en la caja de herramientas funcionan.
goroutine por vCPU (LockOSThread)
+--------------------------------------------------+
| |
| for { |
| err := ioctl(vcpu_fd, KVM_RUN, 0) |
| if err == EINTR || err == EAGAIN { |
| continue // transitorio |
| } |
| switch run.exit_reason { |
| case IO: handleIO(run.io) |
| case MMIO: handleMMIO(run.mmio) |
| case SHUTDOWN: return |
| case INTR: continue |
| case HLT: waitForIRQ(); continue |
| } |
| } |
+--------------------------------------------------+Cuando un guest real arranca, este loop corre millones de veces por segundo. La mayoría de los exits son rápidos — un notify de una cola virtio, una escritura al UART, un tick del timer — y el loop vuelve a KVM_RUN antes de que nadie note. El arte está en hacer barato cada case del switch.
Al final del fin de semana tenía un prompt verde, una VM corriendo, y un arranque en frío bajo 400 ms. Le pondría un sticker de Rust a mi laptop también. Pero Go me dio goroutines que hicieron que “un hilo por vCPU” fuera una funcionalidad de tres palabras, un servidor HTTP en unas doscientas líneas, JSON config que Just Works, y el ecosistema de librerías OCI más maduro de cualquier lenguaje. Cross-compilar a ARM64 son dos variables de entorno. Las peleas que tuve con Go fueron reales pero acotadas; la conveniencia se compuso por meses.
4 Las siguientes tres semanas
La VM arrancó. Un comando. La vida era hermosa. Y luego la misma propiedad de “goroutines baratas” que hizo agradable el primer fin de semana empezó a cobrar intereses.
Cada bug empezó como un síntoma distinto y terminó en el mismo lugar: dos goroutines discutiendo sobre quién era dueño de un pedazo de estado del kernel. El jailer dejaba los zapatos en la puerta — bind mounts que sobrevivían a un sandbox caído, envenenando la siguiente VM. Un close desnudo sobre un file descriptor de vsock compartido resultó ser una operación de refcount, no un mensaje de protocolo; el host se bloqueaba para siempre hasta que el usuario, de pura desesperación, presionaba una tecla, lo cual desbloqueaba una goroutine de fondo, soltaba la última referencia, y finalmente mandaba el paquete de shutdown. Un panic durante el cleanup dejaba la terminal en modo raw. Cada release del runtime de Go quería un syscall más que el filtro seccomp no conocía, y “Bad system call” se convirtió en la banda sonora de cada semana de release.
Quince goroutines bajo un gabán siguen siendo quince goroutines. La lección no fue tanto sobre un bug específico sino sobre la forma de todos: en una microVM, el kernel del host conserva estado sobre tu VM, y si no lo limpias en el camino feliz, nadie lo va a limpiar en el camino triste. Después del quinto race en tres días, el arreglo estructural no fue “ten más cuidado” — yo ya estaba teniendo cuidado — sino prender -race en CI y escribir pruebas que deliberadamente hacían competir a productor y consumidor, para que las race conditions aparecieran de forma reproducible. El race detector es la opción más importante que puedes prender en un proyecto Go. Es de diez a cien veces más lento, y vale cada ciclo.
Cuando CI dejó de fallar de forma intermitente, finalmente tuve una VM en la que confiaba lo suficiente como para medirla.
5 Estaba mirando la caja equivocada
Por unas dos semanas creí que gocracker tenía un problema 2× contra Firecracker. El campo duration que estaba imprimiendo pasaba de ~30 ms sin el jailer a ~55 ms con él. Dos veces peor. Fork-exec era el villano. El jailer era el villano. Siete REST PUTs eran el villano.
Mira el reloj de pared. Unos 860 ms vs unos 880 ms. Unos veinte milisegundos de diferencia, sobre una base de 860 ms. Eso no es 2×; es alrededor de 2%, y 2% es ruido. El “2×” estaba enteramente en un campo duration que medía cosas distintas en los dos caminos de código. El camino in-process medía vmm.New puro. El camino de worker medía fork-exec, setup del jailer, chroot, siete REST PUTs, más vmm.New. Los dos paraban el reloj antes de que el kernel del guest hubiera impreso un solo byte. Ninguno medía tiempo hasta guest útil. Ninguno era comparable con el otro.
Dividir la medición en cuatro fases honestas — orquestación, setup del VMM, arranque, primera salida del guest — hizo evaporar el 2×:
// pkg/vmm/timings.go
//
// BootTimings es el desglose por fase de cuánto tomó traer a
// la vida a una microVM.
//
// - Orchestration: trabajo en el host *antes* de arrancar el kernel
// - VMMSetup: tiempo dentro de vmm.New() — KVM_CREATE_VM, memoria...
// - Start: KVM_RUN arranca en las goroutines de vCPU
// - GuestFirstOutput: primer byte que el guest imprime en la UART
El jailer costaba unos 30 ms en orquestación, encima de unos ~300 ms de arranque del kernel del guest que ambos caminos compartían. Un impuesto honesto de orquestación de ~10%, no una penalización de 2×.
antes del desglose después del desglose
(un solo número engañoso) (cuatro números honestos)
+-----------------------+ +--------------------+
| duration = ~55ms | | orchestration ~30ms|
| (en runViaWorker esto | | vmm_setup ~8ms|
| incluye el jailer, | | start ~2ms|
| fork, unos REST PUTs,| | guest_first ~320ms|
| después vmm.New, | | total ~360ms|
| después start) | +--------------------+
+-----------------------+ |
v
"2x más lento" "Estaba mirando la
caja equivocada."Y entonces algo más cayó del análisis: unos trescientos de esos milisegundos eran Linux arrancando dentro de la VM. Si quería una VM más rápida, mi código no era el problema. Mi kernel sí.
Debuggear performance es auditar gastos. Te quedas viendo las líneas. Te quedas viendo hasta que encuentras la que dice “comida de negocios $480” y el restaurante resulta ser un Costco. La trampa nunca está donde la esperas.
6 El kernel era el problema
Forké el kernel del guest en dos perfiles: el genérico que envío por defecto y uno “minimal” que arranca cualquier cosa que una VM con virtio y nada más nunca va a necesitar. Se fue ACPI NUMA. Se fue hibernación. Se fue todo el subsistema de USB. Power management, profiling, SCSI, loop devices, XFS, NFS — todo fuera. Virtio quedó. ext4 quedó. kvm-clock quedó. El kernel se encogió un 12% y el arranque bajó un pedazo solo por correr menos initcalls.
Después vino el cambio pequeño que importó más que cualquiera de los otros. Agregué un parámetro a la línea de comandos del kernel: loglevel=4. Le dice al kernel “solo imprime warnings y arriba en la consola; todo lo demás sigue yendo al ring buffer y se puede ver con dmesg”. El grueso del output de arranque dejó de ir a la UART emulada.
Resulta que una UART virtualizada es cara por byte. Cada byte que el kernel escribe en la consola serial es un MMIO exit a userspace, que es un context switch, que son unos microsegundos perdidos. Multiplica por unos miles de bytes de arranque y el boot estaba dominado por imprimir. Silenciar la consola le quitó unos 130 ms al arranque.
Una línea.
Las victorias chicas siguieron el mismo tema: dejar de pelearse con el kernel y pedirle que haga su trabajo. Cachear un probe de discard cuya respuesta solo depende del filesystem del host, no del guest. Rutear las interrupciones de x86 a través de eventfd + IRQFD en lugar de un ioctl por aserción, igual que ya hacía el backend de ARM64. Apagar el garbage collector de Go en el subproceso del VMM:
// cmd/gocracker-vmm/main.go
import "runtime/debug"
func init() {
debug.SetGCPercent(-1) // proceso corto-vivido; el SO recupera memoria
}El proceso no necesita un GC; corre felizmente hasta el final y el SO recupera la memoria. Unos milisegundos aquí, unos allá. Ninguna ingeniosa por sí sola. Acumulativas.
Apilando las victorias como gráfico de barras a la escala de las mediciones reales:
kernel estándar:
[==orq==][vmm][======guest_first_output: ~305ms======] ~390ms
~70ms ~15 ~305
esto es Linux arrancando.
kernel mínimo:
[==orq==][vmm][=====guest_first_output: ~280ms=====] ~365ms (-25)
menos initcalls.
mínimo + loglevel=4:
[==orq==][vmm][guest_first_output: ~170ms] ~250ms (-115)
el 80% del costo
era *imprimir*.Después de todo eso: arranque en frío entre 150 y 170 ms. Unos 45 ms detrás de Firecracker, bajando de bastante más. Un gap Go-vs-Rust medible en milisegundos, sobre un arranque dominado por un kernel Linux ajeno que yo no controlo. Ese es el lugar correcto donde terminar. Si alguien te dice que su microVM está unas decenas de milisegundos detrás de Firecracker, asientes con cortesía; si te dicen que está 2× detrás, tienes preguntas.
7 Una vez que el arranque en frío se hizo chico, el camino caliente se volvió vergonzoso
La restauración de snapshots solía ser el camino rápido. 80 ms de restore encima de 400 ms de arranque en frío es un error de redondeo. 80 ms de restore encima de 170 ms de arranque en frío es la mitad del presupuesto.
El restore viejo hacía la cosa obvia: reservar un mmap anónimo fresco de 128 MiB para la RAM del guest, leer el archivo de snapshot completo a un []byte de Go, memcpy todo a su posición. Los pasos uno al tres tomaban unos 80 ms, exactamente como esperarías si alguna vez has tenido que memcpy 128 MiB en cada request.
Luego vino la pregunta: ¿y si simplemente no lo copio?
Linux tiene una bandera llamada MAP_PRIVATE. Cuando mapeas un archivo con ella, el kernel no hace I/O por adelantado. Arma una entrada de tabla de páginas que dice “si userspace toca esta página, falla al kernel, léela del archivo, mapéala. Si userspace escribe en la página, falla, copy-on-write a una página anónima privada, y redirige el mapping a la copia”. El archivo en sí nunca se modifica.
La analogía de Netflix es la que sigo usando. Netflix no descarga primero la película entera a tu dispositivo y luego la reproduce. Empieza a reproducirla de inmediato y pide cada minuto mientras lo ves. Si te adelantas, esas partes nunca se descargan. Pagas por minuto visto, no por película seleccionada. MAP_PRIVATE es ese patrón para la RAM del guest.
El nuevo camino mapea el snapshot directamente sobre la región de memoria del guest:
mem, _ := unix.Mmap(int(f.Fd()), 0, int(memSize),
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE)
_ = unix.Madvise(mem, unix.MADV_HUGEPAGE)Las páginas que el guest nunca toca, nunca se cargan. Las que lee pero no escribe quedan compartidas con el page cache. Las que escribe van a copias COW privadas y el archivo del snapshot queda intacto.
ANTES: copia eager
+----------------+ +----------------+ +----------------+
| mem.bin |-->| os.ReadFile |--->| copy(ram, mem) |
| 128 MiB | | lee 128 MiB | | memcpy 128 MiB |
+----------------+ +----------------+ +----------------+
|
v
~80ms antes de este punto
DESPUÉS: mmap lazy (MAP_PRIVATE)
+----------------+ +----------------------------+
| mem.bin |<--| mmap(fd, PRIVATE) |
| 128 MiB | | solo arma la page table |
+----------------+ +----------------------------+
|
v
el guest toca la página N
|
v
minor fault (-> page cache)
el kernel la mapea sobre la marcha
|
v
~20ms a "corriendo"El baile completo del page fault por debajo se ve así:
vCPU guest host kernel (KVM + mm) snapshot
+---------+ +-------+
| read P |---(EPT miss)--->| PTE not-present, PRIVATE | en |
| | | -> minor fault | disco |
| | | -> lookup en page cache | |
| | | (o leer de disco) <---+ |
| | | -> instalar PTE de lectura | |
| |<-----(resume)---| | |
+---------+ +-------+
después, el guest escribe en P:
+---------+ +-------+
| write P |---(EPT miss)--->| PTE de solo lectura | |
| | | -> COW fault | |
| | | -> alocar página anon | |
| | | -> copiar desde page cache | |
| | | -> instalar PTE de escritura | |
| | | (¡snapshot intacto!) | |
| |<-----(resume)---| | |
+---------+ +-------+Cada paso ahí es lo que Linux ya hace para cualquier mmap respaldado por archivo. No tuve que escribir una sola línea de manejo de page faults. Solo dejar de pelearme con el kernel y pedirle que haga su trabajo.
Sobre un snapshot de Alpine de 128 MiB, el restore bajó de ~80 ms a unos 20 ms. La resumición desde snapshot quedó de pronto varias veces más rápida que el arranque en frío. (Un caveat importante: no borres el archivo del snapshot mientras hay VMs corriendo sobre él. Pregúntame cómo lo sé.)
8 Mise en place para VMs
Entras a un restaurante decente al mediodía. Pides el bistec con papas. Llega en seis minutos. El bistec solo es una cocción de seis minutos, calculando generoso. Las papas son doce. La bearnesa son quince. ¿Cómo lo hizo la cocina en seis minutos?
Mise en place. Las papas están pre-cocidas y escurridas antes de que llegaras. La bearnesa está emulsionada y mantenida tibia. El plato salió del calentador en el momento que el pedido llegó al pase. Lo único que la cocina hace después de tu pedido es el sellado final.
Una warm pool es mise en place para VMs. El proveedor de sandboxes líder en el benchmark público estaba alrededor de 100 ms — exactamente lo que esperarías de saltarte el restore por completo teniendo una VM ya corriendo, pausada, esperando que alguien diga ya. Si el líder gana pre-cocinando, deja de optimizar la estufa.
La warm pool terminó siendo tres decisiones de diseño, cada una resultado de una discusión con un mal día hipotético.
Primero, Acquire no bloquea. La tentación con APIs de pool es hacer que Acquire bloquee hasta que haya un worker disponible. Ese “siempre dale un worker al usuario” se siente seguro. No lo es. Si la pool está vacía, algo ya salió mal, y hacer esperar al usuario por un restore fresco es estrictamente peor que caer al camino de arranque en frío que ya funciona. La pool es de mejor esfuerzo. Un miss nunca debe hacer al usuario más lento que la línea base.
Segundo, liberar a un worker lo mata. Toda librería de pooling eventualmente quiere reciclar un worker de regreso a la pool. En un mundo multi-tenant, el worker que acaba de terminar una request tocó lo que el último tenant le pidió. Entregárselo al siguiente tenant es un hueco de aislamiento, y que nadie lo haya explotado todavía no es un argumento de seguridad. Cada Acquire devuelve un proceso que nunca ha servido una request. El refill ocurre en segundo plano, así que el siguiente que llama no paga nada. La pool siempre se está moviendo. Nunca se reutiliza.
Tercero, el refill es asíncrono, con tope, y race-safe. Una ráfaga de pedidos de refill para el mismo template no debe estampedar en diez spawns paralelos; un spawn de refill que compite contra el shutdown debe limpiar después de sí mismo; y un reloj debe ser inyectable para que las pruebas de staleness sean determinísticas. Nada de esto es ingenioso. Son los invariantes que lamentas haberte saltado la primera vez que la pool corre en producción.
El flujo completo con caché y pool conectados:
llega una request
|
v
warmcache.Lookup(key)
|
+-- miss --> arranque en frío (~250ms) <-- línea base
|
hit, snapshotDir=S
|
v
pool.Acquire(key, S)
|
+-- vacío --> restore_directo (~20ms) <-- igual mejor
|
worker tibio listo
|
v
worker.Resume (~3ms) <-- camino más rápido
|
v
sirve la request
|
v
pool.Release(w) --> worker.Close()
|
v
EnsureRefill en el fondo
|
v
arrancar reemplazo (~20ms fuera del camino caliente)La superficie de la API de la pool es pequeña a propósito:
// 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)En el camino caliente: el worker tibio ya tiene la RAM del guest mapeada, el estado del vCPU cargado, la VM pausada. Acquire devuelve. Un solo ioctl de resume lo voltea de pausado a corriendo. Tres milisegundos después, el guest ya ha dicho hola. Mise en place.
9 Nueve sandboxes quemando una CPU por nada
Nueve sandboxes corriendo, pausados, ociosos. Sin tráfico. Sin sesiones de exec. Sin HTTP. Sentados en un prompt de shell dentro de una warm pool, esperando a que alguien les pida trabajar. top mostraba al host en 46% de un core.
Cuarenta y seis por ciento para mantener vivos nueve guests Linux ociosos. Unos cinco por ciento de un core por VM ociosa. Una caja Linux física ociosa usa alrededor de 0.1% de un core en hardware moderno. Un guest virtualizado bien hecho debería ser más barato, no cincuenta veces más caro.
Algo estaba muy mal.
La forma limpia de ver qué está haciendo realmente un hilo de vCPU es muestrearlo. Unos segundos de perf devolvieron un stack trace inequívoco: entrar a KVM, salir casi de inmediato, dormir un milisegundo, volver a entrar. Una y otra vez, mil veces por segundo, en cada hilo de vCPU, en paralelo. Nueve hilos haciendo esto a la vez eran exactamente el 370% de un core que el host reportaba.
La causa era una cobertura. En lo profundo del loop del vCPU, el exit HLT estaba siendo “manejado” con un sleep de un milisegundo:
case KVM_EXIT_HLT:
// El guest está ocioso. No spin; dale un respiro.
time.Sleep(time.Millisecond)El sleep era razonable en un mundo donde el VMM tiene el controlador de interrupciones en userspace. gocracker no. gocracker usa IRQCHIP in-kernel — el default correcto para casi cualquier workload — donde KVM debería mantener el hilo dentro del ioctl hasta que la siguiente interrupción dispare, sin exit alguno. El sleep era código muerto que sobrevivió a un cambio de diseño que nadie cuestionó.
El fix fue una eliminación:
case KVM_EXIT_HLT:
// No-op. IRQCHIP in-kernel ya bloquea el vCPU hasta la siguiente
// interrupción. No hay trabajo productivo para userspace aquí.
En la siguiente iteración del loop, el código llama de vuelta a KVM, y KVM — porque es dueño del IRQCHIP y sabe que no hay interrupción pendiente — bloquea el hilo dentro del kernel mientras el guest se mantenga ocioso.
Mismo test de nueve sandboxes ociosos. top: 7%. No siete por ciento por VM. Siete por ciento para toda la flota. De ~370% a ~7% eliminando una línea. Cincuenta veces menos.
El patrón general vale la pena nombrarlo. El código que agregaste “por si acaso” suele ser el código que más vale la pena borrar, porque nadie lo cuestiona. Las partes de un sistema con las que peleas se revisan a muerte. Las partes de las que nadie se queja pueden pudrirse en paz. Cuando la podredumbre finalmente te cobra, te cobra cincuenta veces más que cualquier cosa en la que alguna vez pensaste.
10 Una microVM rápida es una caja de herramientas, no un producto
Cuando Acquire a primera instrucción del guest fueron tres milisegundos, estuve orgulloso de eso por una semana. Luego intenté construir algo con ello.
Lo que quería era lo que todo el mundo quiere estos días: un REST API donde un cliente dice “dame un sandbox de Python 3.12 con numpy y pandas, déjame correr código ahí”, aparece un sandbox, tres segundos después le devuelven un stdout, y siguen con su vida. Un gocracker run crudo no puede hacer nada de eso. Arranca una VM. Eso es todo. Si una microVM es un bloque de motor, lo que yo necesitaba era el resto del auto.
La primera decisión fue la más importante: mantener a gocracker exactamente como era, y construir la capa de administración como cosa separada. gocracker se queda como el VMM de bajo nivel, el caché de snapshots, y la warm pool de workers. Habla bytes e ioctls. No tiene opiniones sobre clientes ni templates. sandboxd es un demonio nuevo que se sienta encima y es dueño de templates, leases, pools, y tokens de preview. El SDK solo le habla a sandboxd. sandboxd solo le habla a gocracker sobre un unix socket. El round-trip extra es una característica, no un bug.
Aprendí el valor de ese split por no hacerlo limpiamente primero, y luego pasar tres horas debuggeando una race condition que solo existía porque dos capas estaban compartiendo un puntero que no tenían por qué compartir. Cruzar una frontera de proceso te obliga a negociar. Compartir un puntero te deja hacer trampa. La frontera es el cinturón de seguridad.
El split es un flujo limpio de tres capas:
SDK (Python / Go / TS)
|
| HTTP sobre unix socket
v
sandboxd <-- demonio de runtime gestionado
| (templates, leases, pools, tokens de preview)
| HTTP sobre unix socket
v
gocracker serve <-- orquestador VMM de bajo nivel
| (ioctls KVM, snapshots, warm pool de workers)
| ioctls KVM + vsock
v
guest VM
|
+-- agente toolbox (escucha en un puerto vsock dentro del guest)Tres saltos. Dos demonios. El SDK nunca le habla a gocracker directamente — no sabe que gocracker existe. sandboxd es la única superficie pública de API; todo lo de abajo es detalle de implementación. Cruzar una frontera de proceso por un unix socket es barato (sub-milisegundo para JSONs chicos), y la separabilidad se paga sola la primera vez que quieres reiniciar sandboxd sin matar cien VMs vivas.
Los templates son la otra idea que carga peso. Un cliente no quiere “una VM Linux”. Quiere el entorno que usa para su agente de AI — una imagen base específica, unos paquetes apt, unos paquetes pip, un directorio de trabajo, unas variables de entorno. Un template captura esa mezcla, más el snapshot que resulta de arrancar la spec una vez y dejar que llegue a un estado estable. Dos templates con specs idénticos comparten snapshot. Un segundo create con la misma spec es un no-op.
type Template struct {
ID string
Name string
SnapshotDir string
SpecHash string // huella canónica de imagen, kernel, mem, env...
ContextHash string // tarball de contexto cuando usas Dockerfile
WarmPolicy WarmPolicy
}Eso suena obvio hasta que imaginas la vida de un SaaS real: la mayoría de los creates de template son reintentos idempotentes. Un deploy se vuelve a correr. Un job de CI reenvía. Un SDK hace un ensure-exists antes de un create. Si cada uno costara un docker build fresco, estarías enviando un producto de $40 al mes sobre una infra de $400 al mes. La identidad content-addressable en cada capa se compone: el warm cache era content-addressable, los templates son content-addressable encima, y los sandboxes son baratos porque los templates son baratos.
11 Cinco warm ready. Ninguno lo estaba.
Estaba corriendo un load test contra un sandboxd recién reconstruido. Nada elaborado — crea un sandbox, exec echo hi, borra el sandbox, en un loop. La pool estaba configurada para tres hot-ready y tres paused-ready para un solo template. Cada create debería ser esencialmente instantáneo, porque la pool debería mantener seis sandboxes tibios vivos y yo solo necesitaba uno a la vez.
Funcionó por unos noventa segundos.
Después cada create empezó a fallar. No lento. No con backpressure. Cada uno, con variaciones de “runtime returned 404: unknown vm”. El endpoint de status de la pool reportaba tres hot-ready, dos paused-ready, cero leased. Una pool perfectamente sana, según ella misma. Las VMs llevaban muertas minutos.
Ese fue un martes divertido.
La primera versión del reconciliador confiaba en su propio registro en memoria. Contaba entradas marcadas warm_ready, comparaba el conteo con MinHot, y concluía: sano, no hay acción. Nada en el reconciliador estaba mirando. Una VM warm-ready murió silenciosamente — panic de vCPU, OOM-kill, guest atascado, error de tipeo en fstab cayendo a rescue mode en systemd, lo que sea — y sandboxd la siguió contando como viva. Los leases siguientes fallaban al attach con 404s, el handler marcaba la entrada como “rota” y caía al cold boot, pero las entradas rotas quedaban en memoria como warm_leased hasta que una goroutine separada de cleanup las cosechaba. Mientras tanto la pool seguía reportando cinco warm, el reconciliador seguía sin tomar decisiones, y cada request cold-booteaba.
La cascada no fue espectacular. Sin alarmas. Sin pager. El sistema se estaba degradando silenciosamente a peor-caso, un 404 a la vez, mientras reportaba verde con diligencia.
vista de sandboxd estado real del runtime
+-----------------+ +----------------------+
| warm_ready: 3 | | VM #1: muerta |
| warm_ready: 2 | | VM #2: muerta |
| leased: 0 | | VM #3: viva pero OOM |
| total: 5 | | VM #4: desaparecida |
+-----------------+ | VM #5: desaparecida |
^ +----------------------+
| |
| "sano, sin acción" | intento de lease -> 404
| |
| v
tick del reconciliador el handler marca rota,
cuenta estado en memoria cae al cold boot
compara con MinHot |
no hace nada v
el usuario ve un cold boot de 2s
en cada requestLa degradación silenciosa es el peor modo. Una falla ruidosa te deja paginar sobre ella. Una falla silenciosa significa que el gráfico se ve verde mientras los clientes se van.
El fix fue estructural y chico. El reconciliador ahora hace tres cosas en orden, y el orden carga peso:
func (m *Manager) reconcileTemplate(tpl *Template) {
m.reapDead(tpl) // sondear runtime, eliminar fantasmas
inv := m.inventoryFor(tpl.ID) // contar desde estado honesto
m.pruneExcess(tpl, inv)
m.replenishUpToMin(tpl, inv)
}Primero, sondear cada sandbox warm que el manager cree que tiene, y eliminar todo lo que el runtime ya no conoce — “inconcluso” cuenta como muerto, porque una pool de VMs quizá-vivas es peor que una pool con un agujero. Segundo, contar desde el inventario ahora honesto. Tercero, podar el exceso y rellenar hasta el mínimo. Antes del fix: cinco sandboxes fantasma, cada request cold-booteaba, la pool reportando salud con alegría. Después: creates instantáneos otra vez.
He chocado contra exactamente este bug antes. Sospecho que tú también, si llevas suficiente tiempo construyendo backends. Cada vez viste un disfraz un poco distinto — un controlador de Kubernetes que confía en el estado cacheado de un Pod en lugar del reporte real del kubelet, un connection pool que marca un backend sano porque la última respuesta fue 200, sin notar que el socket lleva treinta segundos cerrado en silencio, un service registry cuyo hilo de heartbeat es independiente del hilo de trabajo, así que el servicio puede estar deadlockeado y seguir pingueando, un browser que cachea un registro DNS más allá de la realidad. El error subyacente es el mismo cada vez: confiar en una representación en memoria del mundo, cruzando una frontera de proceso, sin sondear. El estado en memoria y la realidad fuera de proceso siempre se desincronizan. La pregunta no es si lo vas a notar; es cuándo, y cuánto daño visible al usuario se acumula en el ínterin.
Dos guardias más entraron por la misma puerta. Un backoff por template para que un único template roto — digamos, uno cuyo snapshot está sutilmente corrupto — no pueda mantener al reconciliador clavado spawneando VMs fallidas cada tick, matando de hambre a los templates sanos. Y un presupuesto global de spawns en vuelo por host, porque diez templates queriendo rellenar tres VMs cada uno a la vez son treinta spawns paralelos, suficiente para hacer que cada spawn sea más lento de lo necesario, lo cual aprieta los timeouts, lo cual cascadea. Los topes por template no son suficientes. El número de cosas que pueden salir mal simultáneamente en N templates crece más rápido que lo que el tope por template retiene.
12 Lo que la base me enseñó
Mirando el arco entero, unas cuantas cosas salen lo suficiente como para llevárselas hacia adelante.
El estado del kernel del host sobrevive a tu proceso. Límpialo al arrancar tanto como al apagarte. close(fd) es una operación de refcount, no un mensaje de protocolo — si necesitas que el peer sepa que te fuiste, tienes que decírselo. Todo camino de salida necesita restauración de terminal, porque defer es una sugerencia que las señales y los seccomp trips ignoran. El race detector en CI no es negociable para ningún proyecto Go que mantiene estado entre goroutines.
Tu mayor costo casi seguro no es lo que tú escribiste. Linux arrancando dentro de la VM eran tres cuartos de un arranque en frío de cuatrocientos milisegundos. Nada de lo que yo escribí importaba hasta que me puse a reducir eso. Una UART virtualizada es cara por byte; silenciar el log del kernel en el camino de la consola fue la sola ganancia más grande de rendimiento del proyecto. MAP_PRIVATE es dinero gratis para restaurar snapshots. El garbage collector de Go es un impuesto que puedes elegir no pagar en subprocesos corto-vivos.
Confía en el kernel más que en tu instinto. IRQCHIP in-kernel ya resuelve la suspensión de vCPUs ociosos. El sleep defensivo encima era trabajo negativo. El código defensivo es un detector de mentiras para tus suposiciones que cambiaron desde entonces: revisita las coberturas cuando el sistema subyacente se mueve. Y a veces la mayor victoria es una eliminación.
Una vez que una warm pool se sienta encima de una microVM, las reglas cambian. La pool es de mejor esfuerzo — un miss nunca debe hacer al usuario más lento que la base. Mata workers al liberarlos; nunca le entregues a un tenant un proceso que ha tocado los datos de otro tenant. Los loops de reconciliador tienen que observar antes de actuar, porque lo único más peligroso que un caché equivocado es un caché que el sistema dejó de cuestionar. Y lo inconcluso siempre es muerto en una pool — el costo de tratar un sandbox quizá-vivo como muerto es un arranque en frío; el costo de tratar a uno muerto como vivo es la falla de lease que ve tu cliente.
Ninguna de estas victorias es ingeniosa por sí sola. Cada una es algo que alguien más resolvió años atrás — mmap, copy-on-write, aislamiento por tenant, eventfd más IRQFD, mise en place como concepto, confiar en el scheduler del IRQCHIP in-kernel. Nada inventado aquí. Lo que pasó fue dejar de pelear con cada uno, uno a la vez. Así es como suceden arranques en frío visibles al usuario de ~3 ms. Te los ganas capa por capa. No hay un único cambio heroico. Hay una pila de pequeños cambios honestos, cada uno haciendo más barato escribir el siguiente.
Las partes interesantes de la máquina ya están. Lo que queda es mantenerlas honestas.