We hebben de Linux microVM-stack op Apple Silicon herbouwd

Encore bouwt en implementeert backend-applicaties, en sinds medio 2022 draait elke build in een Firecracker microVM. Firecracker stript de geëmuleerde hardware terug naar wat een Linux-kernel nodig heeft, waardoor elke build de isolatie van een virtuele machine krijgt met een opstarttijd die dicht bij die van een container ligt.

Firecracker stuurt KVM aan, dus het heeft een Linux-host nodig met /dev/kvm. Geen enkele Mac heeft dit, terwijl de meeste engineers bij Encore op een Mac ontwikkelen. De maintainers hebben geen plannen om dit te veranderen; ze wezen een werkend proof-of-concept af dat was gebouwd op Apple's Virtualization.framework en gaven aan dat ze op korte termijn geen ondersteuning voor macOS plannen.

Hierdoor betekende werken aan het build-systeem vier jaar lang dat we dit ergens anders moesten doen. We wilden hetzelfde build-systeem op onze laptops kunnen draaien terwijl we Firecracker in productie behouden. Daarom hebben we crackling gebouwd: een enkele microVM API die Firecracker aanstuurt op Linux en de hypervisor van Apple op macOS. Om dezelfde images op beide platforms te booten, moesten we een groot deel van de Linux image-toolchain herbouwen zodat deze op macOS kon draaien.

Vier jaar ontwikkelen op een gedeelde remote machine

We onboardden elke engineer met een script dat één keer werd uitgevoerd. Dit script maakte via SSH verbinding met de gedeelde build-machine als root, haalde je publieke sleutel op van https://github.com/<jouw-gebruiker>.keys, maakte een gebruiker aan en voegde je toe aan de kvm- en docker-groepen, zodat je de hypervisor kon bereiken en containers kon draaien. De VM-images werden gekopieerd naar ~/images en het Firecracker-binary werd hard-linked naar ~/binaries, aangezien elke gebruiker dit in zijn eigen boom op de machine nodig had. Je eindigde met een persoonlijke omgeving in een datacenter, bereikbaar via Tailscale, vlak naast die van je collega's.

Om een wijziging in die omgeving te krijgen, was een tweede script nodig. Dit las je gebruikersnaam en poort uit de CUE-configuratie (uit een per-engineer gitignored bestand), omdat we allemaal dezelfde host deelden en hadden afgesproken niet met elkaar te conflicteren. Binaries waren het makkelijke deel: we compileerden cross-platform met GOOS=linux GOARCH=amd64, rsyncten de resultaten over en telden de overgedragen bestanden om te bepalen of er iets herstart moest worden.

Images waren het lastige deel, omdat Firecracker een block-device boot en Docker layers produceert. We konden geen bestaande tool vinden die Docker-layers kon converteren naar een block-device dat Firecracker kon booten, dus bouwden we de conversie zelf, half op de laptop en half via SSH:

# tools/dev-builder/deploy-dev-builder.sh
docker save -o "$imagesdir/$name.tar" "$docker_image"
tar -C "$layersdir" -xf "$imagesdir/$name.tar"          # explode the layers
tar -C "$dst/" -xf "$imagesdir/$name.tar" manifest.json
rsync -azP $layersdir  ${username}@builder:~/images/
rsync -azP "$dst"      ${username}@builder:~/images/
ssh ${username}@builder -- \
"bash -l -s squash_layers \"images/${outputdir}\" \"images/${name}\"" < $scriptpath

