From 1a34e49c4bf5e05acdeb69bbcdfa6ad2fafd3c84 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 18 Sep 2026 09:30:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs(ops):=20=E8=B5=B7=E5=8B=95=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=82=92=E3=83=AA=E3=83=9D?= =?UTF-8?q?=E3=82=B8=E3=83=88=E3=83=AA=E7=AE=A1=E7=90=86=E4=B8=8B=E3=81=AB?= =?UTF-8?q?=E7=BD=AE=E3=81=8D=E3=80=81=E6=9C=AC=E7=95=AA=E9=9A=9C=E5=AE=B3?= =?UTF-8?q?=E3=81=AE=E7=BD=A0=E3=82=92=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PM2 が実行している /opt/appcfg/toolify-start.sh は VPS 上にしか存在せず、 全ブランチの履歴を通じて一度も追跡されていなかった(VPS のディスクが飛べば 復元手段がない)。現物をそのまま ops/toolify-start.sh として取り込む。 あわせて docs/DEPLOY.md に 2026-09-17〜18 の本番障害で判明した罠を記録: - rsync -az --delete の同期先に起動スクリプトを置くと毎回消える(502 の原因) - HOSTNAME=127.0.0.1 は自己 proxy の EPROTO で全ページ 500。公開ドメイン名を使う - 既定ポート 3000 は別アプリが占有しているので PORT=8500 が必須 - NODE_OPTIONS の heap 上限 300 では V8 が OOM abort する(現在 400) - dump.pm2 の古い値は実効値を汚さない。判定は /proc//environ で行う - 上記はいずれもデプロイが「成功」表示のまま起きる(落ちるのはスモークテストだけ) deploy.yml は変更しない。自動配置は上記1点目と干渉するため反映は手動のままとする。 本番の挙動は変わらない(ドキュメントとスクリプトの保全のみ)。 Claude-Session: https://claude.ai/code/session_01ENHPNZ5ngcdrTHJ2H6oAFd --- docs/DEPLOY.md | 64 ++++++++++++++++++++++++++++++++++++++++++++ ops/toolify-start.sh | 32 ++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100755 ops/toolify-start.sh diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 3b51ccbe..30ffbc4a 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -23,6 +23,70 @@ GitHub (main) ──push──▶ Actions: deploy.yml - VPS では PM2 がプロセス `toolify` を常駐させ、ポート **8500** で待ち受け - nginx が `toolify365.com` (443) → `127.0.0.1:8500` にプロキシ。SSL は Let's Encrypt (certbot) +## 0. 起動スクリプトと踏んだ罠(2026-09-17〜18 の本番障害より) + +PM2 が実行しているのは **`/opt/appcfg/toolify-start.sh`**。 +リポジトリ上の正本は **`ops/toolify-start.sh`** で、VPS へは**手動でコピー**する +(`deploy.yml` は配置しない。自動配置は下記 (1) と干渉するため意図的に手動)。 + +```bash +scp ops/toolify-start.sh :/opt/appcfg/toolify-start.sh +ssh 'chmod +x /opt/appcfg/toolify-start.sh && pm2 restart toolify' +``` + +以下は実際に本番を落とした罠。**どれも「デプロイ成功」表示のまま起きる**。 + +### (1) 起動スクリプトを `/opt/apps/toolify/` に置いてはいけない → 502 + +手順4の `rsync -az --delete` は `.next/standalone/` をそのディレクトリへ同期するため、 +**standalone に含まれないファイルは毎回のデプロイで消される**。 +2026-09-17 にここへ置いた `start.sh` が消え、PM2 が起動不能になり 502。 +そのため `/opt/appcfg/`(同期先の外)へ退避した。 + +### (2) `HOSTNAME=127.0.0.1` は全ページ 500 + +`HOSTNAME` は bind アドレスであると同時に **Next が絶対 URL を組む際の host** でもある。 +`127.0.0.1` にすると locale redirect が `https://localhost:8500/en` を返し、 +Next 自身がそこへ自己 proxy して TLS 失敗(`EPROTO`)→ 全ページ 500。 +**公開ドメイン名(`toolify365.com`)を入れる**と redirect が相対 `/en` になり 200。 + +切り分けは **nginx と同じヘッダを付けて**叩く。素の curl は 200 を返すので当てにならない: + +```bash +curl -H 'Host: toolify365.com' -H 'X-Forwarded-Proto: https' http://127.0.0.1:8500/en +``` + +レスポンスに `x-middleware-rewrite: https://localhost:8500/...` が出ていればこの症状。 + +### (3) `PORT=8500` は必須(既定 3000 は別アプリが占有) + +standalone の既定ポート 3000 は同居する別アプリ(`30sec`)が使用中。 +未指定だと `EADDRINUSE` で起動できない。 + +### (4) `NODE_OPTIONS` の heap 上限が小さいと OOM abort + +`--max-old-space-size=300` では V8 が `Ineffective mark-compacts` で落ちた(9/18 00:08・00:28)。 +現在は **400**。原則は **定常 RSS(約110〜220MB) < heap cap < `max_memory_restart`(500MB)**。 + +### (5) 設定の実効値は保存ファイルでなくプロセスで確認する + +`dump.pm2` には古い `NODE_OPTIONS=300` が残っているが、 +**`pm2 save` でも `pm2 restart --update-env` でも書き換わらない** +(PM2 は初回登録時の env を保持し、スクリプト内の `export` は記録されない)。 +ただし起動時にスクリプトの `export` が上書きするため**実効値は正しく、実害はない** +(`pm2 delete` → `pm2 resurrect` で実測確認済み)。 + +```bash +# 実効値の確認(これが唯一の真実) +ssh "tr '\0' '\n' < /proc/\$(pgrep -f 'toolify.*server.js')/environ | grep -E 'PORT|HOSTNAME|NODE_OPTIONS'" +``` + +### (6) デプロイ「成功」は本番の生存を意味しない + +上記いずれの場合も、ビルド・型・テスト・rsync・`pm2 reload` はすべて success になり、 +**落ちるのは最後のスモークテストだけ**。Actions を上から眺めると成功に見えるので、 +失敗時は必ず最終ステップと `pm2 logs toolify --err` を見ること。 + ## 1. デプロイ(通常運用) `main` ブランチへ push すると `deploy.yml` が自動実行される。手動実行も可: diff --git a/ops/toolify-start.sh b/ops/toolify-start.sh new file mode 100755 index 00000000..b30bed29 --- /dev/null +++ b/ops/toolify-start.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Next.js standalone の起動スクリプト。 +# 正本: リポジトリの ops/toolify-start.sh。VPS の配置先は /opt/appcfg/toolify-start.sh。 +# 反映は手動 (deploy.yml は配置しない): +# scp ops/toolify-start.sh :/opt/appcfg/toolify-start.sh +# ssh 'chmod +x /opt/appcfg/toolify-start.sh && pm2 restart toolify' +# 経緯と他の罠は docs/DEPLOY.md の「0. 起動スクリプトと踏んだ罠」を参照。 +# ⚠️ /opt/apps/toolify/ 配下に置いてはいけない。deploy.yml の rsync -az --delete が +# .next/standalone/ をそのディレクトリへ同期するため、standalone に含まれない +# ファイルは毎回のデプロイで消える(2026-09-17 に消えて本番 502)。 +# そのため 2026-09-18 に /opt/appcfg/ へ退避し、PM2 もそちらを指すようにした。 +cd /opt/apps/toolify +set -a; [ -f .env.local ] && source .env.local; set +a +# nginx は 127.0.0.1:8500 へ proxy_pass する。standalone の既定 3000 は +# 別アプリ(30sec)が占有しており、未指定だと EADDRINUSE で起動できない。 +export PORT=8500 +# HOSTNAME は bind アドレスであると同時に、Next が絶対URLを組む際の host にもなる。 +# 127.0.0.1 にすると next-intl の locale redirect が `https://localhost:8500/en` を +# 返し、Next 自身がそこへ自己 proxy して TLS ハンドシェイク失敗(EPROTO) → +# 全ページ 500 になる(2026-09-18 実障害)。公開ドメインなら redirect は相対 /en で 200。 +# ufw は 22/80/443 のみ許可(既定 DROP)なので公開IPに bind しても 8500 は外部到達不可。 +export HOSTNAME=toolify365.com +# V8 の heap 上限。**PM2 のメモリ内 env だけに置いてはいけない**。 +# 2026-09-18 の実障害: 稼働プロセスは 300MB で走っていたのに dump.pm2 には 400MB が +# 保存されており(復旧時に pm2 set したが稼働プロセスへは反映されていなかった)、 +# どこにも固定されていないため `pm2 resurrect` の度に値が変わりうる状態だった。 +# PORT が同じ理由で 3000 に落ちた前例がある(2026-09-17)。 +# 値の原則は 定常RSS < heap cap < max_memory_restart(500MB)。 +# toolify は Next.js SSR で定常 RSS 約 220MB あり、300MB では比 0.73 と余裕が無く +# 実際に V8 が `Ineffective mark-compacts` で abort した(00:08 / 00:28)。 +export NODE_OPTIONS="--max-old-space-size=400" +exec node server.js From b90e84b4b00026903c67a168baa9b2145784f349 Mon Sep 17 00:00:00 2001 From: appdevelopsk Date: Fri, 18 Sep 2026 10:10:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(ops):=20=E5=AE=9F=E5=8A=B9env=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E3=81=AE=20pid=20=E5=8F=96=E5=BE=97=E3=82=92=20pgrep?= =?UTF-8?q?=20=E3=81=8B=E3=82=89=20pm2=20jlist=20=E3=81=B8=E6=98=AF?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pgrep -f 'toolify.*server.js' は ssh で送った自分のコマンド文字列に 先当たりし、無関係プロセス(bash -c ...)の environ を読んで NODE_OPTIONS=300 という誤答を返していた。VPS 上で再現確認済み (pgrep が返すのは自分自身の pid のみ、本体 3950723 は含まれない)。 pm2 jlist の JSON から name=="toolify" の pid をキーで取り出す方式に変更。 実測で PORT=8500 / HOSTNAME=toolify365.com / heap=400 を正しく返すことを確認。 Claude-Session: https://claude.ai/code/session_01ENHPNZ5ngcdrTHJ2H6oAFd --- docs/DEPLOY.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 30ffbc4a..a368ec51 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -78,9 +78,14 @@ standalone の既定ポート 3000 は同居する別アプリ(`30sec`)が ```bash # 実効値の確認(これが唯一の真実) -ssh "tr '\0' '\n' < /proc/\$(pgrep -f 'toolify.*server.js')/environ | grep -E 'PORT|HOSTNAME|NODE_OPTIONS'" +# pid は pm2 に聞く。pgrep -f は ssh で送った自分のコマンド文字列に先当たりし、 +# 無関係プロセスの environ を読んで誤答(heap=300 等)を返す。 +ssh "PID=\$(pm2 jlist | python3 -c 'import sys,json; print([p[\"pid\"] for p in json.load(sys.stdin) if p[\"name\"]==\"toolify\"][0])') && tr '\0' '\n' < /proc/\$PID/environ | grep -E 'PORT|HOSTNAME|NODE_OPTIONS'" ``` +`pm2 jlist` は JSON を 1 行で返すので、`pid` を綴りでなくキーで取り出せる。 +`pm2 pid toolify` でも取れるが、クラスタ時に複数行になる点に注意。 + ### (6) デプロイ「成功」は本番の生存を意味しない 上記いずれの場合も、ビルド・型・テスト・rsync・`pm2 reload` はすべて success になり、