DistrolessなNext.jsのイメージにsharpをインストールする

忙しい人向け

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 を参照して余計なパッケージをインストールしないように、適当に名前を変えています。

動作確認

before-optimization

after-optimization

上が最適化前で下が最適化後です。3つとも比較的小さい画像なのですが、それでも画像サイズが減少しロードが早くなっていることが確認できます。

あとがき

Vercel だと sharp は勝手にインストールされているので、この辺の問題は気にしなくて良いらしいです。Next.js プロジェクトをクラウドにデプロイする場合は Vercel が一強だなぁ...