Cloud Build での Next.js のビルドを最適化する
はじめに
Zenn のプロジェクトでは、フロントエンドに Next.js を使っています。実行環境は Google Cloud の Cloud Run で、ビルドは Cloud Build で行っています。
以降、すべてステージング環境の話となります。
Cloud Build は、GitHub の 特定のブランチの push をトリガーとして、Next.jsのビルドを行い Dockerイメージを作成し、リポジトリに push します。その後、Cloud Run に新しいリビジョンを作成し、新しいDockerイメージをデプロイします。
この一連の処理に、平均して 8 分程度かかっていました。
さすがに長いと思い、最適化を試みた結果、平均して 3 分 20 秒 程度まで短縮することができました。
先に結論
効果があった変更点は、以下の通りです。
- Cloud Build のロケーションを、Artifact Registry(Docker イメージのリポジトリ) と同じリージョンにする
- 依存関係のインストールには pnpm を使う。そして kaniko のレイヤーキャッシュを無効化する
最初の状態
まず、最初の状態を説明します。
Cloud Build の処理は、大まかに以下のステップで構成されています。
- 依存関係のインストール
- Next.js のビルド
- 1 と 2 で作成した Docker イメージを push
- Cloud Run の新しいリビジョン作成と切り替え
Docker イメージのビルドには kaniko を使っています。kanikoは、Docker イメージをビルドするためのツールで、機能としてイメージのレイヤーキャッシュをリモートに保存することができます。これにより、Cloud Build の環境であっても、レイヤーキャッシュを利用できるようになります。実際に、1 の依存関係のインストールは、ほとんどの場合(依存関係に変更がない場合、もしくはキャッシュの期限が切れない限り)、レイヤーキャッシュを使用していました。
Cloud Build の マシンタイプは、 E2_HIGHCPU_32
です。(kaniko のレイヤーキャッシュを利用すると、 E2_HIGHCPU_8
ではメモリ不足で処理が失敗するため)
最初の状態での処理時間の内訳をみると、以下のようになっていました。
タスク | 時間 |
---|---|
依存関係のインストール(レイヤーキャッシュの取得・展開) | 3 分 40 秒 |
Next.js のビルド | 1 分 50 秒 |
レイヤーキャッシュの作成と push | 20 秒 |
Docker イメージの push | 15 秒 |
Cloud Run の新しいリビジョン作成と切り替え | 50 秒 |
その他、細かい処理を合わせると、合計で 8 分程度かかっていました。
※ 依存関係のインストールは、ほとんどの場合レイヤーキャッシュがヒットするので、レイヤーキャッシュを使った場合の時間を示しています。
以降、LLMと対話しながら、ボトルネックを解消していきました。
Cloud Build のローケーションを変更する
最初の状態では、Cloud Buildのロケーションはデフォルトの グローバル(非リージョン)
でした。
ロケーションの選択については、公式のドキュメントに以下のように書かれています。
ビルドのリージョンを選択するときは、レイテンシと可用性を第一に考慮してください。一般的には、Cloud Build のユーザーに最も近いリージョンを選択しますが、ビルドと統合される他の Google Cloud プロダクトやサービスのロケーションも考慮する必要があります。使用するサービスが複数のロケーションにまたがっていると、アプリケーションのレイテンシだけでなく、料金にも影響します。
レイヤーキャッシュをの取得やpushにかかる時間を短縮するため、Cloud Build のロケーションを Artifact Registry と同じリージョンに変更しました。
すると、処理時間の内訳が以下のようになりました。
タスク | 変更前 | 変更後 |
---|---|---|
依存関係のインストール(レイヤーキャッシュの取得・展開) | 3 分 40 秒 | 2 分 10 秒 |
Next.js のビルド | 1 分 50 秒 | 1 分 20 秒 |
レイヤーキャッシュの作成とpush | 20 秒 | 10 秒 |
Docker イメージの push | 15 秒 | 2 秒 |
Cloud Run の新しいリビジョン作成と切り替え | 50 秒 | 50 秒 |
全体的に、イメージの取得やpushにかかる時間が短縮されました。また、なぜか Next.js のビルドも短縮されました。これにより、全体の処理時間が 8分 から 5分30秒 程度に短縮されました。
依存関係のインストールに pnpm を使う
依然として、「依存関係のインストール(レイヤーキャッシュの取得・展開)」に大きく時間がかかっていました。
レイヤーキャッシュはデフォルトで、 --destination
フラグ(Docker イメージの push 先)に /cache
を加えたパスに保存されています。「依存関係のインストール」のレイヤーキャッシュのサイズを確認したところ、1.4GB
ありました。
これを Cloud Shell 環境に pull したところ、 Download は 20秒くらいでしたが、Extractingに多くの時間がかかりました。内容はもちろん node_modules
の中身です。kaniko のレイヤーキャッシュの詳しい仕組みはわからないものの、大量のファイルが含まれるイメージを展開するのに時間がかかっていると推測しました。
この時点で、依存関係は yarn v1
で管理していましたが、ファイル数を減らすため pnpm
を使うことにしました。(これも詳しくはないのですが、 pnpm
は node_modules
をシンボリックリンクで管理するため、ファイルが重複することなく、ディスクサイズを節約できるという理解です)
試しにローカル環境で依存関係をインストールし、node_modules
を tar でまとめてサイズを比較したところ、以下のようになりました。
パッケージマネージャ | サイズ | インストール時間 |
---|---|---|
yarn v1 | 841MB | 60 秒 |
pnpm | 439MB | 10 秒 |
Cloud Build 環境でも以下のように改善されました。
タスク | 変更前 | 変更後 |
---|---|---|
依存関係のインストール(レイヤーキャッシュの取得・展開) | 2 分 10 秒 | 30 秒 |
Next.js のビルド | 1 分 20 秒 | 1 分 20 秒 |
レイヤーキャッシュの作成とpush | 10 秒 | 1秒 |
Docker イメージの push | 2 秒 | 1秒 |
Cloud Run の新しいリビジョン作成と切り替え | 50 秒 | 50 秒 |
これにより、全体の処理時間が 5分30秒 から 3分40秒 程度に短縮されました。
kaniko のレイヤーキャッシュを無効化する
ここまで来てようやく気づきました。実は最初から、「依存関係のインストール」のレイヤーキャッシュを使うより、依存関係をパッケージマネージャーでインストールするほうが速かったということに。そしてパッケージマネージャーを pnpm
に変更したことでインストール時間はさらに速くなりました。
また、他のレイヤーについてもキャッシュが有効なシーンはほとんどありません。ということで、kaniko のレイヤーキャッシュを無効化することにしました。無効化するには --cache=false
フラグを指定します。
タスク | 変更前 | 変更後 |
---|---|---|
依存関係のインストール | 30 秒 | 5 秒 |
Next.js のビルド | 1 分 20 秒 | 1 分 20 秒 |
レイヤーキャッシュの作成とpush | 10 秒 | - |
Docker イメージの push | 2 秒 | 1秒 |
Cloud Run の新しいリビジョン作成と切り替え | 50 秒 | 50 秒 |
これにより、全体の処理時間が 3分40秒 から 3分20秒 程度に短縮されました。
レイヤーキャッシュを無効化したので、もはや kaniko を使う必要もなくなりました。最終的には docker build
や docker push
を gcr.io/cloud-builders/docker
で実行するようにに変更しました。
おわりに
大量のファイルを含むレイヤーキャッシュをリモートから取得すると、展開に時間がかかるということがわかりました。
ただ、Cloud Build で kaniko は使わない方がいいのかというと、一律でそういうわけでもなく、Zenn の バックエンドサーバーである Ruby on Rails の Docker ビルドでは、kaniko のレイヤーキャッシュを使ったほうが処理時間が短いので、引き続き利用しています。
Discussion