![GitHub Actions Self-hosted Runner の登録フロー図。Dockerコンテナが登録トークン取得時に競合しリトライする問題と、ファイルロックやリトライループで正常に登録される解決策を、男性キャラクターが説明している。](https://wakatchi.dev/wp-content/uploads/2026/05/github-actions-self-hosted-runner-registration-race-condition-eyecatch.webp)

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

``bash
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 に渡す必要があります。

`bash
# 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文字だけを出力します。

`bash
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行に圧縮してログに記録します。

`bash
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) で排他制御します。

`bash
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 ファイルを共有します。

`yaml
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 に失敗した場合のリトライも組み込みます。

`bash
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秒のクールダウン**を入れます。

`bash
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)を受け取れるようにします。

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

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

## Phase 3:smoke test

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

`bash
#!/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 かつ正しい名前プレフィックスか

`bash
# コンテナ側チェック
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 競合が原因だと特定できず、的外れな修正をしていた可能性があります。

## 参考リンク

- [flock(1) - Linux man page](https://man7.org/linux/man-pages/man1/flock.1.html)
- [GitHub REST API - Create a registration token](https://docs.github.com/en/rest/actions/self-hosted-runners#create-a-registration-token-for-an-organization)