# instinct: PM2の max_memory_restart は「bash -c 起動」だと効かない

2026-07-27 vvv#606(shadow global OOM)の調査で判明。**設定したのに効かない罠**。

## 症状

shadow で global OOM が繰り返し発生(7/26 に1回、7/27 に 00:24 と 02:17 の2回)。
`pm2-ubuntu.service` 配下の python3 が **anon-rss 6.3GB** で kill され、**PM2 全24プロセスが道連れ**で再起動。

## 罠その1: PM2 が bash を監視していて閾値が届かない

`vvv-api` は次の形で起動していた:

```
pm_exec_path: /usr/bin/bash
args: ['-c', 'venv/bin/python3 -m uvicorn app.main:app --workers 2']
```

- **PM2 が監視するのは bash プロセス(RSS 10MB)**
- 実際にメモリを食う uvicorn ワーカーは**孫プロセス**
- → `max_memory_restart` を設定しても**永久に発火しない**

```
bash (PM2監視対象・10MB) ← 閾値はここに掛かる
└ uvicorn 親
├ worker (175MB〜6GB) ← 本当に膨らむのはここ
└ worker
```

### 正しい形

**`exec` でシェルを uvicorn 親に置き換える**起動スクリプトを使う。

```bash
# start-vvv-api.sh 末尾
exec "${PYTHON}" -m uvicorn app.main:app --host "$HOST" --port "$PORT" --workers "$WORKERS"
```

```javascript
// ecosystem.config.js
{
script: 'scripts/deployment/start-vvv-api.sh',
interpreter: 'bash',
max_memory_restart: '1500M', // exec があって初めて実効を持つ
treekill: true, // 孤児worker防止
}
```

## 罠その2: 設定ファイルと稼働プロセスの乖離

**`ecosystem.config.js` には既に正しい設定(#602 対策)が入っていたのに、
稼働中のプロセスは古い `bash -c` 起動のままだった。**

- `pm2 restart` では **script/interpreter の変更は反映されない**
- 起動方式を変えたら **`pm2 delete ` → `pm2 start ecosystem.config.js --only `**
- **`pm2 jlist` で実際の `pm_exec_path` / `args` を確認する**こと。設定ファイルを読んで安心しない

## ⚠️ 真因は別だった(vvv-bots#659 が特定・訂正)

当初「vvv-api ワーカーの Playwright 直接起動」を容疑者としたが、**#659 の方が正確**だった。

**真因 = 単独リークではなく「システム全体のメモリ枯渇 + 最大RSSが選ばれた」**

決定的根拠: 同一コードで成功と失敗が混在し、**OOM の1回はわずか198秒で発生**。
漸進的リークで6.3GBに達したなら短時間実行で死ぬ説明がつかない。
`thumbnail_scraper`(Chromium起動でRSS急増)が走った瞬間に全体が破綻し、
oom-killer が最大RSSプロセスを選んだだけ。**引き金であって単独犯ではない**。

### 子プロセスには RLIMIT_DATA を使う(RLIMIT_AS は誤爆)

PM2 常駐でなく `scheduler_tick` が spawn する子プロセスは、PM2 の `max_memory_restart` では制御できない。
fork 時に rlimit をかけるのが正攻法。**ただし種類を間違えると誤爆する**:

| rlimit | 結果 |
|--------|------|
| `RLIMIT_AS` 5GB | ❌ **Chromium が起動不能**。RSS 112MB でも **VSZ 50GB を予約**するため即死 |
| `RLIMIT_DATA` 3GB | ✅ 匿名メモリ(ヒープ)の実割当のみ制限(Linux 4.7+)。mmapの仮想予約を数えないので誤爆しない |

実測で PR #660(RLIMIT_AS) は CLOSED、**PR #661(RLIMIT_DATA) がマージ**された。
適用後の実測: 14:18 ✓saved=122 / 16:18 ✓saved=170 で**誤爆なし・OOM再発なし**。

→ **#659 = 子ボット側(RLIMIT_DATA) / #606 = API側(max_memory_restart)** で両経路を塞いだ形。

## 診断手順(再現用)

```bash
# 1. OOMの犯人PIDを特定
sudo dmesg -T | grep -iE "Out of memory: Killed"

# 2. OOM直前のプロセステーブルから犯人行(rss列は4KBページ単位)
sudo dmesg -T | grep -E ''
# → [2723946] 1000 2723946 2033981 1577253 ... python3
# rss=1577253 → 1577253*4/1024 = 約6.0GB

# 3. PM2の監視対象が本当にその親か(pm_exec_path が bash なら罠)
pm2 jlist | python3 -c "..."
pstree -ap

# 4. 「短時間でOOMしたか」を必ず見る → 単独リークか全体枯渇かの分岐点
```

## 観測ツール

`~/.claude/scripts/vvv_api_memwatch.sh`(1分毎cron)。
1000MB超で **py-spy スタックダンプ + アクセスログ + open fd数** を自動保全。
py-spy は `ptrace_scope=1` のため `sudo -n env "PATH=$PATH" py-spy dump --pid N` で取る。

## 関連
- vvv#606 / vvv-bots#659 / PR vvv-bots#661 / vvv#602(worker孤児化)
- vvv#610 — 常時swap漬け(63%)は未解決。増設判断が本筋