Encore собирает и деплоит бэкенд-приложения, и с середины 2022 года каждая такая сборка выполнялась внутри microVM Firecracker. Firecracker урезает эмулируемое оборудование до минимума, необходимого ядру Linux, что даёт каждой сборке изоляцию виртуальной машины при времени запуска, близком к контейнерному.

Firecracker работает через KVM, а значит требует Linux-хост с /dev/kvm, которого нет ни на одном Mac — при этом большинство инженеров Encore разрабатывают именно на Mac. Мейнтейнеры проекта не планируют закрывать этот пробел: они отклонили рабочий proof-of-concept на базе Apple Virtualization.framework и заявили, что не собираются поддерживать macOS в обозримом будущем.

Поэтому четыре года работа над системой сборки означала работу где-то ещё. Команда хотела запускать ту же систему сборки на своих ноутбуках, сохранив Firecracker в продакшене, и для этого построила crackling — единый microVM API, который управляет Firecracker на Linux и гипервизором Apple на macOS. Чтобы загружать одни и те же образы на обеих платформах, пришлось пересобрать значительную часть Linux-инструментария для работы с образами под macOS.

Четыре года разработки на общей удалённой машине

Каждого инженера подключали к системе через скрипт, который запускали один раз. Он заходил по SSH на общую сборочную машину под root, забирал публичный ключ из https://github.com/<you>.keys, создавал пользователя, а затем добавлял его в группы kvm и docker, чтобы тот мог обращаться к гипервизору и запускать контейнеры. Скрипт копировал образы VM в ~/images и создавал жёсткую ссылку на бинарник firecracker в ~/binaries, поскольку каждому пользователю требовалась своя копия в дереве на одной и той же машине. В итоге у каждого получалось личное окружение в дата-центре, доступное через Tailscale, соседствующее с окружениями всех остальных.

Чтобы изменение попало в это окружение, требовался второй скрипт, читавший имя пользователя и порт из CUE-конфига — из файла, добавленного в gitignore для каждого инженера, потому что все делили один хост и должны были договариваться, чтобы не пересекаться. Бинарники были более простой частью: их кросс-компилировали с GOOS=linux GOARCH=amd64, переносили через rsync и сверяли количество переданных файлов, чтобы понять, нужен ли перезапуск.

Сложнее было с образами, потому что Firecracker загружается с блочного устройства, а Docker производит слои. Готового инструмента, конвертирующего слои Docker в блочное устройство для загрузки Firecracker, не нашлось, поэтому конвертацию собрали сами — частично на ноутбуке, частично через 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

Последняя строка передаёт shell-функцию в login shell на удалённой стороне и запускает её там. Позже bash переписали на Go, но пайплайн и хост не изменились. Функция squash_layers заново распаковывала каждый слой в порядке манифеста, удаляла маркеры-заглушки .wh..wh..opq через find, поскольку tar сам их не применяет, записывала жёстко заданный /etc/resolv.conf, так как у VM иначе не было DNS, вытаскивала переменные окружения образа из конфига Docker через jq, и наконец вызывала mksquashfs, чтобы получить нечто, что Firecracker сможет загрузить. Кэш индексировался по id Docker-образа, чтобы при совпадении можно было пропустить весь путь целиком. Функция запускалась при любом изменении в образе — а это происходило почти всегда, если велась работа над гостевой частью.

Перезапуск требовал третьего SSH-сеанса — чтобы убить контейнер пользователя и запустить его замену:

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 запускается внутри Docker-контейнера, поэтому пришлось прокидывать --privileged, /dev/kvm и /dev/net/tun: процесс внутри контейнера сам создавал tap-устройства и загружал собственные виртуальные машины.

Firecracker ожидает, что эти tap-устройства подключатся к мосту на хосте, а внутри Docker-контейнера моста нет. Поэтому его собрали в shell-скрипте, который запускался до старта самого сервиса сборки, buildsvc:

# 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

Скрипт создаёт внутри контейнера мост с именем docker0, подчиняет ему собственный eth0 контейнера, затем переносит IP-адрес с eth0 на мост. После этого tap-устройствам было к чему подключаться — достаточно похоже на настоящий Docker-хост.

Система сборки работала везде, кроме ноутбуков

Схема работала, поэтому и продержалась с 2022 года до недавней замены. Но её цена было сложно оправдать в компании, чья суть — сделать разработку бэкенда гладкой: чтобы можно было написать приложение, запустить его локально, а инфраструктура следовала из кода, а не из горы YAML, которую поддерживают вручную. При этом та часть продукта, которая превращает git push в работающее приложение, оказалась единственным, что нельзя было запустить на машинах, где пишется сам код.

