• 作成:
  • 更新:

Atticを利用して個人用にNixのキャッシュサーバを自宅サーバに構築する

背景

Nixはその個人やプロジェクトに合わせたキャッシュサーバの有無で快適性が大違いになります。

業務で使う分には、 Cachix - Nix binary cache hosting を契約して利用しています。

個人でもオープンソースプロジェクトでは無料で5GBまで使って良いそうなので、プロジェクトごとにflake.nixに設定して、 GitHub ActionsでCIする時にpushするように設定して、便利に利用しています。

機能面でCachixに不満はほぼありません。せいぜいはGitHubと連携した時に利用許可メンバーの同期が不安定なことぐらいでしょうか。その時は数人追加すれば良いだけなのでメールアドレスを手動で追加しましたが。

Cachixの大きな問題は有料プランの一番安いものがStarterの月額50ユーロであることです。 1ユーロが約170円の現在では月額約8720円になります。個人で支払うにはちょっと高すぎますね。仕事なら作業している間の人件費とか考えると使うのが合理的な判断ですが。

まあ自分が個人で開発するのは特に理由がなければオープンソースソフトウェアなので、実はCachixの無料枠でほとんど十分ではあるのですが。

今現在Cachixでキャッシュを完全にカバーできていないのは、 ncaq/dotfiles: dotfiles, NixOS and home-manager. です。

dotfilesのキャッシュが不十分なのは、 OS全体のソフトウェア情報をビルドするだけにダウンロードするので、 GitHub ActionsのCIで全体を一回ビルドしようとすると、ストレージのサイズがランナーの制限を超えてしまうからです。なのでnix flake checkで一通りのNixの正しさだけを確認するようになっています。

またGitHub ActionsのCIはUbuntuで動くので、 home-manager向けの設定はビルドしてインストールをdry runできますが、 NixOS向けの設定はビルドできません。 QEMUとかで頑張ればNixOSで動かすことも出来るかもしれませんが、今のところそこまで頑張る気にはなれていません。みんなが使うソフトウェアならともかく、自分のdotfilesは自分が困るだけなので。

各端末が全てのビルド結果を単一のキャッシュサーバにpushすれば、他の端末や他のOSでのビルド時に二回目のビルドは必要ありません。それは雑に使いまわしても怒られにくい個人用のキャッシュサーバがあれば達成できます。

そして最近自宅サーバを再構築して、 bcacheを使ってSSDをキャッシュにする、それなりの大容量になったRAID 1 HDDシステムを構築しました。存在するリソースは有効に使いたいという気持ちもあります。

Nixのキャッシュサーバを構築するソフトウェア

今回は、 zhaofengli/attic: Multi-tenant Nix Binary Cache を使用することにしました。

他の選択肢としては、

などがあるようですが、 atticはcachixと同じようにトークンでアクセスを管理できるのが魅力的だったので、 atticを選ぶことになりました。他のソフトウェアは基本的にBASIC認証でアクセスを管理するようで、 BASIC認証もちゃんと設定すれば安全だとは思いますが、ちゃんと設定できるかどうか不安だったので、それぞれの端末にトークンを発行して管理できるatticを選びました。

PostgreSQLの設定

あくまでキャッシュサーバで吹き飛んでもさほどの問題はないデータなので、データベースにはサーバの共有のPostgreSQLを利用することにしました。

以下のようにPostgreSQLのユーザとデータベースを作成しました。

{ pkgs, ... }:
{
  services.postgresql = {
    # 雑に使えるサーバグローバルのPostgreSQLサーバを有効にしておきます。
    enable = true;
    # PostgreSQLのバージョンによって`dataDir`などが変更されます。
    # `stateVersion`依存でPostgreSQLのバージョンは定まります。
    # しかし忘れて全体をアップデートして壊れたりするのが嫌なので明示的に指定しておきます。
    # JITコンパイラは必要かわかりませんが、単純なクエリには使われないらしくデメリットが薄いそうなので、雑に有効にしておきます。
    package = pkgs.postgresql_17_jit;
    # サービス側で追加する方が良いかもしれませんが、
    # ここでデータベース一覧をまとめるメリットもあるのでこちらでの定義を選択します。
    ensureDatabases = [ "atticd" ];
    ensureUsers = [
      {
        name = "atticd";
        ensureDBOwnership = true;
      }
    ];
  };
}

