長時間動くデータ処理ジョブを launchd で運用していて、稀に 24 時間タイムアウト に達して SIGKILL で殺されることがありました。SIGKILL はファイナライザを呼ばないので、書きかけのファイルや途中の DB トランザクションが中途半端な状態で残ります。
「殺される前に自発的に終了する」preemptive graceful shutdown を仕込むことで、中途半端な状態を防いだ記録です。
問題:SIGKILL は止められない
launchd のタイムアウト挙動
launchd は ExitTimeOut キーで実行時間の上限を設定できます。デフォルトはありませんが、私のジョブでは「最大 24 時間」を設定していました。
1<key>ExitTimeOut</key>2<integer>86400</integer> <!-- 24 時間 -->24 時間に達すると、launchd はまず SIGTERM を送り、20 秒程度待った後に SIGKILL で強制終了します。
SIGTERM では 20 秒しかない
SIGTERM を受けてから graceful shutdown する選択肢もありますが、20 秒では足りない ケースが多いです。
- 書きかけの parquet ファイルを正常クローズ
- DB トランザクションのコミット or ロールバック
- 進捗ファイルの保存
- 一時ファイルの削除
これらを全部やると 20 秒は厳しい。SIGKILL されると、*.parquet.tmp のような中間ファイルが残り、次回起動時に「これ何?」となります。
設計:自発的に「もうすぐ殺される」を察知して終わる
残り時間ベースの判定
launchd の ExitTimeOut を 24 時間に設定しているなら、プロセス起動から 20 時間経過した時点で自発終了する。残り 4 時間あるので、ファイル保存もチェックポイントも安全に終わらせられます。
1import os2import time3import signal4
5START_TIME = time.time()6MAX_RUNTIME_SECONDS = 20 * 3600 # 20 時間で graceful exit7HARD_TIMEOUT_SECONDS = 24 * 3600 # launchd の ExitTimeOut8
9def should_graceful_exit() -> bool:10 elapsed = time.time() - START_TIME11 return elapsed >= MAX_RUNTIME_SECONDS12
13def main_loop():14 for batch in iter_batches():15 if should_graceful_exit():9 collapsed lines
16 logger.warning(17 f"approaching ExitTimeOut "18 f"(elapsed={time.time() - START_TIME:.0f}s), "19 f"shutting down gracefully"20 )21 cleanup_and_exit()22 return23
24 process_batch(batch)バッチの境界で判定
「20 時間経過した瞬間に止める」のではなく、バッチ処理の境界で判定します。バッチの途中で止めると逆に中途半端になるので、1 バッチ完了 → 残り時間判定 → 続行 or 停止、の流れにします。
cleanup の実装
graceful exit 時にやることをまとめた関数を用意します。
1def cleanup_and_exit():2 """中途半端な状態を残さず終わる"""3 try:4 # 1. 書きかけのファイルを完成させる or 削除5 flush_pending_files()6
7 # 2. 進捗をチェックポイントに書き出し8 save_checkpoint({9 "last_processed_id": current_id,10 "exit_reason": "graceful_shutdown",11 "exit_time": datetime.now().isoformat(),12 })13
14 # 3. DB 接続をクローズ15 close_all_connections()9 collapsed lines
16
17 # 4. 通知18 send_slack(f"job exited gracefully at {time.time() - START_TIME:.0f}s")19
20 except Exception:21 logger.exception("cleanup failed but exiting anyway")22 finally:23 # exit 0 でないと launchd が再起動してしまう(KeepAlive 設定次第)24 sys.exit(0)finally で必ず exit するのがポイント。cleanup が失敗しても、ぶら下がるよりは exit したほうが launchd の管理下に戻ります。
チェックポイントによる再開
graceful exit で止めた以上、次回起動時に続きから再開できる必要があります。
1def load_checkpoint() -> dict | None:2 if not CHECKPOINT_PATH.exists():3 return None4 return json.loads(CHECKPOINT_PATH.read_text())5
6def main():7 checkpoint = load_checkpoint()8 if checkpoint and checkpoint.get("exit_reason") == "graceful_shutdown":9 logger.info(f"resuming from checkpoint: {checkpoint}")10 start_id = checkpoint["last_processed_id"]11 else:12 start_id = 013
14 main_loop(start_id)「graceful exit で止まった → 次回 launchd 起動時に再開」のサイクルで、24 時間の上限を超える処理も複数日に分割して実行できます。
SIGTERM ハンドラも併用する
20 時間予測で graceful exit するのが基本ですが、何らかの理由で 手動で launchctl kill SIGTERM を打たれるケースもあります。SIGTERM を受けたら同じ cleanup を呼ぶようにしておきます。
1import signal2
3def handle_sigterm(signum, frame):4 logger.warning("received SIGTERM, shutting down")5 cleanup_and_exit()6
7signal.signal(signal.SIGTERM, handle_sigterm)ただし SIGKILL は捕まえられません。それまでに graceful exit していることが大前提です。
launchd 側の設定
ExitTimeOut の設定
1<key>ExitTimeOut</key>2<integer>86400</integer> <!-- 24 時間 = ハードリミット -->KeepAlive の挙動
KeepAlive を有効にしていると、graceful exit (exit 0) 後にすぐ再起動されます。これは想定通りで、exit するたびに新しい 24 時間タイマー が始まります。
1<key>KeepAlive</key>2<dict>3 <key>SuccessfulExit</key>4 <true/> <!-- exit 0 でも再起動する -->5</dict>ただし、これだと cleanup_and_exit 直後に再起動 → またすぐ 20 時間経過 → exit、を延々ループします。起動間隔の最低時間も設定したほうが安全です。
1<key>ThrottleInterval</key>2<integer>60</integer> <!-- 再起動の最低間隔: 60 秒 -->罠と対処
罠 1: 「20 時間」のハードコード
MAX_RUNTIME_SECONDS = 20 * 3600 を Python に直書きしていると、launchd の ExitTimeOut を変えたときに同期し忘れます。
修正: 環境変数で連動させる。
1HARD_TIMEOUT_SECONDS = int(os.getenv("EXIT_TIMEOUT_SECONDS", 86400))2MAX_RUNTIME_SECONDS = int(HARD_TIMEOUT_SECONDS * 0.83) # 20/24 = 0.83plist 側で EXIT_TIMEOUT_SECONDS を渡せば 1 か所で管理できます。
罠 2: バッチ境界が遠い
1 バッチが 1 時間かかる処理で「20 時間経過したら止める」と書いても、最大 21 時間まで動いてしまいます。
修正: バッチをさらに細かく分割するか、1 バッチの中でも周期的に判定する仕組みを入れる。
1def process_batch(batch):2 for i, item in enumerate(batch):3 if i % 100 == 0 and should_graceful_exit():4 save_partial_progress(batch[:i])5 cleanup_and_exit()6 process_item(item)罠 3: チェックポイントの読み忘れ
cleanup は書いたが、起動時にチェックポイントを 読んでいない バグを踏みました。新規起動扱いで全件再処理してしまい、二重実行に。
修正: 起動時のチェックポイント読み込みを 必須にする(無ければ最初から、あれば続きから)。
罠 4: cleanup 中にも時間が経つ
cleanup 関数自体に時間がかかると、SIGTERM に追いつかれます。
修正: cleanup 内では 「絶対に必要なもの」だけを行い、ログ送信などは非同期にする or 諦める。
1def cleanup_and_exit():2 save_checkpoint(...) # これは必須3 close_db_connections() # これも必須4 try:5 send_slack(...) # 通知は best-effort6 except Exception:7 pass # 失敗しても止めない8 sys.exit(0)効果
| 指標 | Before | After |
|---|---|---|
| SIGKILL 発生 | 月 1〜2 回 | 0 |
| 中途半端な一時ファイル | 月 1〜2 回 | 0 |
| ジョブの平均稼働時間 | 数時間〜23 時間 | 18〜20 時間で計画的に exit |
| 翌日の二重実行 | 月 1 回(チェックポイント未読バグ) | 0 |
学び
1. SIGKILL を「想定内」にしない
「24 時間で殺されるかも」を想定して書くより、「24 時間に達する前に自発で終わる」設計にしたほうが安全です。SIGKILL に頼ると、必ずどこかで中途半端な状態が残ります。
2. ハードリミットとソフトリミットを分ける
ExitTimeOut がハード、MAX_RUNTIME_SECONDS がソフト。ソフトを少し短く設定して、cleanup の余裕時間を確保します。比率は 0.8〜0.85 倍程度がちょうどいいと感じました。
3. チェックポイント前提の設計
「途中で止まる」ことを前提に、いつでも止まれて、いつでも再開できる構造にする。これは launchd だけでなく、AWS Lambda や他のタイムアウト付き実行環境すべてに通用する考え方です。
4. 中途半端を残さない
「.tmp 拡張子の中間ファイル」「未コミットのトランザクション」「未保存の進捗」を残さない設計が、運用の安定性を大きく上げます。
まとめ
| 観点 | 設計方針 |
|---|---|
| ハードリミット | launchd の ExitTimeOut(24h) |
| ソフトリミット | プロセス側で 20h を判定 |
| 判定タイミング | バッチ境界で確認、長いバッチでは内部でも周期判定 |
| cleanup | 必須処理のみ、best-effort は分離 |
| チェックポイント | 起動時に必ず読み込む |
「launchd に殺される前に自発終了する」という発想は、プロセスのライフサイクルを能動的にコントロールする設計でした。
外部の管理機構(launchd・k8s・systemd)に殺されるのを待つのではなく、自分から終わる。長時間ジョブの安定運用には、この能動性が鍵だと感じました。