Поскольку система сборки была удалённой, локальный breakpoint никогда не срабатывал, а чтение логов означало tail файла по SSH. Подключение профилировщика сначала требовало скопировать его на машину. Любое изменение на гостевой стороне также проходило через docker save и rsync распакованного образа, а затем распаковку и mksquashfs на общем хосте — пока другие инженеры выполняли там собственные сборки.

Цикл разработки был настолько длинным, что перед любым экспериментом приходилось дважды подумать. Хотелось запускать систему сборки прямо на Mac, нативно, загружая те же образы, на той машине, за которой уже сидел инженер.

Один API поверх двух гипервизоров, у которых мало общего

Сначала посмотрели, что уже существует: у запуска Linux в VM на macOS есть несколько рабочих реализаций — собственный container от Apple добрался до версии 1.0 этим летом, Lima и Tart делают это уже годами, а podman умеет то же самое через libkrun. Можно даже получить /dev/kvm внутри Linux VM на M3 или новее под macOS 15, где Firecracker запускается без модификаций.

Но ни одно из решений не работает одинаково на обеих платформах, а вложенный вариант всё равно оставляет пользователя внутри Linux VM только на той части ноутбуков, которые это поддерживают. Принятие любого из этих вариантов означало бы второй, по-другому работающий способ запуска сборок, существующий только на ноутбуках.

Crackling — это демон и CLI, загружающие OCI-образы как лёгкие Linux VM на обеих платформах: с одним агентом внутри гостевой системы и одним протоколом, управляющим им в обоих случаях. На Linux бэкендом остаётся Firecracker, а на macOS — Virtualization.framework от Apple, или VZ — этот префикс носит каждый экспортируемый им тип.

Основной crate сделали независимым от конкретного гипервизора. Он описывает машину через MachineSpec, содержащий vcpus, mem, kernel, rootfs, extra_disks, nics, vsock и дополнительные поля для каждого бэкенда, а MachineState отслеживает состояние во время выполнения.

Оба бэкенда реализуют MachineBackend: start, shutdown, pause, resume, snapshot, wait, dispose и, где доступно, connect_vsock. Диспетчеризация бэкенда статическая, поскольку для конкретной целевой платформы существует только один из них, а типаж даёт обеим реализациям общий контракт и позволяет тестам подставлять in-memory бэкенд.

Возможности бэкендов всё же различаются: у Firecracker есть tap-устройства на хосте и сервис метаданных MMDS, а у фреймворка Apple — встроенное NAT-устройство, разделение директорий через virtiofs и трансляция Rosetta для запуска x86-бинарников в arm64-госте. Каждый бэкенд возвращает ошибку с именем запрошенной функции, которую не может реализовать:

// crates/crackling-core/src/backend.rs
pub enum Feature {
    Snapshot,
    /// Creating a machine from a previously captured snapshot.
    /// Distinct from `Snapshot`: VZ can capture (entitlement permitting)
    /// but crackling never restores there, while Firecracker does both.
    SnapshotRestore,
    Mmds, VirtioFs, Rosetta,   // Firecracker / VZ / VZ
    /// Host tap network device (Firecracker).
    TapNetwork,
    /// Built-in NAT network device (VZ).
    NatNetwork,
    MemoryBalloon, Entropy, Vsock,
    /// Machines outlive the controlling process and can be re-attached
    /// (Firecracker). VZ machines live in-process and can never be adopted.
    Adoption,
}

Каждый бэкенд заранее сообщает, что он поддерживает на текущем хосте — ещё до создания какой-либо машины, — что позволяет демону скорректировать спецификацию перед вызовом create. Запросы недоступных режимов сети, adoption или восстановления из снапшота отклоняются на границе API с указанием неподдерживаемой функции.

На Linux каждая VM — это отдельный дочерний процесс firecracker, управляемый через REST API по Unix-сокету небольшим HTTP/1.1-клиентом, написанным специально под эту ограниченную поверхность API. Такие процессы могут пережить сам демон. Временный systemd scope не даёт менеджеру служб «подобрать» их, а PID и время его запуска сохраняются, чтобы переиспользование PID не привело к тому, что старая запись укажет на несвязанный процесс. При перезапуске демон открывает pidfd и проверяет идентификатор инстанса через сокет API. Всё, что не удаётся опознать, остаётся работать. VZ-машины живут внутри процесса демона и завершаются вместе с ним, поэтому на macOS Adoption возвращает ошибку.

