バイナリキャッシュを作ろう
バイナリキャッシュ
バイナリキャッシュは Nix の目玉機能の1つです。Nix のビルドの冪等性を利用し、実際にビルドを実行することなく、登録したバイナリキャッシュストアからビルド成果物を直接取得することができます。
詳細は以下の資料をご覧ください。
公式のバイナリキャッシュ
Nixpkgs は cache.nixos.org からバイナリキャッシュを提供しており、Nix はデフォルトでこのバイナリキャッシュストアを利用するように設定されています。Nixpkgs に登録されたパッケージは Hydra という CI システムでビルドされた後、AWS S3 でホストされたバイナリキャッシュストアに保存されます。
世界最大のオープンソースパッケージリポジトリである Nixpkgs のバイナリキャッシュストアは当然ながら非常に巨大で、2022年時点でホストされているオブジェクトは6億個超(合計425TiB)に上り[1]、2023年の S3 の月間コストは約14,500ドル[2]だったそうです。ヤバ…
Cachix
Cachix はバイナリキャッシュのホスティングサービスです。GitHub Actions や CircleCI など各種 CI システムをサポートしており、簡単にバイナリキャッシュを作ることができます。Nixpkgs 以外でバイナリキャッシュを提供している開発者はほとんどの場合 Cachix を利用しています。
バイナリキャッシュを作る
個人でバイナリキャッシュを提供する最も簡単な方法は Chacix を利用することですが、今回は自分で S3 バイナリキャッシュストアを作ってみましょう。実はバイナリキャッシュを作るのはそんなに難しいことではなく、Nix 本体と S3 互換のオブジェクトストレージがあれば簡単に作ることができます。
今回は、GitHub Actions と Cloudflare R2 を使ってバイナリキャッシュを作成する CI を構築します。完成物は以下のリポジトリにあります。
必要なもの
- Nix 2.24
-
Cloudflare R2
- お財布に優しいので採用
- その他の S3 互換オブジェクトストレージを使う場合は適宜読み替えてください
大まかな流れ
- パッケージをビルド
- 秘密鍵・公開鍵を生成
-
nix sign
でビルド成果物に署名 -
nix copy
でストアオブジェクトをバイナリキャッシュストア(Cloudflare R2)にコピー
パッケージの準備
ビルドするパッケージがないと話が始まりません。今回は比較的コンパイル時間の長い Rust 製のパッケージを用意してみました。諸々のファイルは完成物のリポジトリから引っ張ってきてください。
こんな感じのファイル構造になっています。
./
├── flake.lock
├── flake.nix
├── hello-server/
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── default.nix
│ ├── src/
│ │ └── main.rs
│ └── .gitignore
└── .gitignore
hello-server
http://localhost:3000 で Hello, World!
を返すシンプルな Web サーバーです。tokio と axum を依存に持つため、若干ビルドに時間がかかります。
[package]
name = "hello-server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7.9"
tokio = { version = "1.42.0", features = ["full"] }
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listen on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Nix 式
hello-server をビルドする Nix 式が以下です。callPackage パターンに従って書いています。
{ rustPlatform, ... }:
rustPlatform.buildRustPackage {
name = "hello-server";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
}
そしてこんな感じの flake.nix
を用意します。
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs =
inputs:
let
allSystems = [
"aarch64-linux" # 64-bit ARM Linux
"x86_64-linux" # 64-bit x86 Linux
"aarch64-darwin" # 64-bit ARM macOS
"x86_64-darwin" # 64-bit x86 macOS
];
forAllSystems = inputs.nixpkgs.lib.genAttrs allSystems;
in
{
packages = forAllSystems (
system:
let
pkgs = inputs.nixpkgs.legacyPackages.${system};
in
rec {
default = hello-server;
hello-server = pkgs.callPackage ./hello-server { };
}
);
};
}
nix run
でビルドできたら OK です。git add
を忘れないように![3]
CI を構築する
1. Cloudflare R2 のバケットの作成・トークンの発行
Cloudflare R2 のバケットを作成します。作成後、API トークンを発行して次の情報を控えておいてください。
- API エンドポイント
- ID
- トークン
ここら辺は公式ドキュメントを読みながらやってください。
2. 署名用の鍵の作成
バイナリキャッシュストアにストアオブジェクトを保存するには、nix store sign
を用いて対象のストアオブジェクトに署名する必要があります。署名用の鍵を作成しましょう。
まずは nix key generate-secret
を使って秘密鍵を作ります。言うまでもないですが漏洩しないよう細心の注意を払って管理してください。
鍵の名前は慣例的に cache.nixos.org-1
や nix-community.cachix.org-1
のような <バケットのドメイン>-<番号>
という名前をつけることが多いです。後ろの番号は万が一鍵を作り直すことになった際にインクリメントします。
nix key generate-secret --key-name <鍵の名前> > secret.key
生成した秘密鍵から対応する公開鍵を生成します。
nix key convert-secret-to-public < ./secret.key > ./public.key
ユーザーはこの公開鍵を Nix に登録し、バイナリキャッシュストアからダウンロードしたオブジェクトが正当なものかどうか検証します。どうやって公開鍵を Nix に登録するかは次で説明します。
3. flake.nix に nixConfig を追加
/etc/nix/nix.conf
または ~/.config/nix/nix.conf
には以下のような設定が記述されています。
# 省略
substituters = https://cache.nixos.org/
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
# 省略
substituters
はバイナリキャッシュストアのエンドポイント、trusted-public-keys
は対応する公開鍵です。Nix はパッケージをビルドする際に substituters
に登録された Nix ストアに照会をかけ、キャッシュが見つかった場合はそれをダウンロードします。
ユーザーは以下のように設定することで利用するバイナリキャッシュストアを追加できます。
substituters = <バイナリキャッシュストアA> <バイナリキャッシュストアB>
trusted-public-keys = <バイナリキャッシュストアAの公開鍵> <バイナリキャッシュストアBの公開鍵>
前述の方法でバイナリキャッシュストアを登録できますが、いちいち手動で設定を追加するのは面倒ですよね。実は flake.nix
で nix.conf
と同様の設定を行うことができます。
通常、Nix は /etc/nix/nix.conf
または ~/.config/nix/nix.conf
に記述された設定を読み込みますが、flake.nix
に nixConfig
という attribute を設定することで Flake 専用の設定を記述することができます。
以下のような設定を追加してください。
{
+ nixConfig = {
+ extra-substituters = [ "<バケットのエンドポイント>" ];
+ extra-trusted-public-keys = [ "<署名の公開鍵>" ];
+ };
# 省略
}
これでこの flake.nix
を評価した時に自動的にバイナリキャッシュを利用するようになります。
4. ワークフローの作成
先にワークフローの全体を載せておきます。
env:
AWS_PROFILE_NAME: builder
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
BINARY_CACHE_SECRET_KEY: ${{ secrets.BINARY_CACHE_SECRET_KEY }}
S3_API_ENDPOINT: ${{ secrets.S3_API_ENDPOINT }}
jobs:
copy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- name: Build package
run: nix build . --accept-flake-config
- name: Sign package with secret key
run: |
echo $BINARY_CACHE_SECRET_KEY > ./secret.key
nix store sign --recursive --key-file ./secret.key
- name: Configure AWS credentials
run: |
nix shell nixpkgs#awscli --command aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile $AWS_PROFILE_NAME
nix shell nixpkgs#awscli --command aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile $AWS_PROFILE_NAME
- name: Copy package
run: nix copy --to s3://nix-cache\?profile=$AWS_PROFILE_NAME\&endpoint=$S3_API_ENDPOINT\&compression=zstd
環境変数
リポジトリの設定からシークレットを登録します。
いくつかの環境変数の名前に AWS
という接頭辞がついていますが気にしないでください。 筆者が面倒がって Cloudflare R2 用に書き直さなかっただけです。
環境変数名 | 中身 |
---|---|
$AWS_PROFILE_NAME |
適当な名前 |
$S3_API_ENDPOINT |
Cloudflare R2 の API エンドポイント |
$AWS_ACCESS_KEY_ID |
Cloudflare R2 のアクセス ID |
$AWS_SECRET_ACCESS_KEY |
Cloudflare R2 の API トークン |
$BINARY_CACHE_SECRET_KEY |
生成した署名用の秘密鍵 |
Nix のインストール
DeterminateSystems が提供している action を利用します。
- uses: DeterminateSystems/nix-installer-action@main
ビルド
--accept-flake-config
オプションをつけると flake.nix
に設定された nixConfig
を利用できるようになります。デフォルトでこの挙動をしてほしい場合は、/etc/nix/nix.conf
または ~/.config/nix/nix.conf
に accept-flake-config = true
という行を追加してください。
- name: Build package
run: nix build . --accept-flake-config
後でバイナリキャッシュが効いているかどうか検証するためにつけておきます。
ストアオブジェクトに署名
nix sign
でビルド成果物に署名します。
- name: Sign package with secret key
run: |
echo $BINARY_CACHE_SECRET_KEY > ./secret.key
nix store sign --recursive --key-file ./secret.key
--recursive
オプションをつけることで、clousures(全ての実行時依存のストアオブジェクト)にも署名します。後で使う nix copy
は、対象のストアオブジェクトをコピーする際にその実行時依存も全てコピーする[4]ので、このオプションが必要になります。
バケットアクセス用の credentials の設定
Nix は基本的に AWS S3 を利用することを想定しているので、~/.aws/credentials
からバケットへアクセスするためのシークレット情報を読み取ります。ただのテキストファイルなので echo
などを使って書いてもいいですが、せっかく Nix を使っているので、nix shell
で awscli
をインストールして設定します。
- name: Configure AWS credentials
run: |
nix shell nixpkgs#awscli --command aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile $AWS_PROFILE_NAME
nix shell nixpkgs#awscli --command aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile $AWS_PROFILE_NAME
バイナリキャッシュストアへコピー
最後にビルド成果物をバイナリキャッシュストアにコピーします。
- name: Copy package
run: nix copy --to s3://nix-cache\?profile=$AWS_PROFILE_NAME\&endpoint=$S3_API_ENDPOINT\&compression=zstd
nix copy
では以下の特殊な形式の URL にクエリパラメーターを介していくつかのオプションを設定できます。
s3://nix-cache?profile=$AWS_PROFILE_NAME&endpoint=$S3_API_ENDPOINT&compression=zstd
-
profile
- 利用する credentials のプロファイル。今回は
awscli
で設定したもの。
- 利用する credentials のプロファイル。今回は
-
endpoint
- コピー先のバケットのエンドポイント
-
compression
- バイナリキャッシュの圧縮方式
-
xz
,bzip2
,gzip
,zstd
,none
を設定可能- 今回はより高速で圧縮できる
zstd
を採用
- 今回はより高速で圧縮できる
詳細なオプションは公式リファレンスを読んでください。
結果
GitHub Actions のログを見てバイナリキャッシュの効果を検証してみましょう。以下、私が作成したリポジトリの GitHub Actions のログを載せます。
1回目のビルド
バイナリキャッシュが存在しない初回の実行時間は次のようになりました。
- 全体: 2分7秒
- ビルド: 1分17秒
2回目のビルド
ワークフローを手動で再実行してみます。nix build
に --accept-flake-config
オプションをつけているので、前回作成したバイナリキャッシュを利用してくれるはずです。
結果、ビルド時間が大幅に短縮されました。
- 全体: 1分8秒
- ビルド: 20秒
ビルドログの最後の1行が building
から copying
に変化しています。
(中略)
building '/nix/store/d0dms08lf7l7y3c9wplv9dr2ch6ad1q3-hello-server.drv'...
(中略)
copying path '/nix/store/mnx7gwcszr2bbmi7nxhlppb3s15dibsa-hello-server' from 'https://cache.asa1984.dev'...
まとめ
意外と簡単にバイナリキャッシュを作れることが分かったのではないでしょうか。速さこそ正義なのでどこでも活躍できると思います。個人利用もいいですが、大規模なデプロイにバイナリキャッシュを利用して展開時間を高速化できたらかなりアツいですね。インフラ周りをやっている人に使ってみてほしいです。
いいことづくめなバイナリキャッシュですが、いくつか注意点もあります。
1つ目は、バイナリキャッシュのサイズです。まず前提として、全ての実行時依存を含めたストアオブジェクトが保存されるので、そこそこサイズが膨らみます。その上でソースコードの変更やコンパイラ・共有ライブラリの更新などを行うとストアパスが変化し、新しいバイナリキャッシュが保存されることになるので、無思慮にバイナリキャッシュを作成していると一気にバケットのサイズが増加します。
バケットのサイズの増加が気になる場合は、古いオブジェクトを削除するようなポリシーを作成するといいでしょう。
2つ目は、ビルド環境のプラットフォームです。今回はワークフローの実行環境に ubuntu-latest
(x86_64-linux) を使っているので、ARM CPU や macOS では私たちのバイナリキャッシュを利用できません。これは Nix のバイナリキャッシュに限った話ではありませんが、複数のプラットフォームに対応したい場合は、その分適切なビルド環境を用意しましょう。
今回、GitHub Actions の macos-latest
runner を利用して aarch64-darwin にもバイナリキャッシュを提供することも考えましたが、macos-latest
環境が少ないためか、ワークフロー実行までの待機時間が長すぎて断念しました。
以上を踏まえて面倒だな〜と思った人は Cachix の利用を検討するといいかもしれません。
余談: バイナリキャッシュ関連の面白いプロジェクト
magic-nix-cache
magic-nix-cache は、GitHub Actions 内でバイナリキャッシュを使えるようにする action です。GitHub Actions の cache API を利用して runner のローカルストアをキャッシュし、localhost でバイナリキャッシュサーバーを起動します。
外部公開はできないので GitHub Actions 内専用になります。筆者はこの action を利用して、CI 用の devShell の構築時間を短縮しています。
attic
attic は、Rust で実装されたバイナリキャッシュサーバーです。FastCDC を利用したチャンク分割やプライベートなバイナリキャッシュの作成など、機能が豊富です。
作者の zhaofengli 氏は、attic のホストには fly.io、DB には Neon、オブジェクトストレージには Cloudflare R2 を利用しているそうです。
余談: Nix の論文
Nix の開発者である Eelco Dolstra 氏の論文「Nix: A Safe and Policy-Free System for Software Deployment」「The Purely Functional Software Deployment Model」では、バイナリキャッシュが重要なコンセプトとして述べられています。
そもそも Nix は「正しいデプロイ」の実現を目的として開発されました。ここでの「デプロイ」はソフトウェアを対象のマシンに配置して利用可能にすることを意味しており、要はソフトウェアのインストールのことを指しています。
その上で重要な二項対立として、ソースコードデプロイとバイナリデプロイが挙げられています。ソースコードデプロイはソースコードを対象のマシンに送信してデプロイ先でビルドすること、バイナリデプロイは送信元で事前にビルドを実行し、ビルド成果物を対象のマシンに送信することを指しています。
バイナリデプロイはデプロイの最適化、つまりデプロイ時間の短縮を目的として行われます。ただし、トレードオフとして整合性を損なう可能性があります。バイナリインストールしたら上手くいかなかったので、代わりに手元でビルドしてインストールしたという経験がある人なら身に染みていると思います。
Nix が画期的だったのは、純粋関数的なビルドシステムがソースコードデプロイとバイナリデプロイを等価にした点です。ビルドが決定論的である以上、一からビルドしてもバイナリキャッシュから直接ビルド成果物をダウンロードしても結果が変わらなくなったのです[5]。
以上を踏まえると、Nix が安全性・完全性を目指した結果、副産物としてバイナリキャッシュが実現されたわけではなく、最初から前述の課題意識を持って厳密性・完全性を要求するビルドシステムが発明されていることが分かります。
-
NixOS Foundation's Financial Summary: A Transparent Look into 2022 - Meta / NixOS Foundation - NixOS Discourse ↩︎
-
NixOS Foundation Financial Summary : A Transparent Look into 2023 - Meta / NixOS Foundation - NixOS Discourse ↩︎
-
Git リポジトリ内に作成された Flake は Git を介してファイルを追跡するため、ステージされていないファイルをビルド環境に持ち込めない。詳細は「§3. Flakeを作る|Nix入門: ハンズオン編」参照。 ↩︎
-
故に、
nix copy
を使ってマシン A からマシン B のローカルストアにストアオブジェクトをコピーし、そのままマシン B で実行するという芸当ができる。 ↩︎ -
一応補足しておくと Nix はビットレベルでの同一性を保証するわけではないため、「実用的なレベルで」という注釈が入る。 ↩︎
Discussion