『孤児Chrome』だけを見分けて刈る ― allowlist正規表現とps注入テストの話
前回、note記事の画像リンク切れをフォールバック処理した話を書きました。今回は毛色を変えて、Claude Codeの自動化まわりで地味に事故りやすい**「定期ジョブでChromeを殺す」**話です。
agent-browserスキルやRemotion動画レンダリング、Playwright/Puppeteerのテストは、裏でヘッドレスや専用プロファイルのChromeを立ち上げます。親プロセスが異常終了すると、この子Chromeだけ生き残ってlaunchd(pid 1)に引き取られ、孤児プロセスとしてメモリを食い続けることがあります。これをlaunchdから30分おきに刈り取るchrome-reaper.shというスクリプトを運用しているのですが、狙いを外すと今まさにブラウジングしているChromeまで巻き添えにするのが怖いところです。この記事はその誤爆対策の設計と、実際に踏んだbashのバグの話です。
困りごと:「孤児」と「稼働中」をpsの1行だけで見分ける
chrome-reaper.shがやることはシンプルです。psで全プロセスを列挙し、条件に合うものをkillする。
threshold_seconds=$((MAX_AGE_MIN * 60))
while read -r pid ppid elapsed command_line; do
[ "$ppid" = "1" ] || continue
is_target_process "$command_line" || continue
elapsed_seconds="$(elapsed_to_seconds "$elapsed")"
[ "$elapsed_seconds" -gt "$threshold_seconds" ] || continue
PIDS[((${#PIDS[@]}))]="$pid"
DETAILS[((${#DETAILS[@]}))]="pid=$pid age=$elapsed command=$command_line"
done < <("$PS_BIN" -axo pid=,ppid=,etime=,command=)
条件は3つのAND ―― ①親がlaunchd(ppid=1。異常終了で拾われた証拠)、②is_target_processが「自動化用Chromeだ」と判定する、③既定30分(CHROME_REAPER_MAX_AGE_MIN)を超えて生きている。この3つのうち②を雑にやると、普段使いのGoogle Chromeまで巻き込む可能性があります。
対策:pathとuser-data-dirの両方を縛るallowlist
is_target_processは「殺していいものだけ」を積み上げる形のallowlistです。
is_target_process() {
command_line="$1"
if [[ "$command_line" == *"/Google Chrome.app/Contents/MacOS/Google Chrome"* ]] && \
[[ "$command_line" =~ --user-data-dir(=|[[:space:]])~/dev/ ]]; then
return 0
fi
# scent-media の常駐CDP Chrome(ensure_chrome.sh が port 9223 で意図して常駐) は対象外
[[ "$command_line" == *"user-data-dir=~/dev/scent-media/.profiles/chrome-ig"* ]] && return 1
if [[ "$command_line" == *"/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"* ]] && \
[[ "$command_line" =~ --user-data-dir(=|[[:space:]])~/dev/ ]]; then
return 0
fi
if [[ "$command_line" == *"/ms-playwright/"* ]] && \
{ [[ "$command_line" == *"/Chromium.app/Contents/MacOS/Chromium"* ]] || [[ "$command_line" == *"/chrome-headless-shell"* ]]; }; then
return 0
fi
if [[ "$command_line" == *"/chrome-headless-shell"* ]] && \
[[ "$command_line" == *"/node_modules/.remotion/"* ]]; then
return 0
fi
if [[ "$command_line" == *"/chrome-headless-shell"* ]] && \
[[ "$command_line" =~ --user-data-dir(=|[[:space:]])[^[:space:]]*puppeteer_dev_chrome_profile- ]]; then
return 0
fi
return 1
}
ポイントは実行パス単体では判定しないこと。普通のGoogle Chromeは実行パスだけなら誰でも一致してしまうので、必ず--user-data-dirが~/dev/配下(=自動化用に切ったプロファイル)であることを正規表現でAND条件にしています。逆にscent-mediaのCDP常駐Chrome(ensure_chrome.shがport 9223で意図的に立てっぱなしにしているもの)は、~/dev/配下のパスに一致してしまうので、明示的なreturn 1で先に弾いています。allowlistの中に狭いdenyを混ぜる形です。
テスト:psの出力をfixtureで丸ごと差し替える
このallowlistが「狙った孤児だけ刈れているか」を、本物のプロセスを立てずに検証したい。スクリプト側はpsの呼び出しを変数越しにしています。
PS_BIN="${CHROME_REAPER_PS_BIN:-/bin/ps}"
...
done < <("$PS_BIN" -axo pid=,ppid=,etime=,command=)
テスト側はCHROME_REAPER_PS_BINに本物のpsではなくfixtureスクリプトを渡すだけで、本体コードは一切変えずに任意のプロセス一覧を注入できます。fixtureはこれだけです。
#!/bin/bash
cat <<'EOF'
101 1 02:00:00 ~/node_modules/.remotion/chrome-headless-shell/.../chrome-headless-shell about:blank --headless=old --no-sandbox --user-data-dir=/var/folders/.../puppeteer_dev_chrome_profile-HJ9hz3
102 1 02:00:00 /opt/other-tool/chrome-headless-shell about:blank --user-data-dir=/tmp/puppeteer_dev_chrome_profile-test
103 1 02:00:00 /tmp/ms-playwright/chromium-123/chrome-headless-shell about:blank
104 1 02:00:00 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=~/dev/browser-profile
105 1 02:00:00 /opt/other-tool/chrome-headless-shell about:blank --user-data-dir=/tmp/ordinary-profile
106 1 02:00:00 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/tmp/ordinary-profile
107 1 00:10:00 ~/node_modules/.remotion/chrome-headless-shell/chrome-headless-shell about:blank
108 2 02:00:00 ~/node_modules/.remotion/chrome-headless-shell/chrome-headless-shell about:blank
EOF
8行それぞれに役割があります。101〜104は「刈っていい」パターン(Remotion、汎用puppeteerプロファイル、ms-playwright、devプロファイルのGoogle Chrome)。105・106は同じ実行ファイルだがuser-data-dirが/tmp/ordinary-profile=allowlist対象外。107・108が地味に効いていて、107はallowlistには一致するのに経過時間が10分しかない(閾値30分未満なので見送るべき)、108は実行パスは101と同じだがppidが2(launchdの子ではない=孤児ではない)。つまりこのfixtureは正規表現だけでなく、年齢フィルタとppidフィルタも同時に検証しています。
テスト本体はこの出力を使って、狙った4件だけが混ざっているかを確認します。
output="$(CHROME_REAPER_PS_BIN="$FIXTURE" CHROME_REAPER_MAX_AGE_MIN=30 "$SCRIPT" --dry-run)"
for pid in 101 102 103 104; do
printf '%s\n' "$output" | grep -Fq "pid=$pid " || fail "dry-run missed pid $pid"
done
for pid in 105 106 107 108; do
if printf '%s\n' "$output" | grep -Fq "pid=$pid "; then
fail "dry-run incorrectly included pid $pid"
fi
done
--dry-runで実際にはkillせず、候補一覧だけをログ出力するモードがあるおかげで、本物のスクリプトをそのまま起動して結果を突き合わせられます。
このテストファイルは
is_target_processのコピーを自分でも持っていて(本体スクリプトからsourceしていない)、まず単体でTrue/False判定を確認したあとに、実スクリプトを--dry-runで丸ごと起動する二段構えです。ただしテスト内のコピーには本体にある「Google Chrome for Testing」対応と「scent-media除外」の2分岐が存在せず、fixtureの8行にもこの2パターンは含まれていません。つまりこの2分岐は現状どちらのテストでもカバーされていない穴です。allowlistに分岐を足すたびに、fixtureとテストコピーの両方を同時に更新しないと、静かに検証対象から漏れます。
踏んだ落とし穴:bash 3.2のset -uが「孤児0件」を毎回異常終了に見せていた
chrome-reaper.sh本体には、こういうコメント付きのガードがあります。
# bash 3.2 (macOS標準) は set -u 下で空配列の "${arr[@]}" 展開が unbound variable になる。
# 孤児が0本の回は毎回ここで落ちて exit 1 になり、launchd 側からは常時異常に見えていた。
if [ "${#PIDS[@]}" -gt 0 ]; then
for pid in "${PIDS[@]}"; do
kill -TERM "$pid" 2>/dev/null || true
done
...
fi
chrome-reaper.shの1行目はset -Eeuo pipefail。macOS標準の/bin/bashはいまだにバージョン3.2(GPLv3を避けて更新が止まっている)で、このbash 3.2には空配列に"${arr[@]}"で触れると、set -u下では要素が0個ある配列ではなく"未定義変数"として扱われunbound variableエラーになるという古い癖があります。PIDS=()で宣言した配列を、ガードなしにいきなりfor pid in "${PIDS[@]}"で回すと、孤児が1件も見つからなかった――つまり一番平和で正常なはずの回――に限って即死し、set -eで異常終了する。launchdのログには「実行はされたが毎回異常終了」という状態が積み上がり、実際には何も壊れていないのに監視上はずっと赤信号でした。直し方は単純で、配列を展開する前に必ず${#PIDS[@]}で要素数をチェックしてから触る。allowlistの正確さをどれだけ詰めても、こういう言語仕様側の地雷は別枠で踏みます。
その他、細かく効いた点を並べておきます。
MAX_AGE_MINのバリデーションを先頭でやる ――case "$MAX_AGE_MIN" in ''|*[!0-9]*|0)で空文字・非数字・0を弾く。環境変数の型崩れは実行時ではなく起動直後に落とすのが安全- 経過時間のパースは
psのetime書式ゆれを想定する ――elapsed_to_secondsはD-HH:MM:SS/MM:SS/SSの3パターンをheredoc経由のIFS=: readで分岐している。日数付き経過時間(1-02:00:00のような長時間孤児)を見落とすと閾値判定が壊れる launchdのStartCalendarIntervalは毎時5分・35分の2回(plistのMinute: 5と35)。既定の閾値30分とほぼ噛み合わせてあり、孤児が生まれてから最大1サイクル分の猶予で次の巡回に捕まる設計LowPriorityIO・Nice: 10・ProcessType: Backgroundをplistに明示 ―― 定期ジョブがフォアグラウンド作業のCPU/IOを奪わないようにする、地味だが効くマナー設定
まとめ
- 定期killジョブの誤爆対策は、実行パス単体ではなくpath ×
--user-data-dirのAND条件でallowlistを組む。狭いdenyを先に挟むのも有効 ps呼び出しを環境変数(CHROME_REAPER_PS_BIN)越しにすることで、本体コードを一切変えずにfixtureへ差し替えてallowlist・年齢フィルタ・ppidフィルタをまとめて検証できる- allowlistに分岐を足すときは、fixtureとテスト側のコピーも同時に更新しないとカバレッジの穴になる
- macOS標準bash 3.2の
set -uは空配列展開でunbound variableになる。「0件で正常終了」のパスこそ明示的にガードしないと、launchdからは常時異常に見える
次は、この~/dev/配下の自動化群がどうuser-data-dirを払い出してポート衝突を避けているか、その割り当て側の話を書く予定です。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!