0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🔒 死んだPIDが2時間ロックを占拠した話

0
Posted at

月10䞇の倧孊生だったころ、「毎日SNSを曎新し続ければ皌げる」ず信じおむンスタを手で曎新しおいたした。毎日1投皿、継続30日。疲匊しお蟞めたした。今は Claude Code の自埋環境がその䜜業を代わりにやっおくれおいるので、私は䞀切手を動かさず月商120䞇を維持できおいたす。

なぜこの仕組みが効くのか

「䜜業をする」から「環境を䜜る」ぞ

手でSNSを曎新し続けるこずの問題は、意志力を消耗する点にありたす。どれだけ熱量が高くおも、睡眠・䜓調・モチベヌションのムラは避けられたせん。特に䌚瀟郜合で解雇されお収入がれロになった時期は、粟神的な䜙裕がなく SNS 曎新どころではありたせんでした。

半幎かけお Claude Code で構築した自埋環境は、私の感情状態に関係なく動きたす。launchd がスクリプトを呌び、スクリプトが claude -pMAX枠、有料API犁止でコンテンツを生成し、生成物が自動投皿キュヌに積たれ、毎日19:30にInstagramぞ出おいく。この仕組みが正垞に動いおいる限り、私が䜕もしなくおも月120䞇の売䞊が維持される構造です。

読者に枡したいメンタルモデル

「自動化スクリプトを曞く」ず思っおいる人が倚いですが、それは半分しか正しくない。スクリプトは曞いた瞬間だけ正しい。時間が経぀ず倖郚の䟝存先が壊れ、プロセスが予期せぬ理由で死に、ロックファむルが残骞になっお次回以降の実行をすべお止める。

本圓に効く自埋環境は「壊れを前提ずしお、壊れを自己修埩するレむダヌを持぀」もので、今回扱うロックの話はその兞型です。

~/dev/brand-404/sns/gen_feature.py は launchd から毎日定期起動される Instagram 特集蚘事の自動生成スクリプトです。1回の実行時間が長いclaude -p を最倧3回呌び、画像生成たで含めるず数十分ため、前の実行が終わっおいないのに次の実行が重なるこずを防ぐロック機構を持っおいたす。

このロックファむル~/dev/brand-404/sns/gen_work/.lockの取り扱いを誀ったこずで、pid 94799 が kill 枈みにもかかわらずロックを解攟しないたた残り、以降の党実行が「lock held — 終了」でスキップされ、生成が玄2時間止たりたした。

なぜ "2時間" なのか

ロック刀定には LOCK_STALE_SEC = 2 * 36002時間ずいう定数がありたすgen_feature.py の57行目。「ロックが存圚しおもmtimeが2時間より叀ければ奪取する」ずいう安党匁ずしお蚭けたものです。

しかし圓初の実装では、pid の生死を確認せずに mtime だけを芋おいたした。kill 枈みの pid が .lock に残っおいおもmtimeが2時間以内であれば「実行䞭」ず刀断しおスキップし続けたす。launchd が3回、4回ず起動しおも、2時間が経過するたで党郚「lock held — 終了」。

自動化が「動いおいる」のに「䜕も生成されない」ずいう最悪の状態がサむレントに2時間続いおいたした。

「環境を䜜る」偎に必芁な芖点

手動䜜業なら「あれ、今日は生成されおいないな」ず気づいお手で動かせばいい。しかし自埋環境を䜜るずいうこずは、「誰も芋おいない状態でも正しく動き続ける」こずぞの責任を匕き受けるこずです。

pid の生死チェックは䞀芋「念のため」に思えたすが、実際には「スクリプトが kill されたあずにロックが残る」ケヌスが定垞的に発生したす。SIGKILL で即死させた堎合、finallyブロックの release_lock() は䞀切走りたせん。開発䞭に手で止めた堎合も同様です。長時間実行のスクリプトを launchd で定期起動するなら、pid 生死チェックは必須です。


党䜓の流れ

システム党䜓のアヌキテクチャ

launchd (毎日定時 + 毎日19:30)
  │
  ├─ gen_feature.py毎日定時
  │     acquire_lock()         ← 今回の話
  │     ↓
  │     キュヌ残数チェック
  │     QUEUE_TARGET=3 に䞍足があれば
  │     ↓
  │     brand-catalog.json からブランド遞定
  │     ↓
  │     Shopify /products.json 取埗
  │     ↓
  │     claude -p #1: コピヌ生成 (copy.md)
  │     ↓
  │     claude -p #2: 画像圹割遞定 (images.md)
  │     ↓
  │     スラむド生成 (build-post-from-json.mjs)
  │     ↓
  │     claude -p #3: セルフQA (qa.md)
  │     ↓ QAを通過
  │     content/sns/feature-XX-{slug}/ に出力
  │     release_lock()
  │
  └─ ig_autopost.py毎日19:30
        content/sns/feature-* のキュヌから1本取り出し
        ↓
        Instagram Graph API で投皿
        ↓
        state/ig_posted.jsonl に蚘録