Оба бэкенда собираются и тестируются на каждом pull request — на x86_64 Ubuntu-раннере для Firecracker и arm64 macOS-раннере для VZ. Независимая от бэкенда логика демона прогоняется на каждом раннере против mock-бэкенда, а тесты VZ не требуют ни гипервизора, ни подписи кода, ни гостевого образа.

CLI crackling управляет демоном через gRPC; демон загружает машины через фасад, выбирающий один из двух бэкендов гипервизора на этапе компиляции, и оба обращаются к одному и тому же агенту внутри гостя через vsock.

Единственный поток, на котором настаивает фреймворк Apple

Реализация VZ-бэкенда потребовала соблюдения жёсткого ограничения на потоки: VZVirtualMachine, VZVirtualMachineConfiguration и объекты устройств фреймворка помечены как !Send + !Sync, а каждый вызов VM и обработчик завершения должны выполняться на той же последовательной dispatch-очереди, что создала эту VM. Остальная часть демона построена на tokio, который перемещает future между рабочими потоками по собственному усмотрению, поэтому ни одна схема не может удерживать объект VM через точку await и одновременно удовлетворять обоим ограничениям.

Каждая VM создаётся и используется на одной глобальной для процесса последовательной DispatchQueue, а реестр VM доступен только замыканиям, диспетчеризованным в эту очередь, — так доступ остаётся сериализованным, а сами объекты никогда не попадают в потоки tokio. Асинхронная половина — обычный дескриптор Send + Sync + Clone, который отправляет замыкание, несущее только Send-данные, а затем ожидает ответ:

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

API диспетчеризации требует замыканий Send + 'static, поэтому компилятор не позволяет захватить !Send-объект VM. При этом реестру и самому реактору, который его хранит, всё равно нужны написанные вручную реализации Send и Sync — на них держится инвариант очереди.

VZVirtualMachineConfiguration тоже !Send, поэтому опускание уровня разбили на две фазы: сначала MachineSpec превращается в структуру, содержащую только Send-данные, — на стороне tokio, а затем эта структура становится VZVirtualMachineConfiguration уже на очереди. Обработчик завершения получает сырой указатель NSError, действительный только на время выполнения блока, поэтому его преобразуют в собственную ошибку прямо на очереди перед отправкой ответа. Освобождение последнего дескриптора машины запускает dispose, поскольку фреймворк требует, чтобы освобождение тоже происходило на его собственной очереди.

Сборка загружаемого Linux-образа без Linux

VZ-бэкенд теперь мог создавать и управлять VM, но для загрузки всё ещё требовалось заменить пайплайн образов, работающий только на Linux. Превращение OCI-образа в загружаемую корневую файловую систему обычно требует root и loop-mount — ни того, ни другого нет на macOS, а сборка initramfs обычно вызывает бинарник cpio. Собственный extract-vmlinux из дерева ядра написан под x86 bzImage и не умеет распаковывать arm64-ядро.

Ни один гипервизор не загружает ничего, пока нет несжатого образа ядра, а vmlinuz, поставляемый arm64-дистрибутивами, обычно представляет собой файл EFI zboot — небольшой EFI-исполняемый файл, оборачивающий сжатую полезную нагрузку, которую прошивка обычно распаковывает при загрузке. Здесь прошивки нет, поэтому распаковку пришлось делать самостоятельно. Сигнатуры MZ и zimg определяют формат, а заголовок даёт смещение, размер и способ сжатия полезной нагрузки:

// 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:?}"))),
    };
}

Если передать Virtualization.framework сжатое ядро, старт завершается общей внутренней ошибкой без каких-либо подробностей. Первым подозреваемым оказался entitlement для виртуализации — на переподпись бинарников ушёл целый вечер, прежде чем внимание переключилось на само ядро. Теперь проверяется сигнатура ARMd по смещению 0x38, идентифицирующая сырой arm64 Image, и о сжатом ядре сообщается до попытки загрузки.

Ядру для загрузки нужна корневая файловая система, поэтому слои OCI применяются полностью в user space, а маркеры-заглушки .wh. обрабатываются так же, как это делал squash_layers через find, но прямо в процессе и без последующей очистки. Загрузка образа также потребовала собственного резолвера платформы, поскольку резолвер по умолчанию ориентируется на ОС хоста и никогда не подберёт образ linux/arm64 для запроса с Mac.

Загрузка корневой файловой системы из RAM требует initramfs — cpio-архива в формате newc внутри gzip-потока. Оба слоя генерируются на чистом Rust, и по умолчанию корневая файловая система остаётся в памяти, пока VM не остановлена.

