💀 RSSに映らなかった圧縮メモリ46GBの犯人を10分おきに自動回収する
月10万の大学生から始まって、掛け持ちで月60万まで伸ばし、会社都合で解雇されてゼロに戻り、半年かけてClaude Codeの自律環境を建て直した結果、今は月商120万で回っています。その環境が1日まるごと止まった日の話をします。
なぜこの仕組みが効くのか
「RSSが小さいプロセスは安全」という誤った前提
macOSのメモリ監視は長年 top -o mem で事足りていました。RSSの大きい順に並べて、上から目視で確認する。500MB超えているものがあれば調べる。これが多くの人の運用で、私もそう思っていました。
2026年8月9日に、この前提が完全に崩れました。
SNS自動投稿の全レーンが1日分まるごと0件になっていたのです。Claude Codeが管理するPlaywrightは動いているように見える。ログを遡ると launchPersistentContext が180秒でタイムアウトし続けていました。原因を探してまず top -o mem を実行しました。上位20プロセスにいかにも怪しいものは見当たりません。RSSの一番大きいプロセスでも数GB台、dasd はリストのどこにも登場しない。
top の別画面でSwap使用量を確認した時に気づきました。37GB です。物理RAMの倍近い数字です。スワップがそこまで膨らんでいれば、新しいメモリ要求はほぼディスクI/Oになります。Playwrightがブラウザコンテキストを生成しようとした瞬間、OSはスワップを読み書きしながらページを確保しようとし、それが180秒を超えて落ちた。
ではスワップを37GBまで押し上げた犯人はどこにいるのか。vm.swapusage の数字は明らかに異常なのに、RSS順の top には映らない。
CMPRS列を見ると犯人がいた
macOSの top には、-stats オプションで取得できる cmprs(compressed memory)という列があります。アクティブに使われていないメモリをOS側がzlib圧縮してRAMに押し込み、実際のページとのマッピングだけ保持する仕組みで、この圧縮領域はRSSには計上されません。物理RAMを使っているのに、RSS監視には映らない。
top -l 1 -n 20 -o mem -stats pid,command,mem,cmprs を実行すると、出力の中に dasd がありました。
PID COMMAND MEM CMPRS
...
1842 dasd 264M 46G
RSS相当の MEM は264MB。これがRSS降順リストに絶対に上位20に入らない理由でした。しかし CMPRS は 46GB。スワップ37GBの原因はこれです。dasd(Duet Activity Scheduler、macOSのバックグラウンドタスク調停デーモン)が、起動から21時間で圧縮メモリを46GB抱え込み、OSはスワップで逃げるしか選択肢がなくなっていました。
「作業」ではなく「環境」を直す理由
月商120万の実態は、自動化スクリプトとClaude Codeが並行して動き続けることで成立しています。私が寝ている間もSNSへの投稿が走り、ファネルが回り、翌朝には結果がまとまっている。この仕組みが1日止まると売上にそのままダメージが入ります。
dasd を手で再起動するだけなら5分で終わります。しかし問題は 次また止まる ことです。実際この症状は「稼働21時間で発生」とコメントに書き残しました。翌日も翌々日も同じことが起きるなら、毎朝 top を確認して sudo killall dasd を叩く作業が発生します。それは「作業」であり「環境」ではありません。
自律環境の鉄則は「自分が気づかなくても直っている」です。10分おきに監視スクリプトが走り、閾値を超えたら再起動し、Discordに通知が来る。私が翌朝ログを見たとき「深夜2時32分に回収済み」とあれば、それが環境の正しい動き方です。
さらに、dasd は単体の問題でもありませんでした。iii(agentmemory、エージェントのセッション記憶を管理するNode.jsプロセス)が時速約4GBで増え続ける second leaker として存在していました。2つの漏れが同時に起きている状態で、手動対応を前提にした仕組みでは限界があります。
RSSではなくCMPRSを閾値にする意味
閾値の設計も大事です。dasd のRSSは264MB、閾値をRSS基準にすると永遠に引っかかりません。CMPRSとMEM(≒RSS)を合算した mem 列の値を使います。
スクリプトの to_mb() 関数がこれを担います。
to_mb() {
awk -v v="$1" 'BEGIN {
u = substr(v, length(v), 1); n = substr(v, 1, length(v) - 1) + 0
if (u == "T") { printf "%.0f", n * 1024 * 1024 }
else if (u == "G") { printf "%.0f", n * 1024 }
else if (u == "M") { printf "%.0f", n }
else if (u == "K") { printf "%.0f", n / 1024 }
else { printf "%.0f", v / 1048576 }
}'
}
top が返す "47G" や "264M" を数値MBに変換し、閾値と比較します。dasd は MEM 列が「47G」(CMPRSを含む仮想的な合計)として表示されるため、5120MB(5GB)の閾値で正しく検知できます。RSSの264MBでは永遠に素通りします。
全体の流れ
システム構成図
┌────────────────────────────────────────────────────────────┐
│ launchd (com.lily.mem-hog-guard) │
│ 毎時 :02 :12 :22 :32 :42 :52 に起動(10分間隔) │
└──────────────────────┬─────────────────────────────────────┘
│
▼
top -l 1 -n 20 -o mem \
-stats pid,command,mem,cmprs
│
▼ awk で PID行以降を抽出
┌────────────────────────────┐
│ pid command mem cmprs │ ← MEM列 = CMPRS込み
└────────────┬───────────────┘
│ プロセスごとにループ
▼
┌─────────────────────────────────────────────┐
│ NEVER_TOUCH に含まれる? │
│ kernel_task / WindowServer / launchd / Finder │
│ YES → スキップ │
└──────────────────────┬──────────────────────┘
│ NO
▼
┌──────────────────────────────────────────────┐
│ AUTO_RESTART_NAMES に含まれる?(dasd) │
│ かつ MEM ≥ 5120MB │
│ かつ クールダウン未満(30分)でない │
│ YES → sudo killall / launchd が自動復活 │
└──────────────────────┬───────────────────────┘
│ NO
▼
┌──────────────────────────────────────────────┐
│ LAUNCHD_RESTART_MAP に含まれる?(iii) │
│ かつ MEM ≥ 2048MB │
│ YES → TERM → 2秒待ち → KILL │
│ → kickstart -k │
└──────────────────────┬───────────────────────┘
│ NO
▼
┌──────────────────────────────────────────────┐
│ MEM ≥ 6144MB │
│ YES → Discord通知のみ(触らない) │
└──────────────────────────────────────────────┘
│ 全プロセス処理後
▼
findings があれば Discord通知
swap使用量を添付
plist:10分おきに起動する仕組み
<key>StartCalendarInterval</key>
<array>
<dict><key>Minute</key><integer>2</integer></dict>
<dict><key>Minute</key><integer>12</integer></dict>
<dict><key>Minute</key><integer>22</integer></dict>
<dict><key>Minute</key><integer>32</integer></dict>
<dict><key>Minute</key><integer>42</integer></dict>
<dict><key>Minute</key><integer>52</integer></dict>
</array>
StartInterval(秒数指定のポーリング)でも600秒間隔にすれば同じ頻度になりますが、StartCalendarInterval の分単位指定を選びました。理由はシンプルで、ログの時刻が「:02」「:12」と一定になるため、後から「何時に回収されたか」を目視で確認しやすいからです。RunAtLoad は false にしてあります。ロード直後のシステム起動直後に走ってもswapが安定していないため、最初の10分は様子見です。
Nice は 10(低優先度)、LowPriorityIO は true に設定してあります。監視スクリプト自体がCPUやI/Oを食いつぶしたら本末転倒です。top -l 1 は軽いですが、念のためにOS側のスケジューラに「バックグラウンド優先度でよい」と明示しています。
topの出力をawkでパースする核心部分
スクリプトの核心は、top -l 1 -n 20 -o mem -stats pid,command,mem,cmprs の出力から、PID行以降だけを拾うawk処理です。
TOP_RAW=$(top -l 1 -n 20 -o mem -stats pid,command,mem,cmprs 2>/dev/null)
while read -r pid command mem cmprs; do
[ -n "$pid" ] || continue
case "$pid" in ''|*[!0-9]*) continue ;; esac
mem_mb=$(to_mb "$mem")
cmprs_mb=$(to_mb "$cmprs")
...
done <<<"$(awk '/^PID/ { seen = 1; next }
seen && NF >= 4 {
cmprs = $NF; mem = $(NF - 1); pid = $1
name = ""
for (i = 2; i <= NF - 2; i++) name = name (name == "" ? "" : " ") $i
gsub(/ /, "", name)
print pid, name, mem, cmprs
}' <<<"$TOP_RAW")"
awk の /^PID/ でヘッダ行を検知し、それ以降の行だけを処理します。プロセス名が「Google Chrome Helper」のようにスペースを含む場合があるため、NF-2 列目と NF-1 列目が mem と cmprs、1列目が pid、2列目から NF-2 列目までをすべて結合してプロセス名としています。最後に gsub(/ /, "", name) でスペースを除去することで、後続のシェルの in_list() 関数で単純な文字列比較ができるようにしています。
二重起動防止のロックも入っています。10分おきに起動するため、前回の実行が何らかの理由で長引いていた場合(sudo killall がタイムアウトするケースなど)に同じPIDを2回killしに行くリスクがあります。
LOCK_FILE="/tmp/com.lily.mem-hog-guard.lock"
if ! /usr/bin/shlock -f "$LOCK_FILE" -p "$$"; then
exit 0
fi
trap '/bin/rm -f "$LOCK_FILE"' EXIT HUP INT TERM
shlock はmacOS標準のアトミックなロックファイル作成コマンドで、-f でロックファイルパス、-p でPIDを指定します。すでに有効なPIDがロックを持っていれば失敗して即座に exit 0 します。trap でEXIT/HUP/INT/TERMを捕まえてロックを解放します。
プロセスの種別によって再起動方法が異なる理由
検知したプロセスをどう処理するかは、そのプロセスをOSが管理しているかどうか で分岐します。
dasd のケース(AUTO_RESTART)
dasd はmacOSのroot所有デーモンで、launchdが常時管理しています。sudo killall dasd を実行すると、launchdが即座に新しいプロセスを立ち上げます。ユーザー側は何も設定不要で、ただkillするだけで「再起動」が完了します。
if sudo -n /usr/bin/killall "$command" 2>/dev/null; then
log "restarted $command pid=$pid mem=$mem cmprs=$cmprs"
sudo -n は対話的パスワード入力を要求しない非対話モードです。sudoers に NOPASSWD ルールがない環境では失敗しますが、その場合はDiscord通知に落とし、手動対応を促します。自動化が強権的に失敗するより、通知してオペレータに判断を委ねるほうが安全です。
iii(agentmemory)のケース(LAUNCHD_MAP)
iii はユーザーのlaunchdジョブとして動くNode.jsプロセスです。ここで最初に試した kickstart -k だけのアプローチが失敗しました。
実測(2026-08-09)で判明したのは、iii は起動時に自分自身をdetachしてフォークするため、launchdが親プロセス(node)を入れ替えても、旧プロセスがPPID=1の孤児として生き残るという挙動です。メモリを抱えたまま旧プロセスが動き続け、新しいプロセスが増えるだけで、合計メモリ使用量はむしろ増加しました。
正しい順序は次のとおりです。
kill -TERM "$pid" 2>/dev/null || true
sleep 2
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/$label" >/dev/null 2>&1 || true
sleep 3
if kill -0 "$pid" 2>/dev/null; then
log "restart-failed $label pid=$pid が残存"
TERM → 2秒待ち → KILL → kickstart の順です。先にTERMでグレースフルな終了を試み、2秒後に kill -0 で生存確認し、まだいれば KILLで強制終了します。その後 kickstart -k でlaunchdに新プロセスを起動させます。最後に3秒待って旧PIDが残存していないかを確認し、残っていればログに restart-failed を記録します。
クールダウンの設計:全体1ファイルから「プロセスごと」への修正
最初の実装では、クールダウンを1つのファイルで全体管理していました。
# 旧実装(バグあり)
COOLDOWN_FILE="$STATE_DIR/.mem-hog-last"
これが2026-08-09に問題を起こしました。16時42分に dasd を回収してクールダウンを記録した直後、iii が閾値の1.5倍(3.1GB)まで膨らんでいたにもかかわらず、グローバルクールダウンが有効なため30分間一切回収されませんでした。1件直したら他が30分放置になる設計は誤りです。
修正後は プロセスごとにクールダウンファイルを持つ 構造に変えています。
COOLDOWN_DIR="$STATE_DIR/cooldown"
cooldown_active() {
local key="$1" file last
file="$COOLDOWN_DIR/$(printf '%s' "$key" | tr -c 'A-Za-z0-9._-' '_')"
[ -f "$file" ] || return 1
last=$(cat "$file" 2>/dev/null)
case "$last" in ''|*[!0-9]*) return 1 ;; esac
[ $(( $(date +%s) - last )) -lt "$COOLDOWN_SEC" ]
}
cooldown_mark() {
local key="$1"
date +%s >"$COOLDOWN_DIR/$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_')"
}
プロセス名(またはlaunchdラベル)をファイル名に使います。tr -c 'A-Za-z0-9._-' '_' でファイル名として安全な文字のみに変換します。dasd の回収が走っている30分間でも、iii のクールダウンファイルは独立しているため、iii が閾値を超えた次の10分サイクルで回収が走ります。
NEVER_TOUCHリストも明示的に定義されています。
NEVER_TOUCH="kernel_task WindowServer launchd loginwindow Finder"
kernel_task はmacOSがCPUサーマルスロットリングに使う特殊プロセスで、killすればシステムがフリーズします。WindowServer はGUI全体を管理しており、これを落とすと画面が消えます。これらは閾値以上に膨らんでいても絶対に触りません。NOTIFY_ONLY(6144MB超え)のカテゴリで通知だけして、判断は人間に委ねます。
実装の詳細
launchdコンテキストではPATHを明示する
スクリプトの冒頭はこうなっています。
set -uo pipefail
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
PATH を4ディレクトリに限定しているのは、launchdから起動したプロセスが継承するPATHが /usr/bin:/bin だけだからです。~/.zshrc に何を書いていても、Homebrewが /opt/homebrew/bin に置いたツールも、nvmが管理するNode.jsも、launchdからは一切見えません。
このスクリプトが呼ぶコマンドは top・awk・sysctl・killall・launchctl・shlock のみです。すべて /usr/bin か /usr/sbin に存在します。「欲しいものを全部持つ」のではなく「使うものだけを確実に持つ」設計です。4ディレクトリ以外を含めない、つまり ~/.nvm 配下のランタイムがPATHに乗らないことを保証している、という見方もできます。
set -uo pipefail を使い set -e を避ける理由
set -e を使うと、ゼロ以外の終了コードを返したコマンドでスクリプトが即死します。これが正しくない場面があります。
kill -TERM "$pid" 2>/dev/null || true
sleep 2
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
kill -0 はプロセスの生存確認です。TERMが効いて2秒後に対象プロセスが消えているのは正常系であり、その場合 kill -0 は終了コード1を返します。set -e 環境では || true を書いていても、条件式やサブシェル展開の文脈によって「エラーを吸収できているか」の挙動が微妙に変わるため、「ここは失敗してよい」を確実に表現しにくくなります。
set -u(未定義変数の参照でエラー)と pipefail(パイプの途中コマンドが失敗したら全体を失敗扱い)は必要です。だから set -uo pipefail だけを選び、許容される失敗箇所は || true で明示的にマークする方針を取っています。case "$pid" in ''|*[!0-9]*) continue ;; esac という数値チェックも、set -u がある以上は空文字列参照で落ちるリスクへのガードです。
LAUNCHD_RESTART_MAPのパース
複数のジョブを環境変数ひとつで設定できるよう、"プロセス名=launchdラベル:閾値MB" の形式を採用しています。
LAUNCHD_RESTART_MAP="${MEM_HOG_LAUNCHD_MAP:-iii=com.lily.agentmemory:2048}"
パース部分はこうなっています。
for m in $LAUNCHD_RESTART_MAP; do
case "$m" in "$command="*) entry="${m#*=}"; break ;; esac
done
label="${entry%:*}"
limit="${entry##*:}"
${m#*=} はPOSIX準拠のパラメータ展開で、= より前を最短マッチで削除します。iii=com.lily.agentmemory:2048 から com.lily.agentmemory:2048 を切り出します。続いて ${entry%:*} で : より後ろを最短マッチ削除して com.lily.agentmemory、${entry##*:} で : より前を最長マッチ削除して 2048 を得ます。
**% が「後ろから・短い側」、## が「前から・長い側」**です。launchdラベルは com.foo.bar.baz のようにドット区切りで、コロンを含まない命名規則なので、この展開は確実に機能します。スペースで区切れば複数エントリを並べられますが、現状の管理対象は iii だけなので1エントリです。
DRY_RUNモードで閾値を実データで確認する
case "${1:-}" in
'') ;;
--dry-run) DRY_RUN=1 ;;
*) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;;
esac
launchdにロードする前に bash ~/.claude/scripts/mem-hog-guard.sh --dry-run を実行します。現在のメモリ状態に対して何がトリガーされるかを、実際のkillなしに確認できます。
WOULD RESTART: dasd (pid=1842) が MEM 47G/圧縮 46G を抱えている -> 再起動
swap: total = 40.00G used = 37.12G free = 2.88G (encrypted)
dry-run 終了
閾値が合っているか、NEVER_TOUCHが効いているか、想定外のプロセスが対象になっていないかを本番デプロイ前に実データで確認できます。この確認なしにlaunchdへロードすると、次の10分サイクルで何かが意図せずkillされる可能性があります。閾値を設定する前に必ず --dry-run で現在の状態を見る、がこの種のスクリプトの基本手順です。
通知の集積と最終1回送信
findings=""
# ループ内:
findings="${findings}✅ ${msg}"$'\n'
# ループ後:
if [ -n "$findings" ]; then
swap=$(sysctl -n vm.swapusage | sed 's/vm.swapusage: //')
notify "🧠 mem-hog-guard
$findings
swap: $swap"
fi
findings をループ内で文字列結合して積み上げ、全プロセスの走査が終わった後に1回だけDiscordへ投げます。10分サイクルで複数プロセスが同時に閾値超えしていても、APIコールは1回で済みます。$'\n' はbashの $'...' 記法でリテラル改行を生成します。単純なダブルクォート文字列に改行を混ぜると、展開文脈によってエスケープが崩れることがあるため、この記法を使っています。
スワップ使用量は全走査が終わった後に sysctl -n vm.swapusage で取得します(-n で値のみ)。sed 's/vm.swapusage: //' でキー部分を除去し、数値文字列だけを通知本文に含めます。
私が詰まった話
kickstart -k だけで試したらメモリが増えた
最初にiiiへの対処として launchctl kickstart -k "gui/$(id -u)/com.lily.agentmemory" だけを実行しました。実行直後に ps aux | grep iii で確認すると、2つのPIDが並んでいます。旧プロセスが残存したまま新プロセスが立ち上がっており、合計メモリ使用量は回収前より増えていました。
原因はiiiの起動パターンです。iiiはNode.jsプロセスとして起動しますが、初期化後に自分自身をdetachしてフォークします。launchdが把握している「親プロセス」はフォーク前の短命なnodeプロセスであり、kickstartで置き換えるのはこの親だけです。フォーク後に常駐した実体プロセスは PPID=1(launchd直轄の孤児)になっており、launchdのジョブ管理の外に出てしまっています。
コードにもこの経緯がコメントで残っています。
# kickstart だけでは足りない。実測(2026-08-09): launchd は親(node)を入れ替えるが
# iii は detach して spawn されるため ppid=1 の孤児として生き残り、
# メモリを抱えたまま新プロセスが増えるだけだった。先に本体を落とす。
kill -TERM "$pid" 2>/dev/null || true
sleep 2
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/$label" >/dev/null 2>&1 || true
sleep 3
if kill -0 "$pid" 2>/dev/null; then
log "restart-failed $label pid=$pid が残存"
正しい順序は「まず本体をTERMで落とし、2秒待って kill -0 で生存確認し、まだいればKILLで強制終了、それからkickstartで新プロセスを起動させる」です。kickstartを先に実行してしまうと、「launchd観点では新プロセスが走っている」のに「PPID=1の旧プロセスも走っている」という二重起動状態になります。最後の sleep 3 と kill -0 で旧PIDの残存を確認し、残っていれば restart-failed をログに記録します。この確認ステップを入れたのも、「回収したつもりが実は増えていた」という最初の失敗を踏んだからです。
16:42に回収したら16:52のiiiが30分放置された
初期実装のクールダウンは1ファイルで全プロセス共通でした。
# 旧実装
COOLDOWN_FILE="$STATE_DIR/.mem-hog-last"
2026年8月9日16時42分に dasd の回収が走り、クールダウンファイルにタイムスタンプが記録されました。この直後、iii は閾値2048MBに対して3.1GB(約1.5倍)まで膨らんでいました。しかし16時52分のサイクルでグローバルクールダウンが有効と判定され、iii の回収がスキップされます。同じことが17時02分・17時12分と続き、30分間 iii が放置されました。
「直したばかりだから少し様子を見る」という意図でクールダウンを実装したのに、「dasd を回収したせいで iii の回収が止まる」という逆効果になっていました。ログを見返してこの事実に気づいたのは翌朝です。コードのコメントに 16:42にdasdを回収した30分間は iii が閾値の1.5倍(3.1GB)まで膨らんでも一切回収されなかった と書き残したのは、次にコードを読んだときに同じ設計に戻さないためです。
修正後はプロセス名(またはlaunchdラベル)をファイル名にして独立したクールダウンを持たせています。
COOLDOWN_DIR="$STATE_DIR/cooldown"
cooldown_active() {
local key="$1" file last
file="$COOLDOWN_DIR/$(printf '%s' "$key" | tr -c 'A-Za-z0-9._-' '_')"
[ -f "$file" ] || return 1
last=$(cat "$file" 2>/dev/null)
case "$last" in ''|*[!0-9]*) return 1 ;; esac
[ $(( $(date +%s) - last )) -lt "$COOLDOWN_SEC" ]
}
tr -c 'A-Za-z0-9._-' '_' でlaunchdラベル(com.lily.agentmemory)をそのままファイル名に変換します。dasd のクールダウンファイルは dasd、iiiのそれは com.lily.agentmemory として完全に独立して存在します。2件が同時に閾値超えしても、それぞれのタイマーで独立制御されます。
launchdから起動するとスクリプトが即死する
初期版ではPATHを明示していませんでした。ターミナルからの手動実行は問題なく動くのに、launchdによる定期起動が毎回失敗する事態になりました。log show --predicate 'process == "mem-hog-guard"' で確認すると、スクリプトが終了コード127で即死していました。127はシェルが「コマンドが見つからない」と返す終了コードです。
launchdが起動するプロセスが持つPATHは /usr/bin:/bin だけです。~/.zshrc の設定は完全に無視されます。Homebrewが /opt/homebrew/bin に置いたツールも、nvmが管理するNode.jsも、launchdからは見えません。
問題はターミナルで試しているときには絶対に再現しない点です。zsh を起動すると .zshrc が読み込まれてPATHが通るため、手動実行は成功します。launchdのコンテキストに入って初めて失敗します。launchdスクリプトを書くときに「ターミナルで動いた=launchdでも動く」という前提は成り立ちません。
現在のスクリプトが PATH="/usr/bin:/bin:/usr/sbin:/sbin" を2行目に置いているのは、使うコマンド(top・awk・sysctl・killall・launchctl・shlock)が /usr/bin と /usr/sbin にしか存在しないことを確認したうえで、その4ディレクトリを明示しているからです。この2行を入れてから、launchdによる定期起動でPATH起因の失敗は1度も発生していません。launchdスクリプトを書くとき最初に確認すべき事項として、今では反射的に書くようになっています。
つまずきポイント
前段で詳述した3つの失敗(kickstart単独で孤児が残った・グローバルクールダウンで30分放置・launchdからPATH127死)以外にも、実装中に引っかかったポイントを網羅します。
plist設計まわり
-
RunAtLoad trueにするとOS起動直後に走る。 起動直後はswap統計が安定していないため、正常値を「高負荷」と誤判定してデーモンを再起動することがある。<false/>を明示する。 -
StandardOutPath/StandardErrorPathを相対パスで書くとlaunchdが書けない。 launchdのカレントディレクトリはルート(/)。~/.cache/...のチルダ展開も効かないため、フルパスを直書きする。ログが一切残らないのにplistロードは成功するので、「動いているのに何も記録されない」という状態に気づきにくい。 -
StartInterval 600(秒数ポーリング)にするとOS再起動後に最大10分の空白が生まれる。StartCalendarIntervalの分単位指定(:02/:12/:22/:32/:42/:52)なら、再起動後も次の「:XX分」が来れば確実に発火する。さらにログの時刻が「毎時:02」と固定になるため、「何時に回収されたか」を翌朝ログで追うときに目視しやすい。 -
NiceとLowPriorityIOを省略するとOS側のスケジューラにとってフォアグラウンド扱いになる。 毎10分起動する監視スクリプト自体がI/Oキューに割り込むのは本末転倒。Nice 10とLowPriorityIO trueは最低限入れる。
シェルスクリプトまわり
-
awkでgsub(/ /, "", name)を忘れると、スペース入りプロセス名の比較が永遠に失敗する。Google Chrome Helper (Renderer)はwhile read -r pid command mem cmprsの時点でフィールド分解され、commandがGoogleだけになる。awk内でスペースを除去して1文字列に結合してからin_list()に渡す。 -
case "$pid" in ''|*[!0-9]*) continue ;; esacのガードを外すと、ヘッダ残滓や空行がkillの引数に渡る。-n 20の出力が環境やtopのバージョン差で乱れるケースがあり、純粋数値チェックは外せない。 -
trap '/bin/rm -f "$LOCK_FILE"' EXIT HUP INT TERMを書かないと、途中死亡でロックファイルが残存する。 次のサイクルではshlockが「前のプロセスが生きている」と判定し全スキップし続ける。何も起きないが通知も来ないので、気づくのがさらに遅れる。 -
kill -TERM直後にkickstartを実行すると新旧2プロセスが並走する。 TERM→2秒待ち→kill -0生存確認→KILL→kickstart の順序を守らないと、回収したつもりが「古いプロセス+新しいプロセス」の二重起動になりメモリが増える。
閾値・検知まわり
-
-n 20は上位20件しか取得しない。 MEM降順で並ぶため、RSSが小さくCMPRSが巨大なプロセスは下位に埋もれる。監視対象プロセスが既知であれば-n 50に増やすか、後工程でps -eo pid,comm,rssを補完する。 -
NEVER_TOUCHを空にするとkernel_taskがkillの候補になる。kernel_taskはCPUサーマルスロットリングのためにOSが使う特殊プロセスで、killするとシステムがフリーズする。kernel_task WindowServer launchd loginwindow Finderの5つは最小セットとして必ず定義する。自分の環境で「絶対に落としてはいけない」プロセスは事前にDRY_RUNで洗い出して追加する。 -
sudo -n killall dasdはsudoersにNOPASSWDルールがない環境でサイレント失敗する。 スクリプトはこのときrestart-failed(no-sudo)をログに書き、Discord通知に「手動:sudo killall dasd」を添えてフォールバックする。デプロイ前にsudo visudoで/usr/bin/killallだけに限定してNOPASSWDを付与しておく。ALL=(ALL) NOPASSWD: ALLのように広げると別のリスクが生まれる。 -
DRY_RUNなしに本番launchdへロードしない。bash ~/.claude/scripts/mem-hog-guard.sh --dry-runを実行すると現在の状態に対して「WOULD RESTART」「WOULD NOTIFY」が出力される。意図しないプロセスが引っかかっていないか、閾値が実数値に対して妥当かを確認してからlaunchctl loadする。 -
通知スクリプト(
~/.discord/notify.sh)の実行権限(xビット)がないと通知なしでサイレント動作する。notify()関数は[ -x "$NOTIFY_SCRIPT" ]で権限確認し、なければ静かにreturn 1する。エラーは出ないが通知も来ない。ls -la ~/.discord/notify.shでxビットを確認する。
ベストプラクティス
1. CMPRSを含む mem 列で閾値を設定する
top -stats pid,command,mem,cmprs の mem 列はCMPRS込みの合計値です。RSSだけで監視していると、RSS 264MB・CMPRS 46GBのプロセスは永遠に検知できません。to_mb() 関数でT/G/M/Kの全単位を変換し、数値MBで閾値比較するのが最小確実な設計です。
2. launchdスクリプトの2行目でPATHを固定する
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
この2行を入れた後、launchdによる定期起動でのPATH起因失敗は1度も発生していません。使うコマンド(top・awk・sysctl・killall・launchctl・shlock)が全て /usr/bin か /usr/sbin にあることを先に確認し、その4ディレクトリだけを宣言します。「ターミナルで動いた=launchdで動く」は成り立ちません。
3. set -uo pipefail だけ使い、失敗許容箇所を || true で明示する
set -e は kill -0(プロセス生存確認)の終了コード1でスクリプトが死ぬ場面があります。未定義変数参照とパイプ途中の失敗だけを set -uo pipefail で拾い、失敗してよいコマンドには || true を添えて意図を明確にします。
4. クールダウンはプロセスごとに独立したファイルで管理する
COOLDOWN_DIR="$STATE_DIR/cooldown"
file="$COOLDOWN_DIR/$(printf '%s' "$key" | tr -c 'A-Za-z0-9._-' '_')"
dasd と iii のクールダウンが独立しているため、2件が同時に閾値超えしても片方が30分放置されることがありません。プロセス名をファイル名にする際の tr -c 'A-Za-z0-9._-' '_' でlaunchdラベルのドット区切りも安全にファイル名へ変換できます。
5. launchd管理プロセスの再起動は「TERM → 2秒待ち → KILL → kickstart → 3秒待ち → 残存確認」の順を守る
順序の意味は明確です。TERMでグレースフルな終了を試みる。2秒後に kill -0 で生存確認し、まだいれば KILLで強制終了する。その後kickstartで新プロセスを起動させる。最後に3秒待って旧PIDが残存していないかを確認し、残っていれば restart-failed をログに記録します。kickstartを先に打つと新旧二重起動になります。
6. shlock で二重起動を防ぐ
LOCK_FILE="/tmp/com.lily.mem-hog-guard.lock"
if ! /usr/bin/shlock -f "$LOCK_FILE" -p "$$"; then exit 0; fi
trap '/bin/rm -f "$LOCK_FILE"' EXIT HUP INT TERM
shlock はmacOS標準のアトミックなロック作成コマンドです。10分間隔で起動するスクリプトが前回の実行と重複した場合に、同じPIDを2回killしに行くのを防ぎます。trap を忘れると途中死亡でロックが残存し、以降のサイクルが全スキップされ続けます。
7. NEVER_TOUCH リストを明示し、DRY_RUNで事前に引っかかり確認する
最低5プロセス(kernel_task WindowServer launchd loginwindow Finder)を定義し、--dry-run を実行して「WOULD RESTART」に意図しないプロセスが現れないかを確認してからlaunchdにロードします。閾値を低く設定すると普段使いのアプリが対象になることがあります。
8. sudo -n killall はNOPASSWD設定済みを確認してからデプロイする
sudoersへの追加は /usr/bin/killall コマンドのみに限定します。launchdからの起動はログインセッション外なので、パスワード入力を求める通常の sudo は動作しません。NOPASSWDが設定されていない場合は通知フォールバックが必要で、スクリプトはこれを標準で実装しています。デプロイ前に sudo -n /usr/bin/killall --help が成功するか確認します(エラーなく即座に返れば権限あり)。
9. 通知はループ後に1回まとめて送る
findings="${findings}✅ ${msg}"$'\n' # ループ内: 積み上げるだけ
# ループ後:
if [ -n "$findings" ]; then notify "🧠 mem-hog-guard\n${findings}\nswap: $swap"; fi
複数プロセスが同時に閾値超えしてもAPIコールは1回で済みます。スワップ使用量は全走査後に sysctl -n vm.swapusage(-n で値のみ)で取得して添付します。findings が空であれば通知自体を送らないため、静音な正常稼働サイクルにはDiscordの通知が来ません。
10. ローカルログとDiscord通知の両方を残す
ログには [2026-08-11 02:32:17] restarted dasd pid=1842 mem=47G cmprs=46G のようにタイムスタンプ・PID・メモリ量を記録します。Discord通知は即時性のためにあり、ローカルログは追跡のためにあります。「通知は受け取ったが実際には回収されていなかった(旧プロセスが残存した)」という乖離はログの restart-failed から発見できます。
11. to_mb() でT/G/M/Kを全単位カバーする
現行の dasd は "47G" を返していますが、将来 "1.2T" や "512K" を返すプロセスに対しても正しく閾値比較できるよう、全4単位を分岐するawkを入れます。1単位欠けると比較演算に0や空文字が入り、意図しないトリガー(または永遠に検知しない)が起きます。
12. 孤児残存確認ステップを外さない
kickstart後に sleep 3 して kill -0 "$pid" で旧PIDが消えているかを確認します。残っていれば restart-failed をログに記録します。「回収完了」という通知が来ていても旧プロセスと新プロセスが二重起動していた事実は、この確認ステップだけが教えてくれます。2026年8月9日の実装初日にこれを外していたため、「kickstart後にメモリが増えていた」という事実に気づくまでに数サイクルかかりました。
まとめ
top -o mem のRSS降順監視に構造的な盲点があります。macOSの圧縮メモリ(CMPRS)はRSSに計上されないため、RSS 264MBのまま圧縮メモリ46GBを抱えた dasd はリストのどこにも現れません。この盲点がswap 37GBという状態を作り出し、PlaywrightのlaunchPersistentContextが180秒でタイムアウトし続け、SNS自動投稿が1日分まるごとゼロになりました。月商120万の環境が1日止まるとそのままダメージになります。
mem-hog-guard.sh が解くのは3つの問題です。CMPRSを含む mem 列で検知する(RSSでは永遠に素通り)、プロセスの管理主体に応じて再起動方法を変える(OSデーモンは killall のみ、ユーザーlaunchdジョブは「TERM→KILL→kickstart」の順序が必要)、クールダウンをプロセスごとに独立させる(全体1ファイルにすると複数漏れの片方が30分放置される)。
launchdの StartCalendarInterval で10分おきに走らせ、Nice 10 と LowPriorityIO true でバックグラウンド優先度に抑える。ロックファイルで二重起動を防ぎ、クールダウンで連続再起動を防ぎ、--dry-run で本番ロード前に実データ確認する。これらを揃えることで「自分が気づかなくても直っている」環境が成立します。翌朝ログを開いて「深夜2時32分に回収済み、swap 4.1GBまで低下」とあれば、その夜の売上は守られています。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!