🍆

120,000もの大量ページがあるサイトのSSGを高速化するために工夫したいこと。Next.js + Supabase

2022/12/12に公開

2023年2月13日追記:
Cloud Runにデプロイ作業をした結果、大量ページのSSGは絶対にやめた方がいいなっていう結論になりました。ビルドとデプロイで6時間程度かかっています。気軽にデプロイができなくなってしまい、アップデートが憂鬱になってしまいそうです。笑


▲Cloud Buildくんが途中で仕事を放棄しちゃった図▲

時間的なコストが問題になりそうですので、SSRしつつも、この記事で作ったJSONデータを npm run build のタイミングでパースしてデータを気軽に取り出せる tsファイル関数 を作る方がいい気がしています。

ビルドは本番化への準備作業ではなく、JSONデータに問題がないこと、全ページが正常にアクセスできることを確認するためのテスト的な役割として活用する方がいいかもしれません。この規模でSSGするのは1日数百万PVほど見られるサイトぐらいじゃないとメリットなさそうです。個人規模じゃ逆に損してる気がします!(えーん)

諦める方が幸せになれる可能性が高いかもしれません。
とはいえ、この記事の内容が全て無駄な訳ではありません。データをインデックスキーで取得できるようにしてSSRで出力できるようにすれば、Supabaseの帯域幅を節約しながら高速レスポンスを返せるようになるかもしれません。また、サイトマップの静的生成の際に役立ちます。

サイトマップを作る場合は下記を参考にしてみてください。
https://zenn.dev/link/comments/31519a97668727

ということでワイはSSRの最適化頑張ります。

追記終わり。

2023年3月17日追記: -->
SSRで実装を行いました。
JSONファイルはそのまま利用し、リクエストがあったらJSONファイルからキーインデックスでデータを取得する方式にしました。おかげ様でSSRなのにSSGと同じぐらいのレスポンスを実現できています。この記事で試行錯誤した内容は無駄ではなかった!(と言い聞かせてます。)
※ユーザー生成のコンテンツには向きません。大量データ収集した結果をWeb公開する際などに適しています。
<-- 追記終わり


Next.jsのSSGは誰でも簡単に利用できますが、大量ページをビルドすることになると、少し話が変わってきます。SSRするようなテンションでSSGを行ってしまうと、ビルドだけで何十時間もかかってしまいます。

下記は僕の環境でビルドした結果です。

【Before】
ビルド時間: 15分で2500ページ
想定ビルド完了時間: 12時間
感想: ビルド無理なので諦めた。

【After】
ビルド時間: 4分半で122,346ページ全て完了(Finalizing page optimizationの処理は別途3分弱かかりました。)
注意書き: まだ完成しておらず、部分的に404に流しているページがあります。そのため、実際のビルドはもうちょっと遅いと思います。とはいえ、大きく改善する手段だと思うので、現時点で記事投稿しています。

2023/01/04追記:
しっかりとデータを整えた上でビルド実行をしました。
ビルド時間: 15分40秒で122,346ページの生成が完了。(Finalizing page optimization処理は9分30秒ほどかかりました。合計25分ほどです。)
やはりちゃんとページ生成をするとしっかりと時間かかりますね...。とはいえ、Beforeと比べると雲泥の差なので十分に効果あるビルド方法だと言えます。

ちなみにビルド後に生成されたデータのサイズは 39.5GB ほどになりました。

大量ページのSSGを行いたい人の参考になれば幸いです。

▼ビルドのスピード感を動画で見る
動画でも20秒で3,000ページ弱の生成が可能なことが分かります。
https://gyazo.com/c2b9a710220b0cd412c57c8a084b53c8

getStaticProps でAPIを叩くな!ビルドに使うデータは一度、全部ダウンロードしておこう!

外部のWeb APIが絡むSSGをする場合、ビルド時にページ数の分だけAPIが叩かれてしまいます。12万ページある場合には、12万回叩かれることになります。APIにリクエスト数の制限がある場合はマズいですよね...。

ということで、ビルド前に対象となるデータを全てダウンロードしておきましょう。「SSGする = 頻度高く更新しない」でしょうからリクエスト数の制限があってもそれほど問題にはならないでしょう。

今回は、SupabaseのDBからCSV形式で必要なデータをダウンロードしました。そのままでは使いづらいので使いやすい形に変換する変換器を実装して、CSVからJSONデータに加工。

JSONデータを作るときは可能な限り連想配列を使う。インデックスが効くのでデータアクセスへの高速化に繋がります。

JSONデータに変換する際の注意点として、可能な限り連想配列で扱えるように設計しましょう。ビルド速度に大きな差が出ます。連想配列の場合、キーを指定することでインデックスが効く状態でデータにアクセスできるのでオススメです。


※上記画像の supports.hoge.storesが配列になってますが、これは普通にミスです。あくまでメモ書きとして捉えてください。

