開発のこと
GitHub Actions の runner を外に出したら、Vitest が1本ずつ動いていたことに気づいた
2026.10.15
発端:無料枠を使い切った
非公開リポジトリの CI を GitHub Actions の無料枠(月 2,000 分)で回していたところ、枠を使い切りました。そこで Pro(月 3,000 分)に上げました。
ただ、これで足りるのは今のうちだけです。理由は2つあります。
- テストが増え続けている
- E2E はデプロイの PR のときにしか回せていない。本当は普段の PR でも回したい
枠を使っているのは主に単体テスト(Vitest)でした。PR に push するたびに走り、1回 4〜5 分かかります。直近 40 回の CI で、単体テストだけで 184 分を使っていました。
方針:runner だけ外に出す
GitHub はそのまま使い、CI を動かす機械(runner)だけを外に出すことにしました。self-hosted runner で動かしたジョブは、GitHub の利用枠を使いません。
GitLab へ移る案も考えました。しかし GitLab.com の無料枠は月 400 分で、GitHub より少ないです。自前で GitLab を立てれば上限は無くなりますが、それは「自分の機械で CI を動かす」からで、GitHub の self-hosted runner でも同じことができます。
機械は、知人の事務所にある PC を借りました。
| 項目 | 中身 |
|---|---|
| 機械 | Windows 11 の PC。WSL2 の Docker で runner を動かす |
| runner に回す分 | CPU 4 コア・メモリ 5GB。PC 本来の処理より低い優先度 |
| 動かし方 | 使い捨て(ephemeral)。1 ジョブごとにコンテナを作り直す |
| ラベル | self-hosted, linux, kizuna-ci |
| つなぎ方 | 外向きの HTTPS で GitHub につなぐだけ。ルーターの設定は不要 |
ワークフローの変更は runs-on の書き換えだけです。
runs-on: [self-hosted, linux, kizuna-ci]
気をつけたことは3つです。
- PC が止まると CI は待ちになる。GitHub の機械には 長く止めるときは
ubuntu-latestに戻す - PR のコードがその PC で動く。リポジトリが非公開で、書けるのが開発者だけなので受け入れた。公開するときは必ず外す
- runner の登録用のトークンは、このリポジトリだけ ained のものにした
移しても速くならなかった
まず単体テストだけを移して試しました。通りはしましたが、ジョブ全体で 4 分 10 秒かかりました。GitHub の機械(約 4.6分)とほとんど変わりません。
手元の Mac では、同じテストが 4 並列で 42 秒で終わります。runner には 4 コアあるので、もう少し速くてもよいはずです。
Vitest が最後に出す内訳を並べると、理由が見えました。
# runner
Duration 219.90s (transform 6.00s, setup 21.59s, collect 61.01s, tests 24.51s, environment 57.16s, prepare 20.37s)
# 手元の Mac(--maxWorkers=4)
Duration 43.91s (transform 3.42s, setup 15.95s, collect 46.18s, tests 22.69s, environment 41.39s, prepare 15.97s)
括弧の中は、ファイルごとの時間を足し合わせたものです。並列に動いていれば、この合計は全体の時間(Duration)より大きくなります。
- 手元:合計が約 146 秒で、全体は 44 秒。約 3.3 本分が同時に動いている
- runner:合計が約 191 秒で、全体は 220 秒。1 本ずつしか動いていない
テストそのもの(tests)は 24 秒しかなく、残りはテスト環境の立ち上げ(environment)と読み込み(collect)です。この二つはファイルごとにかかるので、並列にしないとそのまま時間になります。
原因:二つが重なっていた
1. 設定が効いていなかった
vitest.config.ts には、CI の並列数を決める設定を書いていました。
poolOptions: {
threads: {
minThreads: process.env.CI ? 2 : 4,
maxThreads: process.env.CI ? 4 : 12,
},
},
ところが Vitest 2 以降、既定の pool は threads ではなく forks です。pool を指定していないので、poolOptions.threads は読まれていませんでした。この設定は、書いてからずっと飾りだったことになります。
2. コンテナの中では「使える CPU は 1 つ」だった
設定が無いとき、Vitest(run)は「使える CPU の数 − 1」本を並べます(最低 1 本)。使える CPU の数は Node のos.availableParallelism() です。
runner でこの値を出してみると、次のようになりました。
nproc=16 availableParallelism=1
OS には 16 コアが見えているのに、Node は「使えるのは 1 つ」と答えています。これで 1 − 1 = 0、最低の 1 本だけが動いていました。
なぜ 1 になるのかは、まだ確かめていません。コンテナに掛けた CPU の上限や優先度の設定を、Node(libuv)がそう読んだのだと思います。
直し方と結果:1 行で半分になった
CI のときは並列数を 4 に決め打ちしました。効いていなかった poolOptions.threads は消しました。
maxWorkers: process.env.CI ? 4 : undefined,
maxWorkers は pool の種類に関わらず効きます。手元は既定のままで、Mac(14 コア)なら 13 本並んで約 34 秒です。
| 直す前 | 直したあと | |
|---|---|---|
| Vitest の全体の時間 | 220 秒 | 74 秒 |
| 単体テストのジョブ全体 | 4 分 10 秒 | 2 分 01 秒 |
ジョブの残りの時間は、Node の準備(約 25 秒)と npm ci(約 14 秒)です。使い捨てのコンテナでも npm のキャッシュは効くので、ここはこれ以上あまり縮みません。
runner は 4 コアまでなので、maxWorkers を 4 より上げてもほとんど速くならないと見ています。
あわせて、単体テストの前に CPU の数をログに出す手順を残しました。機械を替えたときに、同じことが起きていないかすぐ分かります。
- name: CPU 数
run: echo "nproc=$(nproc) availableParallelism=$(node -p 'require("os").availableParallelism()')"
GitHub の機械でも、たぶん 1 本ずつだった
非公開リポジトリの ubuntu-latest は 2 vCPU です。「使える CPU の数 − 1」なので、こちらも 1 本ずつになります。
そう考えると、数字が合います。GitHub の機械では Vitest に 3 分 54 秒かかっていました。これは runner の直す前(220 秒)と同じくらいです。
つまり、枠を食い潰していた単体テストの 1 回 4〜5分のうち半分ほどは、並列に動いていないことによるものだった可能性があります。runner を外に出さなくても、この 1 行で枠の消費は減らせたはずです。
ただし GitHub の機械では実際に測っていません。ここは計算から言えることです。
E2E も外の runner へ(準備中)
E2E(Playwright)も同じ runner で動かします。利用枠を気にしなくてよくなれば、デプロイのときだけでなく普段の PR でも回せます。
最初の 1 回は、ブラウザの準備で止まりました。
sudo: The "no new privileges" flag is set, which prevents sudo from running as root.
npx playwright install --with-deps chromium は、ブラウザが使う OS のライブラリを apt で入れます。そのために sudo で rootになろうとして、止められました。コンテナの中で権限を上げられないようにしてあるためで、安全のための正しい設定です。
そこで、役割を分けました。
- runner のイメージ:OS のライブラリを作るときに入れておく(
npx -y playwright@1.57.0 install-deps chromium) - ワークフロー:self-hosted のときはブラウザだけを入れる。ブラウザは root がなくても入る
- name: Install Playwright browsers
run: npx playwright install ${{ runner.environment == 'github-hosted' && '--with-deps' || '' }} chromium
runner.environment で分けているので、ubuntu-latest に戻したときはこれまでどおり動きます。
runner は 1 つだけなので、dev のデータベースを共有する E2E が同時に 2 本走ることもありません。
E2E が通ったら、かかった時間とメモリの量をここに書き足します。
まとめ
- self-hosted runner は
runs-onを書き換えるだけで入れられる。GitHub の利用枠を使わない - ただし移しただけでは速くならなかった。テストが 1 本ずつしか動いていなかった
- Vitest 2 以降の既定は
forks。poolOptions.threadsを書いているなら、効いているか確かめたほうがよい - コンテナや 2 コアの機械では、Vitest の既定の並列数が 1 になりうる。CI では
maxWorkersを決め打ちするとよい - 並列に動いているかは、Vitest の Duration の内訳を足して、全体の時間と比べれば分かる