自動化システムの定時実行にmacOSのlaunchdを使っています。本番77ジョブ + Pre環境13ジョブの計90ジョブが24時間稼働中です。
LinuxならsystemdやCronを使うところですが、開発マシンがmacOSなのでlaunchdを選択しました。本記事では、90ジョブの管理で発生した問題と、Claude Codeと組み合わせた運用ノウハウを記録します。
launchdの基本
plistファイル
launchdはXML形式のplistファイルでジョブを定義します。~/Library/LaunchAgents/にファイルを置き、launchctl loadで登録します。
1<?xml version="1.0" encoding="UTF-8"?>2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"3 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">4<plist version="1.0">5<dict>6 <key>Label</key>7 <string>com.dataprocessing.collect.swing.usd_jpy</string>8
9 <key>ProgramArguments</key>10 <array>11 <string>/opt/anaconda3/envs/st312/bin/python</string>12 <string>/Users/htada/data-processing/_dataProcessingEngineA/execution/main_forward_test.py</string>13 <string>--pair</string>14 <string>USD_JPY</string>15 </array>23 collapsed lines
16
17 <key>StartCalendarInterval</key>18 <array>19 <dict>20 <key>Hour</key><integer>9</integer>21 <key>Minute</key><integer>0</integer>22 </dict>23 </array>24
25 <key>EnvironmentVariables</key>26 <dict>27 <key>PROJECT_ROOT</key>28 <string>/Users/htada/data-processing</string>29 <key>PYTHONPATH</key>30 <string>/Users/htada/data-processing/_dataProcessingEngineA:/Users/htada/data-processing</string>31 </dict>32
33 <key>StandardOutPath</key>34 <string>/tmp/collect_swing_usd_jpy/stdout.log</string>35 <key>StandardErrorPath</key>36 <string>/tmp/collect_swing_usd_jpy/stderr.log</string>37</dict>38</plist>launchctl コマンド
1# ジョブの登録2launchctl load ~/Library/LaunchAgents/com.dataprocessing.collect.swing.usd_jpy.plist3
4# ジョブの解除5launchctl unload ~/Library/LaunchAgents/com.dataprocessing.collect.swing.usd_jpy.plist6
7# 登録済みジョブの一覧8launchctl list | grep dataprocessing9
10# ジョブの即時実行(テスト用)11launchctl start com.dataprocessing.collect.swing.usd_jpy90ジョブの分類
命名規則
1com.dataprocessing.{エンジン}.{種別}.{対象}2
3例:4com.dataprocessing.collect.swing.usd_jpy # データ収集 Swing USD/JPY5com.dataprocessing.collect.day.eur_jpy # データ収集 Day EUR/JPY6com.dataprocessing.collect.monitor.resources # データ収集リソース監視7com.dataprocessing.domestic.daily.preparation # 国内データ 日次準備8com.dataprocessing.overseas.daily.processing # 海外データ 日次処理9com.dataprocessing.daily_health_check # 日次ヘルスチェック10com.dataprocessing.pre.collect.swing.usd_jpy # Pre環境 データ収集 Swing USD/JPYスケジュール一覧(主要ジョブ)
| ジョブ | スケジュール | 用途 |
|---|---|---|
| データ収集 Swing(9ペア) | 毎時00分 | H4足ベースの中期処理 |
| データ収集 Day(4ペア) | 毎時00分 | H1足ベースのデイ処理 |
| データ収集 Monitor | 毎時30分 | リソース監視・トレーリングストップ |
| 国内データ Preparation | 20:00 JST | 翌日トリガーファイル生成 |
| 国内データ Processing | 09:00 JST | 国内データ稼働時間処理 |
| 海外データ Preparation | 22:00 JST | 海外データシグナル生成 |
| 海外データ Processing | 23:30 JST | 海外データ処理 |
| Health Check | 06:30 JST | 全ジョブのヘルスチェック |
| Pre環境(13ジョブ) | 本番+5分 | Dry-Run検証 |
発生した問題と対策
問題1: 環境変数の罠
launchdはシェルプロファイルを読み込みません。ターミナルでecho $API_KEYすれば値が表示されますが、launchd経由ではNoneになります。
1解決策(3つの方法):21. plistのEnvironmentVariablesに設定(推奨: 非シークレット値)32. スクリプト内でload_dotenv()を呼ぶ(推奨: シークレット値)43. launchctl setenv(非推奨: macOS再起動で消える)問題2: PYTHONPATHの2パス設定
1<!-- NG 1パスだけ設定 -->2<key>PYTHONPATH</key>3<string>/Users/htada/data-processing</string>4
5<!-- OK 2パス設定(エンジン + プロジェクトルート) -->6<key>PYTHONPATH</key>7<string>/Users/htada/data-processing/_dataProcessingEngineA:/Users/htada/data-processing</string>from _dataProcessingEngineA.core.shared.X import Y(絶対インポート)とfrom myModules.X import Y(共有モジュール)の両方を解決するには、2つのパスが必要です。
問題3: __pycache__の汚染
本番環境でPython 3.12、開発環境でも3.12を使っていますが、git pull時に開発環境の__pycache__が混入すると、古いバイトコードが使われることがあります。
1<!-- plistに追加 -->2<key>PYTHONDONTWRITEBYTECODE</key>3<string>1</string>PYTHONDONTWRITEBYTECODE=1で、pycファイルの生成を抑制します。
問題4: ログの肥大化
90ジョブが毎時実行すると、ログファイルが急速に肥大化します。
1# ログローテーション(週次で実行)2for log_dir in /tmp/collect_*/; do3 find "$log_dir" -name "*.log" -mtime +7 -delete4done問題5: macOS再起動後のジョブ消失
macOSを再起動すると、launchctl loadで登録したジョブは自動的に再登録されるはずですが、plistファイルが壊れていると登録されません。
1# 再起動後の確認スクリプト2expected_jobs=(3 "com.dataprocessing.collect.swing.usd_jpy"4 "com.dataprocessing.collect.swing.gbp_jpy"5 # ... 全90ジョブ6)7
8for job in "${expected_jobs[@]}"; do9 if ! launchctl list | grep -q "$job"; then10 echo "MISSING: $job"11 # 自動復旧: plistを再ロード12 plist_file=$(find ~/Library/LaunchAgents -name "*${job}*" 2>/dev/null)13 if [ -n "$plist_file" ]; then14 launchctl load "$plist_file"15 fi2 collapsed lines
16 fi17doneヘルスチェックの設計
validate_launchd_health.sh
毎朝06:30に自動実行されるヘルスチェックスクリプトです。
1#!/bin/bash2# 3つの検証を実行3
4# 1. plist構文検証5echo "[CHECK] plist構文検証..."6for plist in ~/Library/LaunchAgents/com.dataprocessing.*.plist; do7 if ! plutil -lint "$plist" > /dev/null 2>&1; then8 echo "INVALID: $(basename $plist)"9 errors=$((errors + 1))10 fi11done12
13# 2. ログ鮮度チェック14echo "[CHECK] ログ鮮度チェック..."15check_freshness() {23 collapsed lines
16 local log=$1 threshold=$2 label=$317 if [ ! -f "$log" ]; then18 echo "NOT_FOUND: $label"19 return20 fi21 local age=$(( ($(date +%s) - $(stat -f "%m" "$log")) / 3600 ))22 if [ $age -gt $threshold ]; then23 echo "STALE: $label (${age}h > ${threshold}h)"24 fi25}26
27# hourlyジョブは3時間、dailyジョブは28時間が閾値28check_freshness "/tmp/collect_swing_usd_jpy/stdout.log" 3 "データ収集 Swing USD_JPY"29check_freshness "/tmp/domestic_daily_processing/stdout.log" 28 "国内データ Daily Processing"30
31# 3. エラーパターン検出32echo "[CHECK] エラーパターン検出..."33for log_dir in /tmp/collect_* /tmp/domestic_* /tmp/overseas_*; do34 if grep -l "ModuleNotFoundError\|ImportError\|OSError\|Traceback" \35 "$log_dir/stderr.log" 2>/dev/null; then36 echo "ERROR_DETECTED: $log_dir"37 fi38doneSlack通知
異常があればSlackに通知します。
1if [ $errors -gt 0 ]; then2 curl -s -X POST "$SLACK_WEBHOOK_URL" \3 -H "Content-Type: application/json" \4 -d "{\"text\": \"launchdヘルスチェック: ${errors}件の異常検出\"}"5fiAIによるplist生成
90ジョブ分のplistを手書きするのは現実的ではありません。Claude Codeでplist生成スクリプトを作成しました。
1import plistlib2from pathlib import Path3
4def generate_collect_swing_plist(5 pair: str,6 project_root: str,7 is_pre: bool = False,8) -> None:9 label_prefix = "com.dataprocessing.pre" if is_pre else "com.dataprocessing"10 minute = 5 if is_pre else 0 # Pre環境は+5分オフセット11 mode = "--dry-run" if is_pre else "--execute"12 root = f"{project_root}-pre" if is_pre else project_root13
14 config = {15 "Label": f"{label_prefix}.collect.swing.{pair.lower()}",26 collapsed lines
16 "ProgramArguments": [17 "/opt/anaconda3/envs/st312/bin/python",18 f"{root}/_dataProcessingEngineA/execution/main_forward_test.py",19 "--pair", pair,20 mode,21 ],22 "StartCalendarInterval": [{"Minute": minute}],23 "EnvironmentVariables": {24 "PROJECT_ROOT": root,25 "PYTHONPATH": f"{root}/_dataProcessingEngineA:{root}",26 "PYTHONDONTWRITEBYTECODE": "1",27 },28 "StandardOutPath": f"/tmp/collect_swing_{pair.lower()}/stdout.log",29 "StandardErrorPath": f"/tmp/collect_swing_{pair.lower()}/stderr.log",30 }31
32 output_dir = Path.home() / "Library" / "LaunchAgents"33 output_path = output_dir / f"{config['Label']}.plist"34
35 with open(output_path, "wb") as f:36 plistlib.dump(config, f)37
38 # 必ず構文検証39 import subprocess40 result = subprocess.run(["plutil", "-lint", str(output_path)], capture_output=True)41 assert result.returncode == 0, f"Invalid plist: {result.stderr.decode()}"重要: plistlib.dump()を使うこと。json.dump()で生成するとplist形式にならない(30ファイル破損事故の教訓)。
学んだこと
1. launchdは「設定したら放置」ではない
ジョブが登録されていてもplistが壊れていれば再起動で消えます。定期的なヘルスチェックが必須です。
2. 命名規則が90ジョブの管理を可能にする
com.dataprocessing.{エンジン}.{種別}.{対象}の命名規則により、launchctl list | grep dataprocessingで全ジョブを一覧できます。命名規則なしの90ジョブは管理不能です。
3. Pre環境のジョブは本番+5分が最適
同じデータを使いつつ、データ生成→読み込みの依存関係を保つため、5分のオフセットが実用的でした。
4. plist生成はスクリプト化必須
手書きの90ファイルは保守不能です。スクリプトで生成し、plutil -lintで自動検証します。
まとめ
macOS launchdで90ジョブを運用する際に重要なのは以下の3点です。
- 環境変数の明示設定: EnvironmentVariablesにPROJECT_ROOT・PYTHONPATH・PYTHONDONTWRITEBYTECODE。シェルプロファイルは読まれない
- ヘルスチェックの自動化: plist構文・ログ鮮度・エラーパターンの3層チェックを毎朝実行
- plist生成のスクリプト化: plistlib.dump()で生成 + plutil -lint で検証。手書き・json.dump()は禁止
launchdは「Linuxで言うsystemd + cron」ですが、macOS固有の癖(キャッシュ問題、環境変数の非継承)を理解しないと運用できません。