Entersight

開発のこと

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 の内訳を足して、全体の時間と比べれば分かる
← ブログ一覧へ