個人的にはこんな感じでデータがあると便利だなーと事前にメモ書きしました。

ちなみに、Supabaseでは標準でCSVダウンロード機能が付いています。ただし、JSON型が入っていると「Object」という文字列になってしまうので、少々処理が必要になります。

2023年3月1日追記 -->
JSON型がObjectになってしまう問題は解決されているようです。
<-- 追記終了。

▼SupabaseでのJSON型の処理の例
https://zenn.dev/masa5714/articles/cf5169c77d816f

ビルドに使うデータはキャッシュが効くようにしよう!

Next.jsのビルドは標準では、取得したデータをキャッシュしてくれるような動きが無いとのことでした。つまり、せっかくデータをダウンロードしたにも関わらず、ビルド時にページ毎にファイルを開いたり、開いたり、開いたり...と12万回繰り返されてしまうのです。

ということで、下記のようなキャッシュが効かせるために関数を作ります。この関数の元ネタは下記ページをご覧ください。(このページには超絶助けられました!)

https://www.kanjisho.com/devblog/optimize-your-website-using-next.js-and-ssg

import fs from 'fs/promises'

// 気になる人はちゃんと型定義を!
const JSON_CACHE: any = {}

export const loadData = async (fileName: string, filePath: string) => {
  if (JSON_CACHE[fileName]) {
    return JSON_CACHE[fileName]
  }

  console.log('キャッシュを使わずにデータを読み込みました。')

  const jsonBlob = await fs.readFile(filePath + fileName, 'utf8')
  const jsonData = await JSON.parse(jsonBlob)

  JSON_CACHE[fileName] = jsonData
  return jsonData
}

※ ビルド時に console.log('キャッシュを使わずにデータを読み込みました。') が表示されている = キャッシュが使われていないんだな、という判断ができます。

一度読み込んだデータはJSON_CACHEという連想配列に格納されます。二度目以降はキャッシュの中からデータを持ってきてくれます。(とはいえ、ビルド時に何度か開き直しているような動きがありますので、完全100%キャッシュが効くものではないようです。対策しないよりは圧倒的にマシぐらいに考えてください。)

データを呼び出すときは、 getStaticProps の中で、

const prefecturesArray = await loadData('prefecturesForAreaPage.json', `${appRoot}/src/json/`)

こんな感じで指定すれば、 prefecturesForAreaPage.json の呼び出し時にはキャッシュから呼び出してくれます。

ビルドした後のデータが無駄に大きくなりすぎていないかチェックしておこう

https://zenn.dev/masa5714/articles/4927bbded40419

作り変えを行っていると頭ボケてしまい、ちょっとしたミスが発生するかと思います。また、不要なデータを持ってきちゃうかもしれません。

そんなときはこれを使って余計な propsデータが入っていないかチェックしてみてください。意外といらないデータが見つかったりします。

僕は実際に100kbあったドキュメントファイルが10kbまでダイエットさせることに成功しました。


終わり

やってることは大したことありませんが、SSRするテンションでSSGするとビルドで苦労するので注意しましょう。また、ビルド失敗するページが出てしまうかもしれないので、SSGするときは有無を言わさず、データを全てダウンロードしてからビルドすべきなんじゃないかとも思いました。

【教訓】開発時には気づかない問題点に遭遇するかもなので、定期的にビルドして継続的にアップデートが進められる作りになっているかを検証しましょう!


2023/02/13追記:
120,000ページあるプロダクトはVercelにはデプロイできませんでした...。

起こった現象は下記の通りです。

1. Vercel上でのビルド時、ディスクサイズ不足とのことでビルドが中止した。
2. Vercel CLIで vercel deploy --prebuilt 等でローカルでビルドしてデプロイ作業をしたが、謎の Invalid JSON というエラーでデプロイが完了しなかった。(左の内容がエラー文の全文のため、どのJSONのことを差しているかは判明せず。エラーが出るまで4時間ほどかかった...。2回実行するも状況は同じだった。)
3. 大量ページ生成している pages のファイルを除外してデプロイしたところ、問題なくデプロイされた。

ということでビルドが完了はしたものの、デプロイに失敗してしまうという状態です。
※ちなみに Cloudflare Pagesにも挑戦しましたが、トレースファイルが大きすぎるというようなエラーで門前払いでした。

最終手段としてVercelでのデプロイは諦めて GCP 上へのデプロイを検討しています。

SSGしたデータをGCPにデプロイするのも無理でした!
6時間経ってもデプロイ完了せず...。大人しくSSRに方向転換しました。とはいえ、コストの低いGCPへのデプロイのきっかけになって良かったです!


2023年3月8日追記
ちなみにJSONファイルの生成は必ず行うべきという訳ではありません。
あくまでビルド時に生成ページ毎にAPIを叩く必要がある構成であれば、まとめてデータを取得してそれをゴニョゴニョ変換処理してAPIへのアクセス回数を減らしましょうというのが主な内容です。何卒!

Discussion