• 作成:

NixOSでGitHubのセルフホストランナーを建ててx64とaarch64のCIを自宅サーバで回す

背景

私のNixOSの設定は、 ncaq/dotfilesリポジトリで管理しています。このリポジトリのCIでは、 home-managerNix-on-Droidのビルドを行っています。

GitHubのホステッドランナーはCPUの強さは十分なのですが、ディスクの容量がかなり限られています。 jlumbroso/free-disk-spaceを使って不要なファイルを削除しても、特にflake.lockが更新されてNixのキャッシュが切れたときに、 home-managerをビルドするとたまにディスク容量が足りなくなることがありました。リトライすればその分のキャッシュは効いているのでだいたいは成功するのですが。

加えて、 Nix-on-DroidをセットアップしてAndroidの最良のSSHクライアントを手に入れたの記事で書いたように、 aarch64向けのビルドも必要になりました。

そこで自宅サーバ(seminar)にGitHubのセルフホストランナーを建てることにしました。

設計

動機の整理

動機を整理すると以下の通りです。

  • ディスク容量: ホステッドランナーの14GBでは足りない場面がある
  • Nixキャッシュ: ホストのNixストアを共有すればキャッシュヒット率が大幅に上がる
  • aarch64対応: Nix-on-Droidのビルドにaarch64環境が必要

なぜセルフホストランナーか

CPUの強さだけならGitHubのホステッドランナーで十分です。 GitHubのubuntu-24.04ランナーは4 vCPU/16GB RAMで、 CPUはAzure上のIntel Xeon Platinum 8370CやAMD EPYC 7763/9V74などが使われています。自宅サーバのseminarはAMD Ryzen 5 7600(Zen4、6コア12スレッド)なので、シングルスレッド性能は大差ありません。しかしディスク容量に関してはホステッドランナーが14GBしかないのに対し、セルフホストランナーではホストのディスクを使えるため事実上制限がありません。加えてNixのキャッシュをホストと共有できるのが大きな利点です。

アーキテクチャ

2種類のランナーを構築しました。

  • x86_64: NixOSコンテナとして動作。ホストのNixデーモンを直接共有
  • aarch64: QEMU TCGエミュレーションによるmicrovm.nixのVMとして動作

実装

x86_64ランナー

x86_64のランナーはNixOSコンテナとして実装しました。

containers.github-runner-x64 = {
  ephemeral = true;
  privateNetwork = true;
  hostAddress = "192.168.100.40";
  localAddress = "192.168.100.41";
  bindMounts = {
    "github-runner" = {
      hostPath = config.sops.secrets.github-runner.path;
      mountPoint = config.sops.secrets.github-runner.path;
    };
  };
};

コンテナはephemeralにしていて、停止するとファイルシステムの変更は破棄されます。これはセキュリティ上の理由で、信頼できないコードを実行した場合のリスクを最小化するためです。

ポイント

ランナーのインスタンスはbuiltins.genListで4つ生成しています。

services.github-runners = builtins.listToAttrs (
  builtins.genList (
    i:
    let
      name = "dotfiles-x64-${toString i}";
    in
    {
      inherit name;
      value = {
        inherit name;
        enable = true;
        ephemeral = true;
        replace = true;
        url = "https://github.com/ncaq/dotfiles";
        tokenFile = config.sops.secrets.github-runner.path;
        extraLabels = [ "NixOS" ];
        extraPackages =
          githubRunnerShare.githubActionsRunnerPackages.all
          ++ githubRunnerShare.selfHostRunnerPackages;
        extraEnvironment = {
          ACTIONS_RUNNER_HOOK_JOB_STARTED = "${githubRunnerShare.dotfiles-github-runner}/dist/job-started-hook.js";
        };
      };
    }
  ) 4
);

ホストのNixデーモンを共有するため、 github-runnerユーザーのUID/GIDをホストとコンテナで一致させる必要があります。また、ホストのnix.settings.trusted-usersgithub-runnerを追加して、 Nixデーモンとの通信を許可しています。

trusted-usersで七転八倒した話

このtrusted-usersの設定にたどり着くまでに相当苦労しました。

NixOSコンテナはホストのNixデーモンのUnixソケットを共有しています。これは明示的に設定したわけではなく、 NixOSコンテナモジュールがsystemd-nspawnの起動時に自動でバインドマウントしています。