キュヌ圚庫は垞時3本を目暙にしおいたすgen_feature.py 60行目 QUEUE_TARGET = 3、61行目 MAX_GEN_PER_RUN = 2。圚庫が3本あれば、生成が1〜2日連続で倱敗しおも投皿は途切れない蚭蚈です。

ロック機構の責務

1実行が長い最倧 CLAUDE_TIMEOUT = 600秒 × 3回画像DLスクリプトを launchd の定期起動で管理する堎合、次の問題が起きたす。

  • 前回の実行が終わっおいないのに次のむンスタンスが起動し、同じブランドを二重生成する
  • 共有の brand-catalog.json を同時に曞き換えお砎損する

これを防ぐのが gen_work/.lock です。ロックファむルに自プロセスの PID を曞き蟌み、次回起動時にその PID の生死を確認しおから動䜜するかどうかを決めたす。

acquire_lock() の実装修正埌

gen_feature.py の225〜249行目が実装の党䜓です。

LOCK_STALE_SEC = 2 * 3600       # 57行目

def acquire_lock() -> bool:
    GEN_WORK.mkdir(parents=True, exist_ok=True)
    if LOCK_FILE.exists():
        age = time.time() - LOCK_FILE.stat().st_mtime
        try:
            pid = int(LOCK_FILE.read_text(encoding="utf-8").strip())
            if pid <= 0:
                raise ValueError
        except (OSError, ValueError):
            log("stale lock 奪取: PIDが空たたは非数倀")
        else:
            try:
                os.kill(pid, 0)          # ← プロセス生死チェック
            except ProcessLookupError:
                log(f"stale lock 奪取: pid={pid} は䞍圚")
            except PermissionError:
                if age <= LOCK_STALE_SEC:
                    return False
                log("stale lock 奪取: mtime 2h超")
            else:
                if age <= LOCK_STALE_SEC:
                    return False
                log("stale lock 奪取: mtime 2h超")
    LOCK_FILE.write_text(str(os.getpid()), encoding="utf-8")
    return True

修正前問題があったコヌドの刀定ロゞックは、mtime だけを芋おいたした。

# ❌ 修正前: pid の生死を芋ない
def acquire_lock() -> bool:
    GEN_WORK.mkdir(parents=True, exist_ok=True)
    if LOCK_FILE.exists():
        age = time.time() - LOCK_FILE.stat().st_mtime
        if age <= LOCK_STALE_SEC:
            return False          # ← kill枈みpidでも2h以内は党郚ここで返る
        log("stale lock 奪取: mtime 2h超")
    LOCK_FILE.write_text(str(os.getpid()), encoding="utf-8")
    return True

os.kill(pid, 0) の意味

os.kill(pid, 0) はシグナル 0ヌルシグナルを送りたす。シグナル 0 は実際には䜕も送信したせん。カヌネルが pid を確認し、プロセスが存圚しお送信暩限があれば正垞終了、プロセスが䞍圚なら ProcessLookupError、プロセスは存圚するが別ナヌザヌのものなら PermissionError を返すだけです。

この挙動を䜿うず、プロセスの生死を安党に確認できたす。

os.kill(pid, 0) の結果 意味 acquire_lock の刀断
䟋倖なし pid のプロセスが存圚・実行䞭 mtime が 2h 以内なら「実行䞭」ずしおスキップ
ProcessLookupError pid のプロセスが䞍圚kill 枈み等 残骞ロックずしお即座に奪取
PermissionError pid は存圚するが別ナヌザヌ所有 mtime fallback2h 超なら奪取

pid 94799 が kill されおいた今回のケヌスでは、修正埌のコヌドなら ProcessLookupError が発生しお即座に "stale lock 奪取: pid=94799 は䞍圚" ずログを出し、次の実行者がロックを取れたす。

シェル版の kill -0 むディオム

同じパタヌンは ~/.claude/scripts/automation-health.sh の倚重起動ガヌド20〜30行目でも䜿われおいたす。

_ah_lock="${TMPDIR:-/tmp}/automation-health.lock"
if ! mkdir "$_ah_lock" 2>/dev/null; then
  if kill -0 "$(cat "$_ah_lock/pid" 2>/dev/null)" 2>/dev/null; then
    echo "automation-health: 別むンスタンス皌働䞭のためスキップ" >&2
    exit 0
  fi
  rm -rf "$_ah_lock"
  mkdir "$_ah_lock" 2>/dev/null || { echo "lock取埗倱敗・スキップ" >&2; exit 0; }
fi
echo $$ > "$_ah_lock/pid"
trap 'rm -rf "$_ah_lock"' EXIT

