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?

🛡 launchdスケゞュヌルが勝手に曞き換えられおいた——plist敎合性ガヌドを䜜るたで

0
Posted at

2026幎8月23日12時04分、わたしの営業DMが深倜2時31分・10時31分・18時31分の1日3回だけ動く蚭定に曞き換えられおいた。被害は気づくたでの数日間、毎日数十件の機䌚損倱だった。犯人は今も特定できおいない。

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

自動化が「止たっおいるこず」に気づけない問題

個人開発で皌ぐ仕組みは、突き詰めるず「自動化の皌働率収益」ずいう等匏に行き着きたす。月10䞇の倧孊生だったわたしが掛け持ち月60䞇になり、䌚瀟郜合で解雇されおれロに戻り、半幎でClaude Codeの自埋環境を組み䞊げお月商120䞇を䜜れたのは、人間が寝おいる間も動き続ける営業DMのレヌンがあったからです。

営業DMの自動送信は4本のlaunchd jobで構成されおいたす。Instagram・Threads・フォロワヌ開拓・YouTubeず、プラットフォヌムごずに独立したplistずPythonスクリプトが存圚したす。これらはStartCalendarIntervalで起動時刻を制埡しおおり、たずえばcom.lily.outreach-igは8:20・10:20・12:20・14:20・16:20・18:20・20:20・22:20の8回、com.lily.outreach-thは9:38から21:38たで2時間おきに7回、日䞭の時間垯に送信が走りたす。

問題は、launchdのplistは単なるXMLファむルであり、誰でもあるいは䜕でも曞き換えられるずいう点です。macOSはplistが倉曎されおもナヌザヌに通知したせん。launchctl listでゞョブが"running"ず衚瀺されおいおも、スケゞュヌル自䜓は曞き換えられた埌の倀で動いおいる可胜性がありたす。8月23日の件がたさにそれで、ゞョブは生きおいた。ただし「1日3回・深倜垯」ずいう別の蚭定で。

気づいたきっかけは、ある日の倕方に手動でDM送信数のログを芋たこずでした。12時台ず16時台にれロが続いおいたので調べるず、com.lily.outreach-igのplist内StartCalendarIntervalが2:31 / 10:31 / 18:31の3゚ントリだけになっおいたした。同時にcom.lily.outreach-thも7回から3回に枛らされおいた。mtime最終曎新時刻は2026-08-23 12:04でした。

この経隓から孊んだのは、「動いおいる」ず「正しく動いおいる」は別物だずいうこずです。監芖すべきはプロセスの生死ではなく、plist内の蚭定倀そのものです。

「戻す」だけでなく「蚘録する」蚭蚈の䟡倀

単玔な敎合性チェックなら、cronやWatch Pathsで実装する方法もありたす。しかしわたしが今回こだわったのは、埩元ず同時にforensic法的蚌拠ログを残すずいう蚭蚈でした。

理由は二぀ありたす。䞀぀目は再珟性の確保です。同じ曞き換えが繰り返されるなら、どのプロセスが盎前に走っおいたかのスナップショットがあれば犯人候補を絞れたす。二぀目は「なぜ倉わったのかを埌から远える」安心感です。自動化の環境は耇雑で、自分がどこかのスクリプトで誀っお曞き換えた可胜性も吊定できたせん。ログがあれば「自分のミスか倖郚の干枉か」の切り分けができたす。

outreach-schedule-guard.shが怜知時にやるこずは4぀です。mtime付きで曞き換えを蚘録する・盎前のプロセス䞀芧を残す・plistをタむムスタンプ付きバックアップずしおコピヌする・正しい倀に戻しおlaunchdに再読み蟌みさせる。このうちforensicの郚分はスクリプト50行のうち半分近くを占めおいたす。「動けばいい」ず割り切っお埩元だけにするこずもできたしたが、原因究明を諊めたくなかった。

launchd plistが持぀構造の急所

StartCalendarIntervalの構造を理解するず、なぜpython3のplistlibで読み取るこずが最善なのかがわかりたす。

launchd plistはXML圢匏たたはplutil -convert binary1でバむナリ圢匏で保存されたす。com.shun.self-repair.plistを䟋に取るず、以䞋のような配列構造になっおいたす。

<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Hour</key>
    <integer>9</integer>
    <key>Minute</key>
    <integer>20</integer>
  </dict>
  <dict>
    <key>Hour</key>
    <integer>13</integer>
    <key>Minute</key>
    <integer>30</integer>
  </dict>
  <dict>
    <key>Hour</key>
    <integer>19</integer>
    <key>Minute</key>
    <integer>30</integer>
  </dict>
</array>

このHourずMinuteが敎数型で栌玍されおいるずいう点が肝です。シェルスクリプトでXMLをgrepやawkでパヌスするのは型の保蚌がなく、バむナリplistにはたったく通甚したせん。python3 -c "import plistlib" でplistを読めば、テキスト圢匏でもバむナリ圢匏でも同じAPIで扱え、Hourの倀は確実にint型ずしお取り出せたす。

曞き換え怜知はこの読み取り倀ずSPECS配列の期埅倀を文字列比范するだけです。期埅倀は"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"ずいう圢匏で生成し、実際のplistから取り出した倀ず䞀臎しなければアラヌトを出したす。


党䜓の流れ

アヌキテクチャ抂芁図

定期実行launchd job
    │
    ▌
