背景
GitHub Actions を使っていると、どこかのタイミングで ubuntu-latest(GitHub-hosted runner)だけでは足りなくなる場面に出会います。
私の場合は、
- 社内ネットワーク経由でないと触れないリソースがある
- ジョブごとに固定の IP からアクセスさせたい
- Azure CLI や Python、独自ツールを毎回セットアップせず、最初から入った状態で動かしたい
- でも常時 VM を立てておくのは、正直もったいない
といった理由から、self-hosted runner を検討することになりました。
ただ、self-hosted runner と聞くと、まず頭に浮かぶのが「VM を一台用意して、そこに runner を常駐させる」という構成だと思います。これは確かに動きますが、アイドル時間も課金されますし、パッチ当てや OS アップデートの面倒も背負うことになります。
そこで今回は、Azure Container Apps の Job 機能を使い、KEDA の github-runner スケーラーで、ジョブが来た時だけ runner を起動する という構成を作ってみました。アイドル時はゼロスケール、ジョブが詰まれたら必要な数だけ立ち上がる、エフェメラル(使い捨て)な runner です。
注意点 / 前提
先に断っておきます。
- この記事の内容は、私が実際に手元のリポジトリで動かしてみた構成と、その過程で得た知見に基づく 個人の見解 です
- 本番運用に持ち込むなら、組織のセキュリティ要件・監査要件に合わせた追加検討が必要です
- 特に パブリックリポジトリでの self-hosted runner 利用は推奨されていません(不特定多数からの PR で任意コード実行されるリスクがあるため)。プライベートリポジトリ前提で読んでください
- Docker-in-Docker は基本的に動きません。Docker ビルドが必要なワークフローは別の選択肢を検討してください
- コードや手順は記事執筆時点のもので、Azure CLI / KEDA / Actions Runner のバージョン更新で変わる可能性があります
このあたりを踏まえた上で、「現実的な落としどころ」として読んでもらえると嬉しいです。
整理:何が問題で、何が問題ではないか
self-hosted runner の話をする前に、一度整理しておきます。
そもそも GitHub Actions の runner には、大きく分けて以下の選択肢があります。
| 種別 | 例 | 向いている用途 |
|---|---|---|
| GitHub-hosted | ubuntu-latest |
一般的な CI/CD、OSS、追加要件のないワークロード |
| Self-hosted (常駐 VM) | EC2 / Azure VM 上に runner を常駐 | 常に処理が走り続ける環境 |
| Self-hosted (エフェメラル / コンテナ) | ACA Job, AKS + ARC, etc. | 必要な時だけ起動、固定環境を持ちたい |
「self-hosted = 高い・面倒・つらい」とは限らない というのが、まず最初の整理です。
常駐型を選ぶと確かにそうなりがちですが、エフェメラル型なら話が変わります。
そして今回扱う「ACA Job + KEDA」は、
- アイドル時の課金が(ほぼ)ない
- runner イメージにツールを焼き込めるので、ワークフロー側の
setup-*を減らせる - スケール上限・タイムアウト・ポーリング間隔をきっちり制御できる
という意味で、「専用 runner が欲しいけど、運用コストは抑えたい」場合の現実的な選択肢 だと考えています。万能ではありませんが、当てはまる場面では十分使えます。
全体像
構成はこんな形になります。
GitHub Actions Job Queue
|
v
KEDA github-runner scaler (Container Apps Job)
|
v
Azure Container Apps Job (Event Trigger)
|
v
Runner Container
- GitHub Actions Runner (ephemeral)
- Python 3.12 / pip / pytest
- Azure CLI / jq / shellcheck / gh / Node.js 20
|
v
1 ジョブ完了後にコンテナ終了 → アイドル時 0 実行
ポイントは KEDA の github-runner scaler です。これが GitHub API を定期的に叩いて「キューに溜まっているジョブの数」を見にいき、必要数だけ Container Apps Job の execution を起動してくれます。
ジョブが終われば runner プロセスが exit し、コンテナも落ちる。次のジョブが来るまで何も動かない、というのが基本動作です。
実装はリポジトリ内の runner 配下に置きました。中身は次の3ファイルです。
- Dockerfile — runner イメージ定義
- entrypoint.sh — コンテナ起動時に runner を登録・実行・後始末
- deploy.sh — Azure リソース作成+デプロイをまとめて行うスクリプト
順番に見ていきます。
1. Runner イメージ:何を焼き込むか
GitHub-hosted runner は最初からツールが盛りだくさんで便利ですが、self-hosted では自分で揃えます。
私は「ワークフローで毎回 setup-python や apt-get install をしている分」をイメージに焼き込んでしまうことにしました。これだけでジョブの体感速度がだいぶ変わります。
Dockerfile(抜粋):
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
# 共通ツール
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git gnupg jq lsb-release shellcheck \
software-properties-common tar unzip \
&& rm -rf /var/lib/apt/lists/*
# Python 3.12
RUN add-apt-repository ppa:deadsnakes/ppa \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
python3.12 python3.12-dev python3.12-venv \
&& update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 \
&& python3 -m ensurepip --upgrade \
&& python3 -m pip install --no-cache-dir --upgrade pip pytest
# Azure CLI(Microsoft 公式 apt リポジトリ + 署名検証)
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \
| gpg --dearmor -o /etc/apt/keyrings/microsoft.gpg \
&& AZ_REPO="$(lsb_release -cs)" \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/microsoft.gpg] https://packages.microsoft.com/repos/azure-cli/ ${AZ_REPO} main" \
> /etc/apt/sources.list.d/azure-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends azure-cli
ここでひとつ注意点があります。USER runner で非 root 実行にしているのですが、GitHub 公式の actions/setup-python などの中では sudo apt-get を使う処理があり、sudo がないと失敗します。なので NOPASSWD で sudo を入れています。
RUN apt-get install -y --no-install-recommends sudo \
&& echo "runner ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/runner \
&& chmod 0440 /etc/sudoers.d/runner
そして、地味だけど一番ハマりやすいのが Runner のバージョン指定 です。
# 注意: GitHub は古い Runner バージョンを deprecate し、メッセージ受信を停止することがある。
# deprecated 版だと登録までは通るが、ジョブを 1 件も拾えなくなる。
# 目安として 3 か月に 1 回はこの値を最新化すること。
ARG RUNNER_VERSION=2.334.0
ARG RUNNER_ARCH=x64
RUN curl -fsSL -o actions-runner.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz" \
&& tar -xzf actions-runner.tar.gz \
&& rm actions-runner.tar.gz \
&& ./bin/installdependencies.sh
ここは後で改めて触れます。実際にハマったところなので。
2. entrypoint.sh:エフェメラル runner として振る舞う
コンテナ起動時に「runner を登録 → ジョブを 1 本だけ実行 → 自分を後片付け」というライフサイクルを担うのが entrypoint です。
entrypoint.sh(抜粋):
#!/usr/bin/env bash
set -euo pipefail
: "${GITHUB_PAT:?GITHUB_PAT is required}"
: "${REPO_URL:?REPO_URL is required}"
RUNNER_NAME="${RUNNER_NAME:-aca-runner-$(hostname)}"
RUNNER_LABELS="${RUNNER_LABELS:-self-hosted,linux,x64,aca}"
# REPO_URL から owner / repo を抽出(雑なパースだと事故るので関数化)
read -r REPO_OWNER REPO_NAME < <(extract_owner_repo "$REPO_URL")
# 登録トークンを取得(PAT で API を叩く)
REG_TOKEN="$(curl -fsSL -X POST \
-H "Authorization: token ${GITHUB_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/actions/runners/registration-token" \
| jq -r '.token')"
# 終了時に runner を必ず GitHub 側からも除去する
trap cleanup EXIT SIGTERM SIGINT
# --disableupdate は付けない(理由は後述)
./config.sh \
--unattended \
--url "$REPO_URL" \
--token "$REG_TOKEN" \
--name "$RUNNER_NAME" \
--labels "$RUNNER_LABELS" \
--ephemeral
./run.sh
ポイントは2つです。
--ephemeral を必ず付ける
これを付けると、runner は ジョブを 1 本実行したら自動で exit します。コンテナ自体もそこで終わり、KEDA は次のジョブを見て新しい execution を起こす、という綺麗なループになります。常駐させてしまうと、ジョブ間で状態が残って事故るので避けたほうが無難です。
--disableupdate は 付けない
これも大事です。詳しくはトラブルシュートで触れますが、付けてしまうと「runner が deprecate された瞬間にすべてのジョブが拾えなくなる」事故が起きます。エフェメラルなので、起動時に自動更新されてもコストは小さい。素直に自動更新に任せます。
3. deploy.sh:Azure 側のリソースを一気に作る
ここが本丸です。やることは多いのですが、流れはシンプルです。
- リソースグループ作成
- Azure Container Registry(ACR)作成(Admin user は無効)
- Container Apps Environment 作成
- Key Vault 作成 → そこに GitHub PAT を格納
- ACR 上で runner イメージをビルド&プッシュ
- ACR pull 用の User-Assigned Managed Identity を作って RBAC を付与
- Container Apps Job を、KEDA
github-runnerscaler 付きで作成
実体験から言うと、ACR への pull 権限は Job 作成より「先に」用意しておく のがコツです。Job 作成時点でイメージ pull が走るので、後から identity と RBAC を付けると初回が転びます。
deploy.sh(要点抜粋):
# Step 5.5: ACR pull 用 User-Assigned Managed Identity(先に作る)
JOB_IDENTITY_NAME="${JOB_NAME}-identity"
az identity create --name "$JOB_IDENTITY_NAME" --resource-group "$RESOURCE_GROUP"
JOB_IDENTITY_ID="$(az identity show --name "$JOB_IDENTITY_NAME" \
--resource-group "$RESOURCE_GROUP" --query id -o tsv)"
JOB_IDENTITY_PRINCIPAL_ID="$(az identity show --name "$JOB_IDENTITY_NAME" \
--resource-group "$RESOURCE_GROUP" --query principalId -o tsv)"
# AcrPull (Built-in role) を付与
az role assignment create \
--assignee-object-id "$JOB_IDENTITY_PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--scope "$ACR_ID" \
--role "7f951dda-4ed3-4680-a7ca-43fe172d538d" # AcrPull
そして、Container Apps Job 本体の作成です。
ここは長くなりますが、意味のあるパラメータを残して載せます。
az containerapp job create \
--name "$JOB_NAME" \
--resource-group "$RESOURCE_GROUP" \
--environment "$CONTAINERAPPS_ENV" \
--trigger-type Event \
--min-executions 0 \
--max-executions 5 \
--polling-interval 30 \
--replica-timeout 3600 \
--replica-retry-limit 0 \
--replica-completion-count 1 \
--parallelism 1 \
--image "${ACR_SERVER}/${IMAGE_NAME}" \
--cpu 2.0 --memory 4Gi \
--mi-user-assigned "$JOB_IDENTITY_ID" \
--registry-server "$ACR_SERVER" \
--registry-identity "$JOB_IDENTITY_ID" \
--secrets "github-pat=${GITHUB_PAT_VALUE}" \
--env-vars \
"GITHUB_PAT=secretref:github-pat" \
"REPO_URL=${REPO_URL}" \
"RUNNER_LABELS=${RUNNER_LABELS}" \
--scale-rule-name github-runner \
--scale-rule-type github-runner \
--scale-rule-metadata "${SCALE_METADATA[@]}" \
--scale-rule-auth "personalAccessToken=github-pat"
主要パラメータの意図はこうです。
-
--min-executions 0— アイドル時はゼロスケール。これがないと結局常時起動になります -
--max-executions 5— 同時に走れる runner の上限。コストとレイテンシのトレードオフで決めます -
--polling-interval 30— KEDA が GitHub API を叩く間隔(秒)。短くするとジョブの拾いが速いが API 消費が増える -
--replica-completion-count 1/--parallelism 1— 1 execution = 1 runner = 1 ジョブ。エフェメラル設計と整合させる -
--replica-retry-limit 0— runner プロセスが落ちたとき ACA 側で勝手にリトライしない。リトライは GitHub Actions 側に任せる
KEDA の labels メタデータ:地味だけど重要
ここは私が一番ハマったところなので、独立して書きます。
KEDA の github-runner scaler は、内部で reservedLabels = [self-hosted, linux, x64] を自動付与します。なので scaler の labels メタデータには、それ以外の追加ラベル だけを渡す必要があります。
たとえば runner 側のラベルが self-hosted,linux,x64,aca なら、KEDA に渡すべきは aca だけ。
ここを誤って self-hosted,linux,x64,aca を全部渡すと、scaler が「このジョブは自分の対象じゃない」と判断してスケールしません。
症状としては、GitHub 側のジョブが永遠に Waiting for a runner to pick up this job... のまま止まります。Azure 側の execution は 0 件のまま。これは経験すると本当に分からなくて辛いです。
deploy.sh ではこう処理しています。
# RUNNER_LABELS から KEDA reserved labels を除いた追加ラベルだけを抽出
RUNNER_EXTRA_LABELS="$(echo "$RUNNER_LABELS" \
| tr ',' '\n' \
| awk 'NF && tolower($0) != "self-hosted" \
&& tolower($0) != "linux" \
&& tolower($0) != "x64"' \
| paste -sd ',' -)"
SCALE_METADATA=(
"githubAPIURL=https://api.github.com"
"owner=${REPO_OWNER}"
"repos=${REPO_NAME}"
"runnerScope=repo"
"targetWorkflowQueueLength=1"
)
if [[ -n "$RUNNER_EXTRA_LABELS" ]]; then
SCALE_METADATA+=("labels=${RUNNER_EXTRA_LABELS}")
fi
RUNNER_LABELS を変えれば KEDA 側にも自動で反映されるようにしてあるので、運用時はこの環境変数だけ気にしていればよくなります。
4. 実行手順
ここまで来たら、利用者側の手順はとても短くなります。
4-1. GitHub 側で PAT を発行
Settings → Developer settings → Personal access tokens → Tokens (classic) から発行。
- スコープ:
repo+admin:repo_hook - 有効期限は短めに(30〜90 日くらい)
ここで使う PAT は、GitHub Copilot や他用途で使っているものとは 別物として発行する のが安全です。用途を混ぜない。
4-2. ローカルから一発実行
# 必須
export GITHUB_PAT="github_pat_xxxxxxxxxxxxxxxxxxxx"
export REPO_URL="https://github.com/<owner>/<repo>"
# 任意(未指定ならデフォルトが使われる)
export RESOURCE_GROUP="my-runner-rg"
export LOCATION="japaneast"
export RUNNER_LABELS="self-hosted,linux,x64,aca"
az login
./tools/runner/deploy.sh
deploy.sh を再実行すると 既存の Container Apps Job は削除・再作成されます。実行中のジョブがないタイミングで叩いてください。ここはスクリプト側で守ってあげても良いかもしれませんが、運用ポリシー次第なので今回は素直に上書きにしています。
4-3. ワークフローを書き換える
# Before
runs-on: ubuntu-latest
# After
runs-on: [self-hosted, linux, x64, aca]
最初は 1 本だけ移行して動作確認、問題なければ順次広げる、というのが安全です。
5. 実際にハマったこと:deprecated runner 問題
ここは独立した節として残しておきたい話です。
ある日、「ACA Job の execution はどんどん Succeeded で増えているのに、GitHub Actions のジョブは Waiting for a runner... のまま」 という現象が起きました。
各 execution の実行時間は 30 秒〜1 分程度。ログを Log Analytics から掘ると、こんなメッセージが出ていました。
√ Runner successfully added
...
An error occured: Runner version vX.Y.Z is deprecated and cannot receive messages.
Runner listener exit with terminated error, stop the service, no retry needed.
つまり「登録は成功するけど、メッセージ受信を拒否されて即 exit している」状態です。
コンテナは exit 0 で終わるので ACA は Succeeded と表示。KEDA はキューがまだ詰まっていると見えるので、また次の execution を起こす…という無限ループが回っていました。これは課金的にも気持ち的にも結構しんどいやつです。
確認方法
az containerapp job logs show は preview のせいか不安定だったので、Log Analytics を直接叩く方が確実でした。
WORKSPACE_ID=$(az containerapp env show \
-n $CONTAINERAPPS_ENV -g $RESOURCE_GROUP \
--query properties.appLogsConfiguration.logAnalyticsConfiguration.customerId -o tsv)
az monitor log-analytics query \
--workspace "$WORKSPACE_ID" \
--analytics-query "ContainerAppConsoleLogs_CL
| where ContainerGroupName_s startswith '${JOB_NAME}-'
| where TimeGenerated > ago(30m)
| where Log_s contains 'deprecated' or Log_s contains 'Listening for Jobs'
| project TimeGenerated, ContainerGroupName_s, Log_s
| order by TimeGenerated desc" \
-o table
deprecated and cannot receive messages が見えたら確定です。
対処
- https://github.com/actions/runner/releases で最新安定版を確認
- Dockerfile の
ARG RUNNER_VERSION=を更新 - entrypoint.sh の
./config.sh呼び出しから--disableupdateを外す(自動更新で救済できるようにする) - deploy.sh を再実行
「目安として 3 か月に 1 回は RUNNER_VERSION を最新化する」を運用ルールに入れておくと、再発しにくくなります。
ここまでの整理
良い点:
- アイドル時ゼロスケール で、常駐 VM 型に比べてコストが大きく下げられる
- 必要なツールを イメージに焼き込める ので、ワークフロー側の
setup-*を減らせて速い - Container Apps Job の
replica-timeout/parallelism/max-executionsで 挙動を細かく制御可能 - Key Vault + Managed Identity で PAT を Job 環境変数にベタ書きしないで済む
注意点:
- Docker-in-Docker は基本的に動かない。Docker ビルドが必要なワークフローは別の手段が必要
- KEDA scaler の
labelsメタデータ は reserved labels を除いた追加分だけを渡す必要がある - Runner のバージョンは定期的に最新化しないと、deprecated で全滅する事故が起きる
- パブリックリポジトリでの利用は非推奨
限界:
- 大量の重いビルドが常時走るような環境では、起動オーバーヘッドが効いてくる場面がある
- ジョブごとにクリーンな環境というメリットの裏返しで、キャッシュ戦略は別途設計が要る(Actions Cache を素直に使う、ACR にキャッシュ用イメージを置く、など)
まとめ
GitHub-hosted runner で困っていなければ、無理に self-hosted に行く必要はありません。これは本心です。
ですが、
- 固定 IP / 閉域 / 社内リソースアクセスの要件がある
- 自前ツールチェインを毎回セットアップしたくない
- でも常駐 VM のコストと運用は避けたい
このどれかが当てはまる現場では、「ACA Job + KEDA + エフェメラル runner」 は十分に検討に値します。
構築自体は今回の deploy.sh のように一本のスクリプトに落とせますし、運用上気をつけるポイント(KEDA labels、runner バージョン、PAT ローテーション)も限定的です。
最初の 1 本のワークフローだけ runs-on を切り替えて、しばらく走らせてみる。
そこで体感が良ければ広げる。合わなければ戻す。
self-hosted の話は、こういう「小さく試す」が一番だと、私は思っています。