Flakes прочно закрепились в экосистеме Nix, хотя отношение к ним остаётся сдержанным: «так себе». Несмотря на повсеместное распространение, добавление flake input продолжает оставаться небольшой, но неисчезающей досадой. Федеративность flakes преподносится как достоинство, но на практике куда удобнее была бы простота централизованного flake — именно в этом заключалась красота и сила nixpkgs.

Процесс типичный: нужен disko — добавляешь url, затем follows, чтобы он перестал тащить за собой собственный nixpkgs, и повторяешь это для следующей зависимости. В сообществе Nix уже стало мемом то, как каждый flake волочёт за собой свой собственный flake-utils.
Такой пользовательский опыт было решено не принимать. Возник вопрос: может ли один flake нести в себе все остальные, чтобы можно было просто взять нужное? 🤯
Omniflake
omniflake — это тысячи Nix flake'ов за одним flake input.
inputs.omniflake.url = "github:fzakaria/omniflake";
inputs.omniflake.inputs.nixpkgs.follows = "nixpkgs";
После подключения omniflake используется как любой другой input.
Пакет — в shell или в системе:
environment.systemPackages = [
omniflake.flakes.nh.packages.${system}.default
];
Overlay:
nixpkgs.overlays = [ omniflake.flakes.rust-overlay.overlays.default ];
Модуль NixOS:
imports = [ omniflake.flakes.disko.nixosModules.disko ];
Или вообще без flake — прямо из командной строки:
$ nix run 'github:fzakaria/omniflake#flakes.nh.packages.x86_64-linux.default' -- --version
На сайте https://omniflake.com/ доступен список всех включённых flake'ов и небольшая документация.
После добавления доступ открывается почти к двенадцати тысячам flake'ов на момент написания, каждый из которых подгружается лениво по мере необходимости — платить приходится только за то, что реально используется.
Звучит абсурдно, но работает. Flake с тысячами input'ов теоретически должен быть неюзабельным, но благодаря ленивости языка Nix и механизму flake lock всё работает безупречно.
Как вообще возможно уместить внутри одного flake почти все существующие?
Очень короткий ликбез по flakes
Flake — это директория с файлом flake.nix, который декларирует две вещи: inputs (другие flake'и, от которых он зависит) и outputs (функция от этих input'ов).
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }: {
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
};
}
inputs описывает что нужно, но не строго: «nixos-unstable» — это подвижная ветка. Файл flake.lock рядом фиксирует, к какому именно коммиту это разрешилось, обеспечивая воспроизводимость сборки. Это то же самое, что package-lock.json в npm или Cargo.lock в Cargo.
Lock-файл фиксирует не только собственные input'ы — он закрепляет весь транзитивный граф для каждого flake. Это значит, что если два дочерних flake'а зависят от nixpkgs, в lock-файле у каждого окажется своя копия nixpkgs, и это могут быть разные коммиты.
{
"nodes": {
"root": { "inputs": { "agenix": "agenix", "nixpkgs": "nixpkgs" } },
"agenix": { "inputs": { "home-manager": "home-manager" },
"locked": { "rev": "5182...", "type": "github" } },
"home-manager": { "inputs": { "nixpkgs": "nixpkgs_2" }, ... },
"nixpkgs": { "locked": { "rev": "9fbb...", "type": "github" } },
"nixpkgs_2": { "locked": { "rev": "50ab...", "type": "github" } }
}
}
В этом примере есть два nixpkgs: nixpkgs и nixpkgs_2. Это то самое дублирование, на которое все жалуются, и ради этого существует follows: он перенаправляет зависимость на уже имеющийся узел вместо загрузки ещё одной копии. Это уменьшает граф, но сборка перестаёт использовать точно ту версию nixpkgs, против которой тестировал автор пакета.
Вот простой flake, использующий follows для унификации nixpkgs:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
disko.url = "github:nix-community/disko";
disko.inputs.nixpkgs.follows = "nixpkgs";
agenix.url = "github:ryantm/agenix";
agenix.inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs";
};
}
Сплошные стрелки — это input'ы, пунктирные — рёбра follows, схлопывающие граф к одному общему nixpkgs.
Input'ы ленивы
Красота значительной части «безумия» Nix в том, что это ленивый язык. Input, которого не касается ни один output, никогда не загружается.
Это можно доказать разрушительным способом: залочить flake с двумя input'ами, затем испортить одну запись в flake.lock так, чтобы она заведомо не могла разрешиться.
$ sed -i 's/2810303efc.../0000000000000000000000000000000000000000/' flake.lock
$ nix eval .#justB
[ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ]
Это вычислилось без проблем, хотя lock-файл содержал несуществующую ревизию. Ошибку выдаёт только принудительное обращение к отравленному input'у:
$ nix eval .#useA
error: unable to download '.../0000000000000000000000000000000000000000.tar.gz': HTTP error 404
Когда flake добавляется как input, весь его транзитивный граф копируется в lock как метаданные — ничего не загружается и не вычисляется.
$ time nix flake lock
• Added input 'mega'
• Added input 'mega/a' <- the poisoned one
• Added input 'mega/a/nixpkgs'
real 0m0.084s
Неудачный старт
Первая попытка создать гигантский единый flake заключалась в том, чтобы добавить каждый flake напрямую как input в flake.nix и залочить их все.
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
agenix.url = "github:ryantm/agenix";
agenix.inputs.nixpkgs.follows = "nixpkgs";
disko.url = "github:nix-community/disko";
disko.inputs.nixpkgs.follows = "nixpkgs";
home-manager.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs";
# … 11,000 more …
};
}
Неожиданно оказалось, что, несмотря на ленивость Nix, вычисление output'а, который ничего не затрагивает, становится очень медленным по мере роста числа input'ов. Проблема была не в вычислении: при первом создании lock-файла Nix всё равно вынужден вычислить каждый input.
При создании lock-файла каждому узлу требуется уникальное имя, и коллизии имён разрешаются добавлением суффиксов _2, _3 и так далее. Поиск свободного суффикса каждый раз начинался с _2, и при 1000 коллизий приходилось перебирать _2, _3, … вплоть до _1000.
Мегаflake гарантирует коллизии: каждый flake тащит собственный input systems, так что 4000 input'ов породили 3999 узлов с именами от systems_2 до systems_4000 — около 8 миллионов операций форматирования строк за одно вычисление. Это оказалось квадратичной сложностью по числу input'ов и было причиной замедления.
Исправление оказалось относительно простым: запоминать наибольший использованный суффикс для каждого имени и продолжать поиск с него. Патч был отправлен в NixOS/nix#16387, и получаемые lock-файлы побайтово идентичны прежним.
Никто раньше не писал настолько абсурдно большие flake'и, поэтому эта квадратичная стоимость оставалась незаметной. Исправление простое, а ускорение впечатляющее — примерно в 21 раз быстрее для 4000 input'ов.
Несмотря на этот фикс, подход в целом оказался тупиковым. Чтобы завершить создание flake.lock, Nix всё равно должен вычислить каждый input. Lock происходит по одному input'у за раз: для каждого загружается дерево, чтобы прочитать его flake.nix, и один input, который не удаётся залочить, обрывает весь процесс.1
Была предпринята попытка собрать lock-файл вне Nix напрямую из файлов flake.lock каждого input'а, но результат постоянно расходился с тем, что выдавал nix flake lock, и Nix заново перелочивал всё целиком.
Поэтому пришлось обратиться к исходному коду, чтобы понять, что именно потребитель делает с унаследованным lock'ом. Ответ оказался проще ожидаемого. Когда flake добавляется как input, Nix сверяет прямые input'ы этого flake с его flake.nix, а всё, что глубже, копирует в lock непрочитанным. При вычислении код, превращающий lock в flake, делает на каждый узел одно и то же: загружает закреплённое дерево, импортирует его flake.nix, вызывает outputs с input'ами, указанными в lock.
Это просто поиск в таблице и загрузка. Для этого совершенно не обязательно, чтобы flake'и были input'ами.
Flake'и — не input'ы
Оказалось, что от inputs можно вообще отказаться. flake.nix у omniflake декларирует всего пять input'ов: nixpkgs и четыре небольшие вспомогательные библиотеки.
Зато он включает таблицу закреплений (JSONL) для всех flake'ов. Каждая строка index.json — это тот же самый объект locked, что хранится в записи flake.lock, а это ровно то, что нужно builtins.fetchTree для загрузки дерева в режиме чистого вычисления:
{
"disko": {
"locked": {
"narHash": "sha256-RxWs…",
"owner": "nix-community",
"repo": "disko",
"rev": "ff8702b4…",
"type": "github"
}
}
}
Двенадцать тысяч строк, каждая из которых представляет отдельный flake, и их чтение ничего не стоит, пока конкретный flake не запрошен.
Когда запрашивается конкретный flake, например omniflake.flakes.disko, библиотека запускает небольшой загрузчик, воспроизводящий то, что сам Nix делает с lock-файлом.
Библиотека загружает закрепление, читает собственный flake.lock disko, загружает и импортирует каждый указанный в нём input, а затем вызывает outputs. Поддерживаются follows и любые другие возможности flake, поскольку это по сути то же вычисление flake, что делает сам Nix.2
Желание сохранять граф компактным и избегать дублирования удовлетворяется атрибутом overrides, который позволяет заменить любой input на свой собственный.
omniflake предоставляет атрибут flakes, в котором многие популярные flake'и уже сильно унифицированы (например, используют общий nixpkgs), а также набор атрибутов pinned — каждый flake ровно в том виде, в каком его задумал автор.
# nixpkgs и четыре библиотеки — свои
omniflake.flakes.disko
# всё ровно так, как залочил автор disko
omniflake.pinned.disko
# своя политика
omniflake.lib.withOverrides { nixpkgs = nixpkgs-stable; }
Список включённых flake'ов собирается автоматическим скрапингом GitHub и регулярно обновляется, как и каждый отдельный flake.
Не прокляты ли мы этим?
Вероятно, да. Но механизм работает, а удобство именно то, которое хотелось получить — больше никогда не придётся думать о добавлении очередного flake input вручную.
Производительность на удивление хороша, потому что платить приходится только за реально используемое. После добавления omniflake команда nix flake lock занимает около 1,5 секунды, наследует всего шесть узлов и не загружает ни один из тысяч flake'ов, стоящих за ними:
$ time nix flake lock
real 0m1.5s
$ nix eval github:fzakaria/omniflake#lib.count
11975
Мне нравится идея федерации, но хочется простоты централизации. Как пользователь Nix, я хочу и то, и другое сразу: omniflake — мой торт, который можно съесть.