GitHub Actions Self-hosted Runner の登録フロー図。Dockerコンテナが登録トークン取得時に競合しリトライする問題と、ファイルロックやリトライループで正常に登録される解決策を、男性キャラクターが説明している。

Docker Compose で GitHub Actions Self-hosted Runner をスケールアウトしたら、一部のコンテナが再起動を繰り返して止まらなくなりました。

docker compose up -d --scale runner-biz-dev=3

期待:3台とも GitHub に登録されて Idle 状態になる。
現実:1台だけ生き残り、残り2台が Restarting (1) を繰り返す。

Invalid configuration provided for token. Terminating unattended configuration.
Restarting (1)... Restarting (1)... Restarting (1)...

この記事では、原因の特定から修正・検証までの3段階を記録します。

原因:registration token の同時消費

Self-hosted Runner を GitHub に登録するには、registration token を取得して config.sh に渡す必要があります。

# registration token を取得
REG_TOKEN=$(curl -X POST \
  "https://api.github.com/orgs/{org}/actions/runners/registration-token" \
  -H "Authorization: Bearer ${PAT}" | jq -r .token)

# Runner を登録
./config.sh --url https://github.com/{org} --token ${REG_TOKEN} --replace

問題は、registration token は一度使うと無効になることです。3台のコンテナが同時に起動すると、全員が同じ token を取得し、最初に config.sh を実行した1台だけが成功。残りの2台は「Invalid configuration provided for token」で失敗します。

Docker の restart policy が再起動を試みますが、token を再取得せずに同じ無効 token で config.sh を叩くため、永遠にループします。

Phase 1:診断ログの強化

まず修正せずに、何が起きているかを正確に観察できる状態にします。

token のフィンガープリント

token をログに直接出力するのはセキュリティ上 NG です。代わりに先頭4文字と末尾4文字だけを出力します。

