通知が来なかった失敗 — 例外ハンドラの外で、プロセスは死ぬ

著者: なぎゆー公開日: 2026-07-18読了目安: 約 8
カテゴリ:AWS
AWSAWS BatchEventBridge監視運用

自分が運用している動画解析サービスで、あるジョブが失敗した。おかしかったのは、失敗そのものより「通知が一つも来なかった」こと。整備したはずの監視をすり抜けた失敗を追ったら、監視の穴とリソース見積もりの盲点が芋づるで出てきた話。

第一報が、アラートじゃなくて問い合わせだった

自分は動画をアップロードすると解析(文字起こし・動きの検出・盛り上がり判定)をかけるサービスを個人で運用している。解析の本体は、キューに積まれたジョブとしてバックエンドで長時間走る。

ある日、その解析が失敗した。ここまでは、まあ起きる。処理は重いし、動画は千差万別だ。

引っかかったのは失敗そのものではなく、それを自分がどうやって知ったかだった。監視ダッシュボードのアラートでもなく、エラー一覧に赤い行が増えたのでもなく、「解析が失敗しているようです」という問い合わせで初めて気づいた。

第一印象は、原因究明より先に湧いた違和感だった。「あれ、失敗を拾う仕組み、整備したよな?」。エラーを一箇所に集約して管理画面から見られるようにする配線は、少し前に入れたはずだった。それがまるごとすり抜けている。この「来るはずのものが来ない」感覚が、一番不可解だった。

サイレントに消えていた

調べると、失敗はどこにも記録されていなかった。エラー集約用のテーブルにも、管理画面のエラー一覧にも、該当サービスのエントリはゼロ件。ログを遡ればジョブが落ちた事実はあるのに、失敗として通知される経路には一切乗っていない。完全にサイレントに消えていた。

しかも配線が足りていなかったわけではない。エラー報告に必要な環境変数も権限も、ジョブ側にはちゃんと入っていた。仕組みはある。設定もある。なのに一件も届かない。 ここが妙で、しばらく腑に落ちなかった。

なぜ例外ハンドラが動かなかったか

答えは拍子抜けするほど構造的だった。

エラー集約は、ざっくり言えばこういう作りだった。

try {
  await runAnalysis(job);
} catch (e) {
  // ここでエラーを集約基盤に書き込む
  await reportError(job, e);
}

失敗を拾って報告するのは catch の仕事だ。つまりこの報告は「プロセスが生きていて、catch まで到達できる」ことを暗黙の前提にしている

ところが今回の落ち方は、その前提を踏み抜いていた。ジョブは実行時間の上限を超え、実行基盤である AWS Batch にタイムアウトで強制終了された。終了コードは 137128 + 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));

尺の値は再生時に取れる長さをそのまま使う。取れなければサイズ軸だけにフォールバックする。厳密じゃないし、軸の閾値も実測で追い込む前提の暫定値だ。

ただ、この設計判断の裏にある考え方のほうが、自分にとっては本題だった。全部のケースを事前に読み切って、完璧なリソース見積もりを組むという方向には行かなかった。動画は本当に千差万別で、あらゆるパターンを想定して検証用の動画を揃えることすら現実的じゃない。網羅は無理だと、早い段階で割り切った。

だから賭けるところを変えた。「完璧に予測する」より「落ちたら即座に気づいて、早く直す」。今回みたいに実際に落ちた一点を見て、その穴を塞ぎ、次に備える。予測の精度に投資するより、検知と修正の速さに投資する。個人でいくつもサービスを回している以上、これが現実的な落としどころだった。

そう考えると、二つの穴が実は地続きだったことに気づく。「早く気づいて早く直す」に賭けているのに、その『早く気づく』の前提を壊していたのが、あのサイレントな失敗だった。予測しきれない前提に立つなら、監視は付属品じゃなく、戦略の本体だ。通知が来なかったことに一番ゾッとしたのは、たぶん無意識にそれが分かっていたからだと思う。


まとめると、拾った教訓はシンプルだ。

  • 例外ハンドラは、プロセスが生きているうちしか動かない。 即死する失敗はアプリの外から見張る。
  • 後から横断的な仕組みを配るときは、既存を必ず棚卸しする。 検知の「型」は前提とセットで、実行基盤が変わるところで静かに途切れる。
  • 負荷は一次元とは限らない。 一つの軸で見積もると、別の軸で重いケースを取りこぼす。

そして根っこにあるのは、予測を過信しない代わりに、検知と修正の速さに賭けるという運用の構え。その賭けが成立する条件が、他でもない「ちゃんと通知が来ること」だった。


関連記事