忙しい人向け
siloneco/Telepapyrus/Dockerfile をご覧ください。
経緯
このブログは Vercel を利用せず Docker コンテナ上で動いているのですが、先日このようなエラーが出ているのを発見しました。
Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production
Next.js では img タグの代わりに next/Image を利用することで、画像の最適化処理を一任することができます。この処理に lovell/sharp というライブラリを利用するので、インストールが必要ですという旨のエラーです。
一応これを無視しても画像の表示は行われるのですが、解像度を下げたり webp に変換する最適化が一切行われないため、ページのロードが遅くなったり余計な帯域を消費したりしてしまいます。
現在の環境
Next.js には output の選択肢に standalone mode というものがあります。これを指定してビルドすると server.js
ファイルが生成され、next start
を実行せずとも node server.js
を用いて Next.js プロジェクトを起動できるようになります。next start
に比べて起動が早かったり、余分なパッケージを含まないためイメージサイズの縮小が期待できます。
この場合の Dockerfile は、例えば以下のようになります。
Dockerfile.example
FROM node:20-alpine AS build
# corepack enable とか pnpm install は省略
# ビルド
COPY . .
RUN pnpm run build
# 最終的なイメージを作成する
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runner
WORKDIR /app
# public/ にアセットがある場合はコメントを外す
# COPY --from=build --chown=65532:65532 /work/public ./public
COPY --from=build --chown=65532:65532 /work/.next/standalone ./
COPY --from=build --chown=65532:65532 /work/.next/static ./.next/static
# ENV とか EXPOSE は省略
ENTRYPOINT [ "/nodejs/bin/node", "server.js" ]
しかし、この方法では package.json
に sharp が含まれていたとしても runner イメージに必要なファイル群がコピーされず、エラーになってしまうようです。
余談: Distrolessとは
Google が公開している、必要最低限の実行ファイルのみを残したイメージです。シェルすら含まれていないためセキュリティの向上が図れたり、イメージサイズの縮小が期待できます。GitHubは GoogleContainerTools/distroless
Alpine などもイメージサイズが非常に小さいですが、パフォーマンスの問題が指摘されていたり、シェルを含んでいるので Distroless を選択しています。
対処法
基本的な対処法はいくつかあります。
- build ステージから node_modules 配下を全部コピーしてくる
- runner ステージで npm install sharp する
しかし、前者はマルチステージビルドの利点が得られなくなり、後者は Distroless イメージだと npm が存在しないため利用できません。
また、build ステージの node_modules から sharp に必要そうなファイルを割り出して COPY 命令で持ってくることを考えたのですが、以下の理由から断念しました。
- 必要なファイルの特定が難しく、そもそもエラーが消えなかった
- 今後必要なファイルが増えた時、毎回対応するのは面倒
そこで sharp のみをインストールするステージを用意し、そのステージで生成された node_modules を全てコピーするようにしました。こうすれば sharp が必要とする全てのファイルがコピーされ、パッケージマネージャーが正常にインストールを終了した場合は、Next.js 側でも正常に動作するはずです。
また、sharp 以外の余計なパッケージがコピーされないため、イメージサイズも小さいままに保てます。
Dockerfile.example
FROM node:20-alpine AS pnpm
# 省略 ( Enable pnpm )
FROM pnpm AS build
# 省略 ( Install packages )
FROM pnpm AS sharp
WORKDIR /work
COPY package.json ./package.d.json
RUN SHARP_VERSION=`node -p -e "require('./package.d.json').dependencies.sharp"` \
pnpm install sharp@"$SHARP_VERSION"
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runner
WORKDIR /app
COPY --from=sharp --chown=65532:65532 /work/node_modules/ ./node_modules/
COPY --from=build --chown=65532:65532 /work/.next/standalone ./
COPY --from=build --chown=65532:65532 /work/.next/static ./.next/static
# 色々省略
ENTRYPOINT [ "/nodejs/bin/node", "server.js" ]
sharp のバージョンは package.json
を参照するようにしてあります。また、pnpm が package.json
を参照して余計なパッケージをインストールしないように、適当に名前を変えています。
動作確認
上が最適化前で下が最適化後です。3つとも比較的小さい画像なのですが、それでも画像サイズが減少しロードが早くなっていることが確認できます。
あとがき
Vercel だと sharp は勝手にインストールされているので、この辺の問題は気にしなくて良いらしいです。Next.js プロジェクトをクラウドにデプロイする場合は Vercel が一強だなぁ...