通知が来なかった失敗 — 例外ハンドラの外で、プロセスは死ぬ
自分が運用している動画解析サービスで、あるジョブが失敗した。おかしかったのは、失敗そのものより「通知が一つも来なかった」こと。整備したはずの監視をすり抜けた失敗を追ったら、監視の穴とリソース見積もりの盲点が芋づるで出てきた話。
第一報が、アラートじゃなくて問い合わせだった
自分は動画をアップロードすると解析(文字起こし・動きの検出・盛り上がり判定)をかけるサービスを個人で運用している。解析の本体は、キューに積まれたジョブとしてバックエンドで長時間走る。
ある日、その解析が失敗した。ここまでは、まあ起きる。処理は重いし、動画は千差万別だ。
引っかかったのは失敗そのものではなく、それを自分がどうやって知ったかだった。監視ダッシュボードのアラートでもなく、エラー一覧に赤い行が増えたのでもなく、「解析が失敗しているようです」という問い合わせで初めて気づいた。
第一印象は、原因究明より先に湧いた違和感だった。「あれ、失敗を拾う仕組み、整備したよな?」。エラーを一箇所に集約して管理画面から見られるようにする配線は、少し前に入れたはずだった。それがまるごとすり抜けている。この「来るはずのものが来ない」感覚が、一番不可解だった。
サイレントに消えていた
調べると、失敗はどこにも記録されていなかった。エラー集約用のテーブルにも、管理画面のエラー一覧にも、該当サービスのエントリはゼロ件。ログを遡ればジョブが落ちた事実はあるのに、失敗として通知される経路には一切乗っていない。完全にサイレントに消えていた。
しかも配線が足りていなかったわけではない。エラー報告に必要な環境変数も権限も、ジョブ側にはちゃんと入っていた。仕組みはある。設定もある。なのに一件も届かない。 ここが妙で、しばらく腑に落ちなかった。
なぜ例外ハンドラが動かなかったか
答えは拍子抜けするほど構造的だった。
エラー集約は、ざっくり言えばこういう作りだった。
try {
await runAnalysis(job);
} catch (e) {
// ここでエラーを集約基盤に書き込む
await reportError(job, e);
}
失敗を拾って報告するのは catch の仕事だ。つまりこの報告は「プロセスが生きていて、catch まで到達できる」ことを暗黙の前提にしている。
ところが今回の落ち方は、その前提を踏み抜いていた。ジョブは実行時間の上限を超え、実行基盤である AWS Batch にタイムアウトで強制終了された。終了コードは 137。128 + 9、つまり SIGKILL による即死だ。
SIGKILL はアプリケーションに猶予を与えない。catch はおろか finally も走らない。プロセスは外から撃たれて、その場で終わる。だから reportError は一度も呼ばれなかった。
証拠もきれいに残っていた。ジョブのレコードは、失敗を示す errorMessage が空のまま、途中の「解析中」ステータスで凍りついていた。「エラー処理が一度も動いていない」ことが、記録の欠落そのものに表れていた。
ここで得た教訓は、一般化するとこうなる。
アプリ内の例外ハンドラに監視を依存させると、プロセスごと殺される失敗(タイムアウト・メモリ超過・SIGKILL)を構造的に取りこぼす。
例外ハンドラは、プロセスが生きているうちしか動かない。だから「アプリが自分の死を報告する」設計には、原理的な死角がある。本当に落ちてほしくないものほど、アプリの外側——AWS Batch のような実行基盤や、その上のプラットフォームのレイヤ——からも見張る必要がある。
もう一つの穴:横展開の取りこぼし
では外側の見張りはどうだったのか。エラーを集約して通知する受け皿は、すでに存在していた。各サービスの異常を検知し、同じ集約先へ流し込む経路だ。これならアプリの生死に関係なく発火する。
なのに、なぜ届かなかったのか。棚卸ししたら答えが出た。このサービスから集約先へ届ける経路が、そもそも一本も繋がっていなかった。 受け皿は確かにある。ただし、そこへ物を運ぶ配管が無かった。
さらに厄介だったのは、他所で使っていた検知の「型」がそのままでは使えなかったことだ。既存の経路は CloudWatch アラームを起点にしていたが、AWS Batch はジョブの成否をネイティブなメトリクスとして出さない。タイムアウトはログにも何も残さず終わる(ログが途中で途切れるだけだ)。つまり、いつもの型をコピーしても検知できない。結局、ジョブの状態変化を EventBridge のイベントとして拾う別経路を新しく作ることになった。
そして棚卸しでもう一つ分かった。AWS Batch を使っているサービスは他にもあり、それらにも同じく経路が無かった。取りこぼしていたのは「一つのサービス」ではなく、「AWS Batch で動くもの全部」だった。横断的に配ったつもりの安全網が、実行基盤が変わるところで丸ごと途切れていた、という話だ。
このときの感想は、正直かっこいいものではない。「あー、横展開が漏れてたか」。それだけだ。劇的な発見でも、天啓でもない。強いて言えば反省で、後から共通の仕組みを配るときこそ、既存のものを一つずつ棚卸しして確認しておくべきだった、という話に尽きる。
横断的な関心事(監視・ロギング・権限)を後から配るとき、人は「同じ型が全部に貼れる」と思いがちだ。だが型は前提とセットで、前提が変わるところ——実行基盤が違う、失敗の出方が違う——で静かに途切れる。CloudWatch アラームを前提にした型は、メトリクスを出さない実行基盤の手前で止まる。しかも途切れた側はエラーを出さない。貼れていないだけなので、何も起きないまま「守られているつもり」が残る。後付けの横展開には、必ず「既存の棚卸し」がセットで要る。数えるべきは配った数ではなく、配られていない側だ。
そもそも、なぜ落ちたのか
ここまで監視の話をしてきたが、大元の「なぜジョブが落ちたか」も盲点だった。
終了コード 137 を見て、最初に疑ったのはメモリやスペックだった。137 という数字は、感覚的にはリソース不足・OOM を連想させる。実際そう読む人は多いと思う。だが、これは外した仮説だった。
正体は時間だった。落ちた動画は約 430MB。ファイルサイズとしてはむしろ小さい部類で、そのため最小の実行枠(1 vCPU / タイムアウト 1 時間)に割り当てられていた。ところがこの動画は尺が 60 分超の長尺で、中身の処理量が桁違いに重かった。文字起こしに約 23 分、動きの検出だけで約 35 分——1 vCPU が完全にボトルネックになり、合計が 1 時間枠に収まらず、タイムアウトで撃ち殺された。実行時間は 3668 秒。上限 3600 秒を、68 秒だけ超えていた。
つまり、計算リソースをファイルサイズだけで見積もっていたのが盲点だった。必要なメモリやストレージはサイズで決まるが、必要な CPU と処理時間は尺で決まる。「サイズは小さいが尺が長い(=低ビットレートの長尺)」動画は、この一軸の見積もりから抜け落ちる。負荷は一次元じゃなかった。
正直に書いておくと、この切り分け——137 は OOM じゃなくタイムアウト、原因はサイズじゃなく尺——を自分の頭で閃いたわけではない。ログの内訳を突き合わせて詰めていったのは AI(Claude)で、自分は出てきた分析を読んで判断した側だ。手元でうんうん唸って気づいた武勇伝ではない。自分の役割は「おかしいと気づく」ことと「どう直すかを決める」ことのほうにあった。
直し方より、その裏にある賭け
直し自体は素直だ。リソース選択を「サイズ」と「尺」の二軸にして、両方を評価して重いほうの枠を採る。合わせて、最小枠が非力すぎたので底上げした。
// サイズ軸と尺軸、それぞれで必要な枠を出し、大きいほうを採用する
const selectJobDefinition = (fileSize, durationSec) =>
maxTier(sizeTier(fileSize), durationTier(durationSec));
尺の値は再生時に取れる長さをそのまま使う。取れなければサイズ軸だけにフォールバックする。厳密じゃないし、軸の閾値も実測で追い込む前提の暫定値だ。
ただ、この設計判断の裏にある考え方のほうが、自分にとっては本題だった。全部のケースを事前に読み切って、完璧なリソース見積もりを組むという方向には行かなかった。動画は本当に千差万別で、あらゆるパターンを想定して検証用の動画を揃えることすら現実的じゃない。網羅は無理だと、早い段階で割り切った。
だから賭けるところを変えた。「完璧に予測する」より「落ちたら即座に気づいて、早く直す」。今回みたいに実際に落ちた一点を見て、その穴を塞ぎ、次に備える。予測の精度に投資するより、検知と修正の速さに投資する。個人でいくつもサービスを回している以上、これが現実的な落としどころだった。
そう考えると、二つの穴が実は地続きだったことに気づく。「早く気づいて早く直す」に賭けているのに、その『早く気づく』の前提を壊していたのが、あのサイレントな失敗だった。予測しきれない前提に立つなら、監視は付属品じゃなく、戦略の本体だ。通知が来なかったことに一番ゾッとしたのは、たぶん無意識にそれが分かっていたからだと思う。
まとめると、拾った教訓はシンプルだ。
- 例外ハンドラは、プロセスが生きているうちしか動かない。 即死する失敗はアプリの外から見張る。
- 後から横断的な仕組みを配るときは、既存を必ず棚卸しする。 検知の「型」は前提とセットで、実行基盤が変わるところで静かに途切れる。
- 負荷は一次元とは限らない。 一つの軸で見積もると、別の軸で重いケースを取りこぼす。
そして根っこにあるのは、予測を過信しない代わりに、検知と修正の速さに賭けるという運用の構え。その賭けが成立する条件が、他でもない「ちゃんと通知が来ること」だった。