深夜2時。ビルドキューが47分積み上がっていた。
GitHub Actionsのダッシュボードを見つめながら、数字が増え続けるのを眺めていた。開発者がCIのフィードバックを受け取るまで47分待ち。オンコール用のSlackチャンネルは炎上状態だった。エンジニアの一人がホットフィックスをプッシュしたのに、GitHub側のランナーが空くのをただ待つしかない状態が続いていた。
12人チームのために、Actionsの使用料として月約$800を払っていた。リリース日や大きなPRのマージのたびにキューが詰まる。GitHub提供のランナーには優先レーンなどない。待つしかない。全員が待ち続ける。
その夜、3ヶ月前にやるべきだったことをようやく決断した。Actions Runner Controller(ARC)を使って、CI/CDランナーを自社のKubernetesクラスタに移行したのだ。
そこで学んだことを紹介する。公式ドキュメントには載っていない部分も含めて。
なぜGitHub提供のランナーは負荷に耐えられないのか
GitHub提供のランナーは、チームが小規模なうちは便利だ。しかし規模が大きくなると破綻する。その理由を挙げる:
- 同時実行数の上限: FreeプランとTeamプランには同時ジョブ数の制限がある。最も困るリリース日に上限に達してしまう。
- 毎回コールドスタート: 各ランナーは初期状態で起動する。Dockerのレイヤーキャッシュ、pipパッケージ、npmモジュール、すべて消える。外部キャッシュを追加しない限り、毎回ゼロから再構築するコストが発生する。
- シークレットの露出リスク: ランナーはGitHubのインフラ上で動作する。医療や金融系では、これがコンプライアンス上の問題になり得る。
- 内部ネットワークへのアクセス不可: ステージング環境のデータベースに対して結合テストを実行したい場合、VPNトンネルの迂回策が必要になるが、どれもクリーンな解決策ではない。
- コストが急激に膨らむ: 1日200分以上のワークフローを実行するチームなら、月$200〜$1,000に達することも珍しくない。
セルフホストランナーはこれらすべてを解決する。ただし、VMを手動で管理し、登録トークンをローテーションし、古いランナーを削除するといった作業は、これもまたスケールしない。そのギャップを埋めるのがARCだ。
Actions Runner Controllerが実際に行うこと
Actions Runner Controller(ARC)は、ランナーのライフサイクルをエンドツーエンドで管理するKubernetesオペレーターだ。GitHubのリポジトリまたはOrganizationのキューに溜まったジョブを監視し、ランナーPodを自動的に起動し、ジョブ完了後に削除する。
ゾンビランナーなし。手動でのトークンローテーションなし。すべてのジョブがクリーンな独立したPodで実行される。最後の点は聞こえる以上に重要だ。共有されて長期間稼働するランナーは状態を蓄積し、その状態がフレーキーなテストを引き起こす。
ARCは2つのスケーリングモードをサポートする:
- RunnerDeployment: 固定レプリカ数 — 常時起動のランナー、予測可能なキャパシティ
- RunnerSetとHorizontalRunnerAutoscaler: キューの深さに応じて0からNにスケール — 使った分だけのコスト
本番環境ではほぼ必ずオートスケーラーを選ぶべきだ。イベント駆動型のスケーリング戦略については、KEDAによるKubernetesのイベント駆動型オートスケーリングも参考になる。
3つのアプローチを比較する
ARCに決める前に、3つの選択肢を検討した:
選択肢1:セルフホストVM(手動)
EC2やGCP VMにランナーを登録する方法。セットアップは非常に簡単で、完全なコントロールが得られる。ただし、OSのパッチ適用、トークンのローテーション、スケーリングはすべて自分の責任だ。1台のVMで同時実行できるジョブは1つ。10並列ならVMが10台必要になる。コスト曲線は線形で上昇し、ランナーが5台を超えたあたりから管理が難しくなる。
選択肢2:GitHub Actionsのラージランナー
GitHubは現在、4〜64コアのホスト型ランナーを販売している。便利だが高価で、16コアランナーは標準の1分あたり料金の約8倍かかる。コールドスタートは依然として発生し、GitHubのインフラ上で動作し、キューの制限も変わらない。たまに発生する重い処理なら意味があるが、毎日のビルドには費用がかさむ。
選択肢3:KubernetesにARCを導入
ランナーが自社クラスタ内のPodとして動作する。複数のランナーが1つのノードに収容できる。キャッシュはジョブをまたいで持続する。ビルドは内部サービスに直接アクセスできる。スケーリングはGitHubの実際のジョブキューに連動する。初期セットアップに数時間かかるが、一度稼働すれば意識する必要がなくなる。
すでにKubernetesを使っているなら、これが最も自然な選択だ。初期セットアップ後の運用オーバーヘッドはほぼゼロに近い。
ARCのセットアップ:ステップバイステップ
前提条件
- 稼働中のKubernetesクラスタ(EKS、GKE、k3s — どれでも動作する)
- クラスタに向けて設定済みの
kubectl - Helm 3がインストール済み
repoとadmin:orgスコープを持つGitHub Personal Access Token — または GitHub App(本番環境では強く推奨、詳細は後述)
ステップ1:HelmでARCをインストール
# ARC Helmリポジトリを追加
helm repo add actions-runner-controller https://actions-runner-controller.github.io/actions-runner-controller
helm repo update
# コントローラーを専用のnamespaceにインストール
helm install arc \
--namespace actions-runner-system \
--create-namespace \
actions-runner-controller/actions-runner-controller \
--set authSecret.create=true \
--set authSecret.github_token="ghp_YOUR_PAT_HERE"
コントローラーPodが起動するまで待つ。HelmによるKubernetesのYAML管理に慣れていない場合は、先にその基礎を押さえておくと理解が深まる:
kubectl get pods -n actions-runner-system
# NAME READY STATUS RESTARTS
# arc-actions-runner-controller-xxxx-yyyy 1/1 Running 0
ステップ2:RunnerDeploymentを作成
まず固定スケールのデプロイメントから始める。2レプリカで確認し、すべてが登録されたらオートスケーリングを追加する:
# runner-deployment.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: my-runners
namespace: actions-runner-system
spec:
replicas: 2
template:
spec:
repository: your-org/your-repo # org全体に適用する場合は 'organization: your-org' を使用
image: summerwind/actions-runner:latest
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "2"
memory: "4Gi"
kubectl apply -f runner-deployment.yaml
# ランナーがGitHubに登録されたか確認
kubectl get runners -n actions-runner-system
# NAME REPOSITORY STATUS
# my-runners-xxxx your-org/your-repo Running
GitHubリポジトリのSettings → Actions → Runnersを開く。約30秒以内に2つのアイドルランナーが表示されるはずだ。
ステップ3:オートスケーリングを追加
深夜2時に固定レプリカを維持するのはコストの無駄だ。HorizontalRunnerAutoscalerはキューの深さを監視し、ランナー数をリアルタイムで調整する:
# runner-autoscaler.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
name: my-runners-autoscaler
namespace: actions-runner-system
spec:
scaleTargetRef:
name: my-runners
minReplicas: 0 # アイドル時はゼロにスケールダウン
maxReplicas: 10
metrics:
- type: PercentageRunnersBusy
scaleUpThreshold: '0.75'
scaleDownThreshold: '0.25'
scaleUpFactor: '1.5'
scaleDownFactor: '0.5'
kubectl apply -f runner-autoscaler.yaml
ステップ4:ワークフローをセルフホストランナーに向ける
ワークフローファイルを1行変更するだけだ。複数チームでワークフローを再利用しているなら、Reusable WorkflowsとComposite ActionsでGitHub Actionsを共通化するアプローチと組み合わせると保守性がさらに上がる:
# .github/workflows/ci.yml
jobs:
build:
runs-on: self-hosted # またはカスタムラベルを使用
steps:
- uses: actions/checkout@v4
- name: ビルドとテスト
run: make test
異なるリソースサイズや異なるチーム向けに、ジョブを別のランナープールにルーティングしたい場合はラベルを使う:
# RunnerDeployment specの中:
spec:
template:
spec:
labels:
- large
- gpu
# ワークフローの中:
runs-on: [self-hosted, large]
本番環境での堅牢化:ドキュメントに書かれていないこと
GitHub AppはPATより常に優れている
PATは有効期限がある。また特定のユーザーアカウントに紐付いているため、そのエンジニアが退職した瞬間、そのトークンを使うすべてのワークフローが金曜日の深夜3時に壊れる。こんな目に遭わないようにしよう。
代わりにGitHub Appを作成する。ARCはファーストクラスのサポートを持ち、トークンのローテーションは自動的に処理される。シークレット管理全般のベストプラクティスについてはGitleaksをCI/CDに統合してシークレット漏洩を防ぐ方法も合わせて押さえておきたい:
# GitHub Appの認証情報からシークレットを作成
kubectl create secret generic arc-github-app \
--namespace actions-runner-system \
--from-literal=github_app_id="YOUR_APP_ID" \
--from-literal=github_app_installation_id="YOUR_INSTALL_ID" \
--from-literal=github_app_private_key="$(cat private-key.pem)"
Dockerビルド:特権コンテナを避ける
DinD(Docker-in-Docker)は動作するが、特権コンテナが必要になる。クラスタ内では実際の攻撃対象領域となる。代わりにKanikoやBuildKitを別のPodとして使う — ルートレスで、権限昇格不要だ。Remote CachingとDocker BuildKitでCI/CDパイプラインをさらに高速化する手法と組み合わせることで、ビルド効率を最大限に引き出せる:
# 特権モードなしでDockerイメージをビルド
- name: イメージのビルド
uses: int128/kaniko-action@v1
with:
push: true
tags: ghcr.io/your-org/your-app:latest
永続キャッシュ:最大の恩恵
ここがセルフホストランナーがGitHub提供のランナーに本当に勝る点だ。PVCをランナーPodにマウントし、実行をまたいでキャッシュを保持する:
spec:
template:
spec:
volumeMounts:
- mountPath: /home/runner/.cache
name: runner-cache
volumes:
- name: runner-cache
persistentVolumeClaim:
claimName: runner-cache-pvc
Dockerのレイヤー、npmモジュール、pipパッケージ — 次回の実行時にすべてウォームな状態で使える。この1つの変更だけで、ビルド時間が14分から4分に短縮された。他のどんな改善もこれには及ばなかった。
本番稼働3ヶ月後の結果
ワークフローの80%をセルフホストランナーに移行した。3ヶ月の実運用後:
- コスト: GitHub Actionsの費用が月約$800から$120以下に減少。残りは外部ネットワークアクセスが必要な一部のワークフローにGitHub提供のランナーを使い続けているため。
- キュー待ち時間: 混雑日のP95待機時間が12分から90秒以内に短縮。
- セキュリティ: ランナーが内部ステージング環境に直接アクセスできるようになった。VPNトンネルの迂回策も、パブリックへの露出もない。
- 信頼性: 3ヶ月間でランナーPodが2回失敗した。ARCはどちらも自動的に再起動した。開発者はどちらの障害にも気づかなかった。
このプロジェクト全体のきっかけとなった深夜2時のキュー危機?あれ以来一度も起きていない。
すでにKubernetesを運用していて、GitHub Actionsの費用を実際に払っているなら、ARCはセットアップ時間を数週間以内に回収できる。設定に数時間。次のリリース日にはすぐ効果が出る。

