500点出す!
2022-11-21
Core Web Vitals
Web
Cloudflare
天然パーマです。
「Web Speed Hackathon 2022」という「非常に重たいWebアプリをチューニングして、いかに高速にするかを競う競技」があります。 リモート参加で11月1日から27日まで開催されています。 ここで言う「高速」とはCore Web Vitalsのスコアが高いことを言い、Lighthouseのスコアをベースにした500点満点の争いです。 ISUCONのフロントエンド版ですね。 以前にも同じ課題で「学生向け」と「社内(サイバーエージェント)向け」が行われたらしく、まだ500点を出した人はいません。 そこで僕は「満点を出したい」と思い、初日から、いやむしろフライングしていたからその前から頑張ってきました。 そして、先日(17日)、ついに500点満点を出しました!
たぶん、レギュレーションはクリアしている、はずです(もし違反してたらすいません…)。 自動で行われる「Visual Regression Test=VRT」はパスしていて、チェックリストは何度も確認したのでだいじょうぶなはず! たぶん。
そこで、500点までの道のりを紹介します。 Web Speed Hackathonは現在も開催中でありまして、参加中の方には盛大なるネタバレになる可能性があるので、 閲覧にはご注意ください。
高速化の対象は「CyberTicket」という「じゃんけんの勝ち負けを予想する」サイトなのですが、こいつが最初クソおもいです。 CyberTicketがどんなものかとデモサイトのリンクをクリックしても一向に開きません。 cloneしてきたものをローカルで立ち上げても遅い。それもそのはずLighthouseにかけてみたら2点でした。 敵は強敵です。
これを100点にしていきます。
採点はLeaderboardのレポジトリにIssueをつくってURLを貼り付けるとGitHub Actionsが自動で走り、VRTとScoringをしてくれます。 終わったら、500点中何点だったかがIssueのコメントに付きます。すごくよくできた仕組みです。 スクショが一番最初のものです。11点でした。
さて、点数を確認するために毎回/retry
とコメントして、GitHub Actionsの実行終了を待つのは非常にだるいです。
VRTのレギュレーションテストは少しでも見た目がずれると違反になってしまいます。
例えば画像が正しくリサイズされているかを確認するだけでも、時間がかかってしまいます。
まず本体に手を付ける前に、ここをなんとかすることにしました。
ありがたいことにVRTとスコアリングをローカルのマシンでやる方法が書いてあったので、 そのまま採用させてもらいます。
点数はとある5つのエンドポイントに対してつくのですが、
これがWSH_SCORING_TARGET_PATHS
というsecret変数になっていて、どこが叩かれるかわからないようになっています。
どうしたものかと言うと、単純で、採点中にHerokuサーバーのアクセスログを見て判断しました。
これでローカルでも、コマンド一発でVRTのテストができるようになりました。
イテレーションが速く回せます。
難しいことを考える前に何も考えずにできることからやります。
アセットのファイルがassets/images
以下で85MBとバカでかいので削ります。
アプリ内では、TrimmedImage
というReactのコンポーネントを画像表示のためにつかっていて、
これが元画像を読み込みつつCanvasでクロップとリサイズとかまどろっこしいことをしているのでこいつを消しました。
macOSにはもともとsips
コマンドが入ってるので、これでリサイズ。
さらにSquooshという画像最適化ソフトのCLIをつかって、WebPにします。
sips --resampleWidth 500 --resampleHeight 359 hero-small.jpg
squoosh-cli --webp '{quality: 60}' hero-small.jpg
次にフォントです。オッズ表の表示のところで、外部のフォントを使っていてこれが5.8MBとでかい。 よく見てみると数字と一部記号しか使っていない。 そこで、それ以外を削って、最小のフォントセットを作成。さらにttfからwoffへ変換をかけて読み込むことにしました。
またアイコンセットにFont Awesomeを使ってるのですが、これもよく見ると3箇所しか使われてない様子。 そこでその3つのみSVGを出すようにして、全体のCSS、JSとフォントセットを読まないようにしました。
画像なら例えばimgixを挟んでエッジでリサイズ・クロップするという手がありますが、 今回のように手作業でやっちゃた方が早いし、速い場合があります。
次にJSです。main.js
が30MBあります。これが中身。
zengin-data.js
は置いといて、やめられるものはやめる。より小さい代替があるものはそれに変えます。
ブラウザのサポートも切って、最低限のものにします。
これで、必要な外部ライブラリはReact、React Router、Styled Components、dayjsくらいになりました。
誰もがぶち当たるであろうものが、「Zengin問題」です。
サイト内で銀行情報を入力するダイアログで使われている「全国の銀行とその支店コード」を収録している3MBある.js
ファイルです。
これをどうするか。データを圧縮したり、APIにするという方法が思いつきますが、Dynamic Importで簡単に解決しました。
このzengin-data.js
が必要になるのは、ダイアログのコンポーネントからのみで、ページで必要になるわけではありません。
ダイアログをDynamic Importして、Webpackがそれを解釈してCode Splittingしてくれればメインの.js
には含まれなくなりました。
const ChargeDialog = React.lazy(() => import("./internal/ChargeDialog"));
React.lazy()
で読み込ませたあと、Suspense
で囲みます。
<Suspense fallback="">
<ChargeDialog ref="{chargeDialogRef}" onComplete="{handleCompleteCharge}" />
</Suspense>
これで、コードが分割され、遅延で読み込まれることになります。 この手法は後ほど他のコンポーネントでも使われ、さらに最適化を加えると最終的なバンドルサイズは以下のようになりました。
メインの.js
は開発用で1.08MB、gzipして278.KB、プロダクトで353KB、gzipで62KBです。
JSのサイズが小さくならなくてはTBTのスコアが上がらず、最後までこれには手を焼きました。
ページごとにJSを吐くといったCode Splittingをしたり、ReactをPreactにするなどの作戦でもっと小さくできそうですが、
今回は採用せずに、500点を目指します。
これまで偉そうに書いてきましたが、実は僕、業務でReactを触ったことがなく、趣味で触るくらいなので、わりとReactわかりませーん。 でもわからないなりに、チューニングしていきます。JavaScriptの実行時間が足を引っ張っているのです。
主にやったのは「memo化」です。why-did-you-updateというライブラリ(deprecatedなのでwhy-did-you-renderを使ったほうがいいかもです)を使って、怒られたところをReact.memo
で囲うという簡単なお仕事です。
無駄な再レンダリングを避けることができます。memo化だけで、だいぶパフォーマンスがあがりました。
フロントエンドだけではなくバックエンドにも手を付けてみます。
このサイトは当初いわゆるSPAになっています。裏ではFastifyが動いていて、/api/*
にレース情報を配信するAPIが生えています。
そして、残りはエントリーポイントとなるHTMLと.js
、アセットファイルがfastify-static
というミドルウェアを通して、配信されています。
アセットファイルの配信はそのままにして、HTMLの配信は後ほど紹介するSSR、もしくは「SPAとSSRの中間」を実現するために、
ダイナミックにします。具体的には、/
や/:date
、/races/:raceId/*
というルートを生やし、その中でHTMLをダイナミックに生成します。
HTMLを組み立てるのには変数にベタ貼りしたコードとReactのSSRを使いました。これについては後述します。
データのストアにはSQLiteを使っているので、それにもテコ入れします。
貼れてなかったところにインデックスを貼ります。
採点の最初には/api/initialize
というエンドポイントが叩かれ、seeds.sqlite
がdatabase.sqlite
へとコピーされます。
この際にCREATE INDEX
するのです。
export async function initialize() {
await fs.copyFile(INITIAL_DATABASE_PATH, DATABASE_PATH);
const db = new sqlite3.Database(DATABASE_PATH);
db.run("create index index_race_id on odds_item(raceId)", [], (err) => {
console.log(err);
});
}
これで、/races/:raceId
のエンドポイントで発行されるクエリが当初より速くなりました。
-- BEFORE
Run Time: real 0.902 user 0.474481 sys 0.070793
-- AFTER
Run Time: real 0.281 user 0.049322 sys 0.025765
推奨されているHerokuを使っていましたが、そろそろ辛くなってきました。 Herokuは遅い! リージョンがUSで遠いです。採点するGitHub ActionsのマシンがHerokuに近ければ点数には響きませんが、 こちらで本番サイトを確認する時に遅いとフラストレーションがたまりますし、手元でLighthouseを実行した時の点も低く出てしまいます。 つまり、Developer Experience = DXが悪いのです。 これからよりテクニカルなことをやっていくのに大変です。
もうHerokuはやめましょう! この時点で350点程度、まだまだHerokuでも頑張れそうです。が、やめます。
以前から馴染みのあるLinodeというVPSを使います。一番低い「Nanode 1 GB」というプランです。「5ドル/月」課金しました。 ちなみに、課金しちゃったけど、最終的にこのHackathonに使った金額はこの「5ドル」だけです(CloudflareとFastlyは以前から契約していました…)!
VPSにはDebianを入れて、そこでNodeを動かします。他にはリバースプロキシにNginxかH2Oを入れるか入れなかったりです。 コンテナは使いません。この場合、それが一番早いし速いです。
Herokuをやめたので、デプロイフローをオリジナルで作らなくてはいけません。
同じようにGitHubレポジトリにpush
したタイミングでデプロイされると嬉しい。
そこで、デプロイ専用のレポジトリを作り、以下のようなGitHub Actionsを組みました。ホストでの操作はAnsibleを使います。
yarn install
とyarn build
これで、VPSでもHerokuと同じような仕組みで、かつ高速なデプロイフローを構築できました。
さて、ここからコアなチューニングをしていきます。
前述した通り、この課題は当初Single Page Application = SPAで作られています。
SPAの最大の問題はメインの.js
をロードしない限り、描画が始まらないことです。
そのため、どうしてもFCPとLCPが遅く=スコアが悪くなります。
また、例えば、ローディング用のテキストとして「loading...
」と表示していたところへ、
長いテキストが入ると改行されてレイアウトシフトが発生します。
となるとCLSを0にするのが難しくなります。
そこで、SPAの対極にあるServer Side Rendering = SSRをしてみます。 Fastifyに手を入れたのが生きてきます。 ようは素のReactからSSRを実装するのです。 Next.jsでは勝手にやってくれるのですが、それを自作します。 なお、こちらの記事を参考にさせてもらいました。
Reactのルーティングをサーバーからも参照して、React RouterのStaticRouter
で囲みます。
import React from "react";
import { StaticRouter } from "react-router-dom/server";
import { Routes } from "../client/foundation/routes";
export const App = ({ location, serverData }) => {
return (
<StaticRouter location={location}>
<Routes serverData={serverData} />
</StaticRouter>
);
};
それをFastifyのハンドラでimport
して、ReactのrenderToNodeStream()
に渡します(renderToString()
でも良さそうですが、Suspense
を使うとエラーがでたのでこれです)。
import React from "react";
import { renderToNodeStream } from "react-dom/server";
import { ServerStyleSheet } from "styled-components";
import { App } from "../App.jsx";
//...
fastify.get("/races/:raceId/*", async (req, res) => {
res.raw.setHeader("Content-Type", "text/html; charset=utf-8");
const repo = (await createConnection()).getRepository(Race);
// 全部持ってくる
const race = await repo.findOne(req.params.raceId, {
relations: ["entries", "entries.player", "trifectaOdds"],
});
// Styled Componentsのために必要
const sheet = new ServerStyleSheet();
// (1) `serverData`の値にraceを渡す
const jsx = sheet.collectStyles(
<App location={req.url.toString()} serverData={race} />
);
const stream = sheet.interleaveWithNodeStream(renderToNodeStream(jsx));
// topを書き込み
// (2) `data-react`の値にrace情報をシリアライズして渡す
const top = `${getHead()}<body><div id="root" data-react=${JSON.stringify(
race
)}>`;
res.raw.write(top);
// streamが終わったら下部を描画
stream.on("end", () => res.raw.end(getBottom()));
// streamを返却
return res.send(stream);
});
ここで肝は(1)と(2)でそれぞれ、コンテンツとなるrace
オブジェクトを<App />
とエントリーポイントとなるHTMLに渡しているところです。
<App />
では、サーバーからserverData
という変数で値をもらい、それを各ページに渡しています。
export const Routes = ({ serverData }) => {
return (
<Suspense fallback="">
<RouterRoutes>
<Route element={<CommonLayout />}>
<Route index element={<Top serverData={serverData} />} />
<Route element={<Top serverData={serverData} />} path=":date" />
<Route path="races/:raceId">
<Route
element={<RaceCard serverData={serverData} />}
path="race-card"
/>
<Route element={<Odds serverData={serverData} />} path="odds" />
<Route
element={<RaceResult serverData={serverData} />}
path="result"
/>
</Route>
</Route>
</RouterRoutes>
</Suspense>
);
};
各々のページでは、ブラウザからの実行か、サーバーからの実行かを判断します。
サーバーからだったら上記で受け取ったserverData
をdata
にセットして、
HTMLの時点で描画、つまりSSRできるようにします。
ブラウザだったら、(2)でdata-react
にセットしたrace
のデータをデシリアライズして、
dataにセットします。これは.js
が読み込まれてからAPIのフェッチまでの描画を担います。
export const RaceResult = ({ serverData }) => {
const { raceId } = useParams();
let { data } = useFetch(`/api/races/${raceId}`);
if (typeof document !== "undefined") {
// ブラウザだったら...
const elem = document.getElementById("root");
const dataPool = elem.dataset.react;
const initialData = JSON.parse(dataPool);
data = initialData;
elem.dataset.react = "";
} else {
// サーバーだったら...
data = serverData;
}
//...
};
最後に、クライアント側のindex.js
でHydrateの指示を出せば完成です。
import { App } from "./foundation/App";
hydrateRoot(document.getElementById("root"), <App />);
流れをまとめると以下のとおりです。
race
を<App />
に渡して、それをHTMLとして描画。<div id="root" ...
のdata-react
属性の値にはrace
をJSON化してセットしておく。.js
が読み込まれたら、data-react
属性の値をデシリアライズしてハイドレートする。さらに今回はAPIからフェッチしたデータを最終的にセットしています。
SSRにすることで、レイアウトシフトの発生を抑えることができ、CLSのスコアを0にすることができます。 しかし、問題はDBから引いてくる時間があるので、サーバーからのレスポンスタイム、TTFBが伸びてしまうことです。 わかりやすいように「SQLiteにインデックスをかけてない」状態でHTMLが返ってくる時間を測ると500msかかってしまいます (当然、インデックスをつければより速いのですがそれでもマシンスペックが十分でないと100ms以上かかってしまいます)。
これでは戦えません。そこで、SPAとSSRのハイブリッドみたいなことします。
データベースから持ってくるところのコードはここです。
const race = await repo.findOne(req.params.raceId, {
relations: ["entries", "entries.player", "trifectaOdds"],
});
Joinしています。これをJoinなしにするとキーで引くだけなのでとても速いです。
const race = await repo.findOne(req.params.raceId);
実は、SSRしてHTMLにする時に必要は情報はこれだけで十分です。 つまりレースページにおける以下の情報です。
これさえあれば、CLSは防げます。
そして、写真のURLが分かるので、リソースヒントを使ったpreloadができます。
FCP、LCPの向上が期待できます。
残りのエントリー情報、オッズ情報はAPIでフェッチしてあとから追加すればいいでしょう。
最終的なコードはこうなります。
今回はLink
ヘッダでリソースヒントをしています。
fastify.get("/races/:raceId/*", async (req, res) => {
res.raw.setHeader("Content-Type", "text/html; charset=utf-8");
const repo = (await createConnection()).getRepository(Race);
// 基本情報だけ持ってくる
const race = await repo.findOne(req.params.raceId);
// LCPの画像を抽出
const match = race.image.match(/([0-9]+)\.jpg$/);
const imageURL = `/assets/images/races/400x225/${match[1]}.webp`;
res.raw.setHeader("Link", `<${imageURL}>; rel=preload; as=image`);
//...
return res.send(stream);
});
これで、TTFBが短くなり、 CLS、FCP/LCPのスコアを維持したまま、全体の描画時間、主にTTIのスコアを上げることができました。
さてもっと速くしましょう。CDNを使います。
CDNはCloudflareとFastly、どちらも試しました。 色々試して、速度で言うとFastlyの方が若干速いかな…という具合ですが、 それがスコアには反映されませんでした。 なので、後述するCloudflare Workersを使いたかったのでCloudflareを使いました。 が、今思えば、Compute@Edgeでも可能なので、そちらでも試してみたいです。
さて、CDNにキャッシュします。
アセットは当然のこと、HTMLもキャッシュします。
HTMLにはハイドレートに必要な情報も入ってますが、「A」というURLに対しての情報は常に「A」なので変わることがありません。
なので、バリバリキャッシュしてOKです。
.js
ファイルもキャッシュしてしまいましょう。
ビルドごとに内容が変わりますが、
上記のデプロイフローでビルドが終わったらCloudflareのキャッシュをAPI経由でパージするフローを追加すればOKです。
さて、あとキャッシュしていないのはAPIです。
ユーザーログインの部分は性質上キャッシュできません。
GET /races/:raceId
はどうでしょう。賭けが終了したものに対してはエントリーやオッズは変更されないので、キャッシュしてもOKです。
ただ、終了していないものについては、POST /races/:raceId/betting-tickets
が走れば更新される可能性があります。
なのでずっとキャッシュしているとデータの不整合が起こる可能性があります。
うーむ。APIのキャッシュは難しそうです。
なので、この時点ではキャッシュの対象から外しました。この時は。
Cloudflareではエッジでのキャッシュのみならず以下をしました。
その頃には「490点」が出るようになっていました。
しかし、大変な事に気づいてしまいました。 今回のレギュレーションにはVRT以外にチェック項目があります。 それをひとつひとつ確認していくと…
各レースがfade-inしながら順に表示されること
これ、めっちゃ見逃していました。アニメーションのコード削っちゃってたのよね…
この「fade-inしながら順に」が厄介で、そのままのコードだとTimerだらけになってJavaScriptのExecutionが増える。 どうしたものかと結構悩んだんですが、CSSを使えばいいじゃんとあっさり解決しました。
もっと良い書き方があるでしょうが、これでOKでした。
const ItemWrapper = styled.div`
@keyframes fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
opacity: 1;
animation-name: fadeInOpacity;
animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 0.5s;
/* ... */
`;
これで、トップページのスコアは落ちずに済みました。
Code Splittingとロードタイミングの最適化などを施し、もうスコアは498点台が出るようになっていました。 本家のLeaderboardをforkした自分のリポジトリで結果を確認していたのですが、 そこで何度もチャレンジしても500点は出ません。
あと1点が遠い。「SPAに戻せばスコア上がるじゃね?」って試してみても、TBTのスコアが上がるが、FCPが落ちてしまいます。 「あちらが立てばこちらが立たず」です。
今まで封じてきた奥の手を使います。そう、APIをキャッシュするのです!
前述したGET /races/:raceId
をキャッシュしてしまえば、課題である/races/:raceId/odds
のスコアが上がるかもしれない。
でも、POST /races/:raceId/betting-tickets
によって、データが更新される可能性があるので、
むやみにキャッシュはできません。
でもどうでしょう。ベットされた瞬間に/races/{対象のraceId}/odds
がパージされたら…
それならよいではないでしょうか!ならば、Cloudflare Workersで実装しましょう!
こういう時のCloudflare Workersです!
Honoで実装します。2つのハンドラーと1つのミドルウェアを作ります。
まずキャッシュをせずにレスポンスをそのまま返すハンドラです。
Cache-Control
ヘッダも強制的に削除しています。
const passHandler: Handler = async (c) => {
const response = await fetch(c.req);
const newResponse = new Response(response.body, response);
newResponse.headers.delete("cache-control");
return newResponse;
};
次に、キャッシュをするハンドラ。maxAge
で指定した秒数をエッジでキャッシュし、
Cache-Control
ヘッダにもその値をセットしています。
const cacheHandler: Handler = async (c) => {
const response = await fetch(c.req, {
cf: {
cacheEverything: true,
cacheTtl: maxAge,
},
});
const newResponse = new Response(response.body, response);
newResponse.headers.delete("cache-control");
newResponse.headers.append("cache-control", `max-age=${maxAge}`);
return newResponse;
};
そして、肝となるパージ用のミドルウェア。
POST /api/races/:raceId/betting-tickets
にアクセスが来たら、
/api/races/${raceId}
のキャッシュをAPI経由で削除します。
const purgeMiddleware: Handler = async (c, next) => {
const raceId = c.req.param("raceId");
const url = new URL(c.req.url);
const apiURL = `https://api.cloudflare.com/client/v4/zones/${c.env.ZONE_ID}/purge_cache`;
const data = {
files: [`https://${url.hostname}/api/races/${raceId}`],
};
const fetchResponse = await fetch(apiURL, {
body: JSON.stringify(data),
headers: {
Authorization: `Bearer ${c.env.API_TOKEN}`,
"Content-Type": "application/json",
},
method: "POST",
});
console.log(await fetchResponse.json());
await next();
};
この3つを各エンドポイントにマップしていきます。
まず認証とチャージ用のエンドポイントではパスします。
app.get("/api/users/me", passHandler);
app.post("/api/users/me/charge", passHandler);
トップページで使っているレース一覧と各レースページで使っているレース詳細のエンドポイントはキャッシュします。
app.get("/api/races", cacheHandler);
app.get("/api/races/:raceId", cacheHandler);
自分がどのチケットにベットしたかを返すエンドポイントは変更される可能性があるのと、 呼ばれる回数が少ないので、キャッシュしなくていいでしょう。
app.get("/api/races/:raceId/betting-tickets", passHandler);
そして、これが今回の肝のエンドポイントです。ここではパージをします。
purgeMiddleware
がミドルウェアになっているので、パージしつつ、パスするという挙動をこう書くことができます。
app.post("/api/races/:raceId/betting-tickets", purgeMiddleware, passHandler);
ただし、キャッシュして返すだけのページやアセットはWorkersを挟まない方が若干ですが速いと分かったので、
Workersルートの設定で、/api/*
のみをWorkers経由にしました。
これで、パージされてから2度目のアクセス以降、APIがキャッシュされます。
/races/:raceId/race-card
、/races/:raceId/odds
、/races/:raceId/result
の描画が爆速になりました。
これでいけるはず!恐る恐る実行してみると…「499.7!」。
まだ、いけるはず…。何度か/retry
していると…出ました!
やったーーーー。あああああああ、報われた、僕の17日間。
と、まぁ頑張ってきました。 振り返るとたくさんのことをやってきたものです。
でも、チューニングの方法はこれだけじゃないでしょう。 他の参加者の方やこれから参加する人は他のやり方を知っています(そう、まだ期限内なのです!)。 特にReact、Webpackのあたりはもっと賢い方法がたくさんありそうです。 やり方はたくさんあります。ひとつじゃありません。 最後にラリー・ウォールの言葉をもじって終わりにしましょう。
There is more than one way to hack it!
PS.
採点システムを含め素晴らしいイベントを開催してくれているサイバーエージェントさんに感謝。
PS2.
ここまで書いておいて、レギュレーション違反してたらごめんなさい。
補足.
Cloudflareのキャッシュのパージについて、これは完全に即時パージではないはずです。 ただ、試したところ申し分なかったのと、アプリケーションの性質上、多少時差があっても問題ないという判断です。