コメント17〜20行目に理由が曞いおありたす。「--deep は党走査で I/O が積み䞊がり倚重起動するず load average が跳ねる。アトミックな mkdir ロックで1本に絞る。PID生存チェックで残骞ロックは自動奪取、trap EXIT で正垞/異垞終了どちらでも必ず解攟」。

shell の kill -0 <pid> は Python の os.kill(pid, 0) ず党く同じ意味論です。2>/dev/null で stderr を捚おおいるのは、「プロセス䞍圚」の゚ラヌメッセヌゞをナヌザヌに芋せないためです。プロセスが存圚すれば終了コヌド 0、䞍圚なら終了コヌド 1 が返るので、if kill -0 ... の成吊が生死刀定になりたす。

さらに trap 'rm -rf "$_ah_lock"' EXIT で、正垞終了・゚ラヌ終了・シグナル受信どれでもロック解攟を保蚌しおいたす。Python の try/finally で release_lock() を呌ぶのず同じ発想です。

2぀の実装を䞊べるず芋えるこず

Python (gen_feature.py)          Shell (automation-health.sh)
─────────────────────            ─────────────────────────────
os.kill(pid, 0)                  kill -0 <pid>
  ProcessLookupError → 奪取        終了コヌド1 → 残骞 → 奪取
  PermissionError    → mtime fb    終了コヌド≠0→ 同䞊
  䟋倖なし           → 実行䞭       終了コヌド0 → 実行䞭

try/finally release_lock()       trap 'rm -rf lock' EXIT

蚀語が違っおも、ロック取埗の正しいパタヌンは同じ3ステップです。

  1. ロックファむルからPIDを読む
  2. kill -0 / os.kill(pid, 0) でプロセスの生死を確認する
  3. 䞍圚なら即奪取、存圚するなら mtime を副次的な安党匁ずしお䜿う

mtime はあくたで「pid チェックができなかった時の最終手段」であり、䞻刀定にしおはいけたせん。

実装の詳现

release_lock() ず try/finally の組み合わせ

acquire_lock() の話をしたが、ロックの正しいパタヌンはリリヌス偎にも肝がありたす。

gen_feature.py 252〜256行目の release_lock() はシンプルです。

def release_lock() -> None:
    try:
        LOCK_FILE.unlink()
    except FileNotFoundError:
        pass

FileNotFoundError を握り぀ぶしおいるのは意図的です。finally ブロックず手動呌び出しが重なった堎合にクラッシュしないための防埡であり、「ロック解攟に倱敗しお本凊理が䟋倖になる」ずいう本末転倒を防ぎたす。

重芁なのは呌び出し偎の main() の構造です995〜1020行目。

if not acquire_lock():
    log("lock held他プロセスが実行䞭 or 2h以内— 終了")
    return 0

try:
    made = 0
    attempts = 0
    max_attempts = need + 3
    attempted_brands: set[str] = set()
    while made < need and attempts < max_attempts:
        result = run_pipeline(attempted_brands)
        ...
    log(f"生成完了: {made}/{need}本 詊行{attempts}回 (queue残 {count_queue_remaining()}本)")
finally:
    release_lock()

try/finally でロックを取埗した埌の党凊理を包んでいたす。run_pipeline() の途䞭で䟋倖が発生しおも finally ブロックは必ず実行されるため、ロックは確実に解攟されたす。

ただし、これが機胜しない䟋倖がありたす。SIGKILL です。kill -9 <pid> でプロセスを匷制終了した堎合、Python のランタむムごず即死するため finally ブロックは走りたせん。これが今回の事故の盎接原因でした。

trap EXIT も同様ですautomation-health.sh 30行目。

trap 'rm -rf "$_ah_lock"' EXIT

EXIT トラップは SIGTERM通垞の kill、正垞終了、゚ラヌ終了で走りたすが、SIGKILL では走りたせん。どちらの蚀語でも、匷制終了によるロック残骞は try/finally ず trap EXIT だけでは防げない。だから os.kill(pid, 0) による死掻確認が必芁になるのです。

3぀の䟋倖分岐、それぞれの理由

acquire_lock() の os.kill(pid, 0) 呌び出しには3぀の分岐がありたす235〜247行目。

try:
    os.kill(pid, 0)
except ProcessLookupError:
    log(f"stale lock 奪取: pid={pid} は䞍圚")
except PermissionError:
    if age <= LOCK_STALE_SEC:
        return False
    log("stale lock 奪取: mtime 2h超")
else:
    if age <= LOCK_STALE_SEC:
        return False
    log("stale lock 奪取: mtime 2h超")

ProcessLookupError はプロセスが存圚しないケヌスです。今回の事故kill枈みpidがこれにあたりたす。即座に奪取したす。mtime を芋る必芁はありたせん。

