zudo-paper

GitHub Actionsのself-hosted runnerをWSL2で構築してフォールバック付きで運用する

Author: Takazudo | 作成: 2026/03/08

概要

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-onself-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.ymlmain-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で親ワークフローのシークレットをそのまま渡している。needsdetect-runnerの完了を待ってから、そのoutputのrunnerラベルをruns-onに使う。

e2eテストなどDockerコンテナを使うジョブはself-hosted runnerでは動かせないので、そういうジョブはubuntu-latest固定のまま。全体の流れは以下のようになる。

qualitybuildは検出されたランナーで動き、e2e-fulldeployは常にubuntu-latestで動く。

RUNNER_CHECK_TOKENの作成

ランナーの状態を取得するAPIにはトークンが必要。

  1. GitHub > Settings > Developer settings > Personal access tokens > Fine-grained tokens
  2. Repository access: Only select repositories(対象のリポジトリを選択)
  3. Permissions: Administration → Read-only
  4. トークンを生成

生成したトークンを、対象リポジトリのSettings > Secrets and variables > Actions でRUNNER_CHECK_TOKENとして登録する。

Self-hosted runnerのセットアップ(WSL2)

最初の失敗: Windowsランナー

最初、WSL2はWindows上で動いているのだからWindowsランナーだろうと思って、Runner imageで「Windows」を選んでセットアップした。

WindowsでRunner setupした画面

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

Runner image選択画面

WSL2側の準備

WSL2にUbuntuをインストールして、systemdを有効にする。

/etc/wsl.confに以下を追加。

[boot]
systemd=true

PowerShellでwsl --shutdownしてからWSLを再起動すると有効になる。

Runnerの登録

GitHubリポジトリのSettings > Actions > Runners > New self-hosted runnerから、表示されるコマンドをWSL2内で実行する。

  1. ランナーのバイナリをダウンロード&展開
  2. ./config.shで登録(URLとトークンは画面に表示される)
  3. ラベルはデフォルトのself-hostedLinuxX64が付く

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として表示される。

ランナーがonlineの状態

動作確認

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

detect-runnerジョブのログ

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

Quality Checksジョブのログ

パフォーマンス比較

自分のサイト(takazudomodular.com)のCIでの比較。Next.jsサイトのビルドで、Quality ChecksはTypeScript型チェック・ESLint・Prettier・ユニットテスト等、Build SiteはNext.jsのstatic export + Storybookビルドを行うジョブ。3回の実行での比較。

JobGitHub-hostedSelf-hosted(cold)Self-hosted(warm)
Quality Checks1m52s2m19s1m59s
Build Site3m11s5m01s4m14s

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節約が目的なら十分