outreach-schedule-guard.sh
    │
    ├─ SPECS配列から期埅スケゞュヌルを生成
    │    com.lily.outreach-ig   → 8,10,12,14,16,18,20,22 時 :20
    │    com.lily.outreach-th   → 9,11,13,15,17,19,21    時 :38
    │    com.lily.followers-outreach → 8,10,12,14,16,18,20,22 時 :35
    │    com.lily.outreach-yt   → 10,12,14,16,18,20      時 :52
    │
    ├─ python3 plistlib で各 plist の StartCalendarInterval を実読み取り
    │
    ├─ [䞀臎] → 凊理なし・終了
    │
    └─ [䞍䞀臎] ─────────────────────────────────────────┐
                                                          │
         ① forensicログ出力                              │
            - plist の mtime を蚘録                       │
            - 珟圚倀 vs 期埅倀を䞊べお蚘録               │
            - ps で python/node/bash/launchctl/plutil      │
              の盎近プロセスをキャプチャ最倧25件     │
                                                          │
         ② バックアップ䜜成                              │
            $label.plist.bak-guard-YYYYmmdd-HHMMSS       │
                                                          │
         ③ python3 plistlib で正しい倀に曞き戻し         │
            d['StartCalendarInterval'] = [                │
              {'Hour': int(h), 'Minute': minute}          │
              for h in hours.split(',')                   │
            ]                                             │
                                                          │
         ④ plutil -lint で敎合性怜蚌                     │
            OK → launchctl bootout → sleep 1              │
                → launchctl bootstrap → 埩元ログ         │
            NG → "🔎 埩元埌のplistが壊れおいる"ログ      │
                 → 手動察応に委ねる                       │

コアロゞックの実装plist読み取りパヌト

スクリプトはシェルのforルヌプで4本のゞョブを順番に凊理したす。各反埩の最初にやるこずは、plistをpython3のヒアドキュメントで読み取り、Hour:MinuteのCSVを暙準出力に吐くこずです。

actual="$(python3 - "$plist" <<'PY'
import plistlib, sys
try:
    with open(sys.argv[1],'rb') as f: d = plistlib.load(f)
    rows = d.get('StartCalendarInterval') or []
    if isinstance(rows, dict): rows = [rows]
    print(','.join(f"{r.get('Hour')}:{r.get('Minute')}" for r in rows))
except Exception as e:
    print('ERR')
PY
)"

open(sys.argv[1], 'rb')でバむナリモヌドで開いおいるのは、plistがXML圢匏でもバむナリ圢匏でもplistlib.load()が自動刀定するためです。rows = d.get('StartCalendarInterval') or []ずしおいるのは、゚ントリが存圚しない堎合のNone察策です。たたisinstance(rows, dict)のチェックは、StartCalendarIntervalが配列ではなく単䞀の<dict>ずしお蚘述されおいた堎合1時刻のみ登録したケヌスに壊れないための防衛凊理です。

出力されるactualは"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"のような文字列になりたす。

コアロゞックの実装期埅倀生成ず比范パヌト

SPECS配列の各゚ントリは"label:minute:hours"ずいう独自圢匏で定矩されおいたす。

SPECS=(
  "com.lily.outreach-ig:20:8,10,12,14,16,18,20,22"
  "com.lily.outreach-th:38:9,11,13,15,17,19,21"
  "com.lily.followers-outreach:35:8,10,12,14,16,18,20,22"
  "com.lily.outreach-yt:52:10,12,14,16,18,20"
)

ここからlabel・minute・hoursをシェルのパラメヌタ展開で分解し、expectedを組み立おたす。

label="${spec%%:*}"; rest="${spec#*:}"
minute="${rest%%:*}"; hours="${rest#*:}"

続いおIFS=','で時間の配列を分割し、"Hour:Minute"圢匏のCSVを生成したす。outreach-igならexpectedは"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"になりたす。

[ "$actual" = "$expected" ] && continueの1行が刀定の本䜓です。䞀臎すればそのゞョブは玠通りし、次のゞョブぞ。䞍䞀臎があった瞬間から、forensic蚘録ず埩元のシヌケンスが走りたす。

コアロゞックの実装埩元パヌト

曞き戻しも読み取りず同じくpython3のヒアドキュメントで行いたす。

python3 - "$plist" "$minute" "$hours" <<'PY'
import plistlib, sys
path, minute, hours = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with open(path,'rb') as f: d = plistlib.load(f)
d['StartCalendarInterval'] = [{'Hour': int(h), 'Minute': minute} for h in hours.split(',')]
with open(path,'wb') as f: plistlib.dump(d, f)
PY

sys.argv[2]でminuteを受け取りint()にキャストするのは、SPECS文字列から来た倀がstr型だからです。plistlib.dump(d, f)はデフォルトでXML圢匏fmt=plistlib.FMT_XMLで曞き出すため、元のバむナリplistをXMLに倉換しおしたう副䜜甚がありたす。ただしlaunchdはXML圢匏のplistも問題なく読み蟌むため、実甚䞊は支障ありたせん。

曞き戻し埌はplutil -lint "$plist"でXML構造を怜蚌したす。launchdに壊れたplistを食わせるず予期しない動䜜を匕き起こすため、このゲヌトは省略できたせん。怜蚌が通った堎合のみ、以䞋の順序でlaunchdぞ再登録したす。

launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
sleep 1
launchctl bootstrap "gui/$(id -u)" "$plist"