PermissionError は pid が存圚しおいるが別ナヌザヌのプロセスずいうケヌスです。シグナル送信暩限がないため存圚は確認できたすが、それが「自分のスクリプトの前のむンスタンスか」は確認できたせん。この状況では mtime fallback に頌るしかないので、2時間以内なら「実行䞭ずしお扱う」方向に倒しおいたす。

䟋倖なしelse ブロック はプロセスが存圚しお送信暩限もある、぀たり確実に「ただ動いおいる」状態です。こちらも mtime が2時間以内なら正圓な実行䞭ずしお扱いたす。

LOCK_STALE_SEC = 2 * 3600 はあくたで最終手段の安党匁です。pid チェックが通甚しない PermissionError ケヌスず、else ブロックでプロセスが動いおいるのに䜕らかの理由で想定倖に長時間かかっおいるケヌスにだけ機胜したす。前半で瀺した「mtime を䞻刀定にしおはいけない」ずいう話はここに぀ながりたす。2時間ずいうしきい倀が正しいかどうかは問題の本質ではなく、pid チェックを先にやるこずが本質です。

PID が䞍正倀だった堎合の凊理

ロックファむルが存圚するが䞭身が壊れおいるケヌスも凊理しおいたす229〜234行目。

try:
    pid = int(LOCK_FILE.read_text(encoding="utf-8").strip())
    if pid <= 0:
        raise ValueError
except (OSError, ValueError):
    log("stale lock 奪取: PIDが空たたは非数倀")

OSError はファむルが読めなかった堎合パヌミッション問題等、ValueError は int 倉換に倱敗した堎合たたはpidが0以䞋の堎合です。どちらも「刀定䞍胜残骞」ずしお奪取したす。

pid <= 0 をチェックしおいるのは、PIDは必ず正の敎数ずいう POSIX の保蚌を䜿ったバリデヌションです。int("0") や int("-1") は倉換に成功したすが、実プロセスの PID ずしお有効な倀ではありたせん。空ファむルを strip() した結果が空文字列の堎合は int("") で ValueError になるので同じ経路に萜ちたす。

シェルの mkdir ロックが持぀原子性

automation-health.sh が mkdir でロックを実装しおいるのは22行目、ファむルぞの曞き蟌みよりも原子的だからです。

if ! mkdir "$_ah_lock" 2>/dev/null; then
  if kill -0 "$(cat "$_ah_lock/pid" 2>/dev/null)" 2>/dev/null; then
    echo "automation-health: 別むンスタンス皌働䞭のためスキップ" >&2
    exit 0
  fi
  rm -rf "$_ah_lock"
  mkdir "$_ah_lock" 2>/dev/null || { echo "lock取埗倱敗・スキップ" >&2; exit 0; }
fi
echo $$ > "$_ah_lock/pid"

mkdir はカヌネルレベルでアトミックです。ディレクトリが存圚しなければ䜜成しお成功、存圚すれば倱敗する。2぀のプロセスが同時に mkdir を呌んでも、カヌネルが䞀方しか成功させたせん。

Python の write_text() にはこの保蚌がありたせん。A が先に曞き、B が盎埌に䞊曞きする競合が理論䞊ありえたす。gen_feature.py の堎合、launchd がむンタヌバル起動なので同時実行の確率は極めお䜎いですが、れロではありたせん。Python で同じ原子性を埗るなら os.mkdir() を䜿う方法がありたす。gen_feature.py があえおその方匏を採甚しおいないのは、このスクリプトで発生頻床の高い問題が「同時起動競合」ではなく「kill枈みpidが残る」だったからです。解決すべき問題の優先順䜍に合わせおシンプルな実装を遞んでいたす。


私が詰たった話

症状䜕も生成されないのに「゚ラヌ」もない

最初の異倉に気づいたのは Discord の #brand-404 チャンネルでした。前日たで毎日届いおいた「🆕 o81 IG特集生成: ...」ずいう通知が来ない。

Discord のアラヌトチャンネルにも䜕も来おいたせん。「゚ラヌが出た」ではなく「䜕も起きなかった」ずいう症状でした。

launchd のゞョブが萜ちおいるのかず思い automation-health.sh を走らせるず、com.lily.gen-feature は「ロヌド枈 / last exit=0」でした。exit 0 は正垞終了です。でも生成は来おいない。

ログを手繰るず、こんな行が3時間分䞊んでいたした。

[gen] lock held他プロセスが実行䞭 or 2h以内— 終了

3回分の launchd 起動が党郚ここで終わっおいたした。ロックを誰かが占有しおいる。

原因特定ロックファむルを盎接芋た

~/dev/brand-404/sns/gen_work/.lock を cat するず、

94799

pid 94799 が曞いおありたした。ps aux | grep 94799 で䜕も出たせん。生きおいないプロセスです。

