Rénich's Blog

Compilaciones ultra rápidas con MicroVMs y libvirt

Hero image for Compilaciones ultra rápidas con MicroVMs y libvirt

22 de julio de 2026 • 7 min de lectura

Hoy me puse a experimentar con la arquitectura de MicroVMs en QEMU y libvirt, y la neta me quedé impresionado.

Si tú me has seguido por acá, sabes que en varios escenarios de migración desde entornos legacy (como VMware) a clusters hiperconvergentes de 3 nodos con libvirt y Ceph, el reto clásico siempre ha sido la eficiencia: las máquinas virtuales tradicionales tardan entre 30 y 90 segundos en arrancar y tragan gigabytes de RAM nomás para estar paradas.

Hoy te voy a enseñar cómo armé un motor de compilación efímero que arranca una máquina virtual aislada por hardware en menos de 200 milisegundos, compila tu código o empaqueta un RPM, guarda los artefactos y se destruye sin dejar rastro.

El problema con los entornos de compilación tradicionales

Normalmente, cuando un desarrollador quiere compilar algo con dependencias nativas (por ejemplo, una aplicación en Crystal con bindings a libssh como mi proyecto shellmin), se topa con dos caminos incómodos:

  1. Mantener servidores de compilación o VMs de CI/CD de larga vida que terminan acumulando residuos y consumiendo recursos innecesarios.
  2. Pedir acceso directo por SSH o root al hypervisor, lo cual desde el punto de vista de seguridad de una organización está completamente fuera de discusión.

Note

La idea aquí es ofrecer un servicio de MicroVM Build-as-a-Service. El desarrollador no necesita acceso shell al servidor ni cuentas en el hypervisor; nomás empuja su código o dispara un webhook y la infraestructura hace todo de volada.

Pensando en términos de Linux: Los archivos repos y build_deps

Para mantener las cosas simples y alineadas a la filosofía de Linux, en lugar de obligar al desarrollador a escribir scripts de inicialización complejos o configurar XMLs mamones, la máquina virtual lee los archivos planos repos y build_deps en la raíz de su repositorio.

En proyectos del mundo real, no basta con declarar únicamente los paquetes en build_deps; muchas veces requerimos habilitar fuentes externas (como repositorios COPR o repositorios de terceros como MariaDB o RabbitMQ) en el archivo repos. Por ejemplo, para compilar nuestra aplicación en Crystal con bindings a libssh (shellmin), necesitamos habilitar el repositorio COPR de zawertun/crystal antes de que el manejador de paquetes pueda instalar crystal.

En el archivo repos declaramos las fuentes externas:

# repos
zawertun/crystal

Y en el archivo build_deps declaramos los paquetes requeridos:

# build_deps
crystal
shards
libssh-devel
gc-devel
pcre2-devel
openssl-devel
gcc
make

Desacoplando la arquitectura: Archivos independientes

Para que la automatización sea totalmente explícita y transparente, desacoplé la configuración en archivos independientes. Nada de andar metiendo bloques culeros dentro del script principal; todo se lee directamente del disco.

Aquí te muestro los componentes principales que utilicé:

El XML de la MicroVM (microvm_build.xml):
Un template XML declarativo que usa la máquina virtual ligera microvm de QEMU con arranque directo de kernel (vmlinuz) y consolas serie.
<!-- microvm_build.xml -->
<domain type='kvm'>
  <name>microvm-source-builder</name>
  <memory unit='MiB'>128</memory>
  <vcpu placement='static'>2</vcpu>
  <os>
    <type arch='x86_64' machine='microvm'>hvm</type>
    <kernel>/boot/vmlinuz-current</kernel>
    <initrd>/var/tmp/microvm-initrd.img</initrd>
    <cmdline>console=ttyS0 quiet reboot=k panic=1 pci=off</cmdline>
  </os>
  <features>
    <acpi/>
  </features>
  <clock offset='utc'/>
  <on_poweroff>destroy</on_poweroff>
  <on_reboot>restart</on_reboot>
  <on_crash>destroy</on_crash>
  <devices>
    <console type='pty'>
      <target type='serial' port='0'/>
    </console>
  </devices>
</domain>
El Init del Guest (microvm_init.sh):
Un script transparente que se ejecuta como PID 1 dentro de la MicroVM, monta los sistemas de archivos virtuales (/proc, /sys, /dev), ejecuta la compilación y apaga la VM de inmediato.
#!/bin/sh

# microvm_init.sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev

printf "\n[GUEST INIT] MicroVM kernel booted successfully!\n"

# Procesar fuentes externas de repositorios (repos)
if [ -f /etc/build_workspace/repos ]; then
    printf "[GUEST INIT] Procesando repositorios externos...\n"
    while IFS= read -r line || [ -n "$line" ]; do
        case "$line" in
            \#*|"") continue ;;
            https://*|http://*)
                printf "[GUEST INIT] Descargando repo: %s\n" "$line"
                curl -sSL -o "/etc/yum.repos.d/$(basename "$line").repo" "$line"
                ;;
            *)
                printf "[GUEST INIT] Habilitando COPR: %s\n" "$line"
                dnf -y copr enable "$line" &> /dev/null || true
                ;;
        esac
    done < /etc/build_workspace/repos
fi

# Procesar paquetes de dependencias de compilación (build_deps)
if [ -f /etc/build_workspace/build_deps ]; then
    printf "[GUEST INIT] Instalando dependencias de compilación...\n"
    deps="$(grep -v '^#' /etc/build_workspace/build_deps | tr '\n' ' ')"
    if [ -n "$deps" ]; then
        dnf -y install $deps &> /dev/null || true
    fi
fi