bootoutずbootstrapの間にsleep 1を挟むのはlaunchdが内郚状態を曎新する猶予を䞎えるためです。これを省くずbootstrapが゚ラヌを返すケヌスがありたす。bootoutの倱敗は2>/dev/nullで無芖しおいるのは、すでにunloadされおいた堎合でもbootstrapさえ通れば問題ないからです。

forensicログの実装

曞き換えを怜知した瞬間に残す情報は3皮類です。

log "$label: 曞き換え怜知 mtime=$(stat -f '%Sm' -t '%F %T' "$plist")"
log "$label:   珟圚   = $actual"
log "$label:   あるべき= $expected"
ps -Ao pid,lstart,comm | tail -n +2 | \
  grep -iE "python|node|bash|launchctl|plutil" | tail -25 | \
  while read -r l; do log "$label:   ps> $l"; done

stat -f '%Sm' -t '%F %T'はmacOS固有の曞匏で、plistの最終曎新時刻をYYYY-MM-DD HH:MM:SS圢匏で取埗したす。これが「い぀曞き換えられたか」の唯䞀の物理的蚌拠です。

psのフィルタでpython|node|bash|launchctl|plutilを察象にしおいるのは、plistを曞き換えられる手段がおおむねこれらのコマンドに限られるからです。tail -25で最倧25行に絞り、ログが肥倧化しないようにしおいたす。

8月23日12時04分の実際のむンシデントでは、このforensicログが残っおいれば容疑プロセスの候補を絞れたはずです。皮肉なこずに、ガヌドスクリプト自䜓はむンシデントの埌に䜜ったものなので、圓時のpsスナップショットはありたせん。「䜜っおおけばよかった」ずいう埌悔から蚭蚈したのが、このforensic蚘録の仕組みです。

実装の詳现

set -uo pipefail ── -e を倖した理由

スクリプトの冒頭は set -uo pipefail です。-e がありたせん。

set -uo pipefail
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"

最初は -euo pipefail で曞いおいたした。「゚ラヌが出たら即終了」がベストプラクティスのはずだからです。ずころが、forensicのps出力郚分でスクリプトが突然死するバグが出たした。原因はここです。

ps -Ao pid,lstart,comm | tail -n +2 | while read -r p rest2; do echo "$p $rest2"; done \
  | grep -iE "python|node|bash|launchctl|plutil" | tail -25 \
  | while read -r l; do log "$label:   ps> $l"; done

grep -iE "python|node|bash|..." は、マッチする行がれロ件のずき終了コヌド1を返したす。set -e 環境では終了コヌド非れロ即exit扱いなので、怪しいプロセスが䞀件も走っおいない正垞な状態でスクリプトが死ぬ、ずいうバグでした。forensicパヌトは「䜕もなければ䜕も出力しない」が正しい動䜜なのに、-e のせいで「䜕もなければ死ぬ」になっおいた。

-e を倖しお pipefail だけ残すこずで、意図しないサむレント倱敗パむプ途䞭での゚ラヌは怜知し぀぀、grep空振りはそのたた継続できるようになりたした。

PATH の明瀺ずlog関数

export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
LOG="$HOME/.claude/logs/outreach-schedule-guard.log"

launchdゞョブはナヌザヌのシェル環境を継承したせん。TerminalからPATHが通っおいるように芋えおも、launchdが起動したシェルでは /opt/homebrew/bin が存圚しないこずがありたす。python3 はHomebrew経由でむンストヌルしおいるため、PATHを明瀺しないずゞョブ起動盎埌に python3: command not found でサむレントに萜ちたす。この経隓から、launchd甚のシェルスクリプトでは必ず冒頭でPATHを䞊曞きするこずを癖にしたした。

ログ関数は printf ベヌスです。

log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }

最初は echo "$(date '+%F %T') $*" >> "$LOG" ず曞いおいたした。しかし echo はシェル実装や -e オプションの有無によっお挙動が倉わりたす。ログメッセヌゞの末尟に曞き蟌む文字列が \n を含む堎合、echoが展開するかどうかで出力が倉わりたす。printf はフォヌマット文字列ず倀を明確に分離するので、ログ出力の改行・空癜がどの環境でも同じになりたす。

plistが存圚しない堎合のガヌド

[ -f "$plist" ] || { log "$label: plist が無い"; continue; }

SPECS配列に定矩したラベルのplistが実際に存圚しない堎合、python3のopen()が゚ラヌを出しおスクリプトが止たりたす。存圚チェックを入れおログを残しおから次のゞョブに進む、ずいう蚭蚈にしおいたす。「plistが無い」がログに残るこずで、launchd偎でアンロヌドしたのか、ファむルごず削陀されたのかを埌から刀断できたす。

バックアップのネヌミング

埩元前に珟状のplistを必ずコピヌしたす。

cp "$plist" "$plist.bak-guard-$(date +%Y%m%d-%H%M%S)"

たずえば com.lily.outreach-ig.plist.bak-guard-20260823-120404 ずいうファむルが残りたす。タむムスタンプをファむル名に焌き蟌む理由は、同じplistが耇数回曞き換えられたずきに、どの時点のものかをlsだけで確認できるからです。拡匵子を .bak でなく .bak-guard-日時 にしおいるのも意図的で、launchdが誀っお読み蟌たないよう、plistずしお認識されにくい名前にしおいたす。

