45395 - シコウサクゴ -

launchd に SIGKILL される前に自発終了する:24h タイムアウトの 20h で graceful exit する設計

長時間動くデータ処理ジョブを 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 時間あるので、ファイル保存もチェックポイントも安全に終わらせられます。

1
import os
2
import time
3
import signal
4
5
START_TIME = time.time()
6
MAX_RUNTIME_SECONDS = 20 * 3600 # 20 時間で graceful exit
7
HARD_TIMEOUT_SECONDS = 24 * 3600 # launchd の ExitTimeOut
8
9
def should_graceful_exit() -> bool:
10
elapsed = time.time() - START_TIME
11
return elapsed >= MAX_RUNTIME_SECONDS
12
13
def 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
return
23
24
process_batch(batch)

バッチの境界で判定

「20 時間経過した瞬間に止める」のではなく、バッチ処理の境界で判定します。バッチの途中で止めると逆に中途半端になるので、1 バッチ完了 → 残り時間判定 → 続行 or 停止、の流れにします。

cleanup の実装

graceful exit 時にやることをまとめた関数を用意します。

1
def 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 で止めた以上、次回起動時に続きから再開できる必要があります。

1
def load_checkpoint() -> dict | None:
2
if not CHECKPOINT_PATH.exists():
3
return None
4
return json.loads(CHECKPOINT_PATH.read_text())
5
6
def 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 = 0
13
14
main_loop(start_id)

「graceful exit で止まった → 次回 launchd 起動時に再開」のサイクルで、24 時間の上限を超える処理も複数日に分割して実行できます。

SIGTERM ハンドラも併用する

20 時間予測で graceful exit するのが基本ですが、何らかの理由で 手動で launchctl kill SIGTERM を打たれるケースもあります。SIGTERM を受けたら同じ cleanup を呼ぶようにしておきます。

1
import signal
2
3
def handle_sigterm(signum, frame):
4
logger.warning("received SIGTERM, shutting down")
5
cleanup_and_exit()
6
7
signal.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 を変えたときに同期し忘れます。

修正: 環境変数で連動させる。

1
HARD_TIMEOUT_SECONDS = int(os.getenv("EXIT_TIMEOUT_SECONDS", 86400))
2
MAX_RUNTIME_SECONDS = int(HARD_TIMEOUT_SECONDS * 0.83) # 20/24 = 0.83

plist 側で EXIT_TIMEOUT_SECONDS を渡せば 1 か所で管理できます。

罠 2: バッチ境界が遠い

1 バッチが 1 時間かかる処理で「20 時間経過したら止める」と書いても、最大 21 時間まで動いてしまいます。

修正: バッチをさらに細かく分割するか、1 バッチの中でも周期的に判定する仕組みを入れる。

1
def 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 諦める。

1
def cleanup_and_exit():
2
save_checkpoint(...) # これは必須
3
close_db_connections() # これも必須
4
try:
5
send_slack(...) # 通知は best-effort
6
except Exception:
7
pass # 失敗しても止めない
8
sys.exit(0)

効果

指標BeforeAfter
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)に殺されるのを待つのではなく、自分から終わる。長時間ジョブの安定運用には、この能動性が鍵だと感じました。

Article title:launchd に SIGKILL される前に自発終了する:24h タイムアウトの 20h で graceful exit する設計
Article author:45395
Release time:2026-05-07

記事へのご質問・ご感想をお聞かせください

フィードバックを送る