atticdの設定

atticのサーバ側のデーモンは以下のように設定しました。

{ username, ... }:
{
  services.atticd = {
    enable = true;
    # ```
    # echo -n 'ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64="'|sudo tee /etc/atticd.env
    # openssl genrsa -traditional 4096|base64 -w0|sudo tee -a /etc/atticd.env
    # echo '"'|sudo tee -a /etc/atticd.env
    # sudo chown atticd: /etc/atticd.env && sudo chmod 640 /etc/atticd.env
    # sudo systemctl restart atticd
    # ```
    environmentFile = "/etc/atticd.env";
    settings = {
      listen = "[::]:10000"; # ポート番号は雑に定めました深く考えていません
      allowed-hosts = [ "nix-cache.ncaq.net" ];
      api-endpoint = "https://nix-cache.ncaq.net/";
      database.url = "postgresql:///atticd?host=/run/postgresql";
      storage = {
        type = "local";
        path = "/mnt/noa/atticd";
      };
      # chunkingはNixの設定でデフォルトが定まっているので任せます

      # compressionはbtrfsがバックエンドであることを考えると
      # むしろ明示的に無効にしておいたほうがストレージ効率は良いですが
      # ネットワーク通信のことを考えると有効にしておいたほうが良いかもしれません
      # デフォルト値に任せます

      # ガベージコレクションはデフォルトに近い緩めの値を設定しておきます
      garbage-collection = {
        interval = "1 day";
        default-retention-period = "6 months";
      };
    };
  };
  users.users.atticd = {
    isSystemUser = true;
    group = "atticd";
  };
  users.groups.atticd = {
    members = [ username ];
  };
  systemd.tmpfiles.rules = [
    "d /mnt/noa/atticd 0755 atticd atticd -"
  ];
  # 管理トークン発行例
  # ```
  # TOKEN="$(sudo atticd-atticadm make-token --sub 'seminar' --validity '4y' --pull 'private' --push 'private' --create-cache 'private')"
  # ```
  # 読み書きトークン発行例
  # ```
  # TOKEN=$(sudo atticd-atticadm make-token --sub 'bullet' --validity '4y' --pull 'private' --push 'private')
  # ```
  # トークンを利用してログインします
  # ```
  # attic login ncaq https://nix-cache.ncaq.net/ "$TOKEN"
  # ```
}

これをインストールした後コメントに書いているようにコマンドを実行して乱数シードを生成したり、クライアント用のトークンを発行して各端末に保存します。

Cloudflare Tunnelの設定

自宅サーバはCloudflare Tunnelでインターネットに公開しているのでatticもCloudflare Tunnelを通して通信させたのですが、

以下のissueに書いた通り、 RequestError: General request error: Bad NAR Hash or Size occurs and prevents pushing · Issue #281 · zhaofengli/attic エラーになってしまいました。

ですがこのissueで作者様から回答を頂き、以下のようにcloudflareの設定でhttp2で通信するように設定したら解決しました。

{
  pkgs,
  username,
  ...
}:
let
  # workaround script that adds --protocol http2 flag to tunnel command.
  cloudflaredWrapper = pkgs.writeShellScriptBin "cloudflared" ''
    # Check if this is a tunnel command
    if [[ "$1" == "tunnel" ]]; then
      # Insert --protocol http2 before other tunnel arguments
      exec ${pkgs.cloudflared}/bin/cloudflared "$@" --protocol http2
    else
      # For non-tunnel commands, pass through as-is
      exec ${pkgs.cloudflared}/bin/cloudflared "$@"
    fi
  '';
in
{
  # To initialize, run in server:
  # ```
  # nix run 'nixpkgs#cloudflared' -- tunnel login
  # ```
  # copy credentialsFile from terraform client to server.
  services.cloudflared = {
    enable = true;
    package = cloudflaredWrapper;
    certificateFile = "/home/${username}/.cloudflared/cert.pem";
    tunnels.seminar = {
      default = "http_status:404";
      credentialsFile = "/home/${username}/.cloudflared/tunnel-seminar.json";
      ingress = {
        "nix-cache.ncaq.net" = "http://localhost:10000";
      };
    };
  };
}

