
## はじめに
CIが赤くなったとき、あなたは最初に何を見ますか。エラーログでしょうか。自分の設定変更の差分でしょうか。それとも、ついさっき自分が大量に積んだコミットでしょうか。
先日、自前のCI環境で `actions/checkout` が突然こんなエラーを返しました。
```
Error: Your account is suspended.
```
「アカウントが凍結された」というメッセージです。心臓に悪い文面です。私は反射的に「大量のPRを短時間にpushしたから、GitHubのanti-abuse(不正利用検知)に引っかかったのではないか」と考え、コミットのauthorメールが食い違っていた可能性まで疑い始めました。そして、それらの仮説を検証するために数十分を溶かしました。
結論から言うと、**すべて見当違い**でした。原因はGitHub側のグローバルな認証障害で、こちらに非はなく、こちらで直せるものでもありませんでした。`https://www.githubstatus.com/` を最初に開いていれば、30秒で「待つのが正解」と分かったはずです。
この記事は、その失敗談を起点に「CIが動かないときの診断順序」を**型**として整理したものです。インシデント対応をする開発者・SREの方に、同じ轍を踏まないための切り分けフローをお渡しします。
## 失敗談:上流を見ずに自分を疑った
時系列はこうでした(時刻はUTC)。
- **10:57** GitHub側で認証系(token validation)の障害が発生(後にステータスページで公開)
- **12:34** 私が進めていたPRのpushで `actions/checkout` が `403 Your account is suspended` を返す
- **13:18** GitHub公式が「mitigated(緩和済み)」を宣言
- **13:37** こちら側で復旧を確認し、retryして無事green
問題は12:34から13:18の間に私が何をしていたかです。私は上流のステータスを一切見ず、いきなり手元の仮説検証に突入しました。
- **仮説A:anti-abuse説** 「短時間に大量のPRをpushしたから、GitHubの不正利用検知にフラグされた」。それらしく聞こえますが、根拠はありませんでした。
- **仮説B:コミットauthorのメール不一致説** 「PRのコミットが実メールとnoreplyで混在しているのが原因では」。これも筋は通りますが、的外れでした。
どちらも「自分のせいかもしれない」というバイアスから生まれた仮説です。エンジニアは真面目なので、まず自分を疑います。ですが、**自分で直せない上流障害のときに自分を疑うと、永遠に直せない問題を直そうとして時間を溶かす**のです。
ちなみにこの環境はGitHub ActionsとローカルのCIサーバーを併用していますが、後者(GitHub OAuthでログインするタイプ)は無事に動いていました。つまり「GitHubの一部認証経路だけが落ちていた」わけで、ユーザーやorgのsuspension(凍結)とは無関係でした。エラーメッセージの文面に引きずられてはいけません。
## 教訓:診断は「自分から遠い順」に確認する
この一件から得た教訓はシンプルです。
> CI障害の切り分けは、**自分から最も遠い層(上流)から確認する**。順序を逆にすると、自分で直せない問題に対して誤った仮説を立て、時間を溶かす。
「自分の変更を疑う」のは正しい姿勢ですが、それは**上流が正常だと確認できてから**やるべきことです。順番を間違えると、健全な自責バイアスが落とし穴になります。
そこで、CIが動かないときの診断順序を Step1〜4 として固定化しました。Step1から順に潰していけば、誤仮説に迷い込みません。
## 切り分けフロー:Step1〜4
### Step 1:上流(GitHub / Cloudflare 等)の障害確認 — 必ず最初に
何よりも先に、依存している外部サービスのステータスページを開きます。
- GitHub: `https://www.githubstatus.com/`(Actions / Git Operations / API / Webhooks)
- Cloudflare(トンネルやCDN経由でCIを公開している場合): `https://www.cloudflarestatus.com/`
進行中のincidentが掲載されていれば、原因はそれで**確定**です。こちら側で直せないので、**復旧を待つのが正解**です。リトライ地獄に入る前に、まずここを見ます。所要30秒です。
CLIで素早く当たりをつけたいなら、こうした生死確認も有効です。
```bash
# GitHub API の到達性をざっと確認(200 が返るか)
curl -s -o /dev/null -w '%{http_code}\n' https://api.github.com/
# トンネル経由でCIを公開している場合のヘルスチェック例
curl -sf https://ci.example.dev/healthz && echo "tunnel OK" || echo "tunnel DOWN"
```
今回の障害は、まさにここで終わっていた話でした。Step1を飛ばしたことが、すべての時間浪費の原因です。
### Step 2:CIエージェントの認証トークン期限切れを疑う
上流が正常なら、次は自前CIのコンポーネント間認証を疑います。サーバー+エージェント構成のCI(自前のWoodpeckerやドローン系、self-hostedの常駐エージェント)では、エージェントがサーバーから発行されたJWTを**インメモリで保持**します。このトークンが期限切れになると、ジョブが「not started yet」のまま無限にpendingになります。
症状の確認はログのgrepです。
```bash
docker compose logs --since 5m ci-agent | grep -i 'token has invalid claims'
```
`token has invalid claims: token is expired` がヒットしたら、トークン期限切れが原因です。ここで**ハマりどころ**があります。
```bash
# これでは効かないことがある(エージェント単独の再生成)
docker compose up -d --force-recreate ci-agent
# 正しい復旧:サーバー+エージェントを同時に restart する
docker compose restart
```
エージェント単独の `force-recreate` は環境変数の反映には十分ですが、**トークン期限切れからの復旧には不十分**でした。サーバーも同時にrestartして、トークンを再発行させる必要があります。restart後も古いpending pipelineが孤立して残ることがあるので、その場合は対象ブランチへ空コミットをpushして新規triggerするのが確実です。
```bash
git commit --allow-empty -m "ci: retrigger after agent restart" && git push
```
### Step 3:実行マシンのリソースを確認する
上流も認証も問題なければ、CIを動かしているマシン(ローカルMacやサーバー)のリソース枯渇を疑います。メモリ逼迫やディスク満杯は、ジョブを静かに失敗させたり、起動を遅延させたりします。
```bash
# コンテナごとのメモリ/CPU 使用量のスナップショット
docker stats --no-stream
# イメージ/ボリュームのディスク使用量
docker system df
# 古いイメージを掃除(30日以上前のもの)
docker image prune -a --filter "until=720h"
```
macOSなら Activity Monitor の Memory Pressure も見ます。黄色(Yellow)が持続するようなら、CIの並列実行数(例:`MAX_WORKFLOWS` のような同時実行上限)を一段下げてマシンに余裕を持たせます。OOMによる断続的なジョブ失敗は、ログだけ見ても原因が掴みにくいので、この観点を切り分けの型に入れておくと迷いません。
### Step 4:WebUIに頼らずDBから直接パイプライン状態を読む
最後に、CIの管理画面(WebUI)が認証必須で開けない・そもそもUIが死んでいる、という状況でも状態を確認する手段を持っておきます。多くのCIサーバーは状態をSQLiteに永続化しているので、認証を介さずにDBを直接覗けます。
ボリュームを使い捨てのalpineコンテナにマウントし、DBファイルをコピーしてからクエリするのが安全です(稼働中DBを直接触らない)。
```bash
docker run --rm -v ci_server-data:/data alpine sh -c \
"apk add --no-cache sqlite >/dev/null 2>&1 && \
cp /data/ci.sqlite /tmp/s.sqlite && \
sqlite3 /tmp/s.sqlite \
'SELECT id, number, status FROM pipelines ORDER BY id DESC LIMIT 5;'"
```
直近のパイプラインがどのステータス(pending / running / failure 等)で止まっているかが一目で分かります。ステップ単位の詳細やログが必要なら、`steps` や `log_entries` といったテーブルを辿ります。WebUIが認証で開けないときの最後の砦として、この手を知っているかどうかで切り分けの粘り強さが変わります。
## まとめ:型があれば迷わない
整理すると、CIが動かないときの診断順序は次の通りです。
- **Step 1**:上流(GitHub / Cloudflare)のステータスページを見る。incidentがあれば復旧待ちが正解。
- **Step 2**:CIエージェントのトークン期限切れを疑う。復旧はサーバー+エージェントの同時restart。
- **Step 3**:実行マシンのメモリ・ディスクを確認する。
- **Step 4**:WebUIに頼らず、DBから直接パイプライン状態を読む。
肝は順序です。**自分から遠い層(上流)から確認する**。これだけで、自分で直せない障害に対して自分を疑い続ける消耗を防げます。インシデント対応に「型」を持ち込むのは、判断を速くするためであり、何より冷静さを失わないためです。
## 現役実装者の視点
この環境(自宅Macに組んだCIステーション)は私自身が構築して日々回しているので、今回の障害も全部自分で踏みました。正直に書くと、`Your account is suspended` を見た瞬間、私の頭の中は「やってしまった」一色でした。直前にPRをまとめて何本もpushしていたので、「短時間に詰め込みすぎてGitHubの不正利用検知に引っかかったんだ」という仮説が、検証する前から確信に近い温度で立ち上がっていたのです。そこからコミットのauthorメールがnoreplyと実メールで混ざっていないか、と二の矢三の矢を自分に向けて、気づけば30分以上が消えていました。上流のステータスページを開くのは、その全部が空振りしたあとでした。
なぜ上流確認を飛ばしてしまうのか、自分を振り返って思うのは、エラー文面が「お前の問題だ」と語りかけてくるからだと思います。`suspended` という強い言葉は、心理的に「自分の落ち度を探せ」という方向へ一気に引っ張ります。しかも自分で組んだ環境だと、「設定をいじったのは自分」「直近で変更を入れたのも自分」という自覚があるぶん、自責の引力がさらに強い。真面目さや当事者意識が、こういう局面ではかえって視野を狭めるのを、身をもって味わいました。上流が落ちているという、自分にはどうにもできない可能性は、心理的にいちばん最後に思い浮かぶのです。
それ以来、私は「赤くなったらまず `githubstatus.com` を開く。話はそれからだ」を完全に手順化しました。効果は地味ですが確実で、先日また別のジョブが固まったときは、反射的にステータスページを開いて「あ、Actions側のincidentだな、待ち」と30秒で判断を畳めました。以前なら確実にログを睨んで自分の差分を疑っていた場面です。順序を一つ固定しただけで、消耗の総量がはっきり減りました。自分の判断力を信じるより、判断する順番をあらかじめ決めておくほうが、障害時の自分にはよほど頼りになる——というのが、何度か時間を溶かして得た正直な実感です。