Die laatste regel pipet een shell-functie naar een login-shell aan de andere kant en voert deze daar uit. De bash-code werd later herschreven in Go, maar de pipeline en de host bleven hetzelfde. We lieten squash_layers elke layer in de manifest-volgorde opnieuw uitpakken, de .wh..wh..opq whiteout-markers verwijderen met find (omdat tar dit niet voor ons doet), een hardcoded /etc/resolv.conf schrijven (anders had de VM geen DNS), de omgevingsvariabelen van de image uit de Docker-config halen met jq, en tot slot mksquashfs aanroepen om iets te produceren dat Firecracker kon booten. We gebruikten een cache gebaseerd op de Docker image-id, zodat dit hele proces kon worden overgeslagen bij een match. Dit proces draaide telkens wanneer er iets in de image was gewijzigd, wat bijna altijd het geval was bij werk aan de guest-zijde.

Herstarten vereiste een derde SSH-verbinding om je container te killen en de vervanger te starten:

docker run --privileged \
-v ~/socks:/var/lib/buildsvc/socks:rw   -v ~/logs:/tmp/encore-builds:rw \
-v ~/.keys:/.keys:ro                    -v ~/binaries:/usr/local/bin:ro \
-v ~/images:/usr/lib/buildsvc/images:ro \
--env-file service-envs \
--device /dev/kvm --device /dev/net/tun \
-p $port:9060 --name "${username}-builder" -d -t buildsvc-tester

Firecracker draait daar in een Docker-container, dus we moesten --privileged, /dev/kvm en /dev/net/tun doorgeven, omdat het proces in die container tap-devices zou aanmaken en eigen virtuele machines zou booten.

Firecracker verwacht dat die tap-devices zijn gekoppeld aan een host-bridge, en in een Docker-container is geen host-bridge aanwezig. Daarom bouwden we er een in een shell-script dat draaide voordat de build-service (buildsvc) werd opgestart:

# tools/dev-builder/container/start.sh
ip link add docker0 type bridge
ip link set eth0 master docker0
addr=$(ip address show eth0 | grep inet | xargs | cut -d " " -f2)
ip address del $addr dev eth0
ip address add $addr dev docker0 broadcast 172.17.255.255
ip link set docker0 up
ip r add default via 172.17.0.1 dev docker0

Het script bouwt een bridge genaamd docker0 in de container, koppelt de eth0 van de container hieraan, en verplaatst het IP-adres van eth0 naar de bridge. De tap-devices hadden zo iets om aan te koppelen dat voldoende leek op een echte Docker-host.

Het build-systeem draaide overal, behalve op onze laptops

De setup werkte, wat verklaart waarom deze van 2022 tot aan de uiteindelijke vervanging is blijven bestaan. Maar de kosten waren moeilijk te rechtvaardigen voor een bedrijf waarvan de hele belofte is dat backend-ontwikkeling soepel moet verlopen: dat je je applicatie moet kunnen schrijven, lokaal moet kunnen draaien, en dat de infrastructuur uit de code moet volgen in plaats van uit een stapel handmatig beheerde YAML-bestanden. Ondertussen was het deel van ons product dat een git push omzet in een draaiende applicatie precies het onderdeel dat we niet konden draaien op de machines waarop we software schrijven.

Omdat het build-systeem remote was, zou een lokale breakpoint nooit afgaan en betekende het lezen van logs het tailen van een bestand via SSH. Om een profiler te koppelen, moest deze eerst naar de box worden gekopieerd. Elke wijziging aan de guest-zijde ging via docker save en een rsync van de uitgepakte image, gevolgd door extractie en mksquashfs op de gedeelde host, terwijl andere engineers daar hun eigen builds draaiden.

De loop was lang genoeg om je twee keer te laten nadenken voordat je iets speculatiefs probeerde. Wat we wilden was om het build-systeem op een Mac te draaien, native, met dezelfde images, op de machine waar we al voor zaten.

Eén API over twee hypervisors met weinig gemeen

We keken eerst naar wat er al bestond. Het draaien van Linux in een VM op macOS heeft verschillende werkende implementaties: Apple's eigen container bereikte deze juni versie 1.0, Lima en Tart doen dit al jaren, en podman kan het ook via libkrun. Je kunt zelfs /dev/kvm krijgen in een Linux VM op een M3 of later met macOS 15, waardoor Firecracker ongewijzigd draait.

