Apple Silicon Mac上でSelf-hosted Runnerを並列稼働させる様子。VirtioFSによる性能向上(コールドキャッシュで2倍速)や、Docker Desktopのメモリ設定に応じた最適なランナー数を計算する「Sweet Spot Formula」の表が描かれている。

GitHub Actions の Self-hosted Runner を Docker Compose で動かしています。1台の Mac(Apple Silicon)に複数 Organization のコンテナ Runner を載せて並列実行する構成です。

問題は「何台まで並列にできるか」。少なすぎると PR が渋滞し、多すぎると OOM で全滅します。この記事では VirtioFS のベンチマーク、アイドル・ワークロード時のメモリ実測、Docker Desktop の推奨設定を元に、sweet spot を決定するまでの過程を記録します。

sweet spot の公式

結論から書きます。

推奨スケール数 = floor((Docker Desktop メモリ - 2 GiB - 1 GiB) / 3 GiB)
  • 2 GiB:Docker Desktop VM(linuxkit)+ Docker Engine の常駐メモリ
  • 1 GiB:BuildKit キャッシュのワーキングスペース
  • 3 GiB:1コンテナあたりの mem_limit(vitest + coverage のピーク使用量から設定)
Docker Desktop メモリ 推奨スケール(org A + org B) 備考
8 GiB 1 + 1(2台) OOM マージン最小、常用非推奨
12 GiB 3 + 3(6台) .envREPLICAS=3 に変更
16 GiB 4 + 4(8台) PR 比率で org 間を調整可
20 GiB 5 + 5(10台) デフォルト設定

以下、この表を導出した過程を説明します。

VirtioFS の実測:cold cache で2倍速

Docker Desktop のファイル共有実装には gRPC FUSE と VirtioFS があります。Self-hosted Runner ではどちらが速いのか、実測しました。

ベンチマーク環境

  • Docker Desktop 8 GiB、scale=3+3
  • named volume mount(bind mount ではない)
  • 各条件で warmup 1回 → 計測 2回 → 中央値を採用

結果

ベンチマーク gRPC FUSE VirtioFS 改善率
20,000 ファイル作成(/tmp = ext4 直接) 78 ms 77 ms 0%
npm install warm cache(158 pkg 再展開) 832 ms 842 ms 0%
npm install cold cache(158 pkg 初回) 10 s 5 s 2倍

コンテナ内の ext4 直接 I/O や warm cache では差が出ません。差が出るのは cold cache での npm install です。週明けや依存関係更新後など、キャッシュがない状態で2倍の差が出ます。

もう1つ重要なのは安定性です。gRPC FUSE は run ごとのバラつきが大きく(run1: 1786ms, run2: 832ms)、VirtioFS は安定しています(run1: 872ms, run2: 842ms)。

結論:VirtioFS を必須採用。gRPC FUSE が優位なシナリオは見つかりませんでした。

アイドルメモリの実測

スケール数を決めるには、コンテナ1台あたりのメモリ消費を知る必要があります。

測定条件

  • tests/smoke-scale-startup.sh PASSES=1(起動→登録→アイドル)
  • キャッシュ cold 状態

結果

SCALE 1台あたりアイドルピーク 合計 コンテナ数
1 40 MiB 81 MiB 2(各 org ×1)
3 40 MiB 238 MiB 6(各 org ×3)
5 40 MiB 408 MiB 10(各 org ×5)

アイドル時は SCALE に関係なく 1台あたり約40 MiB で安定しています。問題はワークロード時です。vitest + coverage の実行中は mem_limit: 3g の上限に近づきます。

つまり、idle メモリではなく ワークロードピーク(3 GiB)を基準にスケール数を決める必要があります。

コンテナのリソース制限

Docker Compose でコンテナごとにリソース上限を設定しています。

x-runner-base: &runner-base
  mem_limit: 3g
  mem_reservation: 512m
  pids_limit: 4096
  ulimits:
    nofile:
      soft: 65536
      hard: 65536
    nproc: 4096
  tmpfs:
    - /tmp:size=512m,mode=1777,nosuid,nodev
    - /dev/shm:size=256m,mode=1777,nosuid,nodev

