majormonitoring-gapverifiedfirsthand
レシート監視ジョブが依存モジュール欠落で11回黙って落ちていた
フォルダにレシート画像を置くと自動でOCRして家計簿の台帳に入れるlaunchdジョブが、`ModuleNotFoundError` で11回続けて落ちていた。通知は一度も出ず、置いた画像は取り込まれないまま残っていた。
原因
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の手前で失敗として記録される。
滞留していた画像はスクリーンショットで、以前の事故のあとに入れた関門で正しく弾かれた。
出典
- 運営者自身が起こした事故の一次記録です。外部に公開された元記事はありません。