ls -la ~/dev/brand-404/sns/gen_work/.lock で mtime を確認するず、玄1.5時間前のタむムスタンプでした。LOCK_STALE_SEC = 2 * 36002時間に察しおただ0.5時間足りない。だからずっず「2h以内 = 実行䞭」ず刀断しおスキップし続けおいたのです。

pid 94799 が死んだのは、前倜のデバッグ䞭に別タヌミナルで kill -9 $(cat ~/dev/brand-404/sns/gen_work/.lock) を実行したからでした。本来なら release_lock() が走っお .lock が消えるはずです。しかし -9SIGKILLは finally ブロックを飛ばしたす。ロックが残ったたた翌朝の launchd 起動を党郚巻き蟌んだのです。

最初の誀った修正LOCK_STALE_SEC を短くした

最初に思い぀いたのは「2時間は長すぎる、30分にしよう」でした。

実際に LOCK_STALE_SEC = 30 * 60 に倉えおコミットしたした。これは間違いでした。

問題は「2時間が長いこず」ではなく「kill枈みpidを生きおいるず刀断するこず」です。30分にしおも、kill埌30分は同じ問題が継続したす。むしろ今床は、正圓な長時間実行claude -p を3回呌ぶ = 最倧 CLAUDE_TIMEOUT = 600 秒 × 3回画像DLを途䞭で奪取しおしたうリスクが生たれたす。

正しい修正は「mtime を芋る前に pid の生死を先に確認する」であっお、mtime のしきい倀を倉えるこずではありたせん。しきい倀の調敎は根治ではなく、症状を軜くするだけの察凊です。このコミットは取り消し、os.kill(pid, 0) を先に確認する珟圚の方針に切り替えたした。

二床目の詰たりPermissionError を芋萜ずした

ProcessLookupError だけを捕たえた最初の修正版を本番に入れた埌、テスト䞭に再び詰たりたした。

確認目的で sudo python3 ~/dev/brand-404/sns/gen_feature.py --dry-run を䞀床走らせた埌、通垞ナヌザヌずしお走らせるず再びロックが残る症状が出たした。

sudo で走ったプロセスは root 所有です。通垞ナヌザヌから os.kill(root_pid, 0) を呌ぶず PermissionError が返りたす。私の最初の修正は ProcessLookupError しか芋おいなかったため、PermissionError が倖ぞ䌝播しおスタックトレヌスが出おいたした。

# ❌ PermissionError を芋萜ずした䞭間バヌゞョン
try:
    os.kill(pid, 0)
except ProcessLookupError:
    log(f"stale lock 奪取: pid={pid} は䞍圚")
# PermissionError は捕たえおおらず、倖ぞ䌝播する
else:
    if age <= LOCK_STALE_SEC:
        return False

Python の try/except/else の構造ずしお、else は「try ブロックで䟋倖が䞀切出なかった堎合」に実行されたす。PermissionError を捕たえおいないので、このケヌスでは except ProcessLookupError にも else にも入らず、䟋倖がそのたた呌び出し元に䌝わっおいたした。最終的に PermissionError も明瀺的に捕たえ、mtime fallback に回す珟圚の圢241〜243行目になりたした。

except PermissionError:
    if age <= LOCK_STALE_SEC:
        return False
    log("stale lock 奪取: mtime 2h超")

教蚓「正垞終了しおいる」は「正しく動いおいる」ではない

この件で最も匷く刻たれたのは、exit 0 ず「生成が完了した」は別物だずいうこずです。

log("lock held — 終了"); return 0 は exit 0 です。launchd のゞョブ履歎は「成功」ずしお蚘録されたす。automation-health.sh もグリヌンを返したす。Discord のアラヌトも来たせん。倖から芋るず党郚正垞で、でも目的の「コンテンツ生成」は䞀切起きおいない。

この皮のサむレント障害は、モニタリングを「プロセスが死んだかどうか」ではなく「意図した副䜜甚が発生したかどうか」で蚭蚈しないず怜知できたせん。

今はキュヌ残数count_queue_remaining()が䞀定時間れロのたたなら Discord にアラヌトを飛ばす監芖を別で持っおいたす。プロセスの健党性チェックず、出力の健党性チェックは分けお蚭蚈する必芁がありたす。自埋環境を「壊れを前提ずした蚭蚈」にするずいうこずは、プロセスが生きおいるかどうかだけでなく、「その環境が意図した出力を生み続けおいるか」を別のレむダヌで監芖するこずを含みたす。

぀たずきポむント

前半ず䞭段では「mtime だけを芋おいた蚭蚈の欠陥」「os.kill(pid, 0) による修正」「PermissionError の芋萜ずし」を扱いたした。ここでは、同じ構造のスクリプトを動かす䞭で実際に詰たった点を補足したす。

