Next.jsをstandaloneモードでビルドする

諸々の事情があってNext.jsをDockerで動かすことになったのですが、モノレポでの記述方法で毎回つまづくのでそのメモ記事です。
また、v12で追加されたstandaloneモードを使ってDockerfileを記述したので、その過程で色々調べたことやstandaloneモードの有無によるファイルサイズの検証も一緒に記載してあります。

目次

モノレポでのDockerfileの記述

まずディレクトリ構成を簡略化して記述します。

.
├── front
│   ├── Dockerfile
│   └── package.json
├── server
│   └── package.json
├── shared
│   └── package.json
├── docker-compose.yml
├── package.json
└── yarn.lock

今回はyarnのworkspace機能を使ってモノレポ化している想定です。
Next.jsのコンテナ化なのでfrontディレクトリ配下のpackage.jsonを使用しますが、yarn.lockはルート直下にあることを考慮する必要があります。

また、frontもserverもそれぞれDockerfileが必要になりますが、これは各ディレクトリ配下に配置することとします。
コンテナで環境を立ち上げる時はそれらをまとめて立ち上げるので、docker-compose.ymlはルート直下に配置します。

docker-compose.yml

ここではfrontのコンテナの分しか記述しません。

version: '3.7'
services:
  front:
    build:
      context: .
      dockerfile: ./front/Dockerfile
    ports:
      - "3000:8080"
    volumes:
      - ./front:/app
    command: yarn dev

ポイントはbuildの部分で、デフォルトだとdocker-compose.ymlがある場所と同じ場所のDockerfileを読み込もうとするので、明示的にDockerfileの場所を指定してやります。

さらにcontextはカレントディレクトリを指定しています。
これは後ほど見るようにDockerfile内でルート直下にあるyarn.lockを参照する必要があるので、front以下をcontextとしてしまっているとcontext外を参照することになりdockerがエラーを吐くからです。

Dockerfile

肝心のDockerfileを見ていきます。

公式のGitHubに例があったのでわりとそのままパクリです。
まず、standaloneモードでビルドするので、next.config.jsに以下の記述を追加しておきます。

{
  output: "standalone"
}

その上でDockerfileを以下のように記述します。

FROM node:18.12.1-alpine as deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY ./front/package.json ./yarn.lock ./
RUN yarn --frozen-lockfile

FROM node:18.12.1-alpine as builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY ./front/ ./
RUN yarn build

FROM node:18.12.1-alpine as runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]

USER nextjs
EXPOSE 8080
ENV PORT 8080

CMD ["node", "server.js"]

先ほどのdocker-compose.ymlでcontextをルート直下にしたのでpackage.jsonなどをCOPYをする時は./front/package.jsonのようにしていますが、yarn.lockはルート直下に存在しているので./yarn.lockとしています。

公式の記述と違うところは22-23行目のところ。
これはレビューで指摘もらって初めて知ったんですが、nodeコマンドをそのまま実行するとPID1問題が発生するのでその対応として入れています。

今回はマルチステージビルドで記述していて、かつstandaloneモードを使っているおかげかイメージサイズは192MBでした。
後ほどstandaloneモードを使わない場合のサイズを測定して比較します。

GitHub ActionsでのECRへのpush

ここまででdocker composeを使って開発環境を構築することはできるようになりました。
今回はECS上で動かすためECRへイメージをpushする必要があります。

該当の記述部分は以下です。

env:
  ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
run: |
  docker buildx build \
    --platform linux/arm64 \
    -t $ECR_REGISTRY/front-test:${{ needs.set_up.outputs.tag }} \
    -f ./front/Dockerfile \
    --push \
    .

これは本当はマーケットプレイスにあるdocker/build-push-actionを使って書くとcacheが効いていい感じに記述できそうです。

(ここには記述していませんが、今回は実は今までルート直下として記述してきたディレクトリはルートからもう一段ネストした場所に存在しているので、上記のymlファイルの記述ではworking-directoryとしてそのディレクトリを記述しています。このworking-directorydocker/build-push-actionを併用して書くことができなかったため、上記のように直接dockerコマンドをrunに記述する形になりました。)

これで開発環境もコンテナ化でき、ECRへのイメージpushもCIとして組み込むことができるようになりました。

standaloneモードの有無によるイメージサイズの変化

さて、ここまではNext.jsのv12?から追加されたstandaloneモードを使ってdockerのビルドを行なってきました。

これによって.nextディレクトリ下にstandaloneフォルダが作成されます。
プロジェクトで実際に利用しているファイルをのみを含めて、さらにnext startコマンドの代わりに使用できる最小限のserver.jsファイルを生成します。
node_modulesを含める必要もなくなるため、かなりイメージサイズが削減されます。

とは言え、実はこれまでNext.jsでコンテナ化をしたことがないのでこれによってどれくらい削減できてるのかわかりません。

ということで今回はDockerfileを少し書き換えてstandaloneモードじゃない状態でビルドしてみてイメージサイズを比較してみます。

まずnext.config.jsoutputをコメントアウトします

{
  // output: "standalone"
}

そしてDockerfileを書き換えます。

FROM node:18.12.1-alpine as deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY ./front/package.json ./yarn.lock ./
RUN yarn --frozen-lockfile

FROM node:18.12.1-alpine as builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY ./front/ ./
RUN yarn build

FROM node:18.12.1-alpine as runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY ./front/package.json ./yarn.lock ./
RUN yarn --frozen-lockfile --production=true
// COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
// COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]

USER nextjs
EXPOSE 8080
ENV PORT 8080

CMD ["yarn", "start"]

standaloneモードじゃない場合は.nextを丸ごとCOPYします。
また、node_modulesが必要になるので、package.jsonyarn.lockをCOPYした上でproduction=trueyarn installすることで用意しています。

他の変更点としては起動コマンドをyarn startに変更しています。

この状態でビルドした結果、イメージサイズが3.62GBになりました…

さすがに大きすぎる気がするので何か間違ってるのかもしれません、、、
が、特に問題なくコンテナを動かすこともでき、間違いはパッと見当たりませんでした
もしかしたらもうちょっとイメージサイズ削減の余地があるのかもしれません。

ここをこれ以上追及する気も特になかったので今回はこの結果を受け入れることにします。
結果として、standaloneモードなしでは3.62GBだったのがstandaloneモードにしただけで192MBになったので減りすぎではというくらい減りすぎています。

ここおかしいという箇所があったらご指摘いただけると嬉しいです。
減るのは間違いなさそうなんですが、変化量に関しては参考程度にしてください。

何はともあれ簡単な設定一つでここまで削減してくれるなんて素晴らしいですね。

まとめ

Next.jsを初めてコンテナ化しましたが、公式のリポジトリ内にsampleがあるのはとてもありがたかったです。
イメージサイズもかなり減りましたし、まさに至れり尽くせりでした。

この記事が参考になったからコーヒーくらいおごってもいいぜという方は、以下からサポートいただけると次の記事書くモチベになりますのでよろしくお願いします

Buy Me A Coffee

参考

あわせて読みたい
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次