書類: No such file or directory で止まらない再起動
自宅の Mac mini で、Python のスケジューラと FastAPI のバックエンドを PM2 で常駐させている。Node.js のアプリではないが、再起動ポリシー・ログのローテーション・cron_restart がまとめて手に入るので、言語を問わずプロセス管理に使っている。
ある晩、秘密情報の読み込みをラッパースクリプト経由に変えた。1Password CLI が使えれば op run で、使えなければ .env を読んでから本体を exec する、よくある作りだ。これを PM2 から呼ぶようにした瞬間、両方のプロセスが再起動ループに入った。
エラーログはこれだけだった。
/bin/bash: 書類: No such file or directory
「書類」はファイル名ではない。リポジトリの置き場所の一部だ。Mac の ~/Documents を iCloud で同期していると、フォルダが次のような名前になる。
/Users/<user>/Documents/書類 - <user>のMac mini/GitHub/algo-trade-bot
空白が2つ入っている。PM2 がどこかでこのパスを空白で分割し、先頭の .../Documents/書類 までを実行しようとして落ちていた。
最初の修正は5分しか持たなかった
最初の設定はこうだった。
{
name: "algo-scheduler",
script: secretsLauncher, // .../scripts/run-with-secrets.sh
args: `${pythonBin} execution/enhanced_scheduler.py`,
interpreter: "none",
cwd: repo,
}
script に .sh を直接置き、args は文字列。interpreter: "none" なのでシェルを挟まず直接実行してくれるだろう、と思っていた。
1本目の修正では interpreter: "/bin/bash" に変えた。bash に明示的に渡せば、パスはひとつの引数として扱われるはずだと考えた。コミットして pm2 restart したら、同じエラーで同じループに入った。コミット時刻を見返すと、この修正は5分で次のコミットに置き換わっている。
原因を確認して分かったのは、.sh を script に置いた時点で、interpreter が none でも /bin/bash でも、PM2 は起動を bash -c "<script> <args>" で包むということだった。しかもこの <script> はクォートされない。bash -c に渡る文字列の中で、空白入りのパスがそのまま単語分割される。interpreter をどちらにしても包み方は変わらないので、1本目の修正は効くはずがなかった。
bash の実体を script にして、残りは配列で渡す
最終的にこう書いた。
{
name: "algo-scheduler",
script: "/bin/bash",
args: [secretsLauncher, pythonBin, "execution/enhanced_scheduler.py"],
interpreter: "none",
cwd: repo,
}
ポイントは3つある。script にはシェルスクリプトではなく bash のバイナリそのものを置く。空白のない /bin/bash なら bash -c で包まれることもない。ラッパースクリプトのパスは args 配列の1要素として渡す。配列の要素は PM2 が再分割しないので、空白入りのパスも1個の引数として bash に届く。interpreter: "none" は PM2 が独自のラップをかけないために明示しておく。
uvicorn 側も同じ形にした。コマンドラインを全部文字列で書いていた箇所は、配列にばらすと長くなる。
args: [
secretsLauncher, pythonBin,
"-m", "uvicorn", "backend.main:app",
"--host", "0.0.0.0", "--port", "8000",
],
見た目は冗長になった。それでも、ここで1行にまとめたくなる気持ちが今回の事故の原因なので、配列のままにしている。直したあとは両プロセスとも online で安定し、バックエンドのヘルスチェックも 200 を返した。
設定ファイルに「なぜこう書いたか」を残す
この修正は、半年後の自分が「冗長だから」と文字列に戻すと一発で壊れる。なので ecosystem.config.cjs のコメントに理由を書き込んだ。
// IMPORTANT: launch the bash *binary* via `script:"/bin/bash"` + an args
// ARRAY, with interpreter:"none". Do NOT put the .sh launcher in `script`,
// and do NOT set interpreter:"/bin/bash" — in BOTH cases PM2 wraps the
// launch in an UNQUOTED `bash -c "<launcher> ..."` (verified) ...
1本目の修正のあとで「interpreter: "/bin/bash" でも同じ包み方になる」と追記するコミットをもう1本入れている。やってはいけない書き方を2通りとも名指ししておかないと、また片方を試して時間を溶かすからだ。
空白の入ったパス自体を避ける手もあった。空白のない場所にシンボリックリンクを張る、リポジトリを移す、といったものだ。ただ、ラッパーの中では $(dirname "${BASH_SOURCE[0]}") からリポジトリのルートを割り出していて、リンク経由だと実体のパスがどちらになるかを別に考えないといけない。起動設定1か所を直すほうが影響範囲が小さかったので、今回はそちらを選んだ。
空白入りパスは「いつか誰かの環境で」ではなく今ここにある
~/Documents を iCloud で同期している Mac では、書類 - <名前>のMac mini のような空白入りのフォルダが普通にできる。自分の手でスペースを入れた覚えがなくても、OS が勝手に入れてくる。「うちのパスに空白はない」は思い込みだった。
PM2 に限らず、プロセスを起動するツールにコマンドを渡すときは、文字列より配列を選ぶ。subprocess.run に shell=True で文字列を渡さないのと同じ理屈だ。文字列で渡した時点で、どこかで誰かがシェルに解釈させる余地が生まれる。今回はその「誰か」が PM2 だった。
同じリポジトリでは、同じく ~/Documents に置いたことが原因で iCloud の EDEADLK にも踏まれている(#1)。場所ひとつで2回痛い目を見たので、常駐させるものは同期フォルダの外に置く、が今の方針になった。