プログラミング
すべてのNix flakeを支配する一つのNix flake
One Nix flake to rule them all (fzakaria.com)
要約
この記事では、Nixの「Flakes」機能における依存関係管理の煩雑さを解消するための「Omniflake」というアプローチを紹介しています。Omniflakeは、数千ものNix flakeを単一のflake入力として集約し、ユーザーは必要なflakeを遅延評価で利用できます。これにより、各flakeが独自のnixpkgsインスタンスをプルして依存関係が肥大化する問題を回避し、シンプルで中央集権的な管理を実現します。記事では、Flakesの基本的な仕組み、依存関係の重複問題、そしてOmniflakeがどのようにしてこの問題を解決しているのかを、技術的な詳細を交えて解説しています。
全文翻訳
Flakesは間違いなく定着しており、私の意見はよく知られています。「まあまあ」です。それらの遍在性にもかかわらず、flake入力の追加は、決して消えることのない小さな煩わしさであり続けています。Flakesがフェデレーションされていることは肯定的な機能として謳われていますが、現実は私が集中化されたflakeのシンプルさを求めているということです。それがnixpkgsの美しさと力でした。
プロセスは次のとおりです。diskoが必要なので、URLを追加し、次に独自のnixpkgsを引き込まないようにfollowsを追加し、次に次のものに対してそれを繰り返します。これは、すべてのflakeが独自のflake-utilsを引き込むというNixコミュニティのミームになりました。私はこのユーザーエクスペリエンスを受け入れることを拒否します。
そこで私は疑問に思いました:一つのflakeが他のすべてのflakeを運ぶことができ、あなたは必要なものをそこから取り出すだけではないか?🤯
Omniflake
Omniflakeは、一つのflake入力の背後にある数千のNix flakeです。
inputs.omniflake.url = "github:fzakaria/omniflake";
inputs.omniflake.inputs.nixpkgs.follows = "nixpkgs";
Omniflakeを入手したら、他の入力と同様に使用できます。
パッケージ(シェル内またはシステム上):
environment.systemPackages = [ omniflake.flakes.nh.packages.${system}.default ];
オーバーレイ:
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のリストと役立つドキュメントを確認できます。
一度追加すると、現在約12,000のflakeにアクセスでき、必要に応じて遅延的に利用できます。使用した分だけ支払います。
これはばかげているように聞こえますが、機能します。数千の入力を持つflakeは使用不可能になるはずですが、Nix言語の遅延性とflakeロックメカニズムのおかげで、それは完璧に機能します。
この中にほぼすべてのflakeを含めることはどのように可能なのでしょうか?
Flakesの非常に短い入門
flakeは、入力(依存する他のflake)と出力(それらの入力の関数)の2つを宣言するflake.nixを持つディレクトリです。
{
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は、それが解決された正確なコミットを示しているため、ビルドは再現可能です。それはnpmのpackage-lock.json、またはCargo.lockです。
ロックファイルは、あなた自身の入力だけをピン留めするわけではありません。それはすべてのflakeのトランジティブグラフ全体をピン留めします。つまり、2つの子flakeが両方ともnixpkgsに依存している場合、ロックファイルにはそれぞれ独自の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_2の2つのnixpkgsがあります。これは皆が不満を言う重複であり、followsが存在する理由です:followsは、依存関係を既に持っているノードを指すように書き換えます。これによりグラフサイズは削減されますが、作者がテストしたnixpkgsの正確なものに対してビルドされなくなります。
followsを使用してnixpkgsを統合するシンプルなflakeを次に示します。
{
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";
};
}
follows
あなた
flake
disko
あなた->disko
agenix
agenix
あなた->agenix
npkgs
nixpkgs
disko->npkgs
follows
hm
home-manager
agenix->hm
hm->npkgs
follows
実線は入力、破線はfollowsエッジが1つの共有nixpkgsに折りたたまれていることを示します。
入力は遅延的です
Nixにおける多くのクレイジーさの美しさは、それが遅延言語であることです。出力が触れない入力は決してフェッチされません。破壊的に証明できます:2つの入力を持つflakeをロックし、flake.lockの1つのエントリを破損させて解決できないようにします。
$ sed -i 's/2810303efc.../0000000000000000000000000000000000000000/' flake.lock
$ nix eval .#justB
[ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ]
これは存在しないリビジョンを含むロックに対して正常に評価されました。毒された入力を強制した場合のみエラーが発生します。
$ nix eval .#useA
error: unable to download '.../0000000000000000000000000000000000000000.tar.gz': HTTP error 404
flakeを入力として追加すると、そのトランジティブグラフ全体がロックにメタデータとしてコピーされます。何もフェッチされず、何も評価されません。
$ time nix flake lock
• Added input 'mega'
• Added input 'mega/a'
• Added input 'mega/a/nixpkgs' <- the poisoned one
real 0m0.084s
偽のスタート
巨大な単一flakeを作成するための私の最初の試みは、すべてのflakeを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の遅延性にもかかわらず、何も触れない出力を評価するのが、入力が増えるにつれて非常に遅くなりました。コストは評価ではありませんでした。しかし、ロックファイルを最初に作成するために、Nixはすべての入力を評価する必要がありました。Nixがロックファイルを作成するとき、すべてのノードは一意の名前を持つ必要があり、名前の衝突は_2、_3などを追加することで解決されます。コードは毎回_2から検索を開始し、1,000の衝突があった場合、_2、_3、…_1000を試します。
メガフレークは衝突を保証します:すべてのflakeは独自のシステム入力を持ち込むため、4,000の入力は3,999のノード(systems_2からsystems_4000)を生成し、評価あたり約800万の文字列フォーマットを生成します。これは入力数の二次関数的な時間複雑性であることが判明し、それが減速の原因でした。修正は比較的簡単でした:名前ごとに使用された最大のサフィックスを記憶し、そこから再開します。NixOS/nix#16387を提出し、それが書き込むロックファイルは以前とバイト単位で同一です。
1980-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.10.5, https://matplotlib.org/
誰も馬鹿げたほど大きなflakeを書いていなかったため、この二次的なコストは目に見えませんでした。修正は比較的簡単で、速度は劇的でした:4,000の入力で約21倍高速になりました。
この修正にもかかわらず、これは偽のスタートであることが判明しました。flake.lockファイルを作成するには、Nixはすべての入力を評価する必要があります。Nixは一度に1つの入力をロックし、各ツリーを取得してそのflake.nixを読み込み、1つのロックできない入力が実行を中止します。11私はNixの外で、各入力のflake.lockファイルからロックファイルを組み立てようとしましたが、nix flake lockと常に一致せず、Nixにロック全体をやり直させました。そのため、ソースにアクセスして、コンシューマーが継承されたロックをどのように扱うかを見つけました。答えは私が想定していたよりも少ないです。flakeを入力として追加すると、Nixはそのflakeの直接の入力をそのflake.nixに対してチェックし、それより深いすべてを未読のままロックにコピーします。そして評価するとき、ロックをflakeに変換するコードはノードごとに1つのことを行います:ピン留めされたツリーを取得し、そのflake.nixをインポートし、ロックが名前を付ける入力で出力を呼び出します。それはテーブルルックアップとフェッチです。それのどれもflakeを入力として必要としません。
Flakesは入力ではありません
実際には、入力全体を回避できることがわかりました。omniflakeのflake.nixは、nixpkgsと4つの小さな他のライブラリという5つの入力のみを宣言しています。それには、すべてのflakeのピンのテーブル(JSONL)が含まれています。index.jsonの各行は同じロックです。