get_registration_token() {
  local token
  token=$(curl -s -X POST ... | jq -r .token)
  
  if [ ${#token} -ge 8 ]; then
    local fp_head="${token:0:4}"
    local fp_tail="${token: -4}"
    log_step "token" "ok" "fp_head=${fp_head}*** fp_tail=***${fp_tail}"
  fi
  
  echo "$token"
}

3台のコンテナのログを並べると、全員が同じ fingerprint を持っていることが確認できます。これで「token の同時消費」が原因だと確定しました。

config.sh の失敗ログ圧縮

config.sh の stderr は複数行に渡るため、docker compose logs で追いにくくなります。先頭20行を1行に圧縮してログに記録します。

log_config_failure_head() {
  local log_file="$1"
  local compressed
  compressed=$(head -20 "$log_file" | tr '\n' '|' | cut -c1-500)
  log_step "config" "error" "head=${compressed}"
}

Phase 2:file lock による直列化 + retry

flock で registration を直列化

org ごとに lock ファイルを用意し、flock(1) で排他制御します。

with_register_lock() {
  local lock_dir="${REGISTER_LOCK_DIR:-/var/lock/runner-register}"
  local lock_timeout="${REGISTER_LOCK_TIMEOUT_S:-180}"
  
  # lock ディレクトリがなければスキップ(単体 runner との後方互換)
  if [ ! -d "$lock_dir" ]; then
    "$@"
    return $?
  fi
  
  local lock_file="${lock_dir}/${ORG_NAME}.lock"
  local t0=$(date +%s)
  
  (
    if ! flock -w "$lock_timeout" 200; then
      log_step "register_lock" "error" "timeout after ${lock_timeout}s"
      exit 1
    fi
    local t1=$(date +%s)
    log_step "register_lock" "ok" "acquired (waited=$((t1 - t0))s)"
    "$@"
  ) 200>"$lock_file"
}

flock はファイルディスクリプタ(FD 200)に対する advisory lock です。プロセスが終了すれば自動的に解放されるため、stale lock の問題が起きません。

Docker Compose で org ごとの named volume をマウントし、同一 org のコンテナ間で lock ファイルを共有します。

services:
  runner-biz-dev:
    environment:
      REGISTER_LOCK_DIR: "/var/lock/runner-register"
    volumes:
      - runner-biz-dev-register-lock:/var/lock/runner-register

volumes:
  runner-biz-dev-register-lock:

retry ロジック

lock を取得した後、registration に失敗した場合のリトライも組み込みます。

register_and_config() {
  local max_attempts="${CONFIG_RETRY_MAX:-3}"
  local attempt=0
  
  while [ "$attempt" -lt "$max_attempts" ]; do
    attempt=$((attempt + 1))
    
    # 毎回新しい token を取得(使い捨てのため)
    local reg_token
    reg_token=$(get_registration_token) || return 1
    
    if ./config.sh --url ... --token "$reg_token" --replace; then
      log_step "config" "ok" "succeeded (attempt=${attempt})"
      return 0
    fi
    
    # エラー分類
    if grep -qE 'Bad credentials|Not Found' "$config_log"; then
      log_step "config" "error" "non-retryable error"
      return 1  # 認証エラーはリトライしない
    fi
    
    # バックオフ
    local wait_s=$((attempt * 2))
    interruptible_sleep "$wait_s"
  done
  
  return 1
}

エラーを3種類に分類しています。

エラーパターン 挙動 理由
Invalid configuration provided for token リトライ(最大3回) token 消費済み、新しい token で再試行
A runner exists with the same name リトライ(最大3回) タイミング競合、--replace で解消可能
Bad credentials / Not Found 即座に失敗 認証エラーはリトライしても無駄
不明なエラー 1回だけリトライ CPU スピン防止

die() のクールダウン

config.sh が最終的に失敗した場合、exit 1 する前に 15秒のクールダウンを入れます。

die() {
  local cooldown="${ENTRYPOINT_FAILURE_COOLDOWN_S:-15}"
  log_step "shutdown" "ok" "sleeping ${cooldown}s before exit"
  sleep "$cooldown"
  exit 1
}

これがないと Docker の restart policy が即座にコンテナを再起動し、ホストの CPU を食い尽くします。

interruptible_sleep

クールダウンやリトライの待機中に SIGTERM(graceful shutdown)を受け取れるようにします。

interruptible_sleep() {
  local secs="$1"
  sleep "$secs" &
  wait "$!"
}

sleep N & + wait $! のパターンにすると、wait 中に SIGTERM が来た場合にトラップが発火します。直接 sleep N を呼ぶとシグナルがブロックされ、deregister 処理が遅延します。

Phase 3:smoke test

修正が正しく動作し続けることを保証するため、回帰テストを追加しました。

#!/usr/bin/env bash
# smoke-scale-startup.sh
PASSES="${PASSES:-5}"
SCALE="${SCALE:-3}"
TIMEOUT="${TIMEOUT:-90}"

テストは2段階で検証します。

  1. コンテナ側docker compose ps --status=running で期待台数が running か
  2. API 側:GitHub API で runner が online かつ正しい名前プレフィックスか
# コンテナ側チェック
running=$(docker compose ps --status=running -q | wc -l)
[ "$running" -eq "$((SCALE * 2))" ]

# API 側チェック
online=$(gh api "orgs/${org}/actions/runners?per_page=100" \
  | jq "[.runners[] | select(.status==\"online\") | select(.name | startswith(\"$prefix\"))] | length")
[ "$online" -eq "$SCALE" ]

5回の再起動 × scale=3 で、毎回全コンテナが正常に登録されることを確認しています。CI では平日毎日 18:00 JST にスケジュール実行し、継続的に回帰を検知しています。

まとめ

段階 施策 目的
Phase 1 診断ログ(token fingerprint + エラー圧縮) 原因の特定
Phase 2 flock 直列化 + retry + cooldown 構造的な修正
Phase 3 smoke test(5 pass × scale=3) 回帰検知

「まず観察、次に修正、最後に検証」の順番を守ったのがうまくいったポイントです。Phase 1 の診断ログがなければ、token 競合が原因だと特定できず、的外れな修正をしていた可能性があります。

参考リンク