アルアカ - Arcadia Academia

Arcadia Academiaは「エンジニアリングを楽しむ」を合言葉に日本のデジタル競争力を高めることをミッションとするテックコミュニティです。

GitHub Actionsのrunがqueuedのまま進まない時の見分け方と復旧手順

Featured image of the post

gh workflow run で投入した run が、何時間たっても queued のまま動かない」「gh run cancel を打ったら "Cannot cancel a workflow run that is completed" と返ってきた」「夜間の自動デプロイが朝まで止まっていて、何も起きなかったように見えた」——GitHub Actions を CLI から無人で回していると、いつか必ず踏む詰まりです。

原因のほとんどは自分のワークフロー設定ではなく、GitHub Actions 側の障害中に投入した run が「ゴースト化」してキューから二度と拾われないことにあります。この記事では、ゴースト run を1コマンドで見分ける方法、待たずに復旧する手順、そして無人スクリプトが朝まで固まらないための gh run watch のタイムアウト設計をまとめます。

💡
30秒で要点
- gh run view <run-id> --json status,conclusion,updatedAtqueued のまま updatedAt が投入時刻から動かなければゴースト run。待っても解けない
- 直近の run が数分で成功しているなら自分の設定の問題ではない。GitHub Status で Actions の障害を確認する
- 復旧は「同じコマンドで再ディスパッチ」。ゴースト run はキャンセルもできないので放置してよい
- 無人スクリプトの gh run watch には必ず timeout を付ける。付けないと lock を握ったまま朝まで止まる
- 証跡は「run を投入した」でなく「run が success した」を条件に書く

目次を開く

症状:queued のまま何時間も動かない run

まず「順番待ち」なのか「ゴースト」なのかを切り分けます。見るのは次の4点です。

見るもの 正常な順番待ち ゴースト run
status queued → 数分で in_progress queued のまま数時間
updatedAt ランナー割当のたびに更新される 投入時刻(createdAt)から一度も動かない
gh run cancel キャンセルできる Cannot cancel a workflow run that is completed と矛盾した応答が返る
直近の run 直前まで数分で success していた
# 状態と最終更新時刻を見る
gh run view <run-id> --repo <owner>/<repo> --json status,conclusion,createdAt,updatedAt

# 直近の run が正常だったかを並べて見る
gh run list --repo <owner>/<repo> --workflow deploy.yml --limit 6

statusqueuedconclusion が空、updatedAtcreatedAt とほぼ同じ時刻のまま何時間も止まっている——この組み合わせが揃ったらゴースト run です。ランナー不足の順番待ちなら updatedAt は動きますし、self-hosted runner 側の問題なら直近の run も落ちているはずです。

原因:Actions の障害中に投入した run はキューから拾われない

実際に踏んだ例では、00:05 に gh workflow run で投入した直後の 00:11 に GitHub Actions の障害("degraded availability for Actions")が始まり、障害が解消した後も run は9時間半 queued のまま拾われませんでした。直近6回の run はすべて3分で成功していたので、ワークフローや権限の問題ではありません。

障害中にキューへ入った run は、復旧後に再スケジュールされないことがある——そう考えておくのが安全です。「もう少し待てば動くはず」は当たりません。障害の有無は GitHub Status(githubstatus.com)の Actions の項目で確認できます。

復旧手順:待たずに再ディスパッチする

  1. GitHub Status で Actions の障害が解消していることを確認する
  2. ゴースト run はキャンセルしない(そもそも cancel が通らない)。そのまま放置してよい
  3. 元と同じコマンドで再投入する
# 例:入力付きのワークフローを再投入する
gh workflow run deploy.yml --repo <owner>/<repo> -f build_mode=cached

# 数秒待ってから、新しい run の id と状態を取る
sleep 10
gh run list --repo <owner>/<repo> --workflow deploy.yml --limit 1 --json databaseId,status --jq '.[0]'

障害が解消していれば新しい run は即 in_progress になり、通常どおり完走します。取り残されたゴースト run は一覧に残り続けますが、実害はありません。

無人スクリプトの穴:gh run watch にタイムアウトが無い

手動なら「進まないな」と気づけますが、無人スクリプトでは事情が違います。gh run watch <run-id> --exit-status は run が終わるまで戻ってきません。ゴースト run を watch すると、スクリプトは flock の lock を握ったまま朝まで止まり、後続の証跡・通知・コミットが一切出ません。翌日の定時起動も「前回がまだ動いている」と判断して skip します。結果として「昨夜は何もなかった」ように見えるのに、実際には途中の工程(この例では CMS への書込み)まで完了している、という一番読みにくい状態になります。

直し方は1行です。timeout を前に付けます。

