9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub Actions の self-hosted runner を、Azure Container Apps Job で実装する

9
Posted at

背景

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-pythonapt-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 側のリソースを一気に作る

ここが本丸です。やることは多いのですが、流れはシンプルです。

  1. リソースグループ作成
  2. Azure Container Registry(ACR)作成(Admin user は無効)
  3. Container Apps Environment 作成
  4. Key Vault 作成 → そこに GitHub PAT を格納
  5. ACR 上で runner イメージをビルド&プッシュ
  6. ACR pull 用の User-Assigned Managed Identity を作って RBAC を付与
  7. Container Apps Job を、KEDA github-runner scaler 付きで作成

実体験から言うと、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 を発行

SettingsDeveloper settingsPersonal access tokensTokens (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 が見えたら確定です。

対処

  1. https://github.com/actions/runner/releases で最新安定版を確認
  2. Dockerfile の ARG RUNNER_VERSION= を更新
  3. entrypoint.sh の ./config.sh 呼び出しから --disableupdate を外す(自動更新で救済できるようにする)
  4. 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 の話は、こういう「小さく試す」が一番だと、私は思っています。

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?