Образ распаковывается и нормализуется один раз, после чего записывается маркер .built, а готовый результат публикуется через атомарное переименование, так что сбой оставляет существующий кэш нетронутым. Каждая VM клонирует кэшированную корневую файловую систему через clonefile на APFS, файловый reflink FICLONE на файловых системах Linux, которые его поддерживают, либо обычным копированием в остальных случаях, а путь с использованием RAM переупаковывает этот клон в собственный initramfs VM.

Получение shell внутри VM

После загрузки ядра и корневой файловой системы crackling требовался способ выполнять команды и перемещать данные внутри гостя. Обе платформы предоставляют vsock, а ядро Alpine virt поставляет AF_VSOCK в виде загружаемых модулей, поэтому гостевой /init загружает vsock, vmw_vsock_virtio_transport_common и vmw_vsock_virtio_transport, среди прочих, прежде чем что-либо сможет начать слушать соединения. Эти модули несут строку vermagic, которая должна точно совпадать с работающим ядром, а несовпадение приводит к сбою загрузки без какой-либо полезной информации дальше по цепочке: VM загружается, агент так и не поднимается, а хост ждёт соединения, которое никогда не придёт. Ядро и его модули берутся из одного пакета linux-virt, чтобы они не расходились между собой. Монтирование ext4 подтягивает хэш crc32c даже при отключённых контрольных суммах, поэтому приходится загружать crc32c_generic и libcrc32c, а интерактивному shell нужен смонтированный /dev/pts, прежде чем заработает openpty.

Каждая VM запускает одного и того же агента — статический бинарник на musl, собранный для aarch64-unknown-linux-musl на Mac и для x86_64-unknown-linux-musl на amd64-хостах. Он слушает AF_VSOCK и использует небольшой протокол с фреймами: 8-байтовый заголовок, за которым следует либо закодированный управляющий фрейм, либо сырые байты для массовых данных, с одним соединением на операцию. Протокол поддерживает exec с потоковой передачей stdout и stderr, интерактивный shell на PTY, cp в обе стороны и forward, превращающий соединение в туннель к порту внутри гостя.

Агент использует vsock для управления, поэтому гость обходится без ручной настройки сети и без демона SSH, установленного crackling. Исходящая сеть — отдельная опция, включаемая явно, а входящий доступ доступен только через пересылку на уровне control-plane, аутентифицированную токеном, генерируемым отдельно для каждой VM при загрузке.

На macOS хостовой конец этого транспорта — это соединение VZVirtioSocketDevice, чей файловый дескриптор нужно немедленно продублировать через dup(2), поскольку фреймворк закрывает оригинал при деаллокации своего Objective-C объекта. На Linux это Unix-сокет с текстовым рукопожатием, а ответ приходится читать байт за байтом:

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

Реализации для macOS и Linux различаются, но обе возвращают байтовый поток, подключённый к агенту, для crackling shell, exec и cp.

Apple не даёт сторонним разработчикам снапшотить VM

Firecracker захватывает состояние памяти и устройств нативно через PUT /snapshot/create, а спецификация с полем restore_from запускает новый VMM, проверяет отпечаток хоста в снапшоте, загружает снапшот в приостановленном состоянии и возобновляет его работу без полноценной загрузки. Апстрим восстанавливает состояние только на совпадающей архитектуре и версии Firecracker, поэтому проверка отпечатка защищает операцию, когда простаивающая песочница приостанавливается на одном хосте, а возобновляется на другом.

Фреймворк Apple, судя по всему, предлагает то же самое: VZVirtualMachine предоставляет saveMachineStateToURL, а validateSaveRestoreSupport у конфигурации спрашивает, подходит ли она для этого. Реализацию написали, валидатор отработал успешно, но само сохранение завершилось с ошибкой VZErrorInternal. Сбой происходит даже на минимальной VM, несущей чуть больше, чем устройство vsock, что делает объяснение через несериализуемое устройство маловероятным.

Для запуска VM нужен com.apple.security.virtualization, на который может подписаться любой разработчик, а для сохранения дополнительно требуется com.apple.private.virtualization, который Apple не выдаёт сторонним приложениям. Поскольку валидатор не проверяет наличие второго entitlement, фреймворк сообщает, что операция поддерживается, падает при вызове и возвращает ту же общую внутреннюю ошибку, что и в случае с некорректным ядром.

Что теперь работает на Mac

Инженер клонирует систему сборки, запускает её на своём ноутбуке и загружает OCI-образы, используемые для сборок и деплоев Encore, через того же агента и тот же путь vsock. Общий хост исчез вместе с docker save, прогоняемым через rsync, и вручную собранным мостом внутри привилегированного контейнера — и теперь можно поставить breakpoint в системе сборки и действительно на нём остановиться.

Локальный рабочий процесс выглядит так:

$ 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