なお珟圚、バックアップの自動削陀凊理は入れおいたせん。同じplistが短期間に䜕床も曞き換えられるず .bak が増殖したすが、それはそれで「曞き換えが繰り返されおいる」ずいう蚌拠ずしおの䟡倀があるので、削陀は手動にしおいたす。

self-repairゞョブのplistが教えおくれるこず

com.shun.self-repair.plist は、outreach-schedule-guardスクリプト本䜓を定期起動するlaunchdゞョブのplistです。このplistを盎接読むず、バックグラりンドゞョブの蚭蚈ずしお参考になる倀がいく぀かありたす。

<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
<key>RunAtLoad</key>
<false/>

LowPriorityIO: true はIOの優先床を䞋げ、他のプロセスのディスクアクセスを劚げない蚭定です。ガヌドスクリプトはplistのread/writeずログ曞き蟌みしかしないので、䜎IO優先で問題ありたせん。

Nice: 10 はCPU優先床を10段階䞋げたす。0がデフォルトで倀が倧きいほど優先床が䜎くなりたす。監芖ゞョブがCPUを食い荒らしおメむンの営業DMスクリプトを遅らせおは本末転倒なので、明瀺的に䞋げおいたす。

ProcessType: Background はmacOSのActivity Monitorで「バックグラりンド」カテゎリに分類されるこずを瀺したす。LowPriorityIO ずの組み合わせで、OSのバッテリヌ・CPU最適化の察象に含たれたす。

RunAtLoad: false は起動盎埌の即時実行をしない蚭定です。launchdはゞョブをロヌドした瞬間に䞀床トリガヌする機胜を持っおおり、true にするず launchctl bootstrap した瞬間に走りたす。ガヌドスクリプトはStartCalendarIntervalで定刻に走れば十分なので、䞍芁な即時実行は切っおいたす。

StartCalendarInterval は9:20・13:30・19:30の1日3回です。営業DMが8時から22時たで動いおいるのに察しおガヌドを3回だけにしおいるのは、曞き換えは連続的に発生するものではなく「䞀斉に曞き換えられおしばらく気づかない」パタヌンだからです。1日3回で十分に怜知できるし、チェック自䜓がplistをread/writeするのでその分を最小化しおいたす。


私が詰たった話

詰たり①python heredocが倉数展開しお壊れた

最初に曞いたコヌドは <<'PY' ではなく <<PY でした。

# NG: クォヌトなしヒアドキュメント
actual="$(python3 - "$plist" <<PY
import plistlib, sys
...
PY
)"

クォヌトなしの <<PY はシェルがヒアドキュメントの䞭身を倉数展開したす。Pythonコヌドの䞭に $() や $倉数 が䞀切なくおも、たずえば sys.argv[1] の [1] がzsh環境でグロブ展開の察象になるケヌスがありたす。実際には [1] は展開されたせんでしたが、別の行で f"{r.get('Hour')}:{r.get('Minute')}" 内の { ず } がシェルに解釈されおSyntax errorが出たした。

<<'PY' ずシングルクォヌトで囲むこずで、ヒアドキュメントの内容がそのたたPythonむンタヌプリタに枡されたす。以来、Bashからpython/ruby/nodeのコヌドをヒアドキュメントで枡すずきは必ず <<'EOXX' 圢匏にしおいたす。

詰たり②StartCalendarInterval が単䞀゚ントリのずき dict が返る

plistlib でplistを読んだずき、StartCalendarInterval の倀は通垞listPython配列です。しかしplistに <array> タグなしで <dict> が盎接曞かれおいた堎合、plistlibはdictを返したす。

<!-- 1時刻だけ登録したplistの䟋 -->
<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key><integer>10</integer>
  <key>Minute</key><integer>0</integer>
</dict>

最初は rows = d.get('StartCalendarInterval') or [] ずしお for r in rows を回しおいたした。dictをむテレヌトするず 'Hour' ず 'Minute' ずいう文字列キヌが返るので、r.get('Hour') は文字列に察するgetになりNoneが出たす。出力は "None:None" になり、比范は圓然䞍䞀臎→毎回埩元凊理が走る、ずいう無限ルヌプ状態になりたした。

# 修正埌
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]

isinstance(rows, dict) チェックで単䞀dictをリストに包む1行が解決策でした。これを入れるたで、1時刻だけ蚭定したテスト甚plistを䜿っお動䜜確認するたびに埩元ログが出続けおいたので、最初はロゞックのバグだず思っお䜕時間も別の堎所を探し回りたした。

詰たり③${expected:+,} を知らずに先頭カンマが入り続けた

期埅倀のCSV生成で最初に曞いたコヌドはこれです。

expected=""
for h in "${harr[@]}"; do expected="${expected},${h}:${minute}"; done

com.lily.outreach-ig の堎合、ルヌプが回るず expected は ",8:20,10:20,12:20..." になりたす。先頭にカンマが付きたす。圓然 actual"8:20,10:20,..." ず䞀臎したせん。毎回䞍䞀臎→毎回埩元凊理、ずいう状態になり、ガヌドスクリプト自䜓が毎回plistを曞き換えおいたした。

Bashの倉数展開 ${var:+value} は「varが空でなければvalueを展開、空ならば空文字」ずいう意味です。

for h in "${harr[@]}"; do expected="${expected}${expected:+,}${h}:${minute}"; done

${expected:+,} ずするこずで「すでに䜕かあるずきだけカンマを先に眮く」動䜜になりたす。最初の芁玠ではexpectedが空なのでカンマは挿入されず、2番目以降は , が入る。結果ずしお "8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20" ずいう正しいCSVになりたす。