PID が再利甚されるず「生きおいる」誀刀定が起きる

Linux/macOS の PID は有限で折り返したす。macOS では最倧 99999 付近で䞀呚したす。長時間皌働する環境では、昚日 kill -9 したプロセスの PID を今日の無関係な別プロセスが䜿っおいる、ずいうこずが起こり埗たす。この状態で os.kill(pid, 0) を呌ぶず「プロセスが生きおいる」ず刀定されたす。gen_feature.py は珟状 PID のみを .lock に曞いおいたすが248行目 LOCK_FILE.write_text(str(os.getpid()), ...)、launchd のむンタヌバルが1日1回であるこずず LOCK_STALE_SEC = 2 * 360057行目の組み合わせで実害はほがありたせん。高頻床で呌ばれるスクリプトでは PID ず開始タむムスタンプをペアで曞き、䞡方䞀臎した堎合のみ「実行䞭」ず刀断する実装を怜蚎したす。

GEN_WORK.mkdir(parents=True, exist_ok=True) を忘れるず初回実行が即死する

acquire_lock() の226行目は GEN_WORK.mkdir(parents=True, exist_ok=True) から始たりたす。この1行がない堎合、gen_work/ ディレクトリが存圚しない初回起動で LOCK_FILE.exists() より前の操䜜が FileNotFoundError を䞊げたす。launchd が起動しおもスクリプトが即死し、last exit=1 のたた䜕も動かない状態になりたす。解攟する前に䜜成する、ずいう順番を守りたす。

write_text() はアトミックではない理論䞊の競合が存圚する

Python の Path.write_text() は曞き蟌み途䞭の状態を他プロセスが読める可胜性がありたす。LOCK_FILE.write_text(str(os.getpid()), ...) の曞き蟌みが完了する前に次のむンスタンスが read_text() するず空文字列を読み、int("") が ValueError → 残骞ずしお奪取 → 2぀のむンスタンスが同時実行される、ずいう競合が理論䞊成立したす。gen_feature.py は launchd からの間欠起動なので同時刻起動はほが起きたせん。しかし1分ごずに呌ばれるような高頻床スクリプトでは os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) でアトミックな曞き蟌みを䜿いたす。automation-health.sh が mkdir でロックを実装しおいる理由はここにありたす22行目 if ! mkdir "$_ah_lock" 2>/dev/null;。

finally が走らないのは SIGKILL だけではない

SIGKILLkill -9が try/finally を飛ばすこずは䞭段で扱いたした。もう䞀぀泚意が必芁なのは、C 拡匵モゞュヌルが内郚で os._exit() を呌ぶケヌスです。sys.exit() は SystemExit 䟋倖ずしお finally が走りたすが、os._exit() はプロセスをランタむムごず即終了させるため finally は走りたせん。倖郚ラむブラリぞの䟝存が増えるほど「finally が走らない可胜性」は䞊がりたす。「finally が走らない前提で pid 生死チェックを蚭蚈する」こずが唯䞀の根本察策です。

ロックファむルのパスを盞察パスで組み立おるず環境䟝存になる

launchd の plist に WorkingDirectory キヌが蚭定されおいるず、スクリプトの起動ディレクトリがそこに倉わりたす。盞察パスでロックファむルのパスを組み立おるず、plist の WorkingDirectory 次第で別の堎所にロックファむルが䜜られたす。gen_feature.py は ROOT = Path(__file__).resolve().parent.parent36行目から党パスを絶察パスで組み立おおいたす。launchd ゞョブで動くスクリプトは __file__ ベヌスの絶察パスを䜿いたす。

_draft-feature-XX-{slug} ディレクトリが蓄積する

run_pipeline() は QA 倱敗・スラむド生成倱敗のずき _draft-feature-XX-{slug} ディレクトリを削陀せずに残したす。これは「倱敗したコンテンツを埌から確認できるようにする」意図的な蚭蚈です954〜959行目。count_queue_remaining() は _draft- prefix のものをカりントから陀倖しおいるため203〜218行目、圚庫蚈算には圱響したせん。ただし蓄積が続くず content/sns/ がゎミだらけになりたす。月次の find content/sns -name '_draft-*' -mtime +30 -type d での枅掃が必芁です。

exit 0 を「正垞」ず解釈するずサむレント障害が通り抜ける

main() 関数は、ロック取埗倱敗時も return 0 で終わりたす996〜997行目。launchd はこれを「正垞終了」ずしお蚘録し、automation-health.sh の launchd チェックも last exit=0 を ✓ ず衚瀺したす。「キュヌ圚庫が足りおいお生成䞍芁だった exit 0」ず「ロック取埗に倱敗しお䜕もしなかった exit 0」が launchd の目には同じに芋えたす。実際の2時間停止はこれで芋えたせんでした。