exec systemd-nspawn \
  --bind-ro=/nix/store:/nix/store \
  --bind-ro=/nix/var/nix/db:/nix/var/nix/db \
  --bind-ro=/nix/var/nix/daemon-socket:/nix/var/nix/daemon-socket \
  ...

/nix/store/nix/var/nix/dbに加えて、 /nix/var/nix/daemon-socketが読み取り専用でマウントされるため、コンテナ内のnixコマンドはホストのnix-daemonと通信します。この仕組みのおかげでコンテナ内に独自のNixデーモンを立てる必要がなく、ホストのNixストアをそのまま活用できます。

Nixデーモンはクライアントの接続を受け付けるとき、 Unixソケット経由でSO_PEERCREDによりクライアントのUIDを取得し、ホスト側の/etc/passwdでユーザー名を解決します。 trusted-usersの判定はユーザー名ベースで行われます。

最初はコンテナ内のnix.settingstrusted-usersを追加していました。しかしコンテナ内のnix.settingsは、コンテナ内に独自のNixデーモンが動いている場合にしか効果がありません。 NixOSコンテナはホストのNixデーモンを使うので、 trusted-usersはホスト側で設定しないと意味がないのです。これに気付くまでに時間がかかりました。

次にUID/GIDの衝突問題にハマりました。最初にuid=990, gid=985を割り当てたのですが、 uid=990はmicrovmモジュールが自動割当するユーザーと衝突し、 gid=985lpadminグループと衝突していました。この衝突によりホスト側でuid=990github-runnerではなく別のユーザーに解決されてしまい、 trusted-usersが機能しませんでした。 uid=980, gid=980に変更して解決しました。

さらにUID/GID変更後も、コンテナ内の/etc/passwdが前回起動時の古い値を保持し続ける問題が発生しました。コンテナをephemeral = trueにすることで起動ごとにファイルシステムを再生成し、常に正しいUID/GIDが使われるようにしました。ランナー自体がephemeralモードなので状態を保持する必要がなく、この変更に問題はありませんでした。

一方でcachixなどのツールはローカルのnix.confを参照するため、コンテナ側にもホストの設定を継承させる必要があります。最終的に以下のようにホストの評価済み設定をそのまま継承する形に落ち着きました。

nix.settings = config.nix.settings;

まとめると以下の3つの条件を全て満たす必要があります。

  • ホスト側のtrusted-usersgithub-runnerを追加する(コンテナ側ではない)
  • ホストとコンテナで同じUID/GIDのgithub-runnerユーザーを定義し、他のユーザーと衝突させない
  • コンテナをephemeralにしてUID/GIDの不整合を防止する

ネットワーク設定のデッドロック問題

NixOSコンテナのネットワーク設定でデッドロックにハマりました。普通のNixOSコンテナでは問題にならないのですが、 github-runnerサービスが起動時にGitHubへのネットワーク通信を必要とするという変則的な条件が重なったために発生しました。

NixOSコンテナモジュールはExecStartPostでホスト側のvethインターフェースにIPアドレスとルートを設定します。しかしExecStartPostはコンテナのdefault targetに到達した後に実行されます。一方でコンテナ内のgithub-runnerサービスの起動にはネットワーク接続が必要です。つまりコンテナの起動完了にはネットワークが必要なのに、ネットワーク設定はコンテナの起動完了後に行われるというデッドロックが発生します。

解決策として、 systemd-networkdでvethインターフェースの設定を行うようにしました。 systemd-networkdはvethインターフェースが作成された直後に設定を適用するため、コンテナ内のサービスが起動する前にネットワークが使用可能になります。

systemd.network.networks."20-github-runner-x64-veth" = {
  matchConfig.Name = "ve-github-runner-x64";
  addresses = [
    { Address = "${addr.host}/32"; }
  ];
  routes = [
    { Destination = "${addr.guest}/32"; }
  ];
};

ただしNixOSコンテナモジュールが生成するpostStartip addr add/ip route addを使うため、 systemd-networkdが先に設定済みの場合にEEXISTエラーで失敗します。 lib.mkForcepostStartを上書きし、 2>/dev/null || trueを付けて冪等にしています。