Bashのパラメヌタ展開は :-デフォルト倀や :=代入はよく知っおいたのに、:+非空時のみ展開はこのバグを螏むたで意識したこずがありたせんでした。

詰たり④launchctl bootstrap が即座に゚ラヌを返した

埩元凊理の最初のバヌゞョンは bootout の盎埌に bootstrap を呌んでいたした。

launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
launchctl bootstrap "gui/$(id -u)" "$plist"  # sleep なし

この順番で実行するず、bootstrap が Load failed: 5: Input/output error を返しお倱敗したした。launchdは bootout を受け取った埌、内郚的にゞョブの終了凊理を非同期で完了させたす。bootout の返り倀が0でも、launchdの内郚状態がただクリアされおいない瞬間に bootstrap を叩くず競合しお゚ラヌになりたす。

launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
sleep 1
if launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null; then
  log "$label: 埩元しお再読蟌した"
else
  log "$label: 🔎 bootstrap に倱敗した手動確認が芁る"
fi

sleep 1 を挟むこずで解消したした。「1秒埅぀」ずいう解決策ぱレガントではありたせんが、launchdのドキュメントには非同期完了の保蚌に関する蚘述がなく、経隓的に1秒が安党マヌゞンずしお十分であるこずが確認できたため、これ以䞊調べる時間をかけずに採甚したした。

珟堎で動かしおみるず「なぜか動かない」→「sleep 1を入れたら動いた」ずいう解決は埀々にしお理由が䞍透明なたた先に進みがちですが、ここではログに 🔎 bootstrap に倱敗した手動確認が芁る を残すこずで、仮に再床倱敗したずきに即気づける安党網を䜜っおいたす。

詰たり⑀psパむプで read -r p rest2 の rest2 が必芁だった理由

forensicのps凊理は、実は䞀床シンプルに曞こうずしおうたくいきたせんでした。

# 最初のナむヌブな実装
ps -Ao pid,lstart,comm | grep -iE "python|node|bash|launchctl|plutil" | tail -25

これでも動くように芋えたしたが、grep に bash を含めおいるため、パむプを凊理しおいる bash プロセス自䜓がgrepにヒットしお出力に混入したした。たた、psの出力フォヌマットが環境によっお列幅が倉わるため、whileでフィヌルドを読み取る際に read -r l だけだず行末の空癜たで倉数に入っおログが汚くなりたした。

実際のコヌドは2段階のパむプになっおいたす。

ps -Ao pid,lstart,comm | tail -n +2 | while read -r p rest2; do echo "$p $rest2"; done \
  | grep -iE "python|node|bash|launchctl|plutil" | tail -25 \
  | while read -r l; do log "$label:   ps> $l"; done

while read -r p rest2 の rest2 は䜿っおいないように芋えたすが、これが重芁です。read -r p rest2 ずするこずで p に最初のフィヌルドPIDが入り、rest2 に残り党フィヌルドが入りたす。その埌 echo "$p $rest2" で再出力するこずで、行頭のヘッダ行tail -n +2 で陀去枈みを陀いた敎圢枈みの行になりたす。read -r l だけで党行を受け取るず、psのフォヌマット由来の䜙分なスペヌスが保持されたすが、p rest2 で䞀床分解しお再組み立おするこずで連続スペヌスがシングルスペヌスに正芏化されたす。

結果ずしお forensicログの ps> 行は 12345 Fri Aug 23 12:04:11 2026 python3 ずいう読みやすい圢匏で残りたす。8月23日のむンシデントが起きたずき、このフォヌマットが定たっおいれば真っ先にそのpid・実行時刻・コマンド名を突き合わせるこずができたはずです。


次のパヌトでは、このガヌドスクリプト自䜓をlaunchdに登録する手順ず、実際にガヌドが働いたずきテストで意図的にplistを曞き換えお確認したずきのログ出力の実䟋を芋おいきたす。

぀たずきポむント

p2で挙げた5぀の詰たりに加えお、実際に䜜りながらぶ぀かった問題をたずめたす。箇条曞きで網矅したす。

① self-repair.plistのProgramArgumentsがスクリプト盎呌びでなかった

com.shun.self-repair.plistを初めお読んだずき、ProgramArgumentsが想定ず違いたした。

<array>
  <string>~/.claude/scripts/claude-quota-guard.py</string>
  <string>--job</string>
  <string>com.shun.self-repair</string>
  <string>--</string>
  <string>/bin/bash</string>
  <string>~/.claude/scripts/self-repair.sh</string>
</array>

/bin/bash self-repair.shを盎接呌ぶのではなく、claude-quota-guard.pyを経由しおいたす。最初は「なぜ䞀枚噛たせおいるのか」が分からず、玠盎に/bin/bash outreach-schedule-guard.shを盎接ProgramArgumentsに曞いおテストしおいたした。ガヌドスクリプト自䜓がClaude APIを呌ぶ凊理将来の拡匵含むを持぀こずを想定し、クォヌタ管理の傘の䞋に眮くための蚭蚈です。ガヌド系のゞョブは党郚このラッパヌ経由にするルヌルを埌から蚭けたした。

② launchctl listでゞョブが"running"なのにPIDが-のたた

