← 事故一覧へ

majormonitoring-gapverifiedfirsthand

レシート監視ジョブが依存モジュール欠落で11回黙って落ちていた

フォルダにレシート画像を置くと自動でOCRして家計簿の台帳に入れるlaunchdジョブが、`ModuleNotFoundError` で11回続けて落ちていた。通知は一度も出ず、置いた画像は取り込まれないまま残っていた。

発生日
深刻度
5/10
影響範囲
uptime
タグ
#launchd#macos#python#dependencies#silent-failure

原因

OCRに必要なpyobjc(Quartz/Vision)がrequirementsファイルのどこにも書かれていなかった。開発時に入れたPythonと、launchdのジョブ定義が呼ぶPythonが別物になっており、インタプリタが変わった時点で黙って壊れた。

結果

少なくとも1日以上、レシートの自動取り込みが止まっていた。台帳そのものは汚れておらず、取り込み前後のSHA-256が一致することを確認している。

対策

依存をrequirementsに明記し、launchdが実際に呼ぶPythonへ導入したうえで、最小環境でimportできることを確かめた。ジョブは監視カーネルに載せ、起動前のpreflightで import Quartz, Vision を試すようにした。

何が起きたか

launchdの定期ジョブを監視する小さなカーネルを作り、 既存のジョブを載せ替えながら各ジョブの最終終了コードを順に確かめていたところ、 レシート監視ジョブだけが exit=1 を返していた。

このジョブは、決まったフォルダにレシートの画像が置かれると起動し、macOSのVisionで OCRをかけて家計簿の台帳に1行足す。ログを開くと、同じトレースバックが11回分 積み上がっていた。

ModuleNotFoundError: No module named 'Quartz'

最後の失敗は発見前日の夜。その時点まで、置いた画像は一枚も取り込まれていなかった。

現場の混乱

厄介なのは、このジョブが「人が画像を置いたときだけ」動く作りだったことだ。 発火が稀なジョブは、壊れていても「今日はレシートが無かっただけ」と区別がつかない。11回落ちて、11回とも誰にも届かなかった。

さらに、開発メモには「pyobjcはpip済み」と書いてあった。嘘ではない。 ただしそれは、ある時点のあるPythonに入れたという記録にすぎなかった。 launchdのジョブ定義が呼んでいたのは /usr/local/bin/python3.14 で、 そこにpyobjcは入っていなかった。

5日間誰も気づかなかった翻訳ジョブの13回連続失敗と同じ型だ。 launchdの文脈と対話シェルで、python3 の行き先が違っていた。

原因

真因は「依存がどこにも宣言されていなかった」ことにある。requirementsファイルには openpyxlとpytestしか無く、OCRモジュールが必要とするpyobjc(Quartz/Vision/Cocoa)の 記載が無かった。依存が書かれていなければ、インタプリタが入れ替わった瞬間に 何が足りなくなったかを知る手段が無い。そして依存が欠けて落ちるジョブは、 見張られていなければ黙って落ち続ける。

対策

  • requirementsファイルに pyobjc-framework-Vision>=12.0 を追記し、経緯もコメントで残した。
  • launchdが実際に呼ぶ /usr/local/bin/python3.14 に pip install --user で導入した。 フレームワーク本体のsite-packagesには触れていない。
  • env -i HOME=$HOME の最小環境でもimportできることを確かめ、launchd文脈での再発を塞いだ。
  • ジョブを監視カーネルに載せ、失敗2回で通知するようにした(FAIL_THRESHOLD=2)。 発火が稀なジョブで3回待つと、検知が数日遅れるためだ。
  • 起動前のpreflightで import Quartz, Vision を試す。欠けていればOCRの手前で失敗として記録される。

滞留していた画像はスクリーンショットで、以前の事故のあとに入れた関門で正しく弾かれた。

出典

  • 運営者自身が起こした事故の一次記録です。外部に公開された元記事はありません。