「゚ラヌが来ない正垞」ずいう蚭蚈思想の眠

Discord ぞのアラヌトは discord_alert() が呌ばれる倱敗ケヌスだけ来たす140〜142行目。ロック取埗倱敗ぱラヌ扱いではなくスキップなのでアラヌトは来たせん。自動化監芖を「悪いこずが起きたら通知」だけで蚭蚈するず、サむレントスキップは氞遠に怜知できたせん。今回の異倉に最初に気づいたのも、#brand-404 チャンネルの「🆕 o81 IG特集生成: ...」ずいう成功通知discord_post_review() = 973行目が来なかったこずでした。

Discord のデフォルト User-Agent が Cloudflare に匟かれる

_discord_post() のコメント93〜94行目にある通り、Python の urllib がデフォルトで送る Python-urllib/x.y ずいう User-Agent を Cloudflare が 403/1010 で匟きたす。ua_header = "lily-o81-gen/1.0" を明瀺しお回避しおいたす。自動化スクリプトで urllib や requests ラむブラリのデフォルト蚭定のたた倖郚サヌビスを叩くず、予告なくブロックされるこずがありたす。

curl のタむムアりトずリトラむのバランスを芋誀る

fetch_products() は Shopify の products.json を attempts=3 でリトラむしたす503〜522行目。倱敗時に15秒埅っおリトラむする蚭蚈は「連続アクセスで䞀時的に空を返す店がある」ずいう実害Beyond The Vines, 2026-07-26から来おいたす。タむムアりトを極端に短くするず「本圓は商品があるのに0件ず刀定されおブランドが skip に降栌する」こずが起きたす。逆に長すぎるず1回の倱敗で数分停たりたす。倖郚 API を叩くスクリプトは「1回のタむムアりト蚭定」ず「䜕回リトラむするか」ず「リトラむ間の埅機時間」の3぀をセットで蚭蚈したす。


ベストプラクティス

前半・䞭段・぀たずきポむントを螏たえお、launchd で動く長時間スクリプトのロック管理に関しお実コヌドから抜出したプラクティスを敎理したす。

1. pid 生死チェックを䞻刀定に、mtime を副刀定に固定する

try:
    os.kill(pid, 0)
except ProcessLookupError:
    pass  # 即奪取
except PermissionError:
    if age <= LOCK_STALE_SEC:
        return False  # mtime fallback
else:
    if age <= LOCK_STALE_SEC:
        return False  # mtime fallback

shell 版は kill -0 "$(cat pid_file)" で同じ意味論を持ちたすautomation-health.sh 23行目。

2. 3぀の䟋倖分岐を必ずセットで実装する

ProcessLookupError だけを捕たえるず PermissionError が倖ぞ䌝播しおスクリプトがクラッシュしたす。3぀をセットで曞きたす。1぀でも欠けるず「そのケヌス専甚のバグ」になりたす。

3. try/finally でロック解攟を必ず包む

if not acquire_lock():
    return 0

try:
    # メむン凊理
finally:
    release_lock()

shell では trap 'rm -rf "$lock"' EXIT が同等です。正垞終了・䟋倖発生・sys.exit() どれでも解攟されたす。

4. release_lock() は FileNotFoundError を握り぀ぶす

finally ず手動呌び出しが重なっおも2回目の unlink() でクラッシュしないための防埡です。「ロック解攟の倱敗で本凊理が䟋倖になる」本末転倒を防ぎたす252〜256行目のパタヌン。

5. LOCK_STALE_SEC は最長実行時間から逆算する

gen_feature.py の最長実行は CLAUDE_TIMEOUT = 600 秒66行目× 最倧3呌び出し + 画像 DL で玄35〜40分です。LOCK_STALE_SEC = 2 * 360057行目はこれに察しお十分なマヌゞンがありたす。しきい倀を短くしおも「kill 枈み pid が2h以内にロックを手攟す」問題は解決したせん。しきい倀の調敎は察症療法であり、pid 生死チェックが根治です。

6. ロックファむルのパスは Path(__file__).resolve() ベヌスの絶察パスで組み立おる

launchd の WorkingDirectory に䟝存しない実装が必須です。gen_feature.py の ROOT = Path(__file__).resolve().parent.parent36行目からすべおのパスを組み立おるパタヌンがそのたたテンプレヌトです。

7. ロック取埗前に GEN_WORK.mkdir(parents=True, exist_ok=True) を必ず実行する

ディレクトリが存圚しない初回起動でクラッシュしないための必須凊理です。acquire_lock() の先頭に眮きたす226行目。

8. shell では mkdir ロックで原子性を確保する

mkdir はカヌネルレベルでアトミックです。2プロセスが同時に呌んでも䞀方しか成功したせんautomation-health.sh 22〜27行目。Python でも原子性が必芁なら os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) を䜿いたす。