launchctl bootstrapが成功した盎埌にlaunchctl list | grep com.shun.self-repairを確認するず、PIDが-でstatusが0でした。「ロヌドに倱敗した」ず焊りたしたが、これは正垞動䜜です。RunAtLoad: falseか぀StartCalendarIntervalでの定刻実行蚭定の堎合、launchdはゞョブを登録するだけで次の定刻たで起動したせん。PIDが-は「埅機䞭」を意味したす。実際に9:20・13:30・19:30のいずれかの時刻を過ぎおからもう䞀床launchctl listを確認しおはじめおPIDの倀が入りたす。PIDで生死を確認するのではなく、定刻埌にログファむルをtail -fで芋匵るのが正しい確認手順です。

③ EnvironmentVariablesのHOMEを明瀺しないず$HOMEが空になる

plistにEnvironmentVariablesブロックを入れる前は、self-repair.shの䞭で$HOME/.claude/logs/ぞのパスが空文字列になるバグが発生したした。launchdはGUI sessionのナヌザヌ環境倉数を郚分的にしか匕き継がず、HOMEが未定矩の状態でシェルスクリプトが起動するケヌスがありたす。

<key>EnvironmentVariables</key>
<dict>
  <key>HOME</key>
  <string>/Users/実ナヌザヌ名</string>
  <key>LANG</key>
  <string>en_US.UTF-8</string>
  <key>PATH</key>
  <string>/Users/実ナヌザヌ名/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...</string>
</dict>

HOMEは~展開が機胜しないplist内では絶察パスで曞くしかありたせん。nvm経由のNode.jsを䜿うスクリプトを呌ぶ堎合、PATHにも~/.nvm/versions/node/vXX.X.X/binを含める必芁がありたす。これを忘れるずnode: command not foundがStandardErrorPathのログに静かに積たれたす。

④ StandardOutPath・StandardErrorPathのディレクトリが存圚しないずゞョブが起動しない

plistに以䞋を曞いおおきながら、ディレクトリを先に䜜っおいなかったこずがありたす。

<key>StandardErrorPath</key>
<string>~/.claude/logs/self-repair.launchd.err.log</string>
<key>StandardOutPath</key>
<string>~/.claude/logs/self-repair.launchd.log</string>

~/.claude/logs/が存圚しない状態でlaunchctl bootstrapするず、ゞョブのロヌドは成功launchctl listでも衚瀺されるにもかかわらず、定刻にスクリプトが実行されたせん。゚ラヌログが曞けないので䜕も残らず、ただ「動いおいない」ずいう結果だけが残りたす。解決策はmkdir -pを先に実行するこずず、outreach-schedule-guard.sh冒頭のmkdir -p "$(dirname "$LOG")"パタヌンをplist登録前のセットアップスクリプトにも組み蟌むこずです。

â‘€ plistlib.dump()がバむナリplistをXMLに倉換しおしたう

曞き戻し凊理ではplistlib.dump(d, f)をデフォルト匕数で呌んでいたす。デフォルトはfmt=plistlib.FMT_XMLなので、元のplistがバむナリ圢匏plutil -convert binary1で倉換枈みのものでも、曞き戻した埌はXML圢匏になりたす。

with open(path,'wb') as f: plistlib.dump(d, f)
# ↑ デフォルトはXML圢匏で出力。バむナリ維持したい堎合は:
with open(path,'wb') as f: plistlib.dump(d, f, fmt=plistlib.FMT_XML)
# たたは
with open(path,'wb') as f: plistlib.dump(d, f, fmt=plistlib.FMT_BINARY)

launchdはXML圢匏もバむナリ圢匏も問題なく読むため実害はありたせんが、plutil -pで䞭身を確認するずきのフォヌマットが倉わりたす。「なんか曞き換えられた」ずバックアップず比范しお混乱したこずがありたす。元フォヌマットを維持したい堎合は読み蟌み時にバむナリか吊かを刀定しおから曞き出すフォヌマットを分岐する必芁がありたす。今回は「launchdが読めればXMLで統䞀」ずいう刀断で割り切りたした。

⑥ バックアップ.bakファむルをlaunchdが誀っおゞョブずしお読む

LaunchAgentsディレクトリに拡匵子.plistのファむルがあれば、macOSのlaunchd管理機胜はそれをゞョブ候補ずしお認識したす。バックアップファむルに.plist拡匵子を付けおいた初期バヌゞョンでは、com.lily.outreach-ig.plist.bakではなくcom.lily.outreach-ig.bak.plistずいう呜名をしおしたい、launchd GUIツヌルLaunchControl等がこれをゞョブずしお衚瀺するずいう混乱が発生したした。珟圚の呜名芏則$label.plist.bak-guard-YYYYmmdd-HHMMSSは、.plistで終わらないこずを意図的に確保しおいたす。

⑩ forensicのpsフィルタでbashを含めるず自分自身がヒットする

grep -iE "python|node|bash|launchctl|plutil"

このパタヌンでbashを含めおいるため、パむプ凊理を担圓しおいるbashプロセス自身がgrepにヒットしたす。ログには/bin/bash /path/to/outreach-schedule-guard.shずいう行が必ず出力されたす。これはノむズですが、あえお陀倖しおいたせん。「ガヌドスクリプト自身の起動時刻がpsに残っおいる」ずいう事実そのものが蚌拠になりえるからです。ただし、psのスナップショットを芋るずき「bash」の行はガヌドスクリプト自身だず解釈するのがほが確実です。

⑧ ガヌドの実行頻床ずDMの送信頻床のギャップ

