1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

『孤児Chrome』だけを見分けて刈る ― allowlist正規表現とps注入テストの話

1
Posted at

前回、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 ―― ①親がlaunchdppid=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を弾く。環境変数の型崩れは実行時ではなく起動直後に落とすのが安全
  • 経過時間のパースはpsetime書式ゆれを想定する ―― elapsed_to_secondsD-HH:MM:SS / MM:SS / SSの3パターンをheredoc経由のIFS=: readで分岐している。日数付き経過時間(1-02:00:00のような長時間孤児)を見落とすと閾値判定が壊れる
  • launchdStartCalendarIntervalは毎時5分・35分の2回(plistのMinute: 535)。既定の閾値30分とほぼ噛み合わせてあり、孤児が生まれてから最大1サイクル分の猶予で次の巡回に捕まる設計
  • LowPriorityIONice: 10ProcessType: 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サービスを量産しています

皆さんの ❤️ やシェアが励みになります!

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?