systemd.services."container@github-runner-x64" = {
  postStart = lib.mkForce ''
    ifaceHost=ve-$INSTANCE
    ip link set dev "$ifaceHost" up
    ip addr add ${addr.host} dev "$ifaceHost" 2>/dev/null || true
    ip route add ${addr.guest} dev "$ifaceHost" 2>/dev/null || true
  '';
};

実際の設定はsystemd-networkdに任せるため、 postStartが失敗しても問題ありません。

aarch64ランナー

aarch64のランナーは、 microvm.nixを使ったQEMU VMとして実装しました。 x86_64ホスト上でTCGモードのエミュレーションで動作します。

microvm.vms.github-runner-arm64 = {
  config = {
    microvm = {
      hypervisor = "qemu";
      cpu = "max";
      vcpu = 11;
      mem = 16 * 1024;
      qemu.extraArgs = [
        "-accel" "tcg"
      ];
    };
  };
};

Nixストアの共有

aarch64 VMでもNixストアを活用するため、 virtiofsでホストの/nix/storeを読み取り専用でマウントし、その上に書き込み可能なオーバーレイを重ねています。

microvm.shares = [
  {
    source = "/nix/store";
    mountPoint = "/nix/.ro-store";
    tag = "ro-store";
    proto = "virtiofs";
  }
];
microvm.volumes = [
  {
    mountPoint = "/nix/.rw-store";
    image = "nix-store-overlay.img";
    size = 50 * 1024;
  }
];

50GBのディスクイメージをオーバーレイの書き込み層として確保しています。これによりVM内でもnix buildが可能になります。

なぜクロスコンパイルではなくエミュレーションか

QEMUのエミュレーションは遅いですが、クロスコンパイルではなくbinfmtエミュレーションを選んだ理由があります。 dotnet-sdkがクロスコンパイルに対応しておらず、このdotnet-sdkはgithub-runner自体が依存しているため避けられません。

VM自体はQEMUのシステムエミュレーションで動くため、本質的にはboot.binfmt.emulatedSystemsは不要なはずです。しかしmicrovm.nixの一部のライブラリがNix評価時にホスト側で直接ビルドを行う箇所があり、そこでaarch64バイナリの実行が必要になるため、ホスト側でもbinfmtエミュレーションを有効にしています。

binfmtのブートストラップ

boot.binfmt.emulatedSystemsの設定には鶏が先か卵が先か問題があります。 NixOS設定にboot.binfmt.emulatedSystems = [ "aarch64-linux" ]を書いても、初回のnixos-rebuild時点ではbinfmtがまだ有効になっていません。しかしnixos-rebuildの過程でaarch64のderivationをビルドしようとするため、 binfmtが有効でないとビルドが失敗します。

この問題を解決するため、 install.shにブートストラップ処理を入れています。