ただこの解決方法はあまりにも乱暴すぎると思うので、 nixpkgsの方のcloudflaredのパッケージにちゃんとオプションとしてプロトコル指定が出来るように設定を追加しようと思います。

また大概のエラーはなくなりましたが、 Cloudflare Tunnelの無料版の容量制限の1リクエスト100MBの制限が原因で、サイズが大きいキャッシュはアップロードできません。

Cloudflare Proも月額25ドルだそうなので、会社ならともかく個人ではこれも躊躇してしまう料金ですね。

こちらに関してはatticにPRを出して、複数のHTTPリクエストで分割アップロード出来るように拡張することを検討中です。

とりあえず現状のnix flake checkがローカルだと失敗するので、それを修正するPRを出しました。テストが壊れていると自分の修正が正しいか判別しづらいですからね。 test: fix local nix flake check permission error by ncaq · Pull Request #288 · zhaofengli/attic

クライアント側で常時キャッシュサーバにpushする

atticのクライアント側でwatch-storeを動かして、バックグラウンドで常時全てのビルド結果をキャッシュサーバにpushするようにします。

最初はNixOSレベルのサービスで動かそうかと思ったのですが、 atticの認証情報は~/.config/attic/config.tomlに保存されるので、このファイルを読み込むためだけにホームディレクトリに依存するが、他のホームディレクトリにアクセスしないというsystemdのサービスを作るのが非常に面倒だったので諦めました。 ReadOnlyPathsでアクセスできるパスを単純に制限するぐらいで収めることにしました。ユーザレベルのサービスのほうがまだ出来ることが少なくて安全ですしね。

{
  pkgs,
  ...
}:
{
  # 全てのビルド結果を非同期にプライベートキャッシュにpushします。
  systemd.user.services.attic-watch-store-ncaq-private = {
    Unit = {
      Description = "Attic Binary Cache Auto-Push Service for ncaq:private";
      # ネットワークなどの準備が整ってから起動します。
      After = [
        "network-online.target"
        "nix-daemon.service"
      ];
    };

    Service = {
      # クラッシュしてもしばらく後に再起動します。
      # 必須のサービスではないので間隔は長めです。
      Restart = "on-failure";
      RestartSec = "15s";

      # 必要なパスのみアクセス許可します。
      ReadOnlyPaths = [
        "/nix/store"
        "%h/.config/attic"
      ];
      # 直接コマンド実行
      ExecStart = "${pkgs.attic-client}/bin/attic watch-store ncaq:private";
    };

    Install = {
      WantedBy = [ "default.target" ];
    };
  };
}

クライアント側でキャッシュサーバを利用する

nix.settings.substitutersなどに設定するだけでは認証は行われません。ビルドする時に毎回403エラーになります。

netrcファイルを書いて指定するとか、 Bearer Tokenをマップして指定するとかの方法があるようですが、なんだか大変になってきたので単純に毎回起動時に~/.config/attic/config.tomlの内容からnetrcファイルを生成してもらうことにしました。

{
  pkgs,
  ...
}:
{
  # 起動時にキャッシュ設定を初期化します。
  systemd.user.services.attic-use-ncaq-private = {
    Unit = {
      Description = "Initialize Attic Cache Configuration";
      After = [
        "network-online.target"
        "nss-lookup.target"
      ];
      Wants = [
        "network-online.target"
        "nss-lookup.target"
      ];
    };

    Service = {
      Type = "oneshot";
      ExecStartPre = "${pkgs.curl}/bin/curl --head --silent --fail --connect-timeout 5 --max-time 15 --retry 3 --retry-delay 5 --retry-all-errors https://nix-cache.ncaq.net/";
      ExecStart = "${pkgs.attic-client}/bin/attic use ncaq:private";
    };

    Install = {
      WantedBy = [ "default.target" ];
    };
  };
}

これ一応動きはしますが、いくらなんでも雑すぎると思います。 curlとか使わずにオフライン状態でstaticにユーザレベルのnetrcファイルとかを生成したほうが良さそうです。 nix buildの時点でコマンドを実行するとか。

まとめ

Atticを利用して個人用のおおむね満足できるNixのキャッシュサーバを自宅サーバに構築できました。まだCloudflare Tunnelの無料版の制限で大きなキャッシュがアップロードできない問題が残っていますが、ごく一部のパッケージの問題ですし、そのうち解決するようにAtticにPRを出す予定です。