概要
GitHub Actionsのminutes消費を節約するために、WSL2上にself-hosted runnerを構築した。ただ、self-hosted runnerのマシンが電源オフのときにCIが止まるのは困るので、ランナーのオンライン状態をGitHub APIで確認して、オフラインならubuntu-latestにフォールバックする仕組みも作った。そのまとめ。
モチベーション
GitHub Actionsは便利だが、Freeプランだとmonthly minutesに上限がある。頻繁にCIが回るプロジェクトだと、月末にminutes枯渇みたいなことが起きうる。最近はClaude Codeで開発しているので、恐ろしい頻度でCIが回ってしまい、先月は8,000円ぐらいをGitHub Actionsに払っていた……。self-hosted runnerを使えばminutes消費は0になるので、それを導入しようという話。
幸い、常時起動しているWindowsマシンがある。もともとゲーム用に買ったマシンだが、最近はMoshiを動かしたり、開発作業に使うことが増えて、ゲームよりもよほど仕事をしている。このマシンのWSL2上にランナーを構築することにした。
ただ、self-hosted runnerには問題がある。
- ランナーが動いているマシンがオフラインのとき、ジョブがpending状態のまま止まる
- 個人アカウントだとリポジトリごとにランナーを登録する必要がある(Organization levelなら共有可能)
- ワークフローの
runs-onをself-hostedに書き換える必要がある
マシンがオフラインのときにCIが完全に動かなくなるのはまずい。なので、ランナーがオンラインかどうかをAPIで確認して、オフラインなら通常のubuntu-latestを使う仕組みが必要になった。
Organizationの検討
個人アカウントだとランナーはリポジトリごとに登録しないといけない。GitHub Organizationを作ればOrg levelでランナーを共有できる。Organizationは無料で作れるのでFree planで十分。
ただ、既存リポジトリの移行に時間がかかるので、まずは個人リポジトリで実装して動作確認してから考えることにした。
detect-runner.yml: ランナー検出の仕組み
detect-runner.ymlという再利用可能ワークフロー(reusable workflow)を作った。workflow_callで呼び出せる。
やっていることはシンプルで、GitHub APIの/repos/{owner}/{repo}/actions/runnersを叩いてオンラインのランナーがいるか確認する。いればself-hosted、いなければubuntu-latestをoutputとして返す。
name: Detect Runner
on:
workflow_call:
outputs:
runner:
description: "Runner label to use (self-hosted or ubuntu-latest)"
value: ${{ jobs.detect.outputs.runner }}
jobs:
detect:
name: Detect Runner
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
runner: ${{ steps.detect.outputs.runner }}
steps:
- name: Check for online self-hosted runner
id: detect
env:
CHECK_TOKEN: ${{ secrets.RUNNER_CHECK_TOKEN }}
run: |
RUNNER_LABEL="ubuntu-latest"
if [ -n "$CHECK_TOKEN" ]; then
RESPONSE=$(curl -s --max-time 10 -w "\n%{http_code}" \
-H "Authorization: Bearer $CHECK_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${{ github.repository }}/actions/runners")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "200" ] && [ -n "$BODY" ]; then
ONLINE=$(echo "$BODY" | jq -r '[.runners[]? | select(.status == "online")] | length' 2>/dev/null)
if [ -z "$ONLINE" ] || [ "$ONLINE" = "null" ]; then
ONLINE=0
fi
if [ "$ONLINE" -gt 0 ]; then
RUNNER_LABEL="self-hosted"
echo "Self-hosted runner detected (online)"
else
echo "No self-hosted runners online, using ubuntu-latest"
fi
else
echo "Runner API returned $HTTP_CODE, using ubuntu-latest"
fi
else
echo "RUNNER_CHECK_TOKEN not set, using ubuntu-latest"
fi
echo "runner=$RUNNER_LABEL" >> "$GITHUB_OUTPUT"
echo "Selected runner: $RUNNER_LABEL"フォールバックが複数段階で入っている。何か問題があったら全部ubuntu-latestに倒れる設計。
ワークフローでの使い方
pr-checks.ymlやmain-deploy.ymlから以下のように呼び出す。
jobs:
detect-runner:
uses: ./.github/workflows/detect-runner.yml
secrets: inherit
quality:
needs: [check-should-run, detect-runner]
runs-on: ${{ needs.detect-runner.outputs.runner }}secrets: inheritで親ワークフローのシークレットをそのまま渡している。needsでdetect-runnerの完了を待ってから、そのoutputのrunnerラベルをruns-onに使う。
e2eテストなどDockerコンテナを使うジョブはself-hosted runnerでは動かせないので、そういうジョブはubuntu-latest固定のまま。全体の流れは以下のようになる。
qualityとbuildは検出されたランナーで動き、e2e-fullとdeployは常にubuntu-latestで動く。
RUNNER_CHECK_TOKENの作成
ランナーの状態を取得するAPIにはトークンが必要。
- GitHub > Settings > Developer settings > Personal access tokens > Fine-grained tokens
- Repository access: Only select repositories(対象のリポジトリを選択)
- Permissions: Administration → Read-only
- トークンを生成
生成したトークンを、対象リポジトリのSettings > Secrets and variables > Actions でRUNNER_CHECK_TOKENとして登録する。
Self-hosted runnerのセットアップ(WSL2)
最初の失敗: Windowsランナー
最初、WSL2はWindows上で動いているのだからWindowsランナーだろうと思って、Runner imageで「Windows」を選んでセットアップした。