com.shun.self-repair.plistのStartCalendarIntervalは9:20・13:30・19:30の3回です。䞀方、営業DMはcom.lily.outreach-igが8:20から22:20たで2時間おきに8回動いおいたす。「ガヌドが最埌に動いたのが19:30で、曞き換えが20:00に起きた堎合、次の怜知は翌朝9:20たで13時間かかる」ずいう蚈算になりたす。

この蚭蚈䞊の欠点は認識枈みです。察策ずしお、ガヌドの実行頻床を䞊げる䟋えば1時間おきにするか、営業DMスクリプト自䜓に起動前の自己敎合チェックを組み蟌むかを怜蚎しおいたす。珟時点では「曞き換えが繰り返される頻床は高くない」ずいう前提で3回に抑え、plistのread/writeによる副䜜甚ずのトレヌドオフを取っおいたす。8月23日のむンシデントでは「数日間気づかなかった」ずいう実害があったので、この刀断は将来芋盎す可胜性がありたす。

⑹ stat -f '%Sm'のフォヌマットはmacOS固有

forensicログでmtimeを取埗しおいるstat -f '%Sm' -t '%F %T'は、macOSのstatコマンドの曞匏です。GNU Linux環境ではstat -c '%y'が同等ですが、このスクリプトはmacOS専甚なので問題ありたせん。ただし、他のmacを䜿っおいる同僚に「このスクリプトLinuxでも動く」ず聞かれたずき、statの郚分は動かないず䌝える必芁がありたす。クロスプラットフォヌム化するならpython偎でos.path.getmtime()を䜿っお統䞀する遞択肢がありたす。


ベストプラクティス

実装ず運甚を通しお埗た刀断基準をたずめたす。

1. launchd甚シェルスクリプトの冒頭でPATHを䞊曞きする

export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"

タヌミナルでは動くのにlaunchdからだずcommand not foundになるほがすべおの原因がPATHです。nvm配䞋のNodeやHomebrewのpython3はシステムPATHに存圚したせん。launchd甚スクリプトのテンプレヌト1行目は垞にこれです。

2. set -eは倖しおpipefailだけ残す

監芖・埩元スクリプトにおいお-eは癟害あっお䞀利なしです。grepの空振り終了コヌド1でスクリプトが死ぬずいうp2で説明した理由に加えお、launchctl bootoutの倱敗すでにunload枈みのずきも-e環境では臎呜的になりたす。pipefailだけ残せば、パむプ途䞭のサむレント゚ラヌは怜知できたす。

3. plistの読み曞きはpython3 plistlibで行う

シェルのgrep/awkによるXMLパヌスは型保蚌がなく、バむナリplistにはたったく察応できたせん。python3は暙準ラむブラリのplistlibでXML/バむナリ䞡圢匏を自動刀定でき、HourずMinuteを確実に敎数ずしお取り出せたす。python3 -c "import plistlib" が1行で完結するため、倖郚䟝存れロです。

4. ヒアドキュメントは必ず<<'EOF'圢匏でクォヌトする

BashからPythonコヌドをヒアドキュメントで枡す際、クォヌトなし<<EOFはシェルがPythonコヌドを倉数展開しようずしお壊したす。{ } $を含むPythonコヌドは特に危険です。<<'PY'ずシングルクォヌトで囲む習慣を最初から持぀こずで、デバッグ難床の高いバグを防げたす。

5. 埩元前に必ずタむムスタンプ付きバックアップを䜜る

cp "$plist" "$plist.bak-guard-$(date +%Y%m%d-%H%M%S)"

埩元凊理が誀動䜜したずきたずえばSPECS配列のミスで正しい蚭定をずっず「䞍䞀臎」ず刀定し続けた堎合、バックアップがなければ元の蚭定に戻せたせん。タむムスタンプを焌き蟌む理由は、耇数回の曞き換えが起きたずきに時系列をls -ltだけで远えるからです。

6. plutil -lintでplist構造を怜蚌しおからlaunchdぞ枡す

曞き戻し埌に構造が壊れたplistをlaunchdに食わせるず、ゞョブがsilentに起動しなくなりたす。

if plutil -lint "$plist" >/dev/null 2>&1; then
  # launchctl bootstrap ぞ進む
else
  log "🔎 埩元埌のplistが壊れおいる戻しおいない"
fi

このゲヌトがあれば、plistlibのバグや曞き蟌み䞭断による䞭途半端なXMLでlaunchdを汚染するリスクを排陀できたす。

7. bootoutずbootstrapの間にsleep 1を挟む

launchdのbootoutは非同期で完了したす。bootoutのreturn倀が0でも、launchd内郚のゞョブ終了凊理が終わっおいない瞬間にbootstrapを叩くずLoad failed: 5: Input/output errorが返りたす。1秒の埅機は経隓的に安党マヌゞンずしお十分です。さらにbootstrapの成吊をif文で受けおログに残すこずで、仮に再倱敗したずきに即気づけたす。

8. forensicログmtime + psスナップショットを必ず残す

埩元だけしお終わる実装は「䜕が起きたか」を闇に葬りたす。曞き換えを怜知した瞬間のplistのmtimeず、実行䞭のプロセス䞀芧を残しおおくこずで、原因究明の手がかりになりたす。8月23日のむンシデントで「ガヌドスクリプトがあれば容疑プロセスを特定できた」ずいう埌悔が、この蚭蚈の動機です。ログさえあれば、同じ曞き換えが繰り返されたずきにパタヌンが芋えおきたす。

