ジャイアントパンダに注意 - Next.js のビルド改善 (株式会社GiXo様)
最近になって Frontend Ops の傭兵として活動を始めました。
Frontend Ops 実践のモデルケースとして、 株式会社GiXo様で Next.js 仕事に取り組ませいただきました。今回、その内容を公開する許可を頂けたので、事例として公開させていただきます。
依頼主
株式会社GiXo様
以下、敬称略
相談内容
フロントエンド関連のリポジトリで、Next.js のビルドが遅くなってしまった。
重いことに起因して Vercel CI で OOM で確率的に落ちるようになった。CIが信用できなくなり、とりあえず再ビルドするクセがついてしまって、生産性が落ちている。
モノレポ内にとくに重いアプリケーションが一つあり、これを調査・解決してほしい。
仮ゴール: VercelCI 上のビルド時間を半分OOM が発生しないようにしたい
調査フェーズ
- リポジトリの閲覧権を頂き、プロジェクト構成の確認
- Next.js v14 | Yarn v4 | PandaCSS
- monorepo の packages/ に複数の Next.js アプリケーション
- モノレポの各アプリケーション (
packages/*
)の push ごとに Vercel CI が反応してビルド実行・デプロイ
packages/
core/...
frontend-core/...
app-a/...
app-b/...
package.json
- 仮説
- 単純にフロントエンドのビルドファイルが多く、モジュールグラフが複雑でCPU負荷が高い
- 何かピンポイントで重いビルド処理を全体で共有してしまっており、それがパフォーマンスの下限になっている
- 調査方法
- 各 chunk size の計測
- .next/trace ファイルを分析する
-
next build
で生成されるトレースファイルを分析した https://zenn.dev/s_takashi/scraps/d13e6300993233
-
計測・調査フェーズ
- ローカルで計測してフルビルドで約160s, キャッシュありビルドで約50s
- 注: 実際はVercelCI上のCPUと合わせる必要があるが、まずは参考値としてこれを改善していく
- 一般的なウェブ開発者の開発機は、CI より CPU の並列度が高いことに留意しておく (CI: 1~2コアに対し、MacBook M2: 8コア)
- チャンクの内訳の分析では、CI上の問題はなさそう
- 今回、フロントエンドパフォーマンスは主目的ではないが、 logging 周りのライブラリが富豪的でやや気になる、という点を一旦共有
-
.next/trace
を凝視してみる- (実際にはそこそこの時間がかかった)
- postcss-loader がパフォーマンスにおいて支配的。(キャッシュ有無に関わらず 44s)
- 全ての問題は pandacss のビルド周辺ではないか?という仮説を立てておく
- というのも、参考にした記事でも Taliwind と PostCSS がボトルネックだった。PandaCSS も似たような問題を内包しているのではないか。
- 問題特定のため
src/globals.css
(全CSSのルート) をコメントアウトしてビルド。 44s => 0s になることを確認
ソースコードと突き合わせて調査
調査フェーズで PandaCSS が問題と絞り込んだので、その周辺のコードを追っていく。
このプロジェクトの構成では、次のような構成になっている。矢印は依存の方向
config/
panda/
baseConfig.ts
packages/
core/
frontend-core/ -> core, panda/config
app-a/ -> frontend-core, panda/config
app-b/ -> frontend-core, panda/config
config/panda は複数のアプリケーションでデザイントークンを共有するために切り出されていて、各アプリケーションから参照されている。これは下記の PandaCSS のガイドラインに従って実装されているのを確認した。
問題の発見
ここまでの状況を整理する。
- 調査対象のアプリケーションに限らず、すべてのアプリケーションのビルドが重い
- PandaCSS に問題がありそう
ということで、全アプリケーションで共通化された PandaCSS の設定に問題があるはずと考えた。結果として、ここで問題を引き起こしたコードを発見。
config/panda/baseConfig.ts
の一部
const BASE_PANDA_CONFIG = ({
// ...
include,
}) => {
return {
include: include ?? ['../**/*.{ts,tsx}', '../../config/panda/@panda-org/**/*/tsx'],
PandaCSS の共通のファクトリ BASE_PANDA_CONFIG
で include
を引数に持たない場合、 '../**/*.{ts,tsx}'
でプロジェクト内の全ての .ts, .tsx が PandaCSS の解析対象になる。
PandaCSS を知らない人のために解説するが、以下のコードのように className={css({...})}
のようにCSSを記述すると、それを対応する CSS を出力する。
分類としては CSS in JS のライブラリで役割としては TailwindCSS に近いが、 型がつくので TypeScript フレンドリーなのが特長。
<div
className={css({
display: 'flex',
flexDirection: 'column',
fontWeight: 'semibold',
color: 'yellow.300',
textAlign: 'center',
textStyle: '4xl',
})}
>...</div>
PandaCSS はこのパターンを発見するためにソースコードを解析する必要がある。 全てのコードを都度解析してcss({...})
を発見するのは大変なので、 "include":
で明示的に指示する仕組みになっている。
本題に戻るが BASE_PANDA_CONFIG
の呼び出しを確認したところ、include
を引数にもつ呼び出しは一箇所もなかった。つまり独立した各Next.jsアプリケーションのビルドにも関わらずリポジトリ内全ての TypeScript にスキャンが走り、これが PandaCSS の処理時間として計上されていた。
packages/
core/**/*.ts <- 共通ロジック。フロントエンドと無関係
frontend-core/**/*.tsx <- 共通ライブラリとして解析したい対象
app-a/components/**.tsx <- 主に解析したい対象
*.ts <- 各種コンフィグやビルドスクリプトも解析対象
本当に解析したいのは一部の .tsx
だけだったはずが、とにかくプロジェクト内すべての TypeScript を都度解析していた。なるほど、これは重い。
調査対象の一番重かったアプリケーションも、結局一番ファイル数が多いプロジェクトだったので Next.js のページごとのビルドにこのオーバーヘッドがのっていた、というオチ。調査対象以外の他の Next.js アプリケーションも潜在的にこの問題を抱えていた。
問題が発生した経緯の分析と、再発防止
Git で変更履歴を追うと、 PandaCSS をNext.js間で共通化しようとした際に、全ソースコードを PandaCSS の解析対象にするという過度に一般化した設定が入り込んだ。プロジェクトの成長とともに解析対象が増え、最も規模が大きなアプリケーションでCIのビルド時に OOM が発生するようになった。
解決方法はシンプルで、config/panda/baseConfig.ts
のベースファクトリ関数で include
引数をオプショナルから必須なものとし、各アプリケーションで明示的に include
を指定することを必須とした。
// panda.config.ts
export default BASE_PANDA_CONFIG({
// include 指定を必須化
include: [
// 各アプリケーションのスコープの .tsx を明示的に対象とする
"../frontend-core/**/*.tsx",
"app/**/*.tsx"
],
// ...
});
*.tsx
のみを解析対象とし、ファイル数が多く PandaCSS の解析する必要がないはずの *.ts
を解析対象外とすることにした。
結果としてはシンプルな解決策に落ち着いたが、過去に原因が仕込まれてからコード量に比例して累積的に問題が大きくなっていた。これはゼロベースで計測し直さない限り発見が困難なケースと言える。
今回は PandaCSS だったが、仕組み的に Tailwind でも全く同じ問題が発生する。また、前提知識として、PurgeCSS に由来するコード中の CSS を抽出する手法に関する知識と、解析対象を広げた時のASTスキャンコストへの直感が必要ではあった。
プログラムは木で、木を歩くのは、結構大変。一般化できることとして、 **/*
の glob パターンを書いたときは、何がどこまでどこまでスコープに入るか注意したい。
最終結果
- ローカル キャッシュなしビルド: 158s => 37s
- ローカル キャッシュありビルド: 52s => 20s
- VercelCI ビルド: 8m => 4m (NO OOM!)
-
next dev
でのインクリメンタルビルドが、運用不可だったものが実用可能に - 残りの改善タスク
- 安全のため CI 上に入ってない最適化があり、まだ伸びしろがある
- Turbopack 有効化で 140% 高速化 + 消費メモリが削減できることを確認
と、こんな感じの最終レポートとして GiXo 様に共有し、修正パッチを提供しました。この記事は公開用に社外秘等を伏せて一般化していますが、実際には生の試行錯誤の調査ログや、その過程で発見したフロントエンド一般の改善提案を、 GitHub の Issue として報告しています。
振り返って、自分がこのプロジェクトを開発を担当していた場合、この問題に気づけたか?というと正直怪しい気がしています。下手にドメイン知識がある分、コードをバッサリ削って二分探索して問題を絞り込む、というアプローチが心理的な問題できなかった可能性が高いです。
というわけで、こんな感じのお仕事ができます。お仕事の相談は mizchi.work@gmail.com あるいは https://twitter.com/mizchi までご連絡ください。
余談: 会社ごとに社内コンテスト化できないか
本当はもっと https://github.com/CyberAgentHack/web-speed-hackathon-2024 のようなフロントエンドパフォーマンスチューニングのコンテストがあると嬉しいのですが、このような問題は作問コストが非常に高く、頻繁に行うのが難しい現実があります。
というのも、現在のフロントエンドのスタックは多様で、特定のスタックでの改善手法を学んだとしても、直接的に他で活かすことができるかが不明なのです。ですが、実際に存在するプロジェクトなら一番作問のコスパがよく、また目指すべきゴールが確認できる、という実利も提供できます。
お仕事をいただけたら分析・計測と同時に任意のプロダクトを社内コンテストの題材にすることもできるので、それを含めて是非ご相談ください。
Discussion