Я системный программист и в своей работе использую NixOS на хостах и Yocto для встраиваемых устройств. В NixOS меня раздражало прежде всего расположение программ и библиотек в /nix/store. Оно изолирует зависимости и позволяет разным версиям сосуществовать, но готовые программы, рассчитанные на обычное FHS-окружение, часто требуют buildFHSEnv, nix-ld или другой адаптации. В Yocto меня раздражала система конфигурации — нетипизированная лапша из строковых переменных, shell-кода, фрагментов Python, неочевидных правил присваивания и соглашений, о которых нужно просто знать.
В феврале 2026 года я захотел попробовать этот ваш вайбкодинг на реальной задаче. Мне хотелось поэкспериментировать с устройством билд-систем и заодно разобраться с Nickel. В итоге получился Bobr — билд-система для Linux, способная работать без root. Сейчас она умеет собирать программы, ядро, initramfs и rootfs, а также запускать минимальную или GNOME-систему в QEMU. Всю гостевую систему при желании можно пересобрать из исходников, но обычно в этом нет необходимости: подобно тому как NixOS получает готовые результаты сборки из binary cache, Bobr умеет брать их из удалённых репозиториев. Один такой подписанный репозиторий я поддерживаю в открытом доступе. Всё это уже можно попробовать по короткой инструкции Getting Started. Сама гостевая система пока остаётся proof of concept: до сих пор основная работа была сосредоточена на билд-системе.
Начиналось всё с простых экспериментов; я не думал, что могу предложить что-то стоящее на замену Nix и Yocto. Сначала я попробовал описывать сборку процедурно, как программу с эффектами. В первом прототипе Nickel-программа строила вычисление из return, bind и примитивных действий, выражающих эффекты системы сборки, а отдельный интерпретатор на Rust выполнял его. Идея была абсолютно безумной, но тогда она мне нравилась. Быстро выяснилось, что такой подход вызывает много проблем, и я решил не выпендриваться и примерно следовать тому, что есть в Nix.
Рецепты и builders
Затем мне как-то сразу стало понятно, что для исходных текстов должны быть отдельные рецепты. Рецепты образовали DAG: его листьями стали рецепты исходников, а входами остальных рецептов — ссылки на другие рецепты.
Но кто, собственно, исполняет рецепт? Так появились builders — внутренние компоненты Bobr, каждый из которых умеет построить один объект. Поле tag выбирает один из доступных builders, config задаёт его конфигурацию, а inputs ссылается на другие рецепты. При выполнении builder получает конфигурацию и объекты этих входов. Исключение — Source: у него нет ни config, ни входов; рецепт заранее объявляет ObjectHash и может задать origin — способ получить объект из локального пути, по HTTP или из OCI registry, если его ещё нет в store.
У типичного рецепта входами сначала были исходники, патчи и скрипты. Позже стало ясно, что файловая система, в которой выполняется сборка, тоже должна быть входом. Так сборочное окружение стало явной частью рецепта, а все описанные им зависимости оказались представлены в DAG. Объекты входов и полученный результат стали храниться в store под своими хешами.
В Nix определения пакетов обычно являются функциями, возвращающими derivations; для их изменения используются makeOverridable, override и overrideAttrs. В Bobr я решил оставить рецепт обычной записью Nickel, которую overlay может непосредственно дополнить или изменить. Эта же модель позволила ввести synthetic builders: их tag обозначает высокоуровневую конструкцию вроде Autotools, которую Nickel перед передачей request в Bobr раскрывает в DAG из настоящих builders.
Identities
Каждый полученный объект хранится в store под своим ObjectHash, который вычисляет fsobj-hash. Обычные объекты хранятся там как файлы и каталоги, непосредственно доступные пользователю. Поэтому ObjectHash учитывает только свойства, имеющие значение в таком представлении: содержимое, структуру каталогов, цели символических ссылок и executable bit. uid, gid, timestamps и остальные mode bits в идентичность объекта не входят. Для неисполняемого файла ObjectHash совпадает с SHA-256 его содержимого. Но ObjectHash результата обычного builder становится известен только после сборки, а понять, не собирали ли мы уже этот рецепт, нужно заранее.
Модель идентичности Bobr исходит из того, что результат рецепта определяется его tag, версией реализации выбранного builder, config и именованными входами. Поэтому идентичность рецепта можно вычислить до его выполнения:
BuildKey = hash(tag, builder version, config, {имя входа: BuildKey входа})
Для Source результат известен заранее, поэтому его BuildKey совпадает с объявленным ObjectHash. Для остальных рецептов store запоминает соответствие BuildKey -> ObjectHash. Если оно уже есть, Bobr берёт готовый объект, не получая объекты входов рецепта.
Но разные рецепты иногда производят одинаковые входные объекты. Поэтому при отсутствии точного соответствия Bobr сначала определяет ObjectHash каждого входа, а затем вычисляет ещё один ключ:
ReuseKey = hash(tag, builder version, config, {имя входа: ObjectHash входа})
Если соответствие ReuseKey -> ObjectHash уже известно, builder тоже можно не запускать. BuildKey идентифицирует рецепт вместе с графом его зависимостей, а ReuseKey позволяет переиспользовать результат, если этот граф изменился, но фактические именованные входные объекты остались теми же.
Bootstrap, sandbox и fs-trees
Поначалу bootstrap примерно следовал Linux From Scratch. Первый sandbox builder был простой обёрткой над Podman, а отправной точкой служил готовый OCI-образ. По мере появления собственных инструментов следующим стадиям требовались образы уже с ними. Перекомпоновка OCI-образов для каждого нового окружения оказалась настолько медленной, что в какой-то момент я решил всё бросить. Выходом стало отделить файлы целевой файловой системы от описания её дерева: сами файлы хранить отдельно, а нужное дерево собирать из них по текстовому manifest. Так появились fs-files и fs-trees.
Каждый регулярный файл сохраняется отдельно в fs-files и адресуется своим FsFileHash. ObjectHash, вычисляемый fsobj-hash, описывает отдельный файл или рекурсивно целый каталог; из файловых метаданных он учитывает только executable bit. FsFileHash, напротив, всегда описывает ровно один регулярный файл и включает его содержимое, полный mode и логические uid и gid целевой системы.
Результат SandboxInstall — обычный объект Bobr: канонический текстовый manifest. Для регулярного файла в нём записаны путь и соответствующий FsFileHash; метаданные каталогов и символических ссылок находятся в самом manifest. Вместе manifest и указанные в нём fs-files описывают файловое дерево.
Когда builder должен получить настоящий каталог, Bobr материализует manifest под fs-trees/<ObjectHash>: создаёт каталоги и символические ссылки, а для регулярных файлов создаёт жёсткие ссылки из fs-files. Поскольку жёсткая ссылка ссылается на тот же inode, uid, gid и mode регулярного файла сохраняются без восстановления из manifest. Sandbox использует материализованное дерево как read-only основу, а изменения файловой системы записывает в отдельный overlay.
Позже оказалось, что текстовые manifests легко объединять, фильтровать и перестраивать, не открывая сами fs-files. Так появились split outputs: например, manifest glibc_libs содержит только библиотечные entries полного результата glibc и ссылается на подмножество его fs-files.
С появлением fs-trees больше не требовалось перекомпоновывать OCI-образ для каждого окружения и запускать builders через универсальный container runtime. Bobr запускает builder в собственном небольшом sandbox runtime, используя rootfs с точным набором нужных файлов и материализуя его только при необходимости. Всё это может работать без root: user namespace отображает логические uid и gid целевой системы в uid пользователя и его subordinate ranges на хосте.
Сначала результат компиляции устанавливался в отдельный каталог наподобие $out в Nix. Собственный sandbox позволил устанавливать его прямо в writable overlay поверх rootfs — в обычные абсолютные FHS-пути вроде /usr и /lib. Результатом SandboxInstall становится manifest добавленного слоя. Это упростило рецепты и позволило таким же способом устанавливать заранее собранные программы: внутри sandbox их можно сразу разложить по ожидаемым FHS-путям. Установка пакета в отдельный staging directory сохранилась там, где она необходима, например, на некоторых стадиях bootstrap.
Рецепт явно перечисляет две группы зависимостей. deps.build добавляет в сборочное окружение инструменты, заголовки и библиотеки; deps.runtime описывает, что должно сопровождать результат в целевой системе. Bobr не пытается выводить эти зависимости из готовых исполняемых файлов.
Большинство таких рецептов используют synthetic builders вроде Autotools или Cargo. Перед передачей request в Bobr Nickel lowering раскрывает их в DAG: добавляет узлы для сборки rootfs из runtime closures зависимостей и узел настоящего sandbox builder.
Store и репозитории
Рабочий store содержит объекты, fs-files и соответствия BuildKey -> ObjectHash и ReuseKey -> ObjectHash. Кроме него Bobr может использовать другие локальные stores и удалённые репозитории. Если Bobr находит соответствие ключа готовому ObjectHash, он получает объект и его полное содержимое из доступных источников и переносит их в рабочий store; иначе запускает builder.
Поиск соответствий и получение содержимого в Bobr независимы. Локальный store или удалённый репозиторий может отвечать на запросы BuildKey -> ObjectHash и ReuseKey -> ObjectHash, отдавать объекты и fs-files по их хешам либо делать и то и другое. Поэтому соответствие можно получить из одного источника, а проверяемое по хешу содержимое — из другого.
В удалённом репозитории все данные неизменяемы; обновляется только подписанный master, указывающий на актуальные наборы metadata. Клиент принимает mappings только через master, проверенный заранее доверенным ключом, а каждый скачанный объект проверяет по ObjectHash или FsFileHash. На этой схеме работает и общедоступный репозиторий Bobr, упомянутый в начале статьи.
Host bundles
Чтобы запускать программы, собранные Bobr, прямо на хосте, я сделал ещё один эксперимент — host bundles. Такой bundle содержит программу и её объявленные runtime-зависимости. Обычный динамический ELF ожидает loader по абсолютному пути на хосте, поэтому точкой входа служит статически собранный launcher: он находит внутри bundle glibc loader и запускает через него программу, передавая пути к библиотекам bundle. Bundle можно запускать прямо из store или целиком скопировать в другое место; Bobr, root и container runtime для этого не нужны.
Host bundle не является контейнером и не изолирует программу: она использует ядро, устройства, сеть и домашний каталог хоста. Например, QEMU bundle содержит сам QEMU, его библиотеки и готовые boot images.
Пока это proof of concept. Для простых программ эта схема работает, но неясно, удастся ли сохранить её простой для приложений с plugins, drivers, дочерними процессами и интеграцией с host services. Идея мне нравится, но её жизнеспособность покажут только дальнейшие эксперименты.
TODO
Готовые рецепты и системные образы пока поддерживают только x86-64; поддержку других архитектур ещё предстоит добавить.
Главное, чего пока недостаёт рецептам, — нормальное конфигурирование целевой системы. Сейчас три готовых образа задаются простым перечислением входящих в них пакетов и замыканием по deps.runtime. Я хочу заменить это типизированной модульной конфигурацией вроде NixOS: она будет описывать состав системы, сервисы и их настройки, а Bobr — собирать по ней полностью определённую файловую систему, включая /etc.
Поскольку материализованный fs-tree состоит из жёстких ссылок на общие файлы в store, монтировать его нужно read-only. В перспективе небольшой initramfs сможет смонтировать выбранный fs-tree прямо из store как idmapped mount: файлы, созданные rootless-сборкой с subordinate UID/GID, будут видны целевой системе с обычными владельцами root, daemon и так далее. После этого останется подмонтировать изменяемое состояние и выполнить switch_root. Обновление сведётся к сборке нового объекта и выбору его при следующей загрузке; потенциально userspace можно будет переключать и без перезапуска ядра через soft reboot.
Отдельная большая задача — систематически пересмотреть весь написанный моделями код. Некоторые его части подробно ревьюились по ходу работы, но многие не ревьюились вообще.
Опыт вайбкодинга
Я использовал в основном модели OpenAI и немного Anthropic. Принципиальной разницы в возможностях я не увидел, но по стилю они различаются: модели Anthropic я бы сравнил с молодыми самоуверенными энергичными программистами, а OpenAI — с прямолинейными педантичными сеньёрами-интровертами.
Вайбкодинг оказался захватывающим занятием. Главный вывод: модели пока не могут самостоятельно проектировать архитектуру. Нужно делать это вместе с ними в диалоге и поручать реализацию только после того, как всё становится на свои места, — по одному модулю за раз. В общем, «всё как у людей».

