Actions Runner ControllerでKubernetes上にセルフホストGitHub Actionsランナーを構築する

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

深夜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がインストール済み
  • repoadmin: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)は動作するが、特権コンテナが必要になる。クラスタ内では実際の攻撃対象領域となる。代わりにKanikoBuildKitを別の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はセットアップ時間を数週間以内に回収できる。設定に数時間。次のリリース日にはすぐ効果が出る。

Share: