こんにちは、マッハバイトでバックエンドエンジニアをしている寺田です。
先日、こんなプレスリリースを出しました。
求人一覧ページのLCP(Largest Contentful Paint)が363ms、主要なアルバイト求人サイトと比べて最大5倍速い、という内容です。
※ 数値の測定環境・方法は、上記のプレスリリースをご参照ください。
「なぜ速くしたのか」の背景はプレスリリースに譲り、この記事では「どうやってここまで速くしたのか」という技術の話を書きます。 やったことの中心は、RailsがつくるHTMLをまるごとCDNにキャッシュする、というシンプルな対応です。 ただ、実際にはいくつかの工夫が必要でした。 その工夫を順に記していきます。
- HTMLをまるごとキャッシュすることにした
- キャッシュするために、まず情報をクライアントサイドに寄せた
- キャッシュのルールは、どこで持つべきか
- キャッシュヒット率が、思ったより上がらない
- 入れて終わりじゃない、安定運用のための備え
- アプリエンジニアが、インフラに踏み込んだ
- まとめ
HTMLをまるごとキャッシュすることにした
これまでのマッハバイトは、ユーザーがページを開くたびにRailsがHTMLを組み立てていました。 一覧や詳細ページで、だいたい300〜700ms、重いページだと1秒以上かかることもあります。 ここがサイト速度のボトルネックでした。
画像やCSS・JavaScriptといった静的ファイルは、以前からCDNにキャッシュして配信していました。 これをHTML本体にも広げて、Railsを通さずに返せないかと考えました。
キャッシュするために、まず情報をクライアントサイドに寄せた
HTMLをキャッシュするうえで、最初にやらなければいけなかったことがあります。
「ページの中身を、誰に見せても同じHTMLにする」ことです。
これまでは、ログイン状態やお気に入りの登録状況、その人向けのレコメンドといった情報をHTMLに埋め込んでいました。 そのため同じURLでも、人によって少しずつ違うHTMLを返していたのです。
この状態だと、HTMLはキャッシュできません。 あるユーザーに返したHTMLを次の人にそのまま配ると、前の人向けの中身が混ざってしまうからです。 とくにログイン中のユーザー固有の情報が別の人に見えてしまうのは、絶対に避けないといけません。
そこで、ユーザーごとに変わる部分はHTMLに埋め込むのをやめ、ページを表示したあとにブラウザのJavaScriptがAPIで取得して差し込む形に作り替えました。 最初に配られるHTMLは「誰が見ても同じ共通の状態」で、ログイン情報やお気に入りは表示後に乗ってきます。
地味な作り替えですが、これがHTMLキャッシュの一番の土台です。 ここを変えられて、はじめて「HTMLをまるごとキャッシュする」が成立しました。
キャッシュのルールは、どこで持つべきか
次に考えたのは、キャッシュルールをどこで持つかです。
CDN側でパスを管理する案は、すぐ苦しくなった
最初によくあるやり方を試しました。 CDN側でURLのパターン(正規表現)を書いて「このパスはキャッシュ、これは除外」「ここは1時間、ここは24時間」と振り分ける方法です。
ところが、マッハバイトでこれをやろうとするとすぐに苦しくなりました。 URL構造が複雑なうえ、ビジネスの都合でパスを組み替えることもあります。
CDN側の正規表現でルールを抱え込むと、アプリの小さな変更のたびにCDNの設定も追従しなければならず、いつか事故る未来が見えました。
キャッシュ可否は、Railsのヘッダーで指示する
そこで方針を変え、「キャッシュしてよいかどうかは、Railsが返すレスポンスヘッダーで指示する」ことにしました。 CDNはそのヘッダーを見て、キャッシュするか・どれくらいの期間キャッシュするかを判断します。
キャッシュのルールをアプリのコントローラー側に持てるので、複雑な条件もRubyのコードとして管理できます。
とはいえ、ヘッダー任せにすると、実装ミスで本来キャッシュすべきでないページがキャッシュされる事故が起こりえます。 なので「絶対にキャッシュしてはいけないページ」だけは、CDN側でも二重に弾くようにしています。
アプリの指示を基本にしつつ、最後の砦をCDNにも置く、という多層防御です。
キャッシュヒット率が、思ったより上がらない
「キャッシュすれば速い」と言っても、実際にキャッシュから返せた割合(ヒット率)が低ければ意味がありません。 導入してみると、これがなかなか上がらず苦労しました。
当初のヒット率は20%台で、せっかくキャッシュしても8割以上は毎回Railsを通っていました。
単純にキャッシュ期限を長くすればヒット率は上がりますが、今度は求人のデータ更新が反映されなくなる、というジレンマがあります。 そこで、求人内容を編集しているチームと「どの更新は即座に反映すべきか、どこまでは遅れても許容できるか」をすり合わせました。 鮮度として許容できるギリギリのラインを決めて握ったのです。
鮮度を保ったまま、ヒット率を底上げする工夫
握ったラインを超えない範囲で、地道な調整を積み重ねていきました。
- 差分更新に追従したパージ+ウォームアップ
- 求人データの更新を検知して、変わったぶんだけを定期的にパージします。
- さらにパージしっぱなしにせず、消したページにこちらからアクセスしてキャッシュを温め直しておきます。
- 1日1回の全体ウォームアップ
- 前日によく見られたページをアクセスログから集計し、毎晩まとめてキャッシュを温め直しておきます。
- URL正規化
- 表示内容に影響しないパラメータ(計測用のトラッキングパラメータなど)は、キャッシュのキーから外し、同じ内容のページが別物としてキャッシュされるのを防ぎます。
- これが最も効果が大きく、ヒット率が一気に20%近く上がりました。
- stale-while-revalidate(SWR)の活用
- キャッシュの期限が切れても、いったん古いものを返しつつ、裏で新しいものを取りに行く手法です。ユーザーを待たせずに鮮度も保てます。
結果、当初20%台だったヒット率が、60〜70%前後まで上昇しました。
入れて終わりじゃない、安定運用のための備え
速くするだけでなく、その速さを安定して保ちつづけることも、最初から運用上の課題として見据えていました。 ここからは、そのために用意した備えを紹介します。
古いHTMLが、消えたCSS / JSを参照してくる
CDNにキャッシュされたHTMLは、そのページが作られた時点のCSS / JSへのリンクを含んでいます。 一方アプリをデプロイすると、CSS / JSは新しいものに置き換わり、古いファイルは消えます。
すると、キャッシュに残った古いHTMLが「もう存在しない古いCSSやJSファイル」を参照して、表示や挙動が崩れる…ということが起こりえます。
iOSやAndroidのアプリで、古いバージョンからのリクエストを考慮しないといけないのと似た構図です。
これに備えて、デプロイのたびにビルド済みのアセットをS3に退避しておき 古いHTMLから古いファイルが要求されても、S3側から返せるようにしました。 古いキャッシュが残っていても表示が壊れない、という安全網です。
壊れたら、すぐ気づけるようにする
備えても想定外は起こるので、フロントエンド側にエラー検知の仕組みを導入し、Assetsの404エラーを検知してアラートが上がるようにしています。 安全網をすり抜けて古いファイルが見つからなかったときも、ユーザーより先にこちらが気づける二段構えです。
「どれくらい速いか」を、数字で見張る
速くしたら、その速さを維持できているかも継続して見る必要があります。 Datadogでキャッシュヒット率や表示速度(LCP)をダッシュボードで可視化し、目標ラインを下回ったらアラートが上がるようにしました。 一度きりの成果で終わらせず、運用上のSLAとして握って維持していく体制にしています。
アプリエンジニアが、インフラに踏み込んだ
今回おもしろかったのは、アプリ側のエンジニアである自分が、CDNなどの設定を行っている terraform まで踏み込んだことです。
正直、最初はterraformもCDNの挙動もよく分かりませんでした。
それでも、Claude Codeにも力を借り、インフラグループに相談しながら進めていきました。 ここまでに書いたS3への退避や監視の仕組み、CDN環境でのABテスト基盤の整備などを、インフラ周りを管理するリポジトリへのPRとして積み重ね、その数は15個にのぼりました。
アプリ側の要件や要望を把握しているからこそスムーズに進む部分も多く、やってみてよかったなと思います。
このように気軽に領域を越えていけるのは、AIの登場もあるかもしれないですが、越境を推進しているリブセンスの良さだなと改めて思います。
まとめ
Railsのモノリスなアプリでも、CDNの使い方を工夫すれば、表示速度で業界トップを狙える。 これが今回の一番の学びでした。
振り返ると
- キャッシュの可否はアプリのヘッダーで制御し、CDN側に最後の砦を置く
- ログインユーザー固有の表示はキャッシュに載せず、非同期で差し込む
- ヒット率は、差分パージ・ウォームアップ・URL正規化・SWRで泥臭く底上げする
- 速さを保ちつづけるために、S3退避・エラー検知・監視を運用に組み込む
派手な銀の弾丸はなく、地道な設計と運用の積み重ねでした。 同じようにRailsアプリの高速化に悩んでいる方の参考になれば嬉しいです。
※ 本記事の「業界トップ」「最大5倍」などの数値の測定環境・方法については、冒頭のプレスリリースをご参照ください。