バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

standaloneモードを利用してNext.jsのデプロイ速度を改善した話

'icatch'

はじめに

こちらはバイセルテクノロジーズ Advent Calendar 2022の19日目の記事です。

前日の記事は天野さんの「ゼロトラストに向けての下地作り、Azure AD Connectの構築」でした。

こんにちは! 開発2部の富澤です。

私が参画しているプロジェクトではNext.js + Cloud Run+ GitHub Actionsという構成で開発していますが、デプロイ時間の遅さが問題になっていました。

以下の2つの方法を組み合わせてデプロイ時間の改善を行ったので、本記事ではその方法を紹介します。

  • Next.jsのstandaloneモードを利用
  • yarn buildの実行をDocker内からDocker外に移行

結果的には2~4分ほど実行時間を短くする事ができました。

少し大胆な方法を採用しましたが、Next.jsを利用していてデプロイ速度に課題のある方がいましたら、ご参考になれば幸いです。

現状の構成と実行時間

まず、元々の構成とデプロイ時間のボトルネックになっている箇所について簡単に説明します。

先に述べたようにNext.js + Cloud Run + GitHub Actionsという構成になっています。

yarn buildはdockerのマルチステージビルドを使ってDockerfile内で実行し、 docker buildはGitHub Actions上から行うという一般的な方法を取っていました。

workflowファイル抜粋

steps:
  - uses: actions/checkout@v3
  
  - name: Authenticate to Google Cloud
    uses: "google-github-actions/auth@v0"
    with:
      workload_identity_provider: ""
      service_account: ""

  - uses: google-github-actions/setup-gcloud@v0

  - name: Set up Docker Buildx
    uses: docker/setup-buildx-action@v2
  
  - name: Build and push
    uses: docker/build-push-action@v3
    with:
       context: .
       push: true
       tags: ${{ env.REGISTRY }}:latest
  
  - name: Deploy to Cloud Run
    uses: google-github-actions/deploy-cloudrun@main
    with:
      service: frontend
      image: ${{ env.REGISTRY }}:latest
      region: asia-northeast1

Dockerfile抜粋

# 参考: https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile

FROM node:16.13.2-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock ./
RUN npm install -g yarn
RUN yarn install --frozen-lockfile

FROM node:16.13.2-alpine AS builder
WORKDIR /app

COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN yarn build

FROM node:16.13.2-alpine AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder ./node_modules ./node_modules

USER nextjs

EXPOSE 3000

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

この状態でworkflow全体の実行に9分前後掛かっており、中でもとくにdocker buildのステップに7分前後掛かっている状態でした。

問題になっているdocker build内の各ステージの実行時間を見てみます。

各ステージの実行時間

deps builder runner push 合計
120 sec 180 sec 30 sec 84 sec 414 sec (6.9 min)

builderステージの実行時間が最も長く、次にdepsが長いという状態である事が分かります。

まずは2つのステージの高速化を考えます。特にdepsはcacheを利用する事で適宜スキップできそうです。

採用しなかったこと

中間イメージのキャッシュを利用

まず検討したのはdockerの中間イメージをキャッシュをする方法です。

Buildxを利用すると--cache-toが指定でき、type=registry,mode=maxとする事で中間ステージのキャッシュも含めて外部レジストリにpushできます。 pushしたキャッシュは--cache-fromを指定する事で利用可能です。

中間ステージのキャッシュを導入する事でキャッシュのリストア/セーブ時のオーバーヘッドが発生しますが、それ以上の高速化を期待して試してみます。

Dockerfileに以下を追加

  - name: Build and push
    uses: docker/build-push-action@v3
    with:
      context: .
      file: Dockerfile
      push: true
      tags: ${{ env.REGISTRY }}:latest
      # 以下を追加
      cache-from: type=registry,ref=${{ env.REGISTRY }}:latest-cache
      cache-to: type=registry,ref=${{ env.REGISTRY }}:latest-cache,mode=max

