この記事で分かること
- pnpmのsymlink構造がNext.js standaloneでなぜ壊れるのか
cp -rLで解決できない場合の対処法- Docker multi-stage buildでのsymlink解決パターン
- 5回のfix PRから得た教訓
背景
Saruは5つのNext.jsフロントエンド(Landing / System / Provider / Reseller / Consumer)をpnpmモノレポで管理している。開発環境ではvolume mountで動かすのでDockerfileは不要だが、本番・デモ環境へのデプロイにはDockerイメージが必要になる。
Next.jsの output: 'standalone' を使えば、必要なファイルだけを .next/standalone/ にトレースしてくれる。これをAlpineベースのrunnerステージにコピーすれば軽量なイメージができる——はずだった。
ハマり1: MODULE_NOT_FOUND(#557)
症状
Error: Cannot find module 'next/dist/compiled/next-server/app-page.runtime.prod.js'
コンテナを docker run した瞬間にクラッシュ。
原因
pnpmは node_modules をsymlinkベースで構築する。例えば:
standalone/node_modules/next → ../../node_modules/.pnpm/next@14.2.x/node_modules/next
Next.jsの @vercel/nft(Node File Tracing)はこのsymlink構造をそのままstandalone出力にコピーする。builderステージ内ではsymlink先が存在するので問題ないが、COPY --from=builder でrunnerステージに持ってくると、symlink先のディレクトリが存在しない。
builder (standalone作成時) runner (COPYした後)
├── standalone/ ├── standalone/
│ └── node_modules/ │ └── node_modules/
│ └── next → ../../.pnpm/... │ └── next → ../../.pnpm/...
└── node_modules/.pnpm/... ✅ 存在 └── (なし) ❌
試した解決策
| |
cp -rL はsymlinkを辿って実ファイルをコピーするPOSIXコマンド。これで一発解決——と思った。
結果: ❌ 失敗
ハマり2: dangling symlink(#560)
症状
cp: can't stat '/app/apps/system/.next/standalone/node_modules/next': No such file or directory
cp -rL 自体がエラーで失敗。
原因
standaloneの node_modules にあるsymlinkの一部が、standalone外のpnpm virtual storeを指していた。standalone内に含まれないパスへのsymlinkは “dangling symlink”(壊れたsymlink)になる。cp -rL はdangling symlinkに遭遇すると停止する。
standalone/node_modules/
├── next → ../../node_modules/.pnpm/next@14.2.x/node_modules/next ← dangling
├── react → ./react (standalone内に実体あり) ← OK
└── ...
試した解決策
「一括ではダメなら1つずつ解決しよう」
| |
symlinkを1つずつ確認し、実体があればコピー、dangling ならスキップする方式。
結果: ❌ 失敗(別の理由)
ハマり3: 解決先のパスずれ(#561)
症状
一見ビルドは通ったが、やはり MODULE_NOT_FOUND。ただしハマり1とは異なるモジュールでエラーが出る。
原因
ハマり2の方式では /app/standalone にコピーした後にsymlinkを解決していた。しかしpnpmのsymlinkは相対パスで書かれている:
next → ../../node_modules/.pnpm/next@14.2.x/node_modules/next
この相対パスは /app/apps/system/.next/standalone/node_modules/ からは正しく解決できるが、/app/standalone/node_modules/ からだとパスがずれて別の場所を指す。
さらに、アプリレベルの node_modules だけでなく、ルートの .pnpm ストアにしか存在しないモジュールもあった。
試した解決策
「コピー後に解決するのではなく、元の場所(パスが正しい場所)で解決してからコピーしよう」
| |
dangling symlinkのモジュールは、ルートの .pnpm ストアから find で探してコピーする方式。
結果: ❌ 失敗(またもや別の理由)
ハマり4: transitive deps と scoped packages(#562)
症状
Error: Cannot find module 'styled-jsx'
Error: Cannot find module '@swc/helpers'
next 自体は見つかるようになったが、nextが依存する styled-jsx や @swc/helpers が見つからない。
原因
pnpmのストア構造には重要な特性がある:
node_modules/.pnpm/next@14.2.x/
└── node_modules/
├── next/ ← パッケージ本体
├── styled-jsx/ ← nextの依存(sibling)
├── @swc/
│ └── helpers/ ← scoped packageの依存(sibling)
└── react/ ← nextの依存(sibling)
pnpmはパッケージの依存関係を同じディレクトリ内のsiblingとして配置する。ハマり3の方式ではモジュール本体だけをコピーしており、sibling(transitive dependency)をコピーし忘れていた。
さらに @swc/helpers のようなscoped packageは @swc/ ディレクトリの下にあり、そのディレクトリ内にも別のsymlinkが存在する。単純な cp -r ではこれらの内部symlinkが壊れる。
解決策
「モジュール単体ではなく、ストアエントリのsibling全部を cp -rL でコピーする」
| |
ポイント:
- symlinkを見つけたら、pnpmストア内でそのモジュールを探す
- モジュールが見つかったら、同じディレクトリ内のsibling全部をコピー
cp -rLを使うことで、sibling内のネストしたsymlinkも解決
結果: ✅ MODULE_NOT_FOUNDは解消
ハマり5: static assetsの404(#565)
症状
コンテナは起動した。HTTPリクエストにもレスポンスが返る。しかしページを開くとCSS/JSが全て404。
原因
| |
Next.js standaloneの server.js は、自分のディレクトリ基準で .next/static を探す。つまり /app/.next/static を期待している。しかしDockerfileではモノレポの元のパス構造のまま /app/apps/system/.next/static にコピーしていた。
解決策
| |
1行の変更。しかしこれに気づくまでにブラウザのDevToolsで404を確認し、server.js のコードを読む必要があった。
結果: ✅ 完全に動作
最終的なDockerfile
| |
なぜ5回もハマったのか
振り返ると、根本的な原因はpnpmのsymlink構造への理解不足だった。
| ハマり | 前提の誤り |
|---|---|
| 1回目 | symlinkを COPY すればDocker側で解決してくれるだろう |
| 2回目 | cp -rL で一括解決できるだろう |
| 3回目 | コピー先でsymlinkを解決すれば相対パスも有効だろう |
| 4回目 | モジュール本体だけコピーすれば依存は解決するだろう |
| 5回目 | standaloneのパス構造はモノレポの構造を維持するだろう |
毎回「だろう」で進めて、実際にDockerをビルド・起動してから失敗に気づいている。
代替案の検討
最初から検討していた代替案と、却下した理由も記録しておく。
| 方法 | 却下理由 |
|---|---|
node-linker=hoisted(.npmrc) | 以前試して失敗。outputFileTracingRootの設定も必要になり、next.config.jsの変更が必要 |
pnpm deploy | ワークスペースパッケージが生TypeScript(transpilePackages前提)なので、パス構造がずれてビルドが壊れる |
| node_modules丸ごとコピー | イメージサイズが10倍(~50MB → ~500MB+)になる |
結局、standaloneのsymlinkを手動で解決するシェルスクリプト方式が最も確実だった。美しくはないが、動く。
教訓
pnpmのsymlinkは相対パス。コピー先で解決しようとするとパスがずれる。必ず元の場所で解決する。
pnpmのストア構造は入れ子。パッケージの依存関係はsiblingとして同じディレクトリに配置される。1つのモジュールだけコピーしても依存が足りない。
Next.js standaloneのパス構造はフラット。モノレポの
apps/xxx/という構造は維持されない。server.jsはstandaloneディレクトリのルートに配置される。cp -rLは万能ではない。dangling symlinkがあると失敗する。個別に処理する覚悟が必要。Docker本番ビルドはローカルで検証しにくい。開発環境ではvolume mountで動くので、symlinkの問題はDocker buildして初めて発覚する。
まとめ
| 項目 | 内容 |
|---|---|
| 問題 | pnpmのsymlinkがDocker multi-stageビルドで壊れる |
| 原因 | Next.js standaloneがsymlink構造をそのまま出力する |
| 解決 | pnpmストアのsiblingごとcp -rLで解決してからCOPY |
| 修正回数 | 5回(#557 → #560 → #561 → #562 → #565) |
| 教訓 | symlinkは元の場所で解決、依存はsibling含めてコピー |
シリーズ記事
- 第1回: 保守不能な複雑さに自動化で立ち向かう
- 第2回: CIでWebAuthnテストを自動化する
- 第3回: Next.js x Go モノレポアーキテクチャ
- 第4回: PostgreSQL RLSでマルチテナント分離
- 第5回: マルチポータル認証の落とし穴
- 第6回: Claude Codeで20万行SaaSをソロ開発
- 第7回: セルフホストCI/CDの地雷と解決策
- 第8回: Claude Code Agent Teamで1人チーム開発
- 第9回: pnpm + Next.js standalone + Docker で5回ハマった話(この記事)