«И что это я, в самом деле, всё про QEMU да про QEMU? Давайте я вам лучше про AppImage расскажу. Короче, случай из жизни nix». (c)
Для сборки QEMU с своими доработками (новые машины, tcg-code-quality, i2c-remote-master, GPIO наружу через GUSE и прочее) у каждого коллеги свой путь: кто-то собирает сам, кто-то берёт готовый бинарник у соседа, кто-то сидит на дистрибутиве, где glib на три года старше, чем нужно, а студенты вообще не хотят ничего собирать сами...
Хотелось простого: один файл, который скачал, сделал chmod +x и запустил. На любом x86_64 Linux, без root, без установки пакетов и без «а у меня не собирается». При этом сама сборка должна быть воспроизводимой, а не «собрано на ноутбуке у Пети».
Ниже история о том, как из этого получился небольшой flake, который собирает QEMU через Nix и упаковывает его в AppImage. Здесь я покажу как подписывать образы, обновлять их дельтами и подсовывать QEMU свою, экспериментальную libfuse так, чтобы она использовалась только для QEMU.
Весь код лежит в репозитории, функционал разбит на четыре шага, каждый отмечен тегом.
Тег |
Что добавлено |
|
Ванильный QEMU из релизного архива, упакованный в AppImage (обычный, отладочный и минимальный варианты) |
|
Подпись GPG в формате appimagetool и проверка |
|
Обновление через appimageupdatetool: .upd_info и .zsync |
|
QEMU и libfuse из git, своя libfuse только для QEMU на примере gpiodev и GUSE |
Можно идти по статье и переключаться между тегами, на каждом шаге все собирается.
Nix и NixOS
Сразу хочу прояснить момент, потому что эти две вещи постоянно смешивают.
Nix и NixOS — это разные вещи. Nix — это пакетный менеджер и система сборки: язык описания пакетов, хранилище /nix/store и коллекция пакетов nixpkgs. NixOS — это дистрибутив Linux, целиком собранный и настраиваемый через Nix. Для всего, что описано ниже, NixOS не нужен.
Nix ставится на любой дистрибутив: он живет в своей директории /nix и не трогает системные пакеты. Иногда он уже есть в пакетном менеджере дистрибутива, можно поставить вот так. Не смотрим, сразу выполняем, да, причем желательно с sudo </sarcasm>. Автор, кстати, поставил из пакетного менеджера:
$ sh <(curl -L https://nixos.org/nix/install) --daemon
«Старый» и «новый» Nix
Исторически Nix — это набор отдельных команд: nix-build, nix-shell, nix-env, nix-instantiate, nix-store. А версии nixpkgs задаются каналами (nix-channel), то есть меняются со временем независимо от вашего проекта.
Новый интерфейс — это одна команда nix с подкомандами (nix build, nix run, nix develop, nix flake ...) и flakes: проект описывается файлом flake.nix с явными входами (nixpkgs, другие репозитории, исходники), а их точные версии закрепляются в flake.lock. Это делает сборку воспроизводимой: тот же коммит репозитория дает тот же результат.
Новый интерфейс и flakes формально до сих пор считаются экспериментальными, их нужно включить:
$ echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
Все, что ниже, использует только новый интерфейс и flakes. Имейте в виду, что nix-build и nix build — это разные инструменты с разной моделью работы. К сожалению, поиск выдает гораздо больше информации по старому интерфейсу.
Почему Nix?
Первая причина — воспроизводимость, которая не требует усилий. В Nix она достигается не дисциплиной, как в Buildroot или Yocto, а является свойством самой системы сборки, потому что все входы закреплены в flake.lock и сборка идет в песочнице без сети. Вторая причина — точечная подмена зависимостей: свою libfuse с GUSE можно подсунуть конкретно QEMU через override, не трогая pkgs.fuse3 и не пересобирая все, что от нее зависит, в том числе и runtime AppImage. Этих двух факторов уже было достаточно, чтобы смириться с незнакомым языком и непривычным процессом.
Почему не статическая сборка
Можно было бы собрать QEMU статически (--static) и раздавать один бинарник. Это прекрасно работает, но:
статически придется пересобрать как минимум glib, pcre, zlib и dtc, а с ростом функциональности список быстро растет;
в консервативных дистрибутивах статические пакеты часто есть, но тогда библиотеки старые, а в свежих их может не быть вовсе;
поддерживать окружение для корректной статической компоновки всех зависимостей придется самому (а для rolling дистрибутивов, как, например, у автора, который пользуется Gentoo, это может быть проблематично).
Вот замер для минимального набора из трех целей:
Статическая сборка |
AppImage (minimal) |
|
|
~93 МБ |
~33 МБ (динамический) |
|
~65 МБ |
~18 МБ (динамический) |
|
~64 МБ |
~18 МБ (динамический) |
Всего |
~221 МБ |
~63 МБ в упакованном виде, ~207 МБ после распаковки |
Если нужен один эмулятор, статический бинарник компактнее. Если нужен набор, размеры сравнимы, но Nix+AppImage сразу дает воспроизводимую среду сборки, которую не надо поддерживать самостоятельно.
Шаг 1. QEMU в AppImage
Что такое AppImage изнутри
AppImage — это обычный исполняемый ELF, за которым следует образ файловой системы squashfs. Начало файла занимает runtime (type2-runtime). При запуске он:
Находит, где внутри файла начинается squashfs.
Монтирует его через FUSE в
/tmp/.mount_*, а если FUSE недоступен (или указан--appimage-extract-and-run), распаковывает во временный каталог.Запускает лежащий в корне образа
AppRun.После завершения отмонтирует образ.
В саму систему при этом ничего не ставится — по крайней мере в корень системы. Программа вполне может хранить конфигурацию или еще что-то в вашем каталоге $HOME.
Почему не flatpak, snap и прочие
Песочница Flatpak плохо совместима с тем, что QEMU делает с системой. KVM, проброс USB и PCI, bridged networking требуют либо ослабления песочницы через --device=all, либо вообще невозможны без root. А тянущиеся за QEMU swtpm, virtiofsd, passt и сетевые утилиты превращают его изготовление в хрупкий и трудоемкий процесс.
Snap — те же проблемы плюс привязка к Snap Store.
Docker/Podman — пользователю нужно разбираться с пробросом директорий и устройств, к тому же надо собирать rootfs, и задача опять же сводится к его созданию. Тем более что AppImage после распаковки можно использовать как rootfs для контейнера.
Откуда берется содержимое образа
Обычно AppImage собирают из «AppDir», куда руками или через linuxdeploy складывают программу и библиотеки. Nix и так знает полное замыкание пакета (closure в устоявшихся терминах Nix), то есть все пути в /nix/store, которые нужны бинарнику для работы. Нужно лишь упаковать это замыкание в squashfs как есть, с путями /nix/store/....
Этим занимается nix-appimage, а точнее его форк, в котором поддерживается несколько бинарников в одном образе:
Замыкание:
writeClosureвыдает список путей store, нужныхqemu-system-*.Образ squashfs:
mksquashfsупаковывает замыкание, сохраняя структуру/nix/store/.... Рядом создаются/usr/binсо ссылками на все бинарники и точка входа.AppRun: вторым проходом добавляется
AppRun(shell-скрипт) вместе со статическимbwrap.AppRunвыбирает бинарник и запускает его в песочнице bubblewrap, где каталогnix/из образа смонтирован как/nix.Runtime: в начало файла дописывается type2-runtime.
Так что внутри AppImage живет минимально необходимый кусочек /nix/store, и QEMU даже не подозревает, что его откуда-то вынули и используют вне экосистемы nix. Здесь требуется пояснение, как именно связан исполняемый файл и /nix/store/:
$ ldd /nix/store/3kndi2m4nij0g6sn3cn7sl20ka77bsir-qemu-11.1.0/bin/qemu-system-riscv32 | grep glibc libm.so.6 => /nix/store/h4wfwic161kxrr74jlzla5lsm28hgary-glibc-2.44-25/lib/libm.so.6 (0x00007f217d3d7000) libc.so.6 => /nix/store/h4wfwic161kxrr74jlzla5lsm28hgary-glibc-2.44-25/lib/libc.so.6 (0x00007f217d000000) libdl.so.2 => /nix/store/h4wfwic161kxrr74jlzla5lsm28hgary-glibc-2.44-25/lib/libdl.so.2 (0x00007f217e232000) libpthread.so.0 => /nix/store/h4wfwic161kxrr74jlzla5lsm28hgary-glibc-2.44-25/lib/libpthread.so.0 (0x00007f217bb74000) librt.so.1 => /nix/store/h4wfwic161kxrr74jlzla5lsm28hgary-glibc-2.44-25/lib/librt.so.1 (0x00007f217ba00000) libresolv.so.2 => /nix/store/h4wfwic161kxrr74jlzla5lsm28hgary-glibc-2.44-25/lib/libresolv.so.2 (0x00007f217b838000) libmvec.so.1 => /nix/store/h4wfwic161kxrr74jlzla5lsm28hgary-glibc-2.44-25/lib/libmvec.so.1 (0x00007f217b18e000)
Именно так: Nix прописывает абсолютные пути ко всем зависимостям, это один из краеугольных камней, на которых строится Nix.
Сам QEMU
За основу берется пакет qemu из nixpkgs. У него уже есть все, что нужно для сборки, остается выключить лишнее. Графику, звук, SPICE, VNC, гостевой агент, документацию и прошивки для эмуляции встраиваемых систем мы убираем, чтобы уменьшить размер итогового образа:
# qemu.nix mkQemuFromSrc = src: extraFlags: (pkgs.qemu.override { brlttySupport = false; guestAgentSupport = false; enableDocs = false; vncSupport = false; gtkSupport = false; enableBlobs = false; alsaSupport = false; sdlSupport = false; openGLSupport = false; virglSupport = false; spiceSupport = false; }).overrideAttrs (oldAttrs: { inherit src; patches = [ ]; outputs = [ "out" ]; configureFlags = (oldAttrs.configureFlags or [ ]) ++ extraFlags; });
Во время разработки я с удивлением обнаружил, что львиную долю добавляет «невинный» brltty — вслед за ним тянется systemd и python.
Дальше каждый вариант получает --target-list из списка архитектур, --disable-tools и свои флаги:
Вариант |
Атрибут flake |
Флаги |
Минимальный |
|
|
Обычный |
|
стандартный набор nixpkgs без графики и звука |
Отладочный |
|
|
Архитектуры задаются одной строкой в flake.nix, по умолчанию [ "riscv32" "riscv64" "arm" ]. Каждое имя превращается в <arch>-softmmu и в бинарник qemu-system-<arch> внутри образа.
Упаковка в flake.nix выглядит так:
makeAppImage = { suffix, variant, arches, bins }: nix-appimage.bundlers.${system}.default { type = "app"; bins = bins; # список путей к qemu-system-* default = 0; # индекс бинарника, запускаемого по умолчанию pname = "qemu${suffix}"; # имя выходного файла: qemu${suffix}.AppImage };
Собираем:
$ nix build .#qemu-appimage-minimal $ ls -lh result/ -r-xr-xr-x 1 root root 64M qemu-minimal.AppImage
Это минимальный вариант, а обычный и отладочный весят около 295 МБ.
Сборка, которая завершилась «успешно»
Пока я переводил сборку на git-дерево QEMU, однажды получил три AppImage по 1,4 МБ. Nix сказал, что все собралось, а внутри образа не было ни одного qemu-system-*.
Причина в верхнем Makefile QEMU: он вызывает ninja как ninja ... | cat. Код возврата конвейера — это код возврата cat, поэтому упавшая линковка сборку не останавливает, make install честно ставит то, что успело собраться, а бандлер честно пакует пустоту. Так ведет себя и ванильный QEMU, поэтому проверка живет уже в первом шаге:
postInstall = '' for arch in ${builtins.concatStringsSep " " arches}; do if [ ! -x "$out/bin/qemu-system-$arch" ]; then echo "error: qemu-system-$arch was not built, see the build log above" >&2 exit 1 fi done '';
Как этим пользоваться
$ chmod +x qemu.AppImage $ ./qemu.AppImage -M help # первый бинарник из списка, qemu-system-riscv32 $ ./qemu.AppImage qemu-system-riscv64 -M help # или явно по имени
Лучше всего завести символические ссылки, тогда бинарник выбирается по имени ссылки:
$ ln -s qemu.AppImage qemu-system-riscv64 $ ./qemu-system-riscv64 -M virt -nographic -bios fw_dynamic.bin -kernel Image
Прошивки в образ не входят (сборка идёт с --disable-install-blobs), поэтому OpenSBI и прочее передаём явно через -bios. По той же причине машине virt для ARM нужен -nic none, иначе QEMU ищет efi-virtio.rom.
Песочница
QEMU внутри AppImage работает в песочнице bubblewrap. Из хоста в нее видны $HOME, /tmp, /dev, /run, /sys и сеть, а /mnt, /media и /opt по умолчанию нет. Если образы дисков или необходимые файлы лежат на /mnt, тогда:
$ SHARE_MNT=1 ./qemu.AppImage qemu-system-riscv64 -M virt -kernel /mnt/images/Image
Поведение AppRun настраивается переменными окружения: SHOW_HELP=1 печатает их все, ENABLE_NET=0 и ENABLE_DEV=0 отключают сеть и /dev, BWRAP_EXTRA_ARGS передает аргументы в bwrap, например --ro-bind-try /srv /srv.
Служебные команды runtime
Runtime понимает следующие опции --appimage-*:
$ ./qemu.AppImage --appimage-extract "qemu-system-*" # достать файлы из образа $ ./qemu.AppImage --appimage-mount # смонтировать и показать путь $ ./qemu.AppImage --appimage-extract-and-run -M virt -nographic # без FUSE
Распакованный squashfs-root/ можно использовать как rootfs для контейнера:
$ podman run --rm -ti --rootfs $PWD/squashfs-root/ /usr/bin/qemu-system-riscv32 -M help
Ограничения
Сборка только для
x86_64-linux.Песочнице нужны непривилегированные user namespaces.
На хостах со своим
/nix/store(NixOS и любая система с Nix) AppImage работает некорректно, так как в образе присутствует свой/nix/store.AppRunне передает код возврата QEMU, AppImage всегда завершается с кодом 0, то есть на него полагаться нельзя.
Последние два пункта, в принципе, легко поправимы.
Непривилегированные user namespaces
Этот момент требует пояснения. Дело в том, что для работы песочниц, таких как bubblewrap, нужны непривилегированные user namespaces. Это механизм ядра Linux (флаг CLONE_NEWUSER), позволяющий обычному пользователю без root создать изолированное пространство имен, где он «root» только внутри, а снаружи остается непривилегированным. Именно это позволяет песочнице ограничить приложение, не давая ему реальных прав на хосте.
Проблема в том, что многие дистрибутивы (Ubuntu 24.04+, старые Debian, RHEL) специально ограничивают или отключают эту функцию из-за истории уязвимостей ядра, связанных с user namespaces. Когда функция выключена, песочница просто не может запуститься.
В ядре всегда есть поддержка по умолчанию. Проверить, разрешены ли они в системе, можно так:
$ unshare --user --pid echo YES YES
Включение поддержки зависит от дистрибутива, как правило, она уже есть.
Шаг 2. Подпись и проверка
Если мы собираемся тиражировать результат, то хотелось бы иметь механизм для подтверждения, что скачали именно его. У type2-runtime для этого есть ELF-секции .sha256_sig (1024 байта), .sig_key (8192 байта) и формат подписи, который понимают AppImageUpdate и его утилита validate. Его же делает appimagetool --sign:
Секции
.sha256_sigи.sig_keyзаполняются нулями.От всего файла берется SHA-256 в виде hex-строки.
Hex-строка подписывается отдельной ASCII-armored подписью GPG в текстовом режиме, подпись записывается в
.sha256_sig.Публичный ключ записывается в
.sig_key.
Appimagetool у нас нет, образ собирает Nix, поэтому то же самое делает небольшой скрипт на bash, coreutils и gpg. Секции он находит, разбирая заголовки ELF через od:
section_range() { local f=$1 section=$2 shoff shentsize shnum shstrndx strtab i base name shoff=$(u "$f" 40 8) shentsize=$(u "$f" 58 2) shnum=$(u "$f" 60 2) shstrndx=$(u "$f" 62 2) strtab=$(u "$f" $((shoff + shstrndx * shentsize + 24)) 8) for ((i = 0; i < shnum; i++)); do base=$((shoff + i * shentsize)) name=$(dd if="$f" bs=1 skip=$((strtab + $(u "$f" "$base" 4))) count=64 status=none \ | tr '\0' '\n' | head -n1) if [ "$name" = "$section" ]; then echo "$(u "$f" $((base + 24)) 8) $(u "$f" $((base + 32)) 8)" return 0 fi done return 1 }
Важный момент: подпись делается после nix build отдельным шагом. Приватный ключ никогда не попадает в Nix store, а сама сборка остается воспроизводимой.
Ключ
Ключ генерируется один раз. Лучше Ed25519: подпись RSA-4096 занимает почти все 1024 байта секции.
$ gpg --quick-gen-key 'QEMU AppImage releases <n.shubin@maquefel.me>' ed25519 sign 5y $ gpg --with-colons --list-keys --fingerprint n.shubin@maquefel.me | awk -F: '/^fpr/{print $10; exit}' 365B56BB197727D9731BC365E3BF2E187E747067
Срок действия потом продлевается через gpg --quick-set-expire, а вот менять сам ключ без необходимости нежелательно: AppImageUpdate откажется обновлять образ, подписанный другим ключом.
Подпись файла
$ nix build .#qemu-appimage .#qemu-appimage-debug .#qemu-appimage-minimal $ nix run .#sign -- -k <FPR> -o signed result*/*.AppImage using key 365B56BB197727D9731BC365E3BF2E187E747067 signed: signed/qemu-debug.AppImage (sha256 ae6fb17b...) signed: signed/qemu-minimal.AppImage (sha256 72f33e80...) signed: signed/qemu.AppImage (sha256 51edfce7...)
Файлы из store доступны только для чтения, поэтому скрипт подписывает копии в signed/. Для CI пароль ключа можно положить в файл и указать в APPIMAGE_SIGN_PASSPHRASE_FILE.
Откуда брать ключ для проверки
Вот тут возникает проблема. Ключ из .sig_key не доказывает подлинность: кто подменит образ, подменит и ключ внутри. AppImageUpdate использует его, только чтобы убедиться, что новая версия подписана тем же ключом, что и старая. Для проверки скачанного файла ключ нужно получить подпись отдельно:
положить
qemu-appimage.ascв репозиторий и продублировать отпечаток там, где его видно без репозитория;опубликовать в DNS записью
OPENPGPKEY(DANE, RFC 7929), запись печатает сам gpg:gpg --export-options export-dane --export <FPR>; без DNSSEC это почти ничего не защищает;через WKD (
https://домен/.well-known/openpgpkey/...);на keys.openpgp.org.
Проверка подписи
$ nix run .#verify -- -k qemu-appimage.asc qemu.AppImage OK: qemu.AppImage key 365B56BB197727D9731BC365E3BF2E187E747067, signed 2026-10-02
Verify специально не верит ключу из образа и проверяет подпись через gpgv по ключу, полученному отдельно. Gpgv есть почти в любом дистрибутиве, даже без полного gpg, поэтому скрипт можно отдать пользователю и без Nix.
Утилита validate из AppImageUpdate тоже принимает нашу подпись:
$ ./validate-x86_64.AppImage qemu-minimal.AppImage Validation result: validation successful Signatures found with key fingerprints: 365B56BB197727D9731BC365E3BF2E187E747067
Но она берет ключ из самого образа, так что это проверка целостности, а не подлинности. Сам AppImage при запуске подпись не проверяет. Хотя технически это осуществимо, это просто бессмысленно.
Шаг 3. Обновление
У AppImage для этого есть AppImageUpdate. Он читает из секции .upd_info адрес, скачивает рядом с образом файл .zsync и пытается докачивать только изменившиеся блоки, используя старый образ как источник. Возможно, конкретно на вашем сценарии это работать не будет, но попробовать всегда стоит.
Адрес релизов задается в flake.nix одной переменной:
updateBaseUrl = "https://releases.example.com/qemu-appimage/latest";
При сборке в .upd_info каждого образа пишется строка вида zsync|<updateBaseUrl>/qemu-minimal.AppImage.zsync. Значение null собирает образы без адреса обновлений.
.zsync должен соответствовать финальному, уже подписанному файлу, поэтому его делает nix run .#sign (через zsyncmake), а не сборка. Выкладывать нужно пару «образ + .zsync» из signed/, а сервер должен поддерживать HTTP Range: без него zsync не сможет скачать отдельные блоки.
У пользователя обновление выглядит так:
$ appimageupdatetool -j qemu.AppImage # есть ли обновление (код 1, если есть) $ appimageupdatetool qemu.AppImage # обновить, старая версия сохраняется как .zs-old $ appimageupdatetool -r qemu.AppImage # обновить и удалить старую
Перед заменой AppImageUpdate проверяет подпись и сверяет ключ нового образа с ключом старого.
Если вы интересуетесь разработкой и эмуляцией железа, взгляните на наши вакансии:
Шаг 4. Своя libfuse только для QEMU
А теперь основная причина выбора именно такого способа тиражирования.
Зачем: gpiodev и GUSE
В нашей ветке QEMU есть подсистема gpiodev. Она выводит GPIO-контроллер эмулируемой платы наружу, на хост, и с линиями гостя можно работать обычными инструментами libgpiod (gpiodetect, gpioget, gpioset, gpiomon), как с настоящим /dev/gpiochipN. Тестовый стенд на хосте дергает и читает ноги эмулируемого контроллера так же, как дергал бы ноги реальной платы. Подробно об этом рассказано в статье «GPIO — тоже интерфейс, а также QEMU GPIODEV и GUSE».
У gpiodev три бэкенда:
Бэкенд |
Как устроен |
Что нужно |
|
GPIO поверх символьного устройства QEMU (сокет, pty и т. п.) |
ничего дополнительного |
|
Символьное устройство в |
стоковая |
|
Устройство GPIO в |
libfuse3 с GUSE и модуль ядра с |
GUSE нет в upstream libfuse, он есть только в нашем форке (guse_lowlevel.h и новый код GUSE_INIT). Значит, QEMU нужно собрать и запускать именно с этой libfuse.
Почему не оверлей
Можно было бы сделать оверлей, который заменяет fuse3 на форк. Но тогда форк libfuse получат все пакеты, которые от нее зависят, и тогда:
их всех придется пересобирать локально, в бинарном кеше nixpkgs таких сборок нет (мы меняем замыкание, а, следовательно, с точки зрения Nix это уже другой пакет);
на форке окажется и runtime AppImage, который сам монтирует образ через FUSE, а также в любом пакете, который зависит от libfuse.
Nix позволяет поступить аккуратнее и подменить библиотеку только для упаковываемого бинарника. QEMU собирается со своей libfuse, она же уезжает в AppImage и используется при запуске. Все остальное, включая runtime AppImage, остается на стоковых версиях и берется из кеша nixpkgs.
Как это сделано
Исходники QEMU и libfuse на финальном шаге из git — как входы flake. Хотя я рекомендую тиражировать tar-болы с исходниками. Для QEMU, например, существует специальный скрипт scripts/archive-source.sh), конкретные коммиты закреплены в flake.lock:
qemu-src = { url = "git+https://gitflic.ru/project/maquefel/qemu.git?ref=refs/heads/n.shubin/gpiodev/v11.1&shallow=1"; flake = false; }; libfuse-src = { url = "git+https://gitflic.ru/project/maquefel/libfuse.git?ref=refs/heads/nshubin/guse&shallow=1"; flake = false; };
Ветки тут даны скорее для удобства, чем из необходимости, и для работы с shallow clone. Но опять же, tar-бол с жестко указанным хешем решает большинство проблем (см. про Подпроекты meson).
Libfuse.nix собирает форк поверх пакета fuse3 из nixpkgs: тот же overrideAttrs, только с новым src. Патчи nixpkgs написаны под upstream-релиз и к форку не прикладываются, поэтому те же правки сделаны через substituteInPlace.
Библиотека передаётся в QEMU через параметр override, а pkgs.fuse3 остается нетронутым:
# flake.nix fuse3 = import ./libfuse.nix { inherit pkgs; src = libfuse-src; }; qemuVariants = import ./qemu.nix { inherit pkgs arches fuse3; src = qemuSrc; }; # qemu.nix (pkgs.qemu.override { inherit fuse3; # libfuse с GUSE только для этой сборки QEMU ... }).overrideAttrs (old: { configureFlags = old.configureFlags ++ [ "--enable-guse" ]; ... })
Проверяем, что получилось. В образе лежит libfuse из форка:
$ ./qemu.AppImage --appimage-extract > /dev/null $ ls squashfs-root/nix/store | grep fuse 9sjlpi3g07y2fhsm8x2h47v9qwxa7gbz-fuse-3.17.0 # форк с GUSE, его использует QEMU
А runtime собран со стоковой статической libfuse, это видно по зависимостям derivation:
$ nix path-info --recursive --derivation .#qemu-appimage | grep -E -- '-fuse-(static-.*-)?3' # QEMU /nix/store/7riv9wznl629fgk7xq1vxwi4ii62l80j-fuse-3.17.0.drv # runtime AppImage /nix/store/lh2z49wp3q74zy752z206gy4k8fg2xzj-fuse-static-x86_64-unknown-linux-musl-3.18.2.drv
Две разные libfuse: одна использовалась для статической сборки runtime, а другая в итоговом образе.
В итоге QEMU работает как положено:
$ ./qemu.AppImage qemu-system-riscv64 -M virt -gpiodev help Available chardev backend types: chardev guse cuse
А на хосте без модуля ядра guse честно говорит, чего ей не хватает:
$ ./qemu.AppImage qemu-system-riscv64 -M virt -nographic \ -gpiodev guse,id=gpio0,devname=gpiochip9 -kernel Image FUSE library version: 3.17.0 guse: device not found, try 'modprobe guse' first
Модуль ядра с /dev/guse по-прежнему нужен, AppImage избавляет только от пользовательской части.
Дополнительные сложности
Подпроекты meson. Релизный архив QEMU содержит подпроекты meson (keycodemapdb, berkeley-softfloat-3, berkeley-testfloat-3), а git-дерево — нет. Скачать их во время сборки нельзя, у сборки Nix нет сети, что всегда правильно и не только для Nix — никакого скачивания во время сборки! Qemu-source.nix скачивает их заранее при вычислении flake, а адреса и ревизии берет из файлов subprojects/*.wrap самого дерева. При переходе на другой коммит QEMU ревизии подтягиваются сами.
Version script. Форк libfuse экспортировал из API GUSE только guse_lowlevel_main, а бэкенд gpiodev вызывает еще guse_lowlevel_setup и guse_lowlevel_teardown. В итоге — undefined reference to guse_lowlevel_setup при линковке, и, благодаря | cat, пустой AppImage. libfuse.nix дописывает недостающие символы в lib/fuse_versionscript.
Не только libfuse, а любая библиотека и не одна
Это, в принципе, работает для любой зависимости: патченная glib, своя сборка libslirp или dtc, отладочная версия библиотеки только для одного бинарника. В qemu.nix достаточно передать ее в pkgs.qemu.override { <имя> = <своя сборка>; }, и она окажется в замыкании пакета QEMU, а следовательно, и в AppImage.
Что дальше
Размер! Можно попробовать dwarfs вместо squashfs и сжатие исполняемых файлов, но это палка о двух концах, так как сильно сжатые файлы будут медленнее запускаться, и это уже заметно на qemu.AppImage (не минимальном).
Итоги
Что в итоге получилось, если собрать всё вместе:
Один файл вместо инструкции по сборке: отправляется ссылка на AppImage, он делает
chmod +xи запускает QEMU на любом x86_64 Linux без root и без установки пакетов. Не нужно просить поставить зависимости или объяснять, какие флаги передатьconfigure. Символические ссылки делают образ неотличимым от обычногоqemu-system-*, так что существующие скрипты продолжают работать.Воспроизводимость по умолчанию: версии nixpkgs, бандлера, QEMU и libfuse закреплены во
flake.lock, поэтому один и тот же коммит репозитория дает одно и то же замыкание на любой машине.Подписанные образы: подпись в формате appimagetool понимают наш скрипт и стандартные инструменты AppImage.
Вероятные дешевые обновления: образы по 300 МБ обновляются дельтами через zsync, и
appimageupdatetoolскачивает только изменившиеся блоки. Но тут все не так просто, для этого надо прилагать усилия при компоновке образа.Зависимости ходят вместе с программой. Вследствие изоляции и особенностей линковки Nix мы можем быть уверены, что все зависимости присутствуют и при исполнении используются именно те, которые мы подразумевали изначально.
Недостатки:
Размер: образ получается больше статического бинарника, если нужен всего один эмулятор.
Конфликт с установленным nix: на хостах с собственным
/nix/storeAppImage работает некорректно.
Рецепт подходит не только для QEMU. Любую программу, которая собирается в nixpkgs, можно так же упаковать в один файл, подписать, обновлять дельтами и при необходимости подсунуть ей свою версию любой зависимости.
Благодарю Сергея Мирошниченко (@zergium) за ценные вопросы и замечания по материалу, а также Максима Москальца <moskalets@landau.one>, не только изучившего репозиторий с демкой, но и давшего ценные советы.
P. S.
Автор очень консервативен: он не рекомендует NixOS в качестве рабочей системы и не агитирует за ее использование повсеместно. На рабочей машине хочется предсказуемости, привычных путей и чтобы любая сторонняя программа запускалась без оберток. А NixOS со своим /nix/store и отсутствием FHS здесь скорее добавляет работы, чем убирает. Особенно это раздражает, если необходимо сделать chroot в rootfs NixOS.
А вот как система сборки BSP — очень хорошая замена для тех, кто устал от Buildroot или Yocto. Задачи у них одни и те же — собрать из исходников набор пакетов с заданными версиями и флагами, подменить в нем отдельные компоненты на свои форки и получить на выходе воспроизводимый результат.
Только Nix дает это «из коробки»:
Воспроизводимость. Все входы закреплены в
flake.lock, сборка идет в изолированной песочнице без сети, и одинаковые входы дают одинаковый результат на любой машине. В Buildroot и Yocto этого приходится добиваться дисциплиной и зеркалами исходников.Бинарный кеш. Все, что не меняли, скачивается готовым с cache.nixos.org, а пересобирается только то, что действительно изменилось. Сравните с первой сборкой Yocto, которая занимает полдня, и с
sstate-cache, который нужно где-то хранить и раздавать.Точечные изменения.
OverrideиoverrideAttrsпозволяют поменять флаги или исходники одного пакета, не форкая все дерево рецептов. Подмена libfuse только для QEMU из шага 4 в Yocto потребовала бы отдельного рецепта и аккуратной работы сDEPENDS, а в Buildroot была бы почти невозможна без второй копии пакета.Готовая база. Nixpkgs содержит десятки тысяч пакетов, которые уже собираются и в том числе кросс-компилируются (
pkgsCross).Один инструмент на всё: тем же Nix собирается и окружение разработчика (
nix develop), и CI (hydraJobs), и финальный артефакт. Нет разницы между тем, что собирается у разработчика и на сервере, причем это достижимо без контейнеризации.
Порог входа у Nix выше: язык непривычный, документация разбросана, ошибки загадочны, а простого официального способа поднять собственное зеркало для собранных пакетов не существует. Но если вы уже держите Buildroot или Yocto ради воспроизводимых сборок для встраиваемых систем, то на Nix стоит посмотреть именно в этой роли — как на систему сборки и атомарных обновлений, а не как на дистрибутив для рабочего стола.
MrBotikkk
Давно есть проект QEMU-AppImage.(get-dependencies.sh и make-appimage.sh) Всегда актуальные релизы под aarch64 и x86_64 QEMU: 11.1.2-1 Метод сборки Anylinux-AppImages.
maquefel Автор
Спасибо, проект хороший, если нужен готовый upstream QEMU.
Статья о другом: QEMU здесь только пример, а речь о том, как средствами Nix собрать AppImage из своего форка (у нас gpiodev) с патченной зависимостью (libfuse с GUSE) только для этого бинарника, с воспроизводимостью через flake.lock, подписью и обновлением. Готовый upstream-релиз такую задачу не закрывает, а get-dependencies.sh/make-appimage.sh пришлось бы допиливать под каждый форк и патч.
MrBotikkk
Понятно. Мне проще собирать и тестировать локальные образы с разными фичами в podman или docker. Удобно переносить между разными хостами. Не зависит от выбора OS
containers.conf
maquefel Автор
Контейнер тоже хороший вариант, особенно для сборки и тестов у себя. Разница в двух местах:
Воспроизводимость. apt-get update сегодня и через полгода даст разные версии зависимостей, а flake.lock фиксирует всё дерево, вплоть до компилятора. Пересобрать тот же самый бинарник через год можно без сохранённого образа.
Что получает пользователь. На выходе контейнера образ, и для запуска нужен podman/docker. AppImage это один файл, который запускается без рантайма контейнеров и без root, его можно просто передать коллеге или положить на стенд.
Плюс подмена одной библиотеки только для QEMU: в Dockerfile её придётся собирать вручную и следить, чтобы её не подхватили другие пакеты, а в Nix это одна строка override.
MrBotikkk
Не спорю про специфику Nix. Фичи
qemu/configure --helpменяются с версиями. AppImage из nix-qemu будет корректно работать на Debian, Ubuntu, Fedora и др.? https://gitflic.ru/project/maquefel/nix-qemu/releasemaquefel Автор
Да, на это и рассчитано. В образ уезжает всё замыкание из Nix, включая glibc и все библиотеки QEMU, поэтому от библиотек хоста он не зависит. Версия дистрибутива не важна. Требования к хосту такие:
x86_64 Linux;
Поддержка FUSE в ядре для монтирования образа (без него работает --appimage-extract-and-run);
разрешённые непривилегированные user namespaces, потому что QEMU запускается в bubblewrap;
Про configure --help: каждый релиз собирается из конкретной, осознанно выбранной версии QEMU. Версия исходников, набор опций (qemu.nix) и все зависимости закреплены в flake.lock. Мы не собираем «то, что лежит сейчас»: новая версия QEMU или новые фичи появляются только отдельным релизом, после явного обновления и проверки. Поэтому изменения в configure между версиями нас не застают врасплох, мы их видим при переходе на новую версию.