頻繁にデプロイされるプロダクトにおける実際の運用では、キャッシュヒットの状態は以下のどちらかになることがほとんどです。

  • 全てのステージがヒットしない
  • depsのみヒットする

この場合の各ステージの実行時間を導入前と比較してみます。

deps builder runner push 合計
キャッシュ導入前 120 sec 180 sec 30 sec 84 sec (1.4 min) 414 sec (6.9 min)
ヒットしない 120 sec 174 sec 30 sec 348 sec (5.8 min) 672 sec (11.2 min)
depsのみヒット x 174 sec 30 sec 348 sec (5.8 min) 672 sec (9.2 min)

キャッシュの導入前後で、push時間が4分半ほど増加している事が分かります。

これは先に述べたように、--cache-toオプション内でmode=maxを指定したことで中間イメージのキャッシュが含まれ、pushするイメージサイズの合計が増加したからです。

仮にdepsがヒットしたとしても、2分前後の短縮にしかならず、キャッシュのリストア/セーブ時のオーバーヘッドを相殺できる結果にはなりませんでした。

かえってデプロイ時間が長くなってしまう事がほとんどあり、この方法は採用しない事にしました。

採用したこと

yarn buildをdocker外に移動

次にyarn buildをdocker内からdocker外に移動するという方法を試しました。

採用した理由は大きく分けて2つです。

  • マシンパワーの観点でGitHub Actions上でのdocker buildは低速であり、docker build内で必要な処理を少なくできれば高速化が見込める。
  • GitHub Actionsではnode環境を構築するactionが公開されており、簡単にyarn buildに必要な環境を構築できる。

懸念点として、yarn buildを実行する環境とランタイムの環境が完全には一致しない点が上げられます。

この点を厳密にしたい場合は採用すべき方法ではありませんが、今回はデプロイ速度の改善を優先し許容する判断をしました。

具体的な流れ

具体的な流れは以下になります。

  1. GitHub Actions上でpackageのinstall
    • cache化しておき、ヒットした場合はstepごとスキップする
  2. GitHub Actions上でyarn buildを実行
  3. 生成されたstandaloneファイルをdockerイメージにCOPYしてdocker build & push
  4. Cloud Runにデプロイする(以前と変更無し)

まずGithub Actionsのworkflowファイルは以下です。(一部省略しております)

steps:
  - uses: actions/checkout@v3

  - uses: yarn/action-setup@v2
    with:
      version: "7"

  - uses: actions/setup-node@v3
    with:
      node-version: "16"
      cache: "yarn"
  
  - uses: actions/cache@v3
    id: yarn-cache
    with:
      path: node_modules
      key: ${{ runner.os }}-yarn-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }}
      restore-keys: |
        ${{ runner.os }}-yarn-

  - name: Install dependencies
    # cacheヒットした場合はstepごとスキップ
    if: steps.yarn-cache.outputs.cache-hit != 'true'
    run: NODE_ENV='production' yarn install --frozen-lockfile --prefer-offline
  
  - name: Run yarn build
    run: NODE_ENV='production' yarn build

  - name: Set up Docker Buildx
    uses: docker/setup-buildx-action@v2
  
  - name: Build and push
    uses: docker/build-push-action@v3
    with:
       context: .
       push: true
       tags: ${{ env.REGISTRY }}:latest
  
  - name: Deploy to Cloud Run
    uses: google-github-actions/deploy-cloudrun@main
    with:
      service: "service_name"
      image: ${{ env.REGISTRY }}:latest
      region: asia-northeast1

次にDockerfileです。

既にGitHub Actions上でyarn buildまで済ませているので、Dockerfile内で最低限やるべき事は.next,node_modulesのCOPYのみです。

Dockerfile

FROM node:16.13.2-alpine
WORKDIR /app

ENV NODE_ENV production

RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001

COPY ./public ./public
COPY ./node_modules ./node_modules

COPY --chown=nextjs:nodejs ./.next ./.next

USER nextjs

EXPOSE 3000

CMD ["next", "start"]

実行結果の比較

この方法と中間イメージをキャッシュしない改善前の状態のdocker pushが完了するまでの実行時間を比較してみます。

