`npm run dev` が突然消える謎 — 正体はWSL2の9pマウントとSIGBUSだった
npm run devを実行すると✓ Readyまで表示されるのに、エラーメッセージが一切出ないまま数秒〜数十秒後にプロセスごと消える- 正体はSIGBUS(バスエラー)というシグナルだった。「シグナル」とは、OSがプロセスに送る強制終了などの指令のこと。
dmesg(カーネルが記録するログ)にpotentially unexpected fatal signal 7という記録が残っていた - 原因は、Next.js の新しいビルドツール「Turbopack」が多用するmmap(ファイルの中身をメモリに直接マッピングして読み書きする仕組み)と、WSL2 + Docker Desktop 特有の9pマウント(Windows側のファイルをLinux側から使うためのネットワーク越しファイル共有)の相性が悪かったこと
- 恒久対策は、リポジトリをWindows側(
C:\...)ではなくWSL2ネイティブのLinuxファイルシステムに置き直すこと - 移設後、
findmnt -T .でFSTYPEが9pからext4に変わったことを確認し、クラッシュが再発しないことも確認できた
何が起きていたか
開発サーバーを起動して npm run dev を叩くと、ログにはこう表示される。
✓ Ready in 2.3s
一見、正常に起動できたように見える。ところが、ブラウザで http://localhost:3000 を開いてもつながらない。curl を叩いても接続失敗。エラーログは一切出ないまま、いつの間にかプロセスが消えている。
エラーが出ないタイプの不具合は厄介だ。「何が悪いのか」の手がかりすらないからだ。
念のため過去の next.log を見返してみると、こちらも同じく ✓ Ready の行で出力が途切れていた。つまり今回たまたま起きた事故ではなく、以前から再現していた現象だったらしい。
裏取りしていく
アプリケーションのログを見てもエラーが出ない以上、そこには手がかりがない。そこで、アプリケーションよりも一段下の「プロセス」と「カーネル」の側から追いかけることにした。
1. プロセスの生死をその場で観察する
nohup npm run dev &
ps aux | grep next-server
しばらく ps aux を叩き続けると、next-server プロセスがある時点でスッと消える。終了ログは一切ない。誰かに黙って首を切られたような消え方だった。
2. dmesg を疑う
エラーメッセージなしでプロセスが消えるのは、アプリケーション自身が例外を出して落ちたのではなく、OS側から強制終了させられた可能性が高い。そこでカーネルのログ dmesg を確認すると、案の定こんな記録が残っていた。
potentially unexpected fatal signal 7.
CPU: 6 PID: 55561 Comm: next-server ...
signal 7 はSIGBUS(バスエラー)。Node.js が自発的にエラーを吐く前に、OSレベルで強制終了させられていたことになる。
3. マウント状況を確認する
SIGBUSは多くの場合、mmap(ファイルをメモリに直接マッピングする仕組み。たとえるなら「ファイルの中身を丸ごとメモリの上に写し取って、そこを直接読み書きする」ようなアクセス方式)へのアクセス時に、ページの読み書きが失敗すると発生する。そこで、作業ディレクトリがどんな方式でマウントされているかを確認した。
findmnt -T .
TARGET SOURCE FSTYPE OPTIONS
/workspaces/dev/hp-sy C:\[/workspace/dev/hp-sy] 9p ...,mmap,access=client,...
プロジェクトディレクトリの実体はWindows側の C:\... にあり、それを**9pプロトコル(drvfs)**経由・mmap オプション付きでdevcontainerにマウントしていた。.devcontainer/devcontainer.json はDocker Compose経由でこのワークスペースをそのままコンテナへバインドマウントしているだけなので、9pマウントの性質はそのままコンテナ内にも引き継がれる。
free -h で見た空きメモリも229MiBとやや逼迫気味だった(buff/cache 込みのavailableは3.7GiB)。メモリに余裕がない状態だったことも、状況証拠として一致する。
原因
Next.js 16のデフォルトビルドツールであるTurbopackは、キャッシュの実装でmmapを多用する。 一方で、WSL2 + Docker Desktopの構成でWindows側のパスをコンテナへバインドマウントすると、9p(drvfs)+ mmap オプションというマウント方式になる。このマウント方式は、mmapのページフォールト処理(メモリに写し取ったファイルの一部を、必要なタイミングで正しく読み書きする処理)を正しくサポートしていない。
特にメモリに余裕がなくなったタイミングでページの読み戻しに失敗し、SIGBUSを引き起こす。これはWSL2 + Docker Desktop + Turbopackという組み合わせで知られている相性問題であり、アプリケーションコードや next.config.ts の設定不備が原因ではなかった。エラーメッセージが一切出ないまま無言で落ちる、という挙動ともきれいに符合する。
対策の選択肢
調査の結果、対策には応急と恒久の2案があった。
案1: Webpackベースのdev serverに切り替える(応急)
next dev をTurbopackではなくWebpackベースで起動すれば、mmapを多用するキャッシュ実装を回避できる。コード変更なしで即座に試せる一方、ビルド速度は低下し、根本原因である「9pマウント自体」は残ったままになる。
案2: リポジトリをWSL2ネイティブのLinuxファイルシステムへ移設する(恒久対応)
Windows側(C:\...)ではなく、WSL2ディストロ内のネイティブファイルシステム(例: ~/dev/hp-sy)にリポジトリを置き直し、そこからdevcontainerを開く。9pマウントを経由しなくなるため、mmap問題そのものが解消する。
今回は根本原因を潰したかったので、案2を採用した。
やったこと
未コミット分の整理
- 調査中に生成された
next.logを削除 .devcontainer/や.gitignoreの変更のうち意図したものをコミット.dev.vars(gitignore対象のシークレット)は手動で移送する前提で対象から除外
- 調査中に生成された
WSL2のネイティブ側にクローン
Windowsの「ターミナル」からWSL(Ubuntuディストロ)を開き、
mkdir -p ~/dev cd ~/dev git clone https://github.com/satoshi-yamamoto-dev/hp-sy.git移設前に
C:\...側で未pushのコミットをpushしておき、差分が残らない状態にしてからcloneした。.dev.varsの移送C:\...\hp-sy\.dev.varsの中身を、clone先~/dev/hp-sy/.dev.varsへ手動でコピー。WSL側からVS Codeを開く
必ずWSLターミナル内(
~/dev/hp-sy)でcode .を実行し、VS CodeをWSL Remoteとして接続する。Windows側から\\wsl$\...パスを直接開くと9p経由のままになってしまうため、ここは注意が必要だった。コンテナを再ビルド
コマンドパレット(
Ctrl+Shift+P)→Dev Containers: Reopen in Container。マウント種別を確認
findmnt -T .FSTYPEが9pではなくext4になっていることを確認。動作確認
npm run devをしばらく放置し、SIGBUSによるクラッシュが再発しないことを確認した。
結果
移設後は npm run dev を放置してもクラッシュせず、安定して動作している。エラーメッセージが一切出ないタイプの不具合だったため、dmesg でカーネルログを確認するという一手間が原因特定の決め手になった。
今日の学び
- エラーログが何も出ないまま落ちるプロセスは、アプリケーションの外側(シグナル)を疑う。
dmesgはこういうときの強い味方になる - WSL2 + Docker DesktopでWindows側パスをそのままコンテナにバインドマウントする構成は、9pマウントを経由する。 Turbopackのようにmmapに依存するツールと組み合わせると、思わぬところで火を噴く可能性がある
- devcontainerを使う場合は、なるべく早い段階でWSL2ネイティブファイルシステム上にリポジトリを置く運用に倒しておいたほうが、後々のトラブルシューティングコストを抑えられる
明日への一言
無言で落ちるプロセスに振り回された一日だったが、dmesg を見る習慣さえあれば怖くない。次に似た現象に出会ったときは、今日より早く「シグナルを疑う」動きに入れそうだ。
本記事は Sonnet 5(claude-sonnet-5)が生成しました。