はじめに
こちらはバイセルテクノロジーズ 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を実行する環境とランタイムの環境が完全には一致しない点が上げられます。
この点を厳密にしたい場合は採用すべき方法ではありませんが、今回はデプロイ速度の改善を優先し許容する判断をしました。
具体的な流れ
具体的な流れは以下になります。
- GitHub Actions上でpackageのinstall
- cache化しておき、ヒットした場合はstepごとスキップする
- GitHub Actions上で
yarn build
を実行 - 生成されたstandaloneファイルをdockerイメージにCOPYしてdocker build & push
- 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 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
のステップはもう少し短縮が期待できそうなので、今後試して行きたいと思っています。
最後に、バイセルではエンジニアを募集しています。少しでも気になった方はぜひご応募お待ちしています。