9. モニタリングはプロセスではなく「意図した出力」で蚭蚈する

automation-health.sh の launchd チェックは last exit=0 を正垞ず芋たすが、ロック取埗スキップも exit 0 です。「コンテンツ生成が完了した」ずいう成功通知discord_post_review(), 973行目が䞀定時間内に来なければアラヌトを出す、ずいう正方向の監芖が正解です。

10. 成功通知を持぀゚ラヌ通知だけに䟝存しない

゚ラヌだけを通知する蚭蚈は、サむレントスキップを芋逃したす。「🆕 o81 IG特集生成: ...」ずいう成功通知が「来ない」こずで異垞を怜知する蚭蚈が、今回の障害を気づかせた唯䞀のシグナルでした。「良いこずが起きた通知」を持぀こずが異垞怜知の実効性に぀ながりたす。

11. キュヌ残数を耇数日分確保する

QUEUE_TARGET = 360行目ず MAX_GEN_PER_RUN = 261行目の組み合わせは「生成が1〜2日連続で倱敗しおも投皿が途切れない」圚庫蚭蚈です。ロック障害で1日止たっおも圚庫3本あれば投皿は維持できたす。バッファを持぀こずが、自動化の単䞀障害点を排陀したす。

12. --dry-run オプションでロックを取らずに状態確認できるようにする

gen_feature.py --dry-run981〜993行目はロックを取らずにキュヌ残数だけを衚瀺したす。本番が動いおいる最䞭でも安党に呌べたす。障害調査時に「ロックを取りに行っお競合する」心配をせずに状態を確認できたす。

13. --force オプションで E2E テストを本番ロゞックのたた実行できるようにする

通垞は QUEUE_TARGET を満たすたでしか生成したせんが、--force はキュヌ残数に関わらず1本生成したす980〜984行目。本番ロゞックを倉えずにパむプラむン党䜓をテストでき、修正埌の怜蚌に䜿えたす。

14. 倖郚 API ぞの urllib 呌び出しには必ず User-Agent を明瀺する

Python デフォルトの Python-urllib/x.y は Cloudflare や䞀郚 CDN に匟かれたす。"lily-o81-gen/1.0" のように識別可胜な文字列を指定したす。Discord・Shopify・その他 Cloudflare 配䞋の API を叩く堎合は必須です。


たずめ

pid 94799 が2時間ロックを占拠したのは1行の欠劂が原因でした。os.kill(pid, 0) でプロセスの生死を確認する凊理が抜けおいたために、kill 枈みの pid を「ただ実行䞭」ず誀刀定し続けた。修正自䜓は数行です。

しかし本圓に理解しなければならないのは、その「なぜ」でした。SIGKILL は finally を飛ばす。launchd の exit 0 は「正しく動いた」ではなく「プロセスが終了した」に過ぎない。モニタリングはプロセスの生死ではなく「意図した出力が発生しおいるか」で蚭蚈する。この3点が自埋環境を「壊れを前提ずした蚭蚈」にするずいう意味の実䜓です。

最初の誀った修正LOCK_STALE_SEC を2時間から30分に瞮めるは、症状を軜くしようずしお根本原因を無芖した兞型的な誀りでした。30分に瞮めおも「kill 枈み pid が2h以内に手攟す」問題は解決したせん。むしろ正圓な長時間実行が途䞭で奪取されるリスクを生みたした。CLAUDE_TIMEOUT = 600 秒66行目×3呌び出しで最倧30分以䞊かかるスクリプトに30分のしきい倀は短すぎたす。

自埋環境を䜜るずいうこずは、「誰も芋おいない状態でも正しく動き続ける」責任を匕き受けるこずです。月10䞇の倧孊生だったころは毎日手で動かしおいたSNSが、今は launchd + Claude Code の自埋環境で回っおいたす。月商120䞇を維持できおいるのは、コンテンツそのものではなく「仕組みが止たらない蚭蚈」に積み䞊げおきた修正の積み重ねです。今回の pid 生死チェックも、その䞀぀です。


仕組みの党䜓像・月120䞇の内蚳・30日手順は有料noteにたずめおいたす
📕 Claude Code自埋環境で、実際どう皌ぐか ― 仕組み・実䟋・始め方・サポヌト


Lily@bokuwalily― 個人開発者。Claude Code で自動化基盀を組みながら、iOSアプリやWebサヌビスを量産しおいたす

  • 制䜜物・蚘事は bokuwalily.com にたずめおいたす🖥
  • AIで「寝おおも回る仕組み」を䜜っお月120䞇にした話は noteの有料蚘事 に💰
  • OSS: github.com/bokuwalily 🐙
  • 最新情報・お問い合わせは X @bokuwalily ぞ🌍

皆さんの ❀ やシェアが励みになりたす

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?