2022」という「非常に重たいWebアプリをチューニングして、いかに高速にするかを競う競技」があります。リモート参加で11月1日から27日まで開催されています。Vitalsのスコアが高いことを言い、Lighthouseのスコアをベースにした500点満点の争いです。ISUCONのフロントエンド版ですね。以前にも同じ課題で「学生向け」と「社内(サイバーエージェント)向け」が行われたらしく、まだ500点を出した人はいません。そこで僕は「満点を出したい」と思い、初日から、いやむしろフライングしていたからその前から頑張ってきました。先日(17日)、ついに500点満点を出しました!たぶん、レギュレーションはクリアしている、はずです(もし違反してたらすいません...)。そこで、500点までの道のりを紹介します。閲覧にはご注意ください。高速化の対象は「CyberTicket」という「じゃんけんの勝ち負けを予想する」サイトなのですが、こいつが最初クソおもいです。CyberTicketがどんなものかとデモサイトのリンクをクリックしても一向に開きません。cloneしてきたものをローカルで立ち上げても遅い。それもそのはずLighthouseにかけてみたら2点でした。これを100点にしていきます。Actionsが自動で走り、VRTとScoringをしてくれます。終わったら、500点中何点だったかがIssueのコメントに付きます。すごくよくできた仕組みです。スクショが一番最初のものです。Actionsの実行終了を待つのは非常にだるいです。VRTのレギュレーションテストは少しでも見た目がずれると違反になってしまいます。画像が正しくリサイズされているかを確認するだけでも、時間がかかってしまいます。本体に手を付ける前に、ここをなんとかすることにしました。そのまま採用させてもらいます。これがWSH_SCORING_TARGET_PATHSというsecret変数になっていて、どこが叩かれるかわからないようになっています。したものかと言うと、単純で、採点中にHerokuサーバーのアクセスログを見て判断しました。これでローカルでも、コマンド一発でVRTのテストができるようになりました。イテレーションが速く回せます。難しいことを考える前に何も考えずにできることからやります。アセットのファイルがassets/images以下で85MBとバカでかいので削ります。これが元画像を読み込みつつCanvasでクロップとリサイズとかまどろっこしいことをしているのでこいつを消しました。macOSにはもともとsipsコマンドが入ってるので、これでリサイズ。Squooshという画像最適化ソフトのCLIをつかって、WebPにします。hero-small.jpg次にフォントです。オッズ表の表示のところで、外部のフォントを使っていてこれが5.8MBとでかい。見てみると数字と一部記号しか使っていない。そこで、それ以外を削って、最小のフォントセットを作成。ttfからwoffへ変換をかけて読み込むことにしました。Awesomeを使ってるのですが、これもよく見ると3箇所しか使われてない様子。そこでその3つのみSVGを出すようにして、全体のCSS、JSとフォントセットを読まないようにしました。今回のように手作業でやっちゃた方が早いし、速い場合があります。main.jsが30MBあります。zengin-data.jsは置いといて、やめられるものはやめる。小さい代替があるものはそれに変えます。ブラウザのサポートも切って、最低限のものにします。Lodashをやめる。AwesomeのJSやめる。Polyfillやめる。core-jsをやめる。Moment.jsやめてdayjsにする。CommonJSやめる。Components、dayjsくらいになりました。誰もがぶち当たるであろうものが、「Zengin問題」です。サイト内で銀行情報を入力するダイアログで使われている「全国の銀行とその支店コード」を収録している3MBある.jsファイルです。Importで簡単に解決しました。このzengin-data.jsが必要になるのは、ダイアログのコンポーネントからのみで、ページで必要になるわけではありません。Splittingしてくれればメインの.jsには含まれなくなりました。import("./internal/ChargeDialog"));React.lazy()で読み込ませたあと、Suspenseで囲みます。/></Suspense>これで、コードが分割され、遅延で読み込まれることになります。この手法は後ほど他のコンポーネントでも使われ、さらに最適化を加えると最終的なバンドルサイズは以下のようになりました。メインの.jsは開発用で1.08MB、gzipして278.KB、プロダクトで353KB、gzipで62KBです。JSのサイズが小さくならなくてはTBTのスコアが上がらず、最後までこれには手を焼きました。今回は採用せずに、500点を目指します。Reactわかりませーんこれまで偉そうに書いてきましたが、実は僕、業務でReactを触ったことがなく、趣味で触るくらいなので、わりとReactわかりませーん。わからないなりに、チューニングしていきます。JavaScriptの実行時間が足を引っ張っているのです。やったのは「memo化」です。why-did-you-updateというライブラリ(deprecatedなのでwhy-did-you-renderを使ったほうがいいかもです)を使って、怒られたところをReact.memoで囲うという簡単なお仕事です。無駄な再レンダリングを避けることができます。memo化だけで、だいぶパフォーマンスがあがりました。Fastifyに手をいれるフロントエンドだけではなくバックエンドにも手を付けてみます。このサイトは当初いわゆるSPAになっています。裏ではFastifyが動いていて、/api/*にレース情報を配信するAPIが生えています。残りはエントリーポイントとなるHTMLと.js、アセットファイルがfastify-staticというミドルウェアを通して、配信されています。ダイナミックにします。具体的には、/や/:date、/races/:raceId/*というルートを生やし、その中でHTMLをダイナミックに生成します。HTMLを組み立てるのには変数にベタ貼りしたコードとReactのSSRを使いました。これについては後述します。SQLiteにインデックスを貼るデータのストアにはSQLiteを使っているので、それにもテコ入れします。貼れてなかったところにインデックスを貼ります。採点の最初には/api/initializeというエンドポイントが叩かれ、seeds.sqliteがdatabase.sqliteへとコピーされます。INDEXするのです。});}これで、/races/:raceIdのエンドポイントで発行されるクエリが当初より速くなりました。0.025765さらばHeroku推奨されているHerokuを使っていましたが、そろそろ辛くなってきました。リージョンがUSで遠いです。こちらで本番サイトを確認する時に遅いとフラストレーションがたまりますし、手元でLighthouseを実行した時の点も低く出てしまいます。これからよりテクニカルなことをやっていくのに大変です。この時点で350点程度、まだまだHerokuでも頑張れそうです。以前から馴染みのあるLinodeというVPSを使います。GB」というプランです。「5ドル/月」課金しました。ちなみに、課金しちゃったけど、最終的にこのHackathonに使った金額はこの「5ドル」だけです(CloudflareとFastlyは以前から契約していました...)!VPSにはDebianを入れて、そこでNodeを動かします。他にはリバースプロキシにNginxかH2Oを入れるか入れなかったりです。コンテナは使いません。この場合、それが一番早いし速いです。デプロイフローHerokuをやめたので、デプロイフローをオリジナルで作らなくてはいけません。同じようにGitHubレポジトリにpushしたタイミングでデプロイされると嬉しい。Actionsを組みました。ホストでの操作はAnsibleを使います。buildサーバーの再起動これで、VPSでもHerokuと同じような仕組みで、かつ高速なデプロイフローを構築できました。SSRを自作するさて、ここからコアなチューニングをしていきます。SPAで作られています。SPAの最大の問題はメインの.jsをロードしない限り、描画が始まらないことです。そのため、どうしてもFCPとLCPが遅く=スコアが悪くなります。長いテキストが入ると改行されてレイアウトシフトが発生します。なるとCLSを0にするのが難しくなります。Fastifyに手を入れたのが生きてきます。ようは素のReactからSSRを実装するのです。Next.jsでは勝手にやってくれるのですが、それを自作します。こちらの記事を参考にさせてもらいました。RouterのStaticRouterで囲みます。);};それをFastifyのハンドラでimportして、ReactのrenderToNodeStream()に渡します(renderToString()でも良さそうですが、Suspenseを使うとエラーがでたのでこれです)。/>とエントリーポイントとなるHTMLに渡しているところです。/>では、サーバーからserverDataという変数で値をもらい、それを各ページに渡しています。);};各々のページでは、ブラウザからの実行か、サーバーからの実行かを判断します。HTMLの時点で描画、つまりSSRできるようにします。dataにセットします。これは.jsが読み込まれてからAPIのフェッチまでの描画を担います。//...};最後に、クライアント側のindex.jsでHydrateの指示を出せば完成です。/>);流れをまとめると以下のとおりです。/>に渡して、それをHTMLとして描画。...のdata-react属性の値にはraceをJSON化してセットしておく。ブラウザでメインの.jsが読み込まれたら、data-react属性の値をデシリアライズしてハイドレートする。今回はAPIからフェッチしたデータを最終的にセットしています。SPAかSSRかSPAとSSRかSSRにすることで、レイアウトシフトの発生を抑えることができ、CLSのスコアを0にすることができます。問題はDBから引いてくる時間があるので、サーバーからのレスポンスタイム、TTFBが伸びてしまうことです。(当然、インデックスをつければより速いのですがそれでもマシンスペックが十分でないと100ms以上かかってしまいます)。そこで、SPAとSSRのハイブリッドみたいなことします。データベースから持ってくるところのコードはここです。"trifectaOdds"],});Joinしています。これをJoinなしにするとキーで引くだけなのでとても速いです。repo.findOne(req.params.raceId);実は、SSRしてHTMLにする時に必要は情報はこれだけで十分です。レースページにおける以下の情報です。これさえあれば、CLSは防げます。写真のURLが分かるので、リソースヒントを使ったpreloadができます。FCP、LCPの向上が期待できます。残りのエントリー情報、オッズ情報はAPIでフェッチしてあとから追加すればいいでしょう。最終的なコードはこうなります。今回はLinkヘッダでリソースヒントをしています。CLS、FCP/LCPのスコアを維持したまま、全体の描画時間、主にTTIのスコアを上げることができました。CDNで本気を出すさてもっと速くしましょう。CDNはCloudflareとFastly、どちらも試しました。それがスコアには反映されませんでした。Workersを使いたかったのでCloudflareを使いました。protected]でも可能なので、そちらでも試してみたいです。CDNにキャッシュします。アセットは当然のこと、HTMLもキャッシュします。HTMLにはハイドレートに必要な情報も入ってますが、「A」というURLに対しての情報は常に「A」なので変わることがありません。キャッシュしてOKです。.jsファイルもキャッシュしてしまいましょう。上記のデプロイフローでビルドが終わったらCloudflareのキャッシュをAPI経由でパージするフローを追加すればOKです。あとキャッシュしていないのはAPIです。ユーザーログインの部分は性質上キャッシュできません。/races/:raceIdはどうでしょう。賭けが終了したものに対してはエントリーやオッズは変更されないので、キャッシュしてもOKです。/races/:raceId/betting-ticketsが走れば更新される可能性があります。キャッシュしているとデータの不整合が起こる可能性があります。APIのキャッシュは難しそうです。この時点ではキャッシュの対象から外しました。Cloudflareではエッジでのキャッシュのみならず以下をしました。DevToolsでは挙動が確認できないので、スコアには反映してなさそう)その頃には「490点」が出るようになっていました。しながら順に表示されることしかし、大変な事に気づいてしまいました。今回のレギュレーションにはVRT以外にチェック項目があります。それをひとつひとつ確認していくと...各レースがfade-inしながら順に表示されることこれ、めっちゃ見逃していました。アニメーションのコード削っちゃってたのよね...この「fade-inしながら順に」が厄介で、そのままのコードだとTimerだらけになってJavaScriptのExecutionが増える。したものかと結構悩んだんですが、CSSを使えばいいじゃんとあっさり解決しました。良い書き方があるでしょうが、これでOKでした。*/`;これで、トップページのスコアは落ちずに済みました。Splittingとロードタイミングの最適化などを施し、もうスコアは498点台が出るようになっていました。そこで何度もチャレンジしても500点は出ません。「SPAに戻せばスコア上がるじゃね?」って試してみても、TBTのスコアが上がるが、FCPが落ちてしまいます。「あちらが立てばこちらが立たず」です。今まで封じてきた奥の手を使います。/races/:raceIdをキャッシュしてしまえば、課題である/races/:raceId/oddsのスコアが上がるかもしれない。むやみにキャッシュはできません。Workersです!Honoで実装します。2つのハンドラーと1つのミドルウェアを作ります。キャッシュをせずにレスポンスをそのまま返すハンドラです。Cache-Controlヘッダも強制的に削除しています。newResponse;};次に、キャッシュをするハンドラ。Cache-Controlヘッダにもその値をセットしています。newResponse;};そして、肝となるパージ用のミドルウェア。/api/races/${raceId}のキャッシュをAPI経由で削除します。next();};この3つを各エンドポイントにマップしていきます。認証とチャージ用のエンドポイントではパスします。passHandler);トップページで使っているレース一覧と各レースページで使っているレース詳細のエンドポイントはキャッシュします。呼ばれる回数が少ないので、キャッシュしなくていいでしょう。passHandler);そして、これが今回の肝のエンドポイントです。ここではパージをします。purgeMiddlewareがミドルウェアになっているので、パージしつつ、パスするという挙動をこう書くことができます。Workersルートの設定で、/api/*のみをWorkers経由にしました。これで、パージされてから2度目のアクセス以降、APIがキャッシュされます。/races/:raceId/race-card、/races/:raceId/odds、/races/:raceId/resultの描画が爆速になりました。500点これでいけるはず!恐る恐る実行してみると...「499.7!」。何度か/retryしていると...出ました!やったーーーー。あああああああ、報われた、僕の17日間。やり方は一つじゃないと、まぁ頑張ってきました。振り返るとたくさんのことをやってきたものです。Workersでキャッシュの制御Workersルートで/apiだけ賢くするでも、チューニングの方法はこれだけじゃないでしょう。他の参加者の方やこれから参加する人は他のやり方を知っています(そう、まだ期限内なのです!)。React、Webpackのあたりはもっと賢い方法がたくさんありそうです。やり方はたくさんあります。ひとつじゃありません。最後にラリー・ウォールの言葉をもじって終わりにしましょう。it!PS.採点システムを含め素晴らしいイベントを開催してくれているサイバーエージェントさんに感謝。PS2.ここまで書いておいて、レギュレーション違反してたらごめんなさい。補足.Cloudflareのキャッシュのパージについて、これは完全に即時パージではないはずです。試したところ申し分なかったのと、アプリケーションの性質上、多少時差があっても問題ないという判断です。yusukebe.com。