Geen van deze opties overspant echter beide hosts, en de geneste route laat je nog steeds binnen een Linux VM zitten op de subset van laptops die dit ondersteunen. Het adopteren van een van deze tools zou betekenen dat we een tweede, anders behandelende manier van builds draaien zouden hebben die alleen op laptops bestaat.

Crackling is een daemon en CLI die OCI-images boot als lichtgewicht Linux VM's op beide platforms, met één agent in de guest en één protocol dat dit aanstuurt. Firecracker blijft de backend op Linux, en op macOS is dat Apple's Virtualization.framework (afgekort als VZ).

We hielden de core-crate onafhankelijk van beide hypervisors. Een machine wordt beschreven via MachineSpec (vcpus, mem, kernel, rootfs, extra_disks, nics, vsock, en backend-specifieke extra's), terwijl MachineState dit tijdens runtime bijhoudt.

Beide backends implementeren MachineBackend: start, shutdown, pause, resume, snapshot, wait, dispose, en, indien beschikbaar, connect_vsock. Backend-dispatch is statisch omdat er voor een specifiek target slechts één kan bestaan, terwijl de trait beide implementaties een gedeeld contract geeft en tests in staat stelt een in-memory backend te gebruiken.

Hun mogelijkheden verschillen echter nog steeds: Firecracker heeft host tap-devices en zijn MMDS metadata-service, terwijl Apple's framework een ingebouwde NAT-device heeft, virtiofs directory sharing, en Rosetta-translatie voor het draaien van x86-binaries in een arm64-guest. Elke backend geeft een foutmelding voor elke gevraagde feature die hij niet kan implementeren:

// crates/crackling-core/src/backend.rs
pub enum Feature {
    Snapshot,
    /// Het maken van een machine uit een eerder vastgelegde snapshot.
    /// Verschilt van `Snapshot`: VZ kan vastleggen (indien toegestaan),
    /// maar crackling herstelt daar nooit, terwijl Firecracker beide doet.
    SnapshotRestore,
    Mmds, VirtioFs, Rosetta,   // Firecracker / VZ / VZ
    /// Host tap netwerkapparaat (Firecracker).
    TapNetwork,
    /// Ingebouwd NAT netwerkapparaat (VZ).
    NatNetwork,
    MemoryBalloon, Entropy, Vsock,
    /// Machines overleven het besturingsproces en kunnen opnieuw worden gekoppeld
    /// (Firecracker). VZ-machines leven in-process en kunnen nooit worden overgenomen.
    Adoption,
}

Elke backend rapporteert welke features hij ondersteunt op de huidige host voordat er een machine bestaat. Hierdoor kan de daemon een spec aanpassen voor het aanmaken. Verzoeken voor niet-beschikbare netwerkmodi, adoption of snapshot-herstel falen bij de API-boundary.

Op Linux is elke VM een apart Firecracker-childproces, aangestuurd via een REST API op een Unix socket door een kleine HTTP/1.1 client. Deze processen kunnen de daemon overleven. Een tijdelijke systemd-scope voorkomt dat de service manager ze opruimt, en we slaan zowel de PID als de starttijd op, zodat PID-hergebruik niet ervoor zorgt dat een oud record naar een ongerelateerd proces wijst. Bij herstart opent de daemon een pidfd en controleert de instance-id via de API-socket. Alles wat niet geïdentificeerd kan worden, blijft draaien. VZ-machines leven binnen ons proces en eindigen daarmee; op macOS geeft Adoption daarom een foutmelding.

Beide backends worden gebouwd en getest bij elke pull request: op een x86_64 Ubuntu runner voor Firecracker en een arm64 macOS runner voor VZ. De backend-agnostische logica van de daemon draait op elke runner tegen de mock-backend, en de VZ-tests vereisen geen hypervisor, code-signing of guest-image.

De crackling CLI stuurt een daemon aan via gRPC; de daemon boot machines via een facade die bij compile-time een van de twee hypervisor-backends selecteert, en beide communiceren met dezelfde in-guest agent via vsock.

Architectuur: crackling CLIgrpccracklingdcompile-time backend

  • Linux → FirecrackervsockGuest agent
  • macOS → Virtualization.frameworkvsockGuest agent

De ene thread waar Apple's framework op staat te hameren

Het implementeren van de VZ-backend bracht een strikte threading-beperking met zich mee: VZVirtualMachine, VZVirtualMachineConfiguration en de device-objecten van het framework zijn !Send + !Sync. Bovendien moet elke VM-call en completion handler draaien op de seriële dispatch queue die de VM heeft aangemaakt. De rest van de daemon gebruikt tokio, die futures verplaatst tussen worker-threads. Geen enkele constructie kan dus een VM-object vasthouden over een await-punt en aan beide beperkingen voldoen.

We maken en benaderen elke VM op één proces-globale seriële DispatchQueue. De VM-registry is alleen beschikbaar voor closures die naar die queue worden verzonden, zodat de toegang geserialiseerd blijft en de objecten nooit de tokio-threads bereiken. Het async-gedeelte is een gewone Send + Sync + Clone handle die een closure verzendt die alleen Send-data bevat, en vervolgens wacht op het antwoord:

// crates/crackling-vz/src/reactor.rs
reactor().queue.exec_async(move || {
    match build_configuration(&cfg) {
        Ok(vm_cfg) => {
            // SAFETY: we pass the reactor's own serial queue; the VM is
            // stored and only ever used from this queue henceforth.
            let vm = unsafe {
                VZVirtualMachine::initWithConfiguration_queue(
                    VZVirtualMachine::alloc(), &vm_cfg, &reactor().queue)
            };
            let id = shared.id;
            if let Ok(mut g) = reactor().state.vms.lock() {
                g.insert(id, ReactorVm { vm, shared });
            }
            let _ = reply.send(Ok(()));
        }
        Err(e) => { /* mark Failed, then: */ let _ = reply.send(Err(e)); }
    }
});

De dispatch API vereist Send + 'static closures, dus de compiler voorkomt dat een !Send VM-object wordt gecapture. De registry en de reactor die deze bevatten, hebben handgeschreven Send en Sync implementaties nodig; de queue-invariant hangt af van deze twee implementaties.

VZVirtualMachineConfiguration is ook !Send. Daarom hebben we het omzetten in twee fasen gesplitst: een MachineSpec wordt op tokio een structuur met enkel Send-data, en die structuur wordt vervolgens op de queue een VZVirtualMachineConfiguration. Een completion handler ontvangt een raw NSError pointer die alleen geldig is voor de duur van het blok; we converteren dit op de queue naar een owned error voordat we antwoorden. Het droppen van de laatste handle naar een machine verzendt een dispose, omdat het framework vereist dat die release ook op zijn eigen queue gebeurt.

Een opstartbaar Linux-image bouwen zonder Linux

De VZ-backend kon nu een VM maken en controleren, maar het booten vereiste het vervangen van de Linux-only image-pipeline. Het omzetten van een OCI-image naar een opstartbaar root-besturingssysteem vereist normaal gesproken root-rechten en een loop-mount, zaken die niet bestaan op macOS. Daarnaast roept het bouwen van een initramfs meestal het cpio-binary aan. De extract-vmlinux van de kernel-tree is geschreven voor x86 bzImage en kan geen arm64-kernel uitpakken.

Geen van beide hypervisors boot iets voordat er een ongecomprimeerde kernel-image is. De vmlinuz die een arm64-distributie verscheept is meestal een EFI zboot-bestand: een klein EFI-executable dat een gecomprimeerde payload bevat die de firmware normaal gesproken bij het booten decomprimeert. Omdat er hier geen firmware is, doen we dit zelf. De MZ- en zimg-signatures identificeren het formaat, en de header geeft ons de offset, grootte en compressie van de payload:

// crates/crackling-image/src/kernel.rs
// EFI zboot: "MZ" at offset 0 and the "zimg" signature at offset 4.
if bytes.len() > 64 && &bytes[0..2] == b"MZ" && &bytes[4..8] == b"zimg" {
    let payload_offset = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
    let payload_size = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
    let comp_end = bytes[24..32].iter().position(|&b| b == 0).unwrap_or(8);
    let compression = std::str::from_utf8(&bytes[24..24 + comp_end]).unwrap_or("");
    let end = payload_offset
        .checked_add(payload_size)
        .filter(|&e| e <= bytes.len())
        .ok_or_else(|| ImageError::Kernel("zboot payload out of range".into()))?;
    let raw = match compression {
        "gzip" => gunzip(&bytes[payload_offset..end])?,
        other => return Err(ImageError::Kernel(
            format!("unsupported zboot compression: {other:?}"))),
    };
}

Als je Virtualization.framework een gecomprimeerde kernel geeft, faalt deze bij de start met een generieke interne fout zonder details. We dachten eerst aan het virtualization entitlement en verloren een middag aan het opnieuw signeren van binaries voordat we naar de kernel keken. We controleren nu op de ARMd-signature op offset 0x38, wat een raw arm64 Image identificeert, en rapporteren een gecomprimeerde kernel voordat we proberen te booten.

De kernel heeft een root-besturingssysteem nodig om in te booten. We passen OCI-layers volledig in userspace toe en houden rekening met .wh. whiteout-entries, net zoals squash_layers deed met find, maar dan in-process en zonder een opruimronde achteraf. Het pullen van de image vereiste ook een custom platform resolver, omdat de default resolver kijkt naar het host-OS en nooit een linux/arm64 image matcht voor een verzoek vanuit een Mac.

Het booten van de rootfs vanuit RAM vereist een initramfs: een newc-formaat cpio-archief in een gzip-stream. We genereren beide layers in pure Rust, en standaard blijft de rootfs in het geheugen tot de VM stopt.

We pakken een image één keer uit en normaliseren deze, schrijven een .built sentinel, en publiceren de voltooide output via een atomaire rename, zodat een crash de bestaande cache intact laat. Elke VM kloont de gecachte rootfs met clonefile op APFS (een per-file FICLONE reflink op Linux-besturingssystemen die dit ondersteunen, of een gewone kopie elders), en het RAM-pad pakt die kloon opnieuw in in de initramfs van de VM.

Een shell krijgen in de VM

Zodra de kernel en rootfs waren geboot, had crackling een manier nodig om commando's uit te voeren en data te verplaatsen in de guest. Beide platforms bieden vsock, en Alpine's virt-kernel verscheept AFVSOCK als loadable modules. De guest /init laadt dus vsock, vmwvsockvirtiotransportcommon en vmwvsockvirtiotransport voordat er iets kan luisteren. Deze modules bevatten een vermagic-string die exact moet overeenkomen met de draaiende kernel; een mismatch faalt bij het laden zonder nuttige foutmeldingen. We halen de kernel en modules uit één linux-virt package zodat ze in sync blijven. Het mounten van ext4 trekt een crc32c-hash aan, zelfs als checksums zijn uitgeschakeld, dus crc32c_generic en libcrc32c moeten worden geladen. Voor een interactieve shell moet /dev/pts gemount zijn voordat openpty werkt.

Elke VM draait dezelfde agent, een statische musl-binary gebouwd voor aarch64-unknown-linux-musl op de Mac en x8664-unknown-linux-musl voor amd64-hosts. Deze luistert op AFVSOCK en gebruikt een klein framed protocol: een header van 8 bytes gevolgd door een encoded control frame of raw bytes voor bulkdata, met één verbinding per operatie. Het protocol ondersteunt exec met gestreamde stdout en stderr, een interactieve shell op een PTY, cp in beide richtingen, en forward, wat een verbinding omzet in een tunnel naar een poort in de guest.

De agent gebruikt vsock voor controle, waardoor de guest geen handmatige netwerkconfiguratie of een SSH-daemon nodig heeft. Uitgaand netwerkverkeer is een aparte opt-in, terwijl inkomende toegang alleen beschikbaar is via een control-plane forward, geauthenticeerd met een per-VM token dat bij boot wordt gegenereerd.

Op macOS is de host-zijde van dat transport een VZVirtioSocketDevice verbinding waarvan de file descriptor onmiddellijk moet worden dup(2)-ed, omdat het framework de originele sluit wanneer het Objective-C object wordt gedealloceerd. Op Linux is het een Unix socket met een tekst-handshake, waarbij het antwoord byte voor byte moet worden gelezen:

// crates/crackling-firecracker/src/machine.rs
// Read the reply one byte at a time so we never swallow payload
// bytes past the newline (a real hazard with buffered reads).
stream.write_all(format!("CONNECT {port}\n").as_bytes()).await?;
let mut line = Vec::with_capacity(16);
loop {
    let b = stream.read_u8().await.map_err(Error::Io)?;
    if b == b'\n' { break; }
    line.push(b);
}

De macOS- en Linux-implementaties verschillen, maar beide retourneren een byte-stream verbonden met de agent voor crackling shell, exec en cp.

Apple staat derde partijen niet toe om een VM te snapshotten

Firecracker legt geheugen- en device-state native vast via PUT /snapshot/create. Een spec met restore_from start een verse VMM, controleert de host-fingerprint van de snapshot, laadt de snapshot in gepauzeerde stand en hervat deze zonder te booten. Upstream herstelt dit alleen op een matching architectuur en Firecracker-versie, dus de fingerprint-check beschermt de operatie wanneer we een idle sandbox suspenderen en hervatten op een andere host.

Apple's framework lijkt hetzelfde aan te bieden, aangezien VZVirtualMachine de methode saveMachineStateToURL exposeert, en validateSaveRestoreSupport in de configuratie vraagt of het systeem hiervoor in aanmerking komt. We schreven de implementatie en de validator gaf succesvol aan dat het kon, maar de save faalde met VZErrorInternal. Het faalt zelfs bij een minimale VM met niets meer dan een vsock-device, wat een niet-serialiseerbaar device als verklaring onwaarschijnlijk maakt.

Het draaien van een VM vereist com.apple.security.virtualization, waar elke developer een signature voor kan krijgen. Het opslaan van een VM vereist echter aanvullend com.apple.private.virtualization, wat Apple niet verleent aan third-party applicaties. Omdat de validator niet controleert op dit tweede entitlement, rapporteert het framework dat de operatie wordt ondersteund, faalt bij aanroep, en geeft dezelfde generieke interne fout terug als bij een slechte kernel.

Wat nu op een Mac draait

Een engineer clonet nu het build-systeem, draait het op zijn laptop, en boot de OCI-images die gebruikt worden voor Encore's builds en deploys via dezelfde agent en vsock-route. De gedeelde host is verdwenen, samen met de docker save via rsync en de handgebouwde bridge in een privileged container. Bovendien kun je nu een breakpoint in het build-systeem zetten en dit daadwerkelijk raken.

De lokale workflow ziet er nu als volgt uit:

$ crackling run --image alpine:3.20
0c1f8f3c-7b21-4a5e-9a10-2b4c6d8e0f11   running   alpine:3.20
$ crackling exec 0c1f8f3c-7b21-4a5e-9a10-2b4c6d8e0f11 uname -a
Linux (none) 6.6.142-0-virt ... aarch64 Linux
$ crackling shell 0c1f8f3c-7b21-4a5e-9a10-2b4c6d8e0f11
~ # cat /etc/alpine-release
3.20.10