#!/bin/bash
set -u
REPO="<owner>/<repo>"
LOCK="/path/to/deploy.lock"
exec 9>"$LOCK"
flock -n 9 || { echo "skip: running"; exit 0; }

gh workflow run deploy.yml --repo "$REPO" -f build_mode=cached
sleep 10
RUN_ID=$(gh run list --repo "$REPO" --workflow deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId')

# ここが要点:timeout を必ず付ける(通常3分の run なら 30m で十分)
timeout 30m gh run watch "$RUN_ID" --repo "$REPO" --exit-status --interval 30
RC=$?
case "$RC" in
  0)   echo "deploy success run=$RUN_ID" ;;
  124) echo "deploy TIMEOUT run=$RUN_ID(未完了・朝に手動確認)" ;;
  *)   echo "deploy failed rc=$RC run=$RUN_ID" ;;
esac
  • timeout の終了コード 124 は「未完了」として証跡に残します。success と区別できないと、朝に見る人が誤読します。
  • 対話セッションで待つときも同じ形にします。timeout 8m gh run watch <run-id> --repo <owner>/<repo> --exit-status --interval 20 のように短めの上限を付け、戻り値を確認する癖をつけると、端末を占有されずに済みます。

すでに止まってしまったスクリプトの救出

朝になって lock を握ったまま止まっているスクリプトを見つけたら、スクリプト本体ではなく watch のプロセスだけ を止めます。

# watch のプロセスを探して止める(スクリプト本体は殺さない)
pgrep -af "gh run watch"
kill <pid>

# lock が解放されたか確認する
flock -n /path/to/deploy.lock true && echo free

watch だけを止めれば、スクリプトは「完了状態不明」として次の工程へ進み、遅れて完走します。そのあとで前述の再ディスパッチを行えば、本番反映も数分で終わります。

証跡は「投入した」でなく「success した」を条件に書く

投入直後の run id をそのまま証跡や台帳に書くと、ゴースト化したときに「取り残された方の id」が記録として残ります。あとから読む人は、その id を見ても何が反映されたのか分かりません。

無人ループの記録は `gh run watch` が 0 で戻った run の id を書く条件にします。timeout で戻ったときは「デプロイ未完了・朝に手動」と明記し、朝に再ディスパッチした run の id で上書きします。

まとめ

  • queued のまま updatedAt が動かず、cancel が「completed」と返すなら、ゴースト run。待っても解けない
  • 直近の run が数分で成功していたなら自分の設定ではなく Actions 側の障害。GitHub Status で裏を取る
  • 復旧は同じコマンドで再ディスパッチ。ゴースト run は放置でよい
  • 無人スクリプトの gh run watch には timeout 30m を付け、終了コード 124 を「未完了」として証跡に残す
  • 止まってしまったら watch のプロセスだけ kill して後続を進める

よくある質問(FAQ)

ゴースト run を放置して問題はありませんか?

実害はありません。一覧に queued のまま残りますが、ランナーを消費せず課金も発生しません。キャンセルしようとしても「completed」と返るので、そのままにしておきます。

timeout は何分にすればよいですか?

そのワークフローの通常の所要時間の数倍〜10倍が目安です。3分で終わる run なら 30m、20分かかるビルドなら 1h 程度。短すぎると正常な順番待ちを「未完了」と誤判定し、長すぎると障害時の復旧が遅れます。

順番待ちとゴーストの見分けが付きません

gh run view <run-id> --json status,createdAt,updatedAt を数分おきに2回打ってください。updatedAt が動いていれば順番待ち、createdAt と同じまま動かなければゴーストです。あわせて gh run list --limit 6 で直前の run が正常に success しているかを見ます。

gh run watch を使わずに完了を待つ方法はありますか?

gh run view --json status,conclusion を自分でポーリングすれば、待ち時間の上限や途中の分岐を自由に書けます。

for i in $(seq 1 60); do
  S=$(gh run view "$RUN_ID" --repo "$REPO" --json status,conclusion --jq '.status + "/" + .conclusion')
  echo "$S"
  case "$S" in completed/*) break ;; esac
  sleep 30
done

この形なら 60 回(30分)で必ず抜けるため、timeout を付け忘れる事故がありません。手元の gh の版で --json に使えるフィールド名は gh run view --help で確認してください。

プログラミング学習でお悩みですか?

現役エンジニアがあなたの学習をマンツーマンでサポートします。

  • 学習の進め方がわからない
  • ポートフォリオの作り方を知りたい
  • 現場で使える技術を学びたい
まずは30分の無料相談

相談は完全無料・オンラインで気軽に

あなたを爆速で成長させるメンタリングプログラムはこちら

メンタープログラムバナー

学習・開発のお悩みは現役エンジニアに相談

メンタープログラムの詳細を見る

エンジニアの基礎学習ゲーム

プログラミング学習支援

無料相談はこちら