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