docker push完了までの時間
改善前 414 sec (6.9 min)
yarn-cacheヒット時 372 sec (6.2 min)
yarn-cache無し 480 sec (8 min)

yarn-cacheヒット時は40秒ほど早くなりましたが、期待していたほど高速化はできませんでした。

改善の余地がある事を期待して、workflow内の主要なステップの実行時間を見てみます。

yarn install yarn build docker build push
108 sec 150 sec 138 sec 84 sec

docker buildの責務はかなり少なくなったはずですが、思った以上に時間が掛かっています。

docker build時のログを調べてみるとビルドコンテキストの生成とnode_modulesのCOPYに時間が掛かっている事が分かりました。

docker buildのログ

#7 transferring context: 1.67GB 95.9s done
#7 DONE 96.3s

...

#12 [6/7] COPY ./node_modules ./node_modules
#12 DONE 33.7s

これはnode_modulesというサイズの大きいファイル群を直接COPYするように変更した事が原因です。 最終的なimageのサイズも380MBとかなり肥大化しています。

参考: Dockerのビルドコンテキストについて

docker buildコマンドを実行したときの、カレントなワーキングディレクトリのことを ビルドコンテキスト(build context)と呼びます。 デフォルトで Dockerfile は、カレントなワーキングディレクトリにあるものとみなされます。

この問題に対応するには以下の2つが考えられます。

  • マルチステージビルドを使う
  • COPYするべきファイルを小さくする

今回の方針ではマルチステージビルドによるyarn installは考えていないので、 2つ目の「COPYするべきファイルを小さくする」方法が無いかを考えます。

Next.jsには後述するstandaloneというモードが存在し、これを利用する事でCOPYするファイルサイズを削減できます。

standaloneの導入

Next.jsにはstandaloneという設定項目が存在しています。

standaloneを設定してyarn buildを行うと.next/standaloneディレクトリ配下に本番環境で必要な最小限のファイル群が集約されて生成されます。 (例外としてpublic,.next/staticディレクトリは.next/standaloneに含まれないため、必要な場合は明示的にCOPYする必要があります。)

.next/standalone
├── node_modules
├── package.json
├── server.js
└── src

DockerfileにCOPYするファイルサイズの削減を期待してこの機能を利用してみます。

やることは設定ファイルの変更とDockerfile COPY部分の修正です。

next.config.js

module.exports = {
  output: 'standalone',
}

Dockerfile

FROM node:16.13.2-alpine
WORKDIR /app

ENV NODE_ENV production

RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001

COPY ./public ./public

# 修正
COPY --chown=nextjs:nodejs ./.next/standalone ./
COPY --chown=nextjs:nodejs ./.next/static ./.next/static

USER nextjs

EXPOSE 3000

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

この状態でstandalone導入前とdocker build/pushの実行時間と最終的なイメージサイズを比べてみます。

build context docker build push image Size
standalone 6 sec 14 sec 14sec 74 MB
通常 96 sec 138 sec 84 sec 380 MB

課題であったbuild contextの時間を短縮でき、imageサイズの削減によってpush時間も削減できる事から、今回はこの方法を採用する事にしました。

最後にこの手法と導入前のworkflowの総実行時間(Cloud Runへのデプロイ完了まで)の実行時間を比較します。

workflowの総実行時間
改善前 9.0 min
yarn-cacheヒット時 4.6 min
yarn-cache無し 6.5 min

全体を通して、2~4分のデプロイ速度改善ができました。

おわりに

「yarn buildをdocker外に移動 + standaloneの導入」という方法でデプロイの改善をしました。

ただし、改善後も総実行時間が5分前後という事で欲を言えばもう少し改善したいところです。

Next.jsはbuild時にcacheを.next/cacheにを吐き出します。

それを活用する事でyarn buildのステップはもう少し短縮が期待できそうなので、今後試して行きたいと思っています。

最後に、バイセルではエンジニアを募集しています。少しでも気になった方はぜひご応募お待ちしています。

herp.careers

参考にした記事