GitHub Actions3年本番運用で気付いた、セルフホストランナーの落とし穴と対策
GitHub Actionsのセルフホストランナーで月200万円の無駄を経験。スケーリング失敗、キャッシュ戦略、本当に効いた対策を3年の試行錯誤から紹介します。
GitHub Actionsに1年溶かした話
うちのチームがGitHub Actionsを本格的に導入したのは2023年。当初は「GitHubネイティブだし簡単だろう」って甘く見てました。3年経った今、月額200万円のランナー費用と、セルフホストランナーの管理地獄を経験してます。
最初は標準のGitHub-hostedランナーで運用してたんですが、ビルドタイムが無駄に長くて、本番デプロイが1時間以上かかることもありました。チームから「遅い」という声が上がり始めたころ、セルフホストランナーを導入すれば解決すると思ってました。
でも実際はそんな単純じゃなかったんですよね。
セルフホストランナーの沼にハマった
最初、ECS上にセルフホストランナーを立てました。「Dockerコンテナ化すれば管理しやすい」という判断です。オートスケーリング使って効率的に回そうと思ってたんですが、実際には3つの大きな問題に直面しました。
問題1:ジョブの粒度とスケーリングのタイミングが合わない
まずジョブの粒度とスケーリングのタイミングが合わなかったんです。GitHub Actionsのジョブキューを監視してECSタスクをスケールアップするのって、遅延が避けられません。キューに溜まってからスケールしても、ジョブが完了しないとタスクを落とせないので、結果的にアイドルタイムが増えるんですよね。
我々の場合、デプロイパイプラインで同時に10個のジョブが走るんですが、そのうち1個だけ時間がかかるとボトルネックになる。その1個を待つために、他の9個のランナーがアイドル状態で課金されてました。月20万円くらい無駄にしてました。
問題2:複数ランナーでキャッシュが効かない
次にキャッシュ戦略の失敗。GitHub Actionsのキャッシュって、ランナーごとに独立してるんですが、複数ランナーがある環境では「キャッシュヒット率が意外に低い」という問題が出ました。
name: Build Pipeline
on: [push]
jobs:
build:
runs-on: [self-hosted, linux, x64]
steps:
- uses: actions/checkout@v4
- name: Restore node_modules
uses: actions/cache@v4
with:
path: |
node_modules/
.next/cache/
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
このキャッシュ設定で、100回のビルドのうち30回しかキャッシュがヒットしないんです。ランナーが複数あると、同じランナーに当たる確率が下がるんですよね。S3をキャッシュバックエンドにする案も浮かびましたが、その時点では速度改善が微妙だった。
問題3:ランナーの汚染問題が深刻
3番目がランナーの汚染問題です。セルフホストランナーって、実行環境を共有するから、ジョブAで入った依存パッケージが、その後のジョブBに影響することがありました。
# 実際に起きたやつ
# Job Aで node 18.x をインストール
# Job Bは node 16.x を期待してたけど node 18.x で実行される
# → テストが通らない、原因不明で2時間ハマる
Dockerで毎回クリーンな環境を作ればいいんですが、そうするとビルドが遅くなる。結局、ランナーのセットアップスクリプトで毎回環境をリセットする工夫をしました。
2026年時点での「現実的な」構成に落ち着いた
3年の試行錯誤の末、うちのチームが本当に安定した構成はこんな感じです:
graph TB
subgraph GitHub["GitHub Repository"]
Actions["GitHub Actions Workflow"]
end
subgraph AWS["AWS Environment"]
subgraph ECS["ECS Cluster"]
Runner1["Selfhosted Runner 1<br/>t3.xlarge persistent"]
Runner2["Selfhosted Runner 2<br/>t3.xlarge persistent"]
Runner3["Selfhosted Runner 3<br/>t3.xlarge persistent"]
end
subgraph Scaling["Auto Scaling Group"]
SpotRunner["Spot Instances<br/>バースト用t3.large"]
end
S3Cache["S3 Cache Bucket<br/>lifecycle rule 7days"]
ECR["ECR Registry<br/>Built Images"]
end
Actions -->|register| Runner1
Actions -->|register| Runner2
Actions -->|register| Runner3
Actions -->|burstload| SpotRunner
Runner1 -->|cache read/write| S3Cache
Runner2 -->|cache read/write| S3Cache
Runner3 -->|cache read/write| S3Cache
Runner1 -->|push image| ECR
Runner2 -->|push image| ECR
Runner3 -->|push image| ECR
ポイントは3つ:
- 常時ランナーを3台固定で配置。これで基本的なジョブはほぼ同じランナーに当たるようにして、キャッシュヒット率を上げる工夫をしてます
- バースト用のSpot Instanceをオートスケーリングで、同時実行数が4以上になったら立ち上げる仕組み
- S3ベースのキャッシュ共有。GitHub Actionsのキャッシュ機能だけに頼らず、ビルド成果物を明示的にS3に保存
これで月のランナー費用が200万円から42万円に削減できました。正直、この成果が出るまで本当に長かった。
キャッシュ戦略の改善——S3+CloudFrontで劇的に変わった
2026年の今、キャッシュ戦略で一番効いてるのはS3をキャッシュバックエンドにすることです。GitHub Actionsのactions/cache@v4は実はS3をサポートしてないんですが、自前で以下のように実装してます:
name: Smart Cache Strategy
on: [push, workflow_dispatch]
jobs:
build:
runs-on: [self-hosted, linux, x64]
steps:
- uses: actions/checkout@v4
- name: Generate cache keys
id: cache-keys
run: |
echo "node-key=node-$(cat package-lock.json | md5sum | cut -d' ' -f1)" >> $GITHUB_OUTPUT
echo "pip-key=pip-$(python -m pip freeze | md5sum | cut -d' ' -f1)" >> $GITHUB_OUTPUT
- name: Download node_modules from S3
run: |
if aws s3 ls s3://our-cache-bucket/node-${{ steps.cache-keys.outputs.node-key }}/ &> /dev/null; then
aws s3 sync s3://our-cache-bucket/node-${{ steps.cache-keys.outputs.node-key }}/ node_modules/
echo "✓ Node cache HIT from S3"
else
echo "✗ Node cache MISS - installing fresh"
fi
- name: npm ci
run: npm ci --prefer-offline
- name: Upload node_modules to S3
if: always()
run: |
aws s3 sync node_modules/ s3://our-cache-bucket/node-${{ steps.cache-keys.outputs.node-key }}/
aws s3api put-object-tagging --bucket our-cache-bucket --key "node-${{ steps.cache-keys.outputs.node-key }}/" --tagging 'TagSet=[{Key=expiry,Value=7days}]'
これでキャッシュヒット率が30%から82%に改善しました。S3からの転送速度って、同じAWSリージョンなら秒単位ですからね。
ただし注意点として、S3の転送コストが無視できません。大量にキャッシュをアップロード・ダウンロードするとS3リクエスト料金がかさみます。我々は7日以上のキャッシュは自動削除するLifecyclePolicy を設定して、月3000円程度に抑えました。
ランナーラベルの自動スケーリング——2026年でやっと安定した
GitHub Actionsって、runs-on でランナーをフィルタリングできるんですが、この仕組みがセルフホストランナーの複雑さの元です。
jobs:
heavy-job:
runs-on: [self-hosted, linux, memory-intensive]
quick-job:
runs-on: [self-hosted, linux, spot]
こういう指定をするときに、実際には地雷がいくつかあります:
- ラベルと実物のマッピングがズレる。ランナーが落ちてるのに、GitHubはまだ「active」と表示してることがある
- 優先度がない。キューに溜まったとき、重いジョブが軽いジョブより後ろになることもある
うちで採った対策は、CloudWatch + Lambda + GitHub API で監視・自動スケーリングする仕組みです:
import boto3
import requests
import json
from datetime import datetime
github_token = 'ghp_...'
repo = 'our-org/our-repo'
def lambda_handler(event, context):
# GitHub APIでジョブキューを確認
headers = {'Authorization': f'Bearer {github_token}'}
# 待機中のジョブを取得(非公式APIの走査)
# 実際はGitHub APIの制限で完全な情報取得は難しいので、
# webhookとDB組み合わせで実装
queue_size = get_pending_jobs_count()
active_runners = get_active_self_hosted_runners()
# ロジック:待機ジョブが多い場合だけSpot instanceを立ち上げ
if queue_size > active_runners * 1.5:
asg = boto3.client('autoscaling')
asg.set_desired_capacity(
AutoScalingGroupName='github-actions-spot',
DesiredCapacity=min(queue_size - active_runners, 10),
HonorCooldown=False
)
print(f"Scaled up: {queue_size} pending, {active_runners} active")
else:
# スケールダウン
asg.set_desired_capacity(
AutoScalingGroupName='github-actions-spot',
DesiredCapacity=0
)
これで無駄なアイドル時間が減りました。ただしGitHub APIの制限が厳しい(60 req/hour)ので、WebhookでジョブイベントをDynamoDBに記録して、それを監視する方式に変えてます。
2026年に見えてきた「正直な選択肢」
ぶっちゃけた話、セルフホストランナーの運用ってめちゃくちゃ手間です。3年やってきた経験から、以下のような選択をお勧めします:
| 状況 | GitHub-hosted | セルフホスト | Buildkite/CircleCI |
|---|---|---|---|
| スタートアップ/小規模チーム | ✅推奨 | ❌非推奨 | △検討 |
| 高頻度ビルド(100+/day) | ❌遅い | ✅推奨 | ✅推奨 |
| GPUが必要 | △高い | ✅推奨 | ✅推奨 |
| セキュリティ厳重 | △限定的 | ✅推奨 | △中程度 |
| 管理工数 | 少ない | 多い | 中くらい |
管理工数が「多い」というのが重要です。2026年時点でGitHub Actionsはめっちゃ成熟してますが、セルフホスト運用は相変わらず泥臭い。環境の世話が絶えません。
もし月額50万円以上のランナー費用がかかってる場合を除いて、正直言ってセルフホストランナーは導入前に真剣に検討すべきです。我々の場合は投資対効果が出ましたが、それは大規模なチーム(エンジニア40名+)だからです。小規模なら GitHub-hosted で十分な場合がほとんど。
実際に使って気づいたTips——2026年版
ワークフロー並列化の粒度は「24分以内」が目安
GitHub Actionsのジョブタイムアウトは6時間ですが、セルフホストランナーで1個のジョブが長すぎると、他のジョブが待たされます。我々は平均24分以内に分割してます。
# ❌ Bad: 45分かかるビルド
jobs:
build:
runs-on: [self-hosted]
steps:
- run: npm ci && npm run build && npm run test
# ✅ Good: 分割することで他のジョブが待たない
jobs:
build:
runs-on: [self-hosted, quick]
steps:
- run: npm ci && npm run build
test:
needs: build
runs-on: [self-hosted, quick]
steps:
- run: npm run test
ランナーの健全性チェックをCronで定期実行
セルフホストランナーって、ディスク満杯になったり、ネットワークが切れたりするんです。以下のようなヘルスチェックワークフロー走らせてます:
name: Runner Health Check
on:
schedule:
- cron: '*/5 * * * *' # 5分ごと
jobs:
health-check:
runs-on: [self-hosted]
steps:
- name: Disk space
run: |
usage=$(df / | tail -1 | awk '{print $5}' | sed 's/%//')
if [ $usage -gt 80 ]; then
echo "⚠️ Disk usage high: $usage%"
# アラート送信
fi
- name: Memory
run: |
mem=$(free | grep Mem | awk '{printf("%.0f", $3/$2 * 100)}')
if [ $mem -gt 90 ]; then
echo "⚠️ Memory usage: $mem%"
fi
- name: Docker daemon
run: docker ps || (echo "Docker down"; exit 1)
Cost tracking を自動化
GitHub ActionsのコストトラッキングってAPIが限定的なので、CloudWatch Metricsをカスタムで送ってます:
- name: Send cost metrics
if: always()
run: |
job_duration=$(( $(date +%s) - ${{ job.start_time }} ))
runner_type="${{ runner.name }}"
# 簡易的な計算(1分1円と仮定)
cost=$(( job_duration / 60 ))
aws cloudwatch put-metric-data \
--namespace GitHubActions \
--metric-name JobCost \
--value $cost \
--dimensions Runner=$runner_type
まとめ
GitHub Actionsの3年本番運用で見えてきたのは、シンプルさと実務のギャップです。
- セルフホストランナー導入は、本当に必要な規模かどうかを冷静に判断する——管理工数が大きいので、よほどの規模でないと割に合いません
- キャッシュ戦略が全てを左右する——GitHub Actions標準のキャッシュより、S3ベースの実装を検討する価値あり
- スケーリング自動化が必須——手動運用はストレスになる。Lambdaで監視するところまでやってやっと楽
- ランナーの健全性チェックを忘れるな——セルフホストランナーの宿命として、環境が汚染される。定期チェックは必須
- 2026年時点では、小規模チームはGitHub-hostedで十分——セルフホストのROIは本当に高い場合だけ
うちのチームもまだ改善し続けてます。最近は障害対応フローとGitHub Actionsを組み合わせて、本番デプロイの信頼性を上げてるところです。
セルフホストランナーやってる人、大変ですよね。でも月額費用を半分に削減できたときの達成感はマジで大きい。試す価値はあります。