9. printfベヌスのログ関数を䜜る

log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }

echoはシェル実装ず-eフラグの有無によっお改行の扱いが倉わりたす。ログメッセヌゞにバックスラッシュ含む文字列が混入したずき、echoだず環境によっお\nが改行に展開されたりされなかったりしたす。printfのフォヌマット文字列ず倀の分離は䞀貫性を保蚌したす。

10. plist䞍圚のガヌドを入れる

[ -f "$plist" ] || { log "$label: plist が無い"; continue; }

SPECS配列に定矩したラベルのplistが消えおいた堎合、python3のopen()が䟋倖を出しおスクリプト党䜓が止たりたす。存圚チェックログ次ルヌプぞのcontinueにするこずで、4本のゞョブのうち1本だけplistが消えおいおも残り3本は正垞にチェックが走りたす。

11. RunAtLoad: falseで登録盎埌の即時実行を防ぐ

launchdはlaunchctl bootstrapでゞョブをロヌドした瞬間に䞀床だけ起動する機胜RunAtLoad: true盞圓を持っおいたす。ガヌドスクリプトは定刻に走れば十分なので、RunAtLoad: falseで䞍芁な即時実行を切っおいたす。埩元凊理は冪等ですが、plistのread/writeはれロでないコストがありたす。

12. LowPriorityIOずNice: 10で監芖ゞョブのリ゜ヌス圱響を最小化する

com.shun.self-repair.plistには以䞋が蚭定されおいたす。

<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>

監芖ゞョブがCPU・IOを食い荒らしお、監芖察象である営業DMスクリプトのパフォヌマンスを萜ずすのは本末転倒です。Nice: 10はCPU優先床を10段階䞋げ0がデフォルト、LowPriorityIOはIOの優先床を䞋げたす。ProcessType: Backgroundずの組み合わせでOSのバッテリヌ・CPU最適化の察象に含たれたす。

13. StartCalendarIntervalの単䞀゚ントリ問題をisinstanceチェックでガヌドする

rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]

この1行がなければ、1時刻だけ蚭定したplist<array>タグなしで<dict>が盎曞きに察しお毎回埩元凊理が走りたす。plistlibがdictを返すのか listを返すのかはXMLの構造次第なので、防衛的にチェックする必芁がありたす。

14. CSVの先頭カンマ問題は${var:+,}で解決する

for h in "${harr[@]}"; do expected="${expected}${expected:+,}${h}:${minute}"; done

${expected:+,}は「expectedが空でなければカンマを挿入、空ならば䜕もしない」ずいうBashのパラメヌタ展開です。expected="${expected},${h}:${minute}"ず曞くず先頭にカンマが入り、期埅倀ず実倀が氞遠に䞀臎しない無限埩元ルヌプになりたす。

15. ガヌド自身のlaunchd登録はbootout → sleep 1 → bootstrapの手順で毎回行う

既存のゞョブを曎新するずきProgramArgumentsやStartCalendarIntervalを倉えたずき、launchctl unloadやbootoutなしにbootstrapしおも倉曎が反映されたせん。以䞋の手順をセットアップスクリプトにたずめおおくず、毎回迷わずに枈みたす。

launchctl bootout "gui/$(id -u)/com.shun.self-repair" 2>/dev/null
sleep 1
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.shun.self-repair.plist
launchctl list | grep self-repair

最埌のlaunchctl list確認でPIDが-でもstatusが0なら正垞登録です。


たずめ

2026幎8月23日12時04分のスケゞュヌル曞き換えから始たったこの仕組みは、結果的に「自動化の皌働率を人間の目で担保しなくおいい環境」を䜜るための䞀歩になりたした。営業DM4本レヌンが1日合蚈29回正しいスケゞュヌルで動いおいるかを、わたしが確認しなくおもoutreach-schedule-guard.shが9:20・13:30・19:30の3回チェックしお異倉があれば自動で戻したす。

コアの実装は73行です。plistlibで読んで、期埅倀ず比范しお、違ったらバックアップしお戻しおlaunchdに再登録する。その間にforensicログを残す。それだけです。耇雑に芋えるlaunchdの内郚も、XMLを盎接読んで曞けるpython3の暙準ラむブラリがあれば、シェルスクリプトから制埡できたす。

個人開発で月商120䞇を維持しおいるのは、「人間が芋おいない時間に自埋で動き続ける仕組み」を積み䞊げおきたからです。䞀぀ひず぀の仕組みはシンプルで、でも止たるず収益に盎結するから、止たったずきに自分で盎る蚭蚈にする。この蚘事で玹介したガヌドスクリプトは、その思想の䞀䟋です。


仕組みの党䜓像・月120䞇の内蚳・30日手順は有料noteにたずめおいたす。

📕 Claude Code自埋環境で、実際どう皌ぐか ― 仕組み・実䟋・始め方・サポヌト


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

  • 制䜜物・蚘事は bokuwalily.com にたずめおいたす🖥
  • AIで「寝おおも回る仕組み」を䜜っお月120䞇にした話は noteの有料蚘事 に💰
  • OSS: github.com/bokuwalily 🐙
  • 最新情報・お問い合わせは X @bokuwalily ぞ🌍
  • AI導入・自動化の盞談ず実装テンプレ7本の配垃は 公匏LINE から💬

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

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?