これは間違い。WSL2の中で動かすのでLinuxランナーが正解。Windowsランナーを選ぶと、ワークフロー内のls、cd、grepなどのLinux/bashコマンドが全部動かなくなる。シェルスクリプトを全てPowerShellに書き換える必要が出てきてしまう。WSL2はLinuxカーネルが動いているので、Runner image は Linux / Architecture は x64 を選ぶ。

WSL2側の準備
WSL2にUbuntuをインストールして、systemdを有効にする。
/etc/wsl.confに以下を追加。
[boot]
systemd=truePowerShellでwsl --shutdownしてからWSLを再起動すると有効になる。
Runnerの登録
GitHubリポジトリのSettings > Actions > Runners > New self-hosted runnerから、表示されるコマンドをWSL2内で実行する。
- ランナーのバイナリをダウンロード&展開
./config.shで登録(URLとトークンは画面に表示される)- ラベルはデフォルトの
self-hosted、Linux、X64が付く
systemdでサービス化
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh statusこれでWSL2が起動していればランナーが自動で動く。
svc.sh installの際にrunsvc.shが見つからないというエラーが出ることがある。その場合は一度sudo ./svc.sh uninstallしてから再度sudo ./svc.sh installすると解決する。
Windows起動時にWSL2を自動起動
Windowsのタスクスケジューラで、ログオン時にwslコマンドを実行するタスクを作成する。これでWindowsの起動とともにWSL2が立ち上がり、systemd経由でランナーも起動する。
登録完了
セットアップが完了すると、GitHubのSettings > Actions > Runnersにランナーがonlineとして表示される。

動作確認
ランナーがオンラインの状態でPRを作ると、detect-runnerジョブでself-hostedが検出される。

その後のquality checksジョブがself-hosted runner上で実行される。

パフォーマンス比較
自分のサイト(takazudomodular.com)のCIでの比較。Next.jsサイトのビルドで、Quality ChecksはTypeScript型チェック・ESLint・Prettier・ユニットテスト等、Build SiteはNext.jsのstatic export + Storybookビルドを行うジョブ。3回の実行での比較。
| Job | GitHub-hosted | Self-hosted(cold) | Self-hosted(warm) |
|---|---|---|---|
| Quality Checks | 1m52s | 2m19s | 1m59s |
| Build Site | 3m11s | 5m01s | 4m14s |
self-hostedの方が少し遅い。WSL2のディスクI/Oがネイティブより遅いのが原因だろう。ただしGitHub Actionsのminutes消費は0なので、速度よりコスト削減が目的なら十分。キャッシュがwarmになるとある程度改善する。
よくある疑問
複数リポジトリでランナーを共有できるか
個人アカウントではリポジトリごとに登録が必要。GitHub Organizationを使えばOrg levelでランナーを共有できる。Organizationは無料で作れる。
複数ランナーは同じディレクトリに置けるか
置けない。1ランナー = 1ディレクトリ。複数ランナーを同じマシンで動かす場合は、それぞれ別のディレクトリに展開する。
ランナーはコンテナで動いているのか
コンテナではなく、マシン上の通常のプロセスとして動いている。リソース制限もない。ジョブはそのマシンのリソースをフルに使える。
複数ジョブの作業ディレクトリは干渉するか
干渉しない。_work/配下にリポジトリごとにディレクトリが分離される。
同時実行はできるか
1ランナーは1ジョブずつしか実行しない。並列実行したい場合は複数のランナーインスタンスが必要。
まとめ
self-hosted runnerとフォールバック機構を組み合わせることで、マシンがオンラインなら自動でself-hosted、オフラインなら通常のGitHub-hostedを使うようにできた。
- WSL2上でLinuxランナーとして動かすのが実用的(Windowsランナーではない)
- reusable workflowとして
detect-runner.ymlを切り出すことで、複数ワークフローで共有できる RUNNER_CHECK_TOKENの設定が必要だが、Fine-grained tokenでAdministration Read-onlyだけあれば良い- パフォーマンスは若干落ちるが、minutes節約が目的なら十分