Zenn Tech Blog
🦀

Cloud Build での Next.js のビルドを最適化する

2024/12/25に公開

はじめに

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 の処理は、大まかに以下のステップで構成されています。

  1. 依存関係のインストール
  2. Next.js のビルド
  3. 1 と 2 で作成した Docker イメージを push
  4. 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のロケーションはデフォルトの グローバル(非リージョン) でした。

https://cloud.google.com/build/docs/locations?hl=ja

ロケーションの選択については、公式のドキュメントに以下のように書かれています。

ビルドのリージョンを選択するときは、レイテンシと可用性を第一に考慮してください。一般的には、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 を使うことにしました。(これも詳しくはないのですが、 pnpmnode_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 builddocker pushgcr.io/cloud-builders/docker で実行するようにに変更しました。

おわりに

大量のファイルを含むレイヤーキャッシュをリモートから取得すると、展開に時間がかかるということがわかりました。

ただ、Cloud Build で kaniko は使わない方がいいのかというと、一律でそういうわけでもなく、Zenn の バックエンドサーバーである Ruby on Rails の Docker ビルドでは、kaniko のレイヤーキャッシュを使ったほうが処理時間が短いので、引き続き利用しています。

GitHubで編集を提案
Zenn Tech Blog
Zenn Tech Blog

Discussion