この記事で分かること

  • 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/... ✅ 存在  └── (なし) ❌

試した解決策

1
RUN cp -rL /app/apps/system/.next/standalone /app/standalone

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つずつ解決しよう」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
RUN cd /app/apps/system/.next/standalone/node_modules \
    && cp -a . /tmp/nm-backup \
    && for mod in *; do \
         [ -L "$mod" ] || continue; \
         target=$(readlink -f "$mod" 2>/dev/null); \
         if [ -e "$target" ]; then \
           rm "$mod" && cp -r "$target" "$mod"; \
         else \
           rm "$mod"; \
         fi; \
       done

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 ストアにしか存在しないモジュールもあった。

試した解決策

「コピー後に解決するのではなく、元の場所(パスが正しい場所)で解決してからコピーしよう」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
RUN cd /app/apps/system/.next/standalone/node_modules \
    && for mod in *; do \
         [ -L "$mod" ] || continue; \
         target=$(readlink -f "$mod" 2>/dev/null); \
         if [ -e "$target" ]; then \
           rm "$mod" && cp -r "$target" "$mod"; \
         else \
           rm "$mod"; \
           real=$(find /app/node_modules/.pnpm -path "*/$mod/package.json" \
                  ! -path "*/node_modules/*/node_modules/*" 2>/dev/null | head -1); \
           [ -n "$real" ] && cp -r "$(dirname "$real")" "$mod" || true; \
         fi; \
       done \
    && cp -r /app/apps/system/.next/standalone /app/standalone

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 でコピーする」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
RUN cd /app/apps/system/.next/standalone/node_modules \
    && for mod in *; do \
         [ -L "$mod" ] || continue; \
         rm "$mod"; \
         pkg=$(find /app/node_modules/.pnpm \
               -path "*/node_modules/$mod/package.json" 2>/dev/null | head -1); \
         if [ -n "$pkg" ]; then \
           store_nm=$(dirname "$(dirname "$pkg")"); \
           for dep in "$store_nm"/*; do \
             dep_name=$(basename "$dep"); \
             [ -e "$dep_name" ] && ! [ -L "$dep_name" ] && continue; \
             [ -L "$dep_name" ] && rm "$dep_name"; \
             cp -rL "$dep" "$dep_name" 2>/dev/null || true; \
           done; \
         fi; \
       done \
    && cp -r /app/apps/system/.next/standalone /app/standalone

ポイント:

  • symlinkを見つけたら、pnpmストア内でそのモジュールを探す
  • モジュールが見つかったら、同じディレクトリ内のsibling全部をコピー
  • cp -rL を使うことで、sibling内のネストしたsymlinkも解決

結果: ✅ MODULE_NOT_FOUNDは解消

ハマり5: static assetsの404(#565)

症状

コンテナは起動した。HTTPリクエストにもレスポンスが返る。しかしページを開くとCSS/JSが全て404。

原因

1
2
# 修正前
COPY --from=builder --chown=nextjs:nodejs /app/apps/system/.next/static ./apps/system/.next/static

Next.js standaloneの server.js は、自分のディレクトリ基準で .next/static を探す。つまり /app/.next/static を期待している。しかしDockerfileではモノレポの元のパス構造のまま /app/apps/system/.next/static にコピーしていた。

解決策

1
2
# 修正後
COPY --from=builder --chown=nextjs:nodejs /app/apps/system/.next/static ./.next/static

1行の変更。しかしこれに気づくまでにブラウザのDevToolsで404を確認し、server.js のコードを読む必要があった。

結果: ✅ 完全に動作

最終的なDockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
FROM node:20-alpine AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
COPY turbo.json ./
COPY apps/system/package.json ./apps/system/
# 全ワークスペースパッケージのpackage.jsonをコピー
COPY packages/types/package.json ./packages/types/
COPY packages/ui/package.json ./packages/ui/
COPY packages/auth/package.json ./packages/auth/
COPY packages/api-client/package.json ./packages/api-client/
COPY packages/config/package.json ./packages/config/
COPY packages/env-validator/package.json ./packages/env-validator/
COPY packages/logger/package.json ./packages/logger/
RUN pnpm install --frozen-lockfile

FROM base AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/system/node_modules ./apps/system/node_modules
COPY --from=deps /app/packages ./packages
COPY apps/system ./apps/system
COPY packages ./packages
COPY turbo.json pnpm-workspace.yaml package.json ./
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN pnpm turbo run build --filter=system

# pnpm symlink解決(ここが本記事の核心)
RUN cd /app/apps/system/.next/standalone/node_modules \
    && for mod in *; do \
         [ -L "$mod" ] || continue; \
         rm "$mod"; \
         pkg=$(find /app/node_modules/.pnpm \
               -path "*/node_modules/$mod/package.json" 2>/dev/null | head -1); \
         if [ -n "$pkg" ]; then \
           store_nm=$(dirname "$(dirname "$pkg")"); \
           for dep in "$store_nm"/*; do \
             dep_name=$(basename "$dep"); \
             [ -e "$dep_name" ] && ! [ -L "$dep_name" ] && continue; \
             [ -L "$dep_name" ] && rm "$dep_name"; \
             cp -rL "$dep" "$dep_name" 2>/dev/null || true; \
           done; \
         fi; \
       done \
    && cp -r /app/apps/system/.next/standalone /app/standalone

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/apps/system/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/system/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

なぜ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を手動で解決するシェルスクリプト方式が最も確実だった。美しくはないが、動く。

教訓

  1. pnpmのsymlinkは相対パス。コピー先で解決しようとするとパスがずれる。必ず元の場所で解決する。

  2. pnpmのストア構造は入れ子。パッケージの依存関係はsiblingとして同じディレクトリに配置される。1つのモジュールだけコピーしても依存が足りない。

  3. Next.js standaloneのパス構造はフラット。モノレポの apps/xxx/ という構造は維持されない。server.js はstandaloneディレクトリのルートに配置される。

  4. cp -rL は万能ではない。dangling symlinkがあると失敗する。個別に処理する覚悟が必要。

  5. 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含めてコピー

シリーズ記事