![Apple Silicon Mac上でSelf-hosted Runnerを並列稼働させる様子。VirtioFSによる性能向上(コールドキャッシュで2倍速)や、Docker Desktopのメモリ設定に応じた最適なランナー数を計算する「Sweet Spot Formula」の表が描かれている。](https://wakatchi.dev/wp-content/uploads/2026/05/github-actions-self-hosted-runner-scale-tuning-sweet-spot-eyecatch.webp)

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 でコンテナごとにリソース上限を設定しています。

`yaml
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 implementation**:**VirtioFS**(必須)
- **Use containerd for pulling and storing images**:有効推奨(Pull 並列化 + image GC 改善)

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

## 診断スクリプト

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

`bash
./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` で切替 |

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

## 参考リンク

- [Docker Desktop - VirtioFS](https://docs.docker.com/desktop/settings/mac/#file-sharing-implementation)
- [Docker Compose - deploy resources](https://docs.docker.com/compose/compose-file/deploy/#resources)