スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

Next.js 製 Web フロントエンドの CI ビルド時間を 1/3 にした話

こんにちは、 Web フロントエンドエンジニアの @progfay です。 今回は PR に紐づいたプレビュー環境のビルドに 10 分半かかっていたところを 3 分半ほどまでに短縮した改善活動についてお話しします。

技術改善 Day

私の所属するスタディサプリ中学講座の開発プロジェクト (通称: tara) では「技術改善 Day」を 2 週間に 1 回実施しています。 「技術改善 Day」とは、案件開発を進めていく中で出てきた技術的負債の解消に丸 1 日チームで集中して取り組む日です。

tara 内の Web フロントエンドメンバーで解消したい技術的負債を考えたところ、その中の一つに Web フロントエンドアプリケーションのビルドに時間がかかっている問題 が挙がりました。

tara プロジェクトではデバッグや QA を効率的に行うために PR ごとに紐づいたプレビュー環境を用意しています。 私たちは Next.js を使った Single Page Application を開発しており、 Web フロントエンド単体でビルドが可能です。 しかしながら、このビルド処理に 10 分以上かかっていたため、待ち時間が長すぎて作業が滞りがちになっていました。

そこで、我々は PR 環境のビルドにかかる時間を短縮しようと試みました。

現状を把握しよう

まずはビルドの各ステップにかかる時間を眺めてみることにしました。 ビルドは PR 環境への push を契機に GitHub Actions 上で以下の処理が実行されています。

  1. docker build で Web フロントエンドアプリケーションを Docker Image としてビルドする
  2. ビルドした Image を ECR に push する

この一連の流れが 10 分 30 秒ほどかかる中、 10 分間は docker build で Image を作成するステップに費やされていました。

Docker Build のログを眺めてみる

GitHub Actions の log を、時間を測りながら見ていくといくつかのことに気付きました。

  • taking snapshot of full filesystem... という log と共に処理が 1 分ほど止まってる
    • さらにこれが 4 回もある
  • yarn install が 2 回実行されていて、それぞれ 1 分ほどかかっていた

Dockerfile を確認してみる

どうして yarn install を 2 回も実行する必要があるのかを調べるために、ビルドに使われる Dockerfile を確認することにしました。

# 当時のコードと全く同じではないですが、大まかな流れは同じになっています
FROM node:16.9.0 AS base

FROM base AS builder
COPY package.json yarn.lock tsconfig.json ./
RUN yarn install --frozen-lockfile --non-interactive
COPY .
RUN yarn build

FROM base AS prod-server
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --non-interactive --production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/next.config.js ./

FROM nginx:1.19
WORKDIR /app
COPY --from=builder /app/build .
COPY --from=builder /app/nginx /etc/nginx

ビルドには Multi-stage Build が用いられています。 yarn installbuilderprod-server の中でそれぞれ呼ばれていました。

前者の builder では、 yarn build のために必要な packages を取得するために yarn install を実行しているようです。

後者の prod-server では、 Next.js の Server を Production 相当で起動するための stage のようです。 というのも、 tara プロジェクトでは開発初期の段階では Next.js の Server を使う予定でした。 それが途中から Static HTML Export の機能を使う路線に切り替えた際に、 Dockerfile を正しく整理しきれていないせいで prod-server Stage が残ってしまっていたようです。 (実際にこの処理を書いたのは過去の自分でした...)

要らない処理は削除してしまおう

prod-server Stage は現在では利用されていないため、完全に削除することにしました。 これによりビルドにかかる処理は 3 分ほど短縮されました。

その他にも、 Visual Regression Test に使用している snapshot 画像を COPY しないように .dockerignore に追記したり、 Next.js 12.3 の SWC Minifier を試すなどして、ビルドに係る処理を 6 分前後に短縮できました。

まだまだ速くしたい

当初のビルド時間から 4 分ほど短縮されましたが、 push するたびに 6 分も待たされるのはまだまだ早いとは言えません。 そこで、更なるビルド時間短縮を目指して追加の高速化に着手しました。

これにあたり、作業は技術改善 Day ではなく、日々の業務の中で進めることにしました。 理由としては、技術改善 Day を契機に負債を解消することへの意識を高めて日常的に改善していく文化を生み出したいからです。 技術改善 Day は負債を解消するいい機会ではあるものの、普段の開発の中で継続して改善したほうが持続可能性が高く理想的だと考えています。 そのための第一歩として、率先して技術改善 Day 以外で細々と負債を解消していく活動をしていくことにしました。

個人的な感想ですが案件とは関係のない改善系は優先度が低いため、作業へのモチベーションもメンバーからの認知度も下がりがちです。 せっかくなら楽しみながら改善に取り組みたいという思いから、自分は改善タスクに馴染みやすいタイトルをつけるなどしています。 今回は高速化を目指していたため、大げさに "Beyond the Lightning..." という副題をつけて改善に取り組みました。

次のボトルネックは?

さて、 6 分前後のビルドの中で一番のボトルネックとなっている部分はどこになっているでしょうか。 時間を計測してみると next build && next export に 3 分ほど掛かっていることがわかりました。

そこで Next.js の CI Build Caching を試してみましたが、実行時間には差が出ませんでした。 なぜ速くならないのかを探るために複数回実行して log を眺めてみると、ビルド step 前に以下の message が表示されていました。

Cache not found for input keys: Linux-nextjs-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

また、ビルド step 後には以下の message が表示されていました。

Warning: Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.

next build を実行しているのだから .next/cache は作られているはずですが、 GitHub Actions 側からは見つからないと言われています。 そんなはずはないと思考を巡らせた結果、 next build は Docker 内で実行されているため .next/cache も Docker の中で生成され、 GitHub Actions からはアクセスできないことに気が付きました。

next build を効かせるためには Docker の外側に .next/cache を吐き出すか next build 自体を Docker 外で行うかのいずれかが必要です。

Dockerfile は local 環境でデバッグする際にも使われていたため、 CI 上でのビルドには不要な処理も含まれてしまっていました。 これらを省くことで更なる速度改善が行えると見込み、 CI 上でビルドする際に使用する Dockerfile を分けました。

またビルドに必要な環境は Docker を使わずとも揃っていたことから、 Docker 外で next build を行うように修正をしました。

結果として Next.js の CI Build Caching が効き、 next build && next export にかかる時間は 1 分未満になりました。 全体的なビルド時間は 3 分半ほどで終わるようになりました。

今後の展望

今の全体の処理とそれに掛かる時間は以下の通りです。

  • setup-node, yarn や Next.js の cache 読み込み: 65 秒前後
  • yarn install: 65 秒前後
  • next build && next export: 50 秒前後
  • image build & push: 30 秒前後

主に yarn cache diryarn install に時間がかかっているため、ここを改善することで更なる高速化が見込めそうです。 また、 Turbopack など Next.js の build performance 最適化にも期待していきたいところです。

おわりに

当初は PR 環境のビルドに 10 分半かかっていたのが、今では 3 分半まで短縮されました。

この改善活動について自分の上司に報告したら「全然 Lightning じゃないじゃん」ツッコまれてしまいましたが、進行の邪魔にならない限りはタスクに自分達が楽しめる名前を付けたりしながらワイワイするのは改善活動を文化として根付かせる活動として有益なのかもしれません。

みなさんにも是非、 "Beyond the Lightning..." などと銘打って余計な時間を削っていただければ幸いです。