はじめに
CIが赤くなったとき、あなたは最初に何を見ますか。エラーログでしょうか。自分の設定変更の差分でしょうか。それとも、ついさっき自分が大量に積んだコミットでしょうか。
先日、自前のCI環境で actions/checkout が突然こんなエラーを返しました。
Error: Your account is suspended.
「アカウントが凍結された」というメッセージです。心臓に悪い文面です。私は反射的に「大量のPRを短時間にpushしたから、GitHubのanti-abuse(不正利用検知)に引っかかったのではないか」と考え、コミットのauthorメールが食い違っていた可能性まで疑い始めました。そして、それらの仮説を検証するために数十分を溶かしました。
結論から言うと、すべて見当違いでした。原因はGitHub側のグローバルな認証障害で、こちらに非はなく、こちらで直せるものでもありませんでした。https://www.githubstatus.com/ を最初に開いていれば、30秒で「待つのが正解」と分かったはずです。
この記事は、その失敗談を起点に「CIが動かないときの診断順序」を型として整理したものです。インシデント対応をする開発者・SREの方に、同じ轍を踏まないための切り分けフローをお渡しします。
失敗談:上流を見ずに自分を疑った
時系列はこうでした(時刻はUTC)。
- 10:57 GitHub側で認証系(token validation)の障害が発生(後にステータスページで公開)
- 12:34 私が進めていたPRのpushで
actions/checkout が 403 Your account is suspended を返す
- 13:18 GitHub公式が「mitigated(緩和済み)」を宣言
- 13:37 こちら側で復旧を確認し、retryして無事green
問題は12:34から13:18の間に私が何をしていたかです。私は上流のステータスを一切見ず、いきなり手元の仮説検証に突入しました。
- 仮説A:anti-abuse説 「短時間に大量のPRをpushしたから、GitHubの不正利用検知にフラグされた」。それらしく聞こえますが、根拠はありませんでした。
- 仮説B:コミットauthorのメール不一致説 「PRのコミットが実メールとnoreplyで混在しているのが原因では」。これも筋は通りますが、的外れでした。
どちらも「自分のせいかもしれない」というバイアスから生まれた仮説です。エンジニアは真面目なので、まず自分を疑います。ですが、自分で直せない上流障害のときに自分を疑うと、永遠に直せない問題を直そうとして時間を溶かすのです。
ちなみにこの環境はGitHub ActionsとローカルのCIサーバーを併用していますが、後者(GitHub OAuthでログインするタイプ)は無事に動いていました。つまり「GitHubの一部認証経路だけが落ちていた」わけで、ユーザーやorgのsuspension(凍結)とは無関係でした。エラーメッセージの文面に引きずられてはいけません。
教訓:診断は「自分から遠い順」に確認する
この一件から得た教訓はシンプルです。
CI障害の切り分けは、自分から最も遠い層(上流)から確認する。順序を逆にすると、自分で直せない問題に対して誤った仮説を立て、時間を溶かす。
「自分の変更を疑う」のは正しい姿勢ですが、それは上流が正常だと確認できてからやるべきことです。順番を間違えると、健全な自責バイアスが落とし穴になります。
そこで、CIが動かないときの診断順序を Step1〜4 として固定化しました。Step1から順に潰していけば、誤仮説に迷い込みません。
切り分けフロー:Step1〜4
Step 1:上流(GitHub / Cloudflare 等)の障害確認 — 必ず最初に
何よりも先に、依存している外部サービスのステータスページを開きます。
- GitHub:
https://www.githubstatus.com/(Actions / Git Operations / API / Webhooks)
- Cloudflare(トンネルやCDN経由でCIを公開している場合):
https://www.cloudflarestatus.com/
進行中のincidentが掲載されていれば、原因はそれで確定です。こちら側で直せないので、復旧を待つのが正解です。リトライ地獄に入る前に、まずここを見ます。所要30秒です。
CLIで素早く当たりをつけたいなら、こうした生死確認も有効です。
# GitHub API の到達性をざっと確認(200 が返るか)
curl -s -o /dev/null -w '%{http_code}\n' https://api.github.com/
# トンネル経由でCIを公開している場合のヘルスチェック例
curl -sf https://ci.example.dev/healthz && echo "tunnel OK" || echo "tunnel DOWN"
今回の障害は、まさにここで終わっていた話でした。Step1を飛ばしたことが、すべての時間浪費の原因です。
Step 2:CIエージェントの認証トークン期限切れを疑う
上流が正常なら、次は自前CIのコンポーネント間認証を疑います。サーバー+エージェント構成のCI(自前のWoodpeckerやドローン系、self-hostedの常駐エージェント)では、エージェントがサーバーから発行されたJWTをインメモリで保持します。このトークンが期限切れになると、ジョブが「not started yet」のまま無限にpendingになります。
症状の確認はログのgrepです。
docker compose logs --since 5m ci-agent | grep -i 'token has invalid claims'
token has invalid claims: token is expired がヒットしたら、トークン期限切れが原因です。ここでハマりどころがあります。
# これでは効かないことがある(エージェント単独の再生成)
docker compose up -d --force-recreate ci-agent
# 正しい復旧:サーバー+エージェントを同時に restart する
docker compose restart
エージェント単独の force-recreate は環境変数の反映には十分ですが、トークン期限切れからの復旧には不十分でした。サーバーも同時にrestartして、トークンを再発行させる必要があります。restart後も古いpending pipelineが孤立して残ることがあるので、その場合は対象ブランチへ空コミットをpushして新規triggerするのが確実です。
git commit --allow-empty -m "ci: retrigger after agent restart" && git push
Step 3:実行マシンのリソースを確認する
上流も認証も問題なければ、CIを動かしているマシン(ローカルMacやサーバー)のリソース枯渇を疑います。メモリ逼迫やディスク満杯は、ジョブを静かに失敗させたり、起動を遅延させたりします。
# コンテナごとのメモリ/CPU 使用量のスナップショット
docker stats --no-stream
# イメージ/ボリュームのディスク使用量
docker system df
# 古いイメージを掃除(30日以上前のもの)
docker image prune -a --filter "until=720h"
macOSなら Activity Monitor の Memory Pressure も見ます。黄色(Yellow)が持続するようなら、CIの並列実行数(例:MAX_WORKFLOWS のような同時実行上限)を一段下げてマシンに余裕を持たせます。OOMによる断続的なジョブ失敗は、ログだけ見ても原因が掴みにくいので、この観点を切り分けの型に入れておくと迷いません。
Step 4:WebUIに頼らずDBから直接パイプライン状態を読む
最後に、CIの管理画面(WebUI)が認証必須で開けない・そもそもUIが死んでいる、という状況でも状態を確認する手段を持っておきます。多くのCIサーバーは状態をSQLiteに永続化しているので、認証を介さずにDBを直接覗けます。
ボリュームを使い捨てのalpineコンテナにマウントし、DBファイルをコピーしてからクエリするのが安全です(稼働中DBを直接触らない)。
docker run --rm -v ci_server-data:/data alpine sh -c \
"apk add --no-cache sqlite >/dev/null 2>&1 && \
cp /data/ci.sqlite /tmp/s.sqlite && \
sqlite3 /tmp/s.sqlite \
'SELECT id, number, status FROM pipelines ORDER BY id DESC LIMIT 5;'"
直近のパイプラインがどのステータス(pending / running / failure 等)で止まっているかが一目で分かります。ステップ単位の詳細やログが必要なら、steps や log_entries といったテーブルを辿ります。WebUIが認証で開けないときの最後の砦として、この手を知っているかどうかで切り分けの粘り強さが変わります。
まとめ:型があれば迷わない
整理すると、CIが動かないときの診断順序は次の通りです。
- Step 1:上流(GitHub / Cloudflare)のステータスページを見る。incidentがあれば復旧待ちが正解。
- Step 2:CIエージェントのトークン期限切れを疑う。復旧はサーバー+エージェントの同時restart。
- Step 3:実行マシンのメモリ・ディスクを確認する。
- Step 4:WebUIに頼らず、DBから直接パイプライン状態を読む。
肝は順序です。自分から遠い層(上流)から確認する。これだけで、自分で直せない障害に対して自分を疑い続ける消耗を防げます。インシデント対応に「型」を持ち込むのは、判断を速くするためであり、何より冷静さを失わないためです。
現役実装者の視点
この環境(自宅Macに組んだCIステーション)は私自身が構築して日々回しているので、今回の障害も全部自分で踏みました。正直に書くと、Your account is suspended を見た瞬間、私の頭の中は「やってしまった」一色でした。直前にPRをまとめて何本もpushしていたので、「短時間に詰め込みすぎてGitHubの不正利用検知に引っかかったんだ」という仮説が、検証する前から確信に近い温度で立ち上がっていたのです。そこからコミットのauthorメールがnoreplyと実メールで混ざっていないか、と二の矢三の矢を自分に向けて、気づけば30分以上が消えていました。上流のステータスページを開くのは、その全部が空振りしたあとでした。
なぜ上流確認を飛ばしてしまうのか、自分を振り返って思うのは、エラー文面が「お前の問題だ」と語りかけてくるからだと思います。suspended という強い言葉は、心理的に「自分の落ち度を探せ」という方向へ一気に引っ張ります。しかも自分で組んだ環境だと、「設定をいじったのは自分」「直近で変更を入れたのも自分」という自覚があるぶん、自責の引力がさらに強い。真面目さや当事者意識が、こういう局面ではかえって視野を狭めるのを、身をもって味わいました。上流が落ちているという、自分にはどうにもできない可能性は、心理的にいちばん最後に思い浮かぶのです。
それ以来、私は「赤くなったらまず githubstatus.com を開く。話はそれからだ」を完全に手順化しました。効果は地味ですが確実で、先日また別のジョブが固まったときは、反射的にステータスページを開いて「あ、Actions側のincidentだな、待ち」と30秒で判断を畳めました。以前なら確実にログを睨んで自分の差分を疑っていた場面です。順序を一つ固定しただけで、消耗の総量がはっきり減りました。自分の判断力を信じるより、判断する順番をあらかじめ決めておくほうが、障害時の自分にはよほど頼りになる——というのが、何度か時間を溶かして得た正直な実感です。