printf "[GUEST INIT] Ejecutando carga de compilación...\n"

if [ -x /bin/builder ]; then
    /bin/builder --version
    /bin/builder
fi

poweroff -f
El script orquestador (run_microvm_build.bash):
El script en Bash que coordina todo el jale en el host, siguiendo los estándares de EVALinux.
#!/usr/bin/bash

# run_microvm_build.bash
set -euo pipefail
IFS=$'\n\t'

readonly ScriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly ResultsDir="${ScriptDir}/results"
readonly DomainName="microvm-source-builder"
readonly XmlTemplate="${ScriptDir}/microvm_build.xml"
readonly InitTemplate="${ScriptDir}/microvm_init.sh"
readonly TmpDir="$(mktemp -d --tmpdir=/var/tmp microvm-gnu-poc.XXXXXX)"
chmod 755 "$TmpDir"

cleanup() {
    local exit_code=$?
    printf "[CLEANUP] Limpiando dominio %s y directorio %s...\n" "$DomainName" "$TmpDir" >&2
    virsh -c qemu:///system destroy "$DomainName" &> /dev/null || true
    virsh -c qemu:///system undefine "$DomainName" &> /dev/null || true
    rm -rf "$TmpDir"
    exit "$exit_code"
}
trap cleanup EXIT

# ... preparación de fuentes e initrd ...

run_microvm_poc() {
    local -r runtime_xml="$TmpDir/runtime_domain.xml"
    local -r exec_log="$JobResultsDir/logs/microvm_source_build.log"

    virsh -c qemu:///system define "$runtime_xml" &> /dev/null
    virsh -c qemu:///system start "$DomainName" &>> "$exec_log" || true

    cp -f "$TmpDir/src/hello" "$ArtifactsDir/gnu-hello"
    chmod +x "$ArtifactsDir/gnu-hello"
}

Note

¿Por qué usar /var/tmp en lugar de /tmp? En sistemas Linux modernos (como Fedora y RHEL), /tmp está montado en memoria RAM usando tmpfs. Si guardas árboles de código o artefactos grandes ahí, saturas la memoria del servidor de volada y no va a escalar. Por eso usamos /var/tmp, el cual está respaldado por almacenamiento en disco.

Resultados con espacios de nombres (Namespacing) para evitar sobreescrituras

Un error común en motores de compilación sencillos es volcar todos los logs y binarios en una carpeta plana como results/. Al hacer esto, compilaciones subsecuentes terminan sobreescribiendo los logs y los binarios producidos.

Para solucionar esto de raíz, cada trabajo de compilación genera un directorio único con espacio de nombres (Namespacing) basado en el proyecto y el ID del trabajo (timestamp o UUID):

results/
└── microvm-shellmin-builder/
    └── job-20260722-060942-2579/
        ├── artifacts/
        │   └── shellmin
        └── logs/
            ├── build.log
            └── microvm.log

De esta manera, cada compilación mantiene un historial 100% aislable, auditable e inmutable, sin riesgo de sobreescribir artefactos o logs anteriores.

Abstrayendo la complejidad: ¿Cómo simplificar este PoC a futuro?

Seamos honestos: en el estado actual de este PoC, mantener scripts de inicialización en shell artesanales (como microvm_init.sh) con montajes manuales de /proc o scripts orquestadores en el host (como run_microvm_build.bash) resulta verboso, intimidante y propenso a errores tanto para desarrolladores como para sysadmins.

Para llevar este patrón de diseño a producción y evitar que el equipo tenga que lidiar con la fontanería interna de KVM/libvirt, la arquitectura puede simplificarse y abstraerse mediante las siguientes mejoras:

  1. Un ejecutable/CLI abstraído (ej. ``microvm-build``): En lugar de mantener scripts de Bash de 150 líneas en el host, la orquestación se puede empaquetar en una herramienta CLI o daemon escrito en Crystal o Go. El desarrollador o el pipeline de CI/CD simplemente ejecutaría microvm-build --src . --output ./results sin tocar XMLs de libvirt ni interactuar con la consola serie.
  2. Aprovisionamiento inmutable en lugar de scripts ``/init`` artesanales: En lugar de escribir scripts /init a mano con comandos mount y parsing en shell, se pueden utilizar motores de aprovisionamiento declarativos estandarizados (como Fedora CoreOS Ignition o unidades simples de systemd dentro del sistema base) para preparar el entorno de compilación de forma determinista.
  3. Especificaciones declarativas en YAML/TOML: En lugar de requerir que el usuario conozca la mecánica interna de la VM, se abstraen las fuentes y dependencias en un manifiesto limpio en YAML (o imágenes de construcción tipo OCI), dejando que el motor construya dinámicamente los recursos.
  4. Cumplimiento FHS y aislamiento de resultados: Internamente, el proceso se alinea al estándar FHS (Filesystem Hierarchy Standard):
    • Espacio de trabajo: /usr/src/<proyecto>/
    • Caché del manejador de paquetes: /var/cache/builder/
    • Logs de inicialización: /var/log/builder/
    • Temporales efímeros: /var/tmp/ (respaldados por disco)

Conclusión

A final de cuentas, las MicroVMs con libvirt y QEMU nos permiten tener lo mejor de dos mundos: la seguridad total de aislamiento por hardware (KVM) que solías tener en VMware, combinada con la velocidad de arranque de sub-segundo (< 200 ms) que esperas de un contenedor.

Si tú estás buscando modernizar tu infraestructura sin regalarle privilegios a nadie ni saturar tus servidores con VMs pesadas, este patrón de diseño te va a hacer un paro chingón.

¿Qué te parece? ¿Te gustaría implementar algo así en tu infraestructura? ¡Platícamelo en los comentarios!