# aarch64-linux用のbinfmtエミュレーションをブートストラップします。
# NixOS設定にboot.binfmt.emulatedSystemsが含まれていても、
# 初回インストール時はbinfmtがまだ有効になっていないため、
# aarch64-linux derivationのビルドに失敗します(chicken-and-egg問題)。
# この関数で一時的にbinfmtを設定することでnixos-rebuildを成功させます。
# リビルド後はNixOSが正式なbinfmt設定を管理します。
bootstrap_binfmt_aarch64() {
  echo "aarch64 binfmtをブートストラップ中..."

  # binfmt_miscファイルシステムがマウントされていなければマウント
  if ! test -e /proc/sys/fs/binfmt_misc/register; then
    sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc
  fi

  # qemu-userをビルド
  local qemu_user
  qemu_user=$(nix build .#qemu-user --print-out-paths --no-link)

  # カーネルにaarch64 binfmtハンドラを登録(未登録の場合のみ)
  # Fフラグ: 登録時にカーネルがインタープリタのファイルディスクリプタを保持するため、
  # Nixサンドボックス内でインタープリタのパスが見えなくても動作します。
  # NixOSのnixos/lib/binfmt-magics.nixからのELFマジックバイトとマスク。
  if ! test -e /proc/sys/fs/binfmt_misc/aarch64-linux; then
    local magic mask interpreter
    magic='\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00'
    magic+='\x02\x00\xb7\x00'
    mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\x00\xff'
    mask+='\xfe\xff\xff\xff'
    interpreter="${qemu_user}/bin/qemu-aarch64"
    printf '%s\n' ":aarch64-linux:M:0:${magic}:${mask}:${interpreter}:F" |
      sudo tee /proc/sys/fs/binfmt_misc/register >/dev/null
  fi

  # NixOS管理のnix.confを一時的にコピーしてextra-platformsを追加します。
  # nixos-rebuild後にNixOSの活性化スクリプトが正しいシンボリックリンクを復元します。
  local nix_conf="/etc/nix/nix.conf"
  # シンボリックリンクの場合のみコピーで実体化する(既に通常ファイルならスキップ)
  if [ -L "$nix_conf" ]; then
    sudo cp --remove-destination "$(readlink -f "$nix_conf")" "$nix_conf"
  fi
  echo "extra-platforms = aarch64-linux" | sudo tee -a "$nix_conf" >/dev/null
  # Nixサンドボックス内でQEMUとその依存ライブラリにアクセスできるようにします。
  # Fフラグでインタープリタ自体はロード済みですが、
  # QEMUの動的リンカがサンドボックス内で共有ライブラリを解決する必要があります。
  local sandbox_paths
  sandbox_paths=$(nix path-info -r "$qemu_user" | tr '\n' ' ')
  printf 'extra-sandbox-paths = %s\n' "$sandbox_paths" |
    sudo tee -a "$nix_conf" >/dev/null
  sudo systemctl restart nix-daemon

  echo "binfmtブートストラップ完了"
}

やっていることを整理すると以下の3ステップです。

  1. binfmt_miscファイルシステムをマウントし、qemu-userをビルドしてカーネルにaarch64のbinfmtハンドラを登録する。Fフラグを付けることでカーネルがインタープリタのファイルディスクリプタを保持し、Nixサンドボックス内でパスが見えなくても動作する
  2. NixOS管理の/etc/nix/nix.confはシンボリックリンクなので実体にコピーしてからextra-platforms = aarch64-linuxを追加する
  3. QEMUの動的リンカがサンドボックス内で共有ライブラリを解決できるよう、nix path-info -rでQEMUの全依存パスを取得してextra-sandbox-pathsに追加する

nixos-rebuildが成功すればNixOSが正式なbinfmt設定を管理するようになるため、このブートストラップは初回インストール時のみ必要です。呼び出し側ではホスト名がseminarかつextra-platformsが未設定の場合にのみ実行するようにしています。

パッケージセット

aarch64ランナーではビルド時間を最適化するため、パッケージセットをminimalに絞っています。 x64ランナーではactions/runner-imagesのUbuntu 24.04イメージと互換性のあるフルセットを提供していますが、 aarch64では必要最低限のパッケージに限定しました。

セキュリティ: ジョブ開始フック

セルフホストランナーでは、信頼できないPRからのジョブ実行を防ぐ必要があります。ワークフロー側のif条件だけでなく、ランナー側でも二重に防御しています。

const trustedBots = ["dependabot[bot]", "renovate[bot]"];

switch (eventName) {
  case "push":
  case "merge_group":
  case "workflow_dispatch":
  case "schedule":
    // リポジトリ書き込み権限が必要なイベントは無条件で許可
    process.exit(0);
    break;
  case "pull_request":
  case "pull_request_target": {
    const event = JSON.parse(readFileSync(eventPath, "utf-8"));
    if (event.pull_request.author_association === "OWNER") {
      process.exit(0);
    }
    if (trustedBots.includes(event.pull_request.user.login)) {
      process.exit(0);
    }
    process.exit(1);
    break;
  }
  default:
    process.exit(1);
}

ACTIONS_RUNNER_HOOK_JOB_STARTED環境変数でこのフックを指定し、ジョブの実行前にチェックしています。

ワークフローの構成

dotfilesリポジトリのワークフローでは、まずis-trustedジョブで信頼できるソースかどうかを判定します。

is-trusted:
  runs-on: ubuntu-24.04
  if: >-
    github.event_name == 'workflow_dispatch' ||
    github.event_name == 'push' ||
    github.event_name == 'merge_group' ||
    github.event.pull_request.author_association == 'OWNER' ||
    github.event.pull_request.user.login == 'dependabot[bot]' ||
    github.event.pull_request.user.login == 'renovate[bot]'
  steps:
    - run: echo "Trusted source confirmed"

if条件が満たされればジョブが成功し、満たされなければスキップされます。他のジョブはneeds: is-trustedでこのジョブの結果を参照して、セルフホストランナーを使うかどうかを決定します。

nix-fast-buildジョブはis-trustedが成功した場合はセルフホストランナーで、スキップされた場合はGitHubのホステッドランナーにフォールバックして実行します。

nix-fast-build:
  needs: is-trusted
  if: always() && !cancelled()
  runs-on: >-
    ${{ needs.is-trusted.result == 'success'
      && fromJSON('["self-hosted", "Linux", "X64"]') || 'ubuntu-24.04' }}

always() && !cancelled()によりis-trustedがスキップされてもこのジョブは実行されます。 runs-onの条件式で信頼できる場合はセルフホストランナー、そうでない場合はubuntu-24.04を選択しています。

build-home-managerbuild-nix-on-droidは信頼できる場合のみ実行します。

build-home-manager:
  needs: is-trusted
  if: needs.is-trusted.result == 'success'
  runs-on: ${{ matrix.runner }}
  strategy:
    matrix:
      include:
        - system: x86_64-linux
          runner: [self-hosted, Linux, X64]
        - system: aarch64-linux
          runner: [self-hosted, Linux, ARM64]

依存関係更新botへの対応

DependabotRenovateなどの自動更新botのPRでも、セルフホストランナーでビルドを実行したいケースがあります。特にflake.lockの更新はNixのキャッシュが大きく変わるため、ホステッドランナーではディスク不足になりやすいです。

ci(github-runner): dependabotとrenovateによるPRビルドを許可で、ワークフローとジョブ開始フックの両方に信頼できるbotのリストを追加しました。

QEMUエミュレーションの速度

aarch64のランナーは信じられないほど遅いです。アーキテクチャのエミュレーションのオーバーヘッドを舐めていました。

ゲームのエミュレータ、例えばDolphinはPowerPCをx86_64にエミュレートしていますが、軽快に動きます。それぐらいのオーバーヘッドを想定していました。しかしゲームのエミュレータはJITコンパイラで最適化しているのに対し、 QEMU TCGモードではシステムコールを全て変換する必要があり、どちらかというとファイルシステムの操作で遅さが出ているようです。

速度的にはGitHubのubuntu-armランナーの方が比べ物にならないほど速いです。しかしキャッシュが効いていればギリギリ許容範囲な速度で動いているので、必須のチェックにはせず、壊れていれば後から気が付きやすい程度の位置づけで動かしています。

わざわざこの程度のことにARMのシングルボードコンピュータを買う話ではないと判断しています。

開発の流れ

振り返ると、以下の順序で段階的に実装を進めました。

  1. feat(lib): GitHub Actions Ubuntu 24.04互換パッケージリストを追加: 準備としてパッケージリストを事前に作成
  2. feat: GitHubのセルフホストランナーの実装: x64セルフホストランナーの実装
  3. refactor: github-runnerを汎用化: aarch64対応に向けたリファクタリング
  4. refactor: github-runner周りの設定を綺麗にする: さらなる構造の整理
  5. fix: runnerパッケージを整備してbazelがminimalに含まれないようにします: パッケージ最適化
  6. fix(github-actions-runner-packages): カテゴリを整理してminimalセットを縮小: minimalセットの整理
  7. feat: セルフホストランナーをaarch64に対応させる: aarch64 QEMUランナーの実装
  8. ci(github-runner): dependabotとrenovateによるPRビルドを許可: 自動更新botへの対応

感想

x64のランナーはNixのキャッシュをホストと共有しているのもあって、極めて軽快に動きます。ホステッドランナーでディスク容量を気にする必要がなくなったのは快適です。

aarch64の方はQEMUのエミュレーションが重いですが、キャッシュが効いていれば実用的な速度で動いています。ネイティブのARMマシンを用意するほどではないと判断して、現状の構成で運用しています。

NixOSの宣言的な設定のおかげで、コンテナやVMの構成をコードで管理できるのは便利です。セルフホストランナーの設定も全てリポジトリで管理されているので、再現性があります。