各設定の意図

mem_limit: 3g
vitest + coverage のピーク消費量から決定。tmpfs(/tmp 512MB + /dev/shm 256MB)はこの 3 GiB に含まれるため、Node.js の実効ヒープは --max-old-space-size=2048 を推奨しています。

pids_limit: 4096
fork bomb への防御。コンテナ内でプロセスが暴走してもホストの PID テーブルを消費しません。

ulimits.nofile: 65536
vitest の並列ワーカーが大量のファイルディスクリプタを消費するため、Docker デフォルトの 1024 から引き上げています。

tmpfs
テスト時の一時ファイル I/O を高速化。ディスク I/O を経由しないため、コンテナ間の I/O 競合も軽減されます。

Docker Desktop の推奨設定

Resources タブ

CPU:      16 vCPU(5+5 の場合。3+3 なら 8 vCPU)
Memory:   20 GiB (5+5 の場合。3+3 なら 12 GiB)
Swap:     1 GiB
Disk:     64 GB+(npm cache + image layers + buildx cache のマージン)

vCPU は「10 コンテナで割って 1.6 vCPU/コンテナ」が目安です。vitest の並列ワーカーは VITEST_MAX_THREADS=2 を推奨——3 以上にするとコンテナ間のコンテキストスイッチが増えて逆効果です。

General タブ

  • File sharing implementationVirtioFS(必須)
  • Use containerd for pulling and storing images:有効推奨(Pull 並列化 + image GC 改善)

設定変更後は Docker Desktop の完全再起動が必要です。

診断スクリプト

設定が正しいかを自動チェックするスクリプトを用意しています。

./scripts/diagnose-test-runner.sh

出力例:

Docker Desktop 設定診断:
  ✓ CPU: 16 vCPU (推奨を満たす)
  ✓ Memory: 20 GiB (推奨を満たす)
  ✓ Storage Driver: overlayfs (containerd 有効)
  ? File sharing implementation: 目視確認必須
    → Settings → General → "VirtioFS" を選択

Per-service リソース制限:
  runner-biz-dev     mem_limit: 3g  mem_reservation: 512m
  runner-const-room  mem_limit: 3g  mem_reservation: 512m

リソース使用率 (snapshot):
  NAME                 CPU%    MEM USAGE
  runner-biz-dev-1     0.30%   38.4MiB / 3GiB
  runner-biz-dev-2     0.25%   41.2MiB / 3GiB
  ...

テストが失敗した場合の切り分け手順も出力されます。

次に試すべき切り分け:
  A1. coverage 外して再現するか: pnpm vitest run --no-coverage
  A2. fork を直列化して再現するか: poolOptions.forks.singleFork
  A3. 並列ファイル実行を切ってか: pnpm vitest run --no-file-parallelism
  A4. native で再現するか: runs-on [self-hosted, macOS, ARM64, native, ...]

3+3 → 5+5 へのデフォルト変更

当初のデフォルトは 3+3(12 GiB 想定)でしたが、実運用で以下の課題が出ました。

  • 複数 PR が同時に来ると 3 並列ではキューが詰まる
  • アイドル時のメモリ消費は 40 MiB/台と軽いため、余裕がある Mac なら増やした方が効率的

Docker Desktop のメモリ割り当てを 20 GiB にした上で、デフォルトを 5+5 に変更しました。12 GiB 以下の Mac を使う場合は .envREPLICAS=3 にオーバーライドできます。

まとめ

判断ポイント 結論
ファイル共有実装 VirtioFS 必須(cold cache で2倍速)
1台あたりの上限メモリ 3 GiB(vitest + coverage のピーク)
推奨スケール数 floor((DD メモリ - 3 GiB) / 3 GiB)
デフォルト設定 5+5(20 GiB)、3+3(12 GiB)は .env で切替

スケール数は「多ければ多いほどいい」ではなく、メモリ上限とワークロードのピーク消費から逆算するのが正解です。診断スクリプトを用意しておくと、環境が変わっても判断を再現できます。

参考リンク