Web Speed Hackathon 2021をISUCONぽく開催する技術

昨年末頃にCyberAgent様にて公開された[Web Speed Hackathon 2021](https://developers.cyberagent.co.jp/blog/archives/32747/)というものが存在します。こちらはどのようなものかというと「表示がくそ重たいサイトをどれだけ速く表示できるようにするか」というお題の元に開催されたものです。"速さ"についてはLighthouseのPerformanceを中心にスコアリングされます。CyberAgent様にて開催されたのは昨年末から今年初めにかけてですが、これをISUCONぽく開催できればイベントとして扱えるなと思い、ISUCONぽいリーダーボードを作成しその中で使われた技術に関する内容です。

謝辞
Web Speed Hackathon 2021をOSSとして公開して頂いたCyberAgent様ならびに宮代 [@3846masa](https://github.com/3846masa) 様に感謝致します。Twitter上でのライセンスに関する問い合わせについても快く対応頂いたことにも感謝しております。

# 使用技術

本稿で書かれたものを以下に公開しております。最後に書きますが微妙だなと思ったところもありますが、その辺の文句はissueなりで投げておいてください。（日本語で大丈夫です）

https://github.com/ateam-dev/web-speed-hackathon-2021-leaderboard

## 使用サービス

全体的な構成は以下のようになっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70363/5274c484-fec4-1a75-11dc-3a13edf7bc5c.png)


### サーバサイド

- Supabase
- Cloud Run
- Cloud Storage

まずサーバサイドですが、GCP + [Supabase](https://supabase.com/)という構成です。DBはCloud SQLでもよかったのですが、Supabaseを採用したのには色々と理由があります。金額的な話もないこともないのですが、1番の理由は機能的なものです。ですので採用した理由は後述するリーダーボードの機能説明を読んでいただければわかると思います。Cloud Runはリーダーボード自体ではなく、スコアを計測するサーバサイドプログラムがあるのですが、それのために使用してます。Cloud Storageも同様にそのスコアの結果を保存するために使用しました。

### クライアントサイド

- Cloudflare Workers

作成したリーダーボードはCloudflare Workers上で動作させる前提となっています。後述しますが、Remixを使用しているので少し書き換えればNode.jsでも動作すると思います。ここは実験的側面が大きいですが、Cloudflare Workersなのでスケールなどは考えなくていいのは楽です。

## 使用ライブラリ

### サーバサイド

- supabase-js
- Prisma

DBとなるSupabaseからデータを取得する方法はいくつか存在します。「Prismaなどで直接コネクションを貼って取得する方法」「[supabase-js](https://github.com/supabase/supabase-js)を使用してAPI（PostgREST）を経由して取得する方法」「Supabaseの`pg_graphql`を有効化してGraphQLで取得する方法」などが存在します。
作成したリーダーボードは色々悩んだのですが、 **「supabase-jsを使用してAPI（PostgREST）を経由して取得する方法」** を採用しました。
まず「Prismaで直接接続する」ということはできません。PrismaはCloudflare Workersで動作する[ドキュメント](https://www.prisma.io/docs/guides/deployment/deployment-guides/deploying-to-cloudflare-workers)は存在しますが、Cloudflare Workersで動作するのはMongoDBへの接続に限ります。なので自由に接続するものを選べるわけではないので選択肢から外れます。
次に「Supabaseの`pg_graphql`を有効化してGraphQLで取得する方法」ですが、正直これでもよかったのですが、リーダーボードを作成してるうちに使用できるようになりました。使ってみたのですが色々とバグを踏んでうまくデータが取得できなかったりしたので泣く泣く諦めました。

ではなぜPrismaが残っているかというとPrsimaはアプリケーションでは使用せずDBのマイグレートツールとして使用しているだけになります。ちょっと勿体ないですが、マイグレートするのは楽になるので。


### クライアントサイド

- Remix

このリーダーボードの特徴といえるのがRemixを採用しているところです。Remixを採用した理由はいくつかあるのですが、やはりエッジコンピューティングで動かせるというのが大きいです。Cloud Runで動作させることも楽なのでいいのですが、Cloudflare WorkersはCloud Runに比べてコールドスタートが速くスケールする速度に影響するという点が大きかったです。もちろんCloud Run(特にNode.js)を採用しないデメリットもあるのですが、それは後述します。

## 機能説明

ここから機能説明と同時に使用したサービスやライブラリをどのように使っているかを合わせて説明したいと思います。

### [計測](https://github.com/ateam-dev/web-speed-hackathon-2021-leaderboard/tree/main/scripts/measure)

いきなりリーダーボードとは違うのはすいません。ですが、これもISUCONぽく開催するための必要な機能です。まず任意のタイミングでスコアリングをできる状況を作り出す必要があります。今回はLighthouseが使われており、そこも考慮する必要があったりスコアリングだけでなく、改善されたサイトのテストを実施したりと様々な処理が必要です。

- VRTによるサイトチェック
- Lighthouseによる特定ページのスコアリング
- 上記VRTやスコアリング結果の保存

これらがこの計測システムに搭載されています。VRTやLighthouseに関しては元々CyberAgent様で開催したものに存在するのでそれを流用させて頂きました。ただ、元々はGitHub Actionsのスクリプトで動作させる前提でした。しかし、任意のタイミングで動かし同時に結果も保存する（後で閲覧するため）必要があります。そこで計測を元々のGitHub Actionsからサーバ上で動作させるように変更しております。このサーバ上で動作させるためにCloud Runを使用しています。Cloud Runを使用した理由は **簡単にサーバを用意できる** という点もあるのですが、もう1つ大きな理由があります。それはLighthouseの計測を安定させるために **1計測ごとに専用サーバを構築するため** です。Lighthouseの計測は動作させるコンピュータリソースに若干依存します。具体的にはマシンスペックが低いほど低くなってしまう可能性があります。マシンスペックだけでなく、Lighthouseの処理以外に他の処理が動いてリソースを専有されると低くなる可能性があります。なので計測を安定させるために1計測ごとに専有のサーバがあるとスコアが安定します。（安定するといっても若干のブレはあります）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70363/89a879d7-d33f-f901-63b0-0b8de0c3f05b.png)

Cloud Runはオートスケールの設定がいくつかあるのですが、`Concurrency` という1コンテナに投げれるリクエスト数を決めるパラメータが存在します。本来であれば1コンテナで受けれるリクエスト数が多いほどコンテナが立ち上がらないので、安価にサーバが運用できます。しかし今回は1計測ごとに専用のサーバいわゆるコンテナが立ち上がってほしいのでこの `Concurrency` を **「1」** にわざと設定しています。そうすることで1計測リクエストごとにコンテナが立ち上がり専有したリソースでスコアリングできるという工夫を行っています。

### [リーダーボード](https://github.com/ateam-dev/web-speed-hackathon-2021-leaderboard/tree/main/scripts/leaderboard)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70363/37d92cba-2c33-80ae-1938-06877d921a1c.png)


こちらも色々と機能が存在します。

- GoogleアカウントによるOAuth2ログイン
- チーム作成および参加機能
- 全チームの結果のグラフ表示機能
- 自チームのスコアリング実施および結果表示機能

#### ログイン機能

まずはユーザを判定させるためにログイン機能を準備しました。認証を行うには様々な方法やサービスが存在するのです。FirebaseやAuth0、CognitoなどがありますがAlternative（代替）として存在するSupabaseを採用したのがその大きな理由の1つです。SupabaseはDBとしての機能だけでなく認証機能も提供してくれており、有名どころの認証はすでに存在します。

- Email
- 電話番号
- SNSアカウント認証
    - Twiter
    - Facebook
    - GitHub
    - Google
    - Apple etc

これを標準で備えてるって素晴らしいサービスです。今回は社内のアカウントを認証させるためにGoogleアカウントによる認証を行っております。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70363/7633f590-1a32-370f-6e3a-072990f0ccfe.png)


### 全チームの結果のグラフ表示機能

これは一見普通にデータを取得してただ描画すればいいだけだと思われがちですが、ISUCONのようなリーダーボードの場合は自チームだけでなく、他チームが計測を行った場合も結果グラフに表示する必要があります。その場合、データを更新するトリガーは表示しているブラウザには存在せず、サーバからPUSH通知を受け取る必要があります。ここでSupabaseを採用した理由の１つであるRealtimeという機能を活用します。端的に言うとDBのデータ登録や更新をトリガーにPUSH通知をブラウザに送る事が出来ます。ブラウザから見て、SupabaseからPUSH通知を受け取る事ができるということはデータの登録や更新を行うべき状態をブラウザ上で把握できるようになる。ということを意味します。更新が必要であることがブラウザ上で判断できれば、supabase-jsを使用してデータを取得できる。ということになるのです。

```tsx
  useEffect(() => {
    const subscribe = supabaseClient
      .from("Measurement")
      .on("INSERT", () => {
        refresh();
      })
      .subscribe();
    return () => {
      subscribe.unsubscribe();
    };
  }, [refresh]);
```

https://github.com/ateam-dev/web-speed-hackathon-2021-leaderboard/blob/main/scripts/leaderboard/app/components/Observer.tsx#L9-L19

セキュリティ上、誰もがブラウザ上からデータの取得や更新ができては困るのでそれを解決する必要があります。そこで必要なのが[Row Level Security](https://supabase.com/docs/guides/auth/row-level-security)(以下: RLS)の設定です。SupabaseのDBはPostgreSQLとなっておりPostgreSQLはRLSが使用できます。Supabaseの場合は認証したユーザというのはSupabaseが管理するデータに保持されており、RLSはsupabase-jsによって認証されたユーザにより認可をかけることが可能です。ですのでRealtimeやブラウザ上でデータを取得する場合はRLSの設定が必須です。

### 自チームのスコアリング実施および結果表示機能

自分たちが改善したサイトを計測するための機能ももちろん存在します。ISUCONならばものの数十秒で計測が終わるかもしれないのですが、今回はスコアリングもさることながらVRTも実施されています。ですので1計測あたり5分前後の時間がかかってしまいます。5分間もの間httpリクエストを待つというのは現実的ではないため、処理を非同期にする必要があります。そこでこの計測というのは大きく以下の処理が存在します。

- 計測開始データの登録
- 計測処理の実施

前者はただ単にDBにデータを登録すればいいので特段問題ないと思います。問題は計測時間から非同期に実施させるための後者です。処理方法としては色々あり、ISUCONのように計測タスクをキューにて処理する方法を当初は考えておりました。しかし、前に書いた通り処理計測の時間からキューでのタスク処理方式をやめて即実行方式に変えております。RemixはCloudflare Workersで動作しているということで以下のように [`waitUtil`](https://developers.cloudflare.com/workers/runtime-apis/fetch-event/#waituntil) という関数を使って非同期にhttpリクエストを送り、ここで送ったhttpリクエストの処理を待たずしてクライアントにレスポンスを返すということで実現しています。

```tsx
  event.waitUntil(activate(res[0].data[0].id));
```

https://github.com/ateam-dev/web-speed-hackathon-2021-leaderboard/blob/main/scripts/leaderboard/app/components/forms/MeasureRequest.tsx#L48

もちろん非同期で処理が返ってくるので、計測結果の完了を受け取る必要がありますが、それが全チームのスコア表示同様にSupabaseのRealtime機能でデータ更新を受け取るということで実現しています。

```tsx
  useEffect(() => {
    if (!teamId) return;
    const subscribe = supabaseClient
      .from(`Queue:teamId=eq.${teamId}`)
      .on("UPDATE", () => {
        refresh();
      })
      .on("INSERT", () => {
        refresh();
      })
      .subscribe();
    return () => {
      subscribe.unsubscribe();
    };
  }, [refresh, teamId]);
```

https://github.com/ateam-dev/web-speed-hackathon-2021-leaderboard/blob/main/scripts/leaderboard/app/components/Observer.tsx#L20-L34

## サービスとして採用する場合の検討事項

今回は（特にRemixを使用する場合を）サービスとして採用するならばという実験的側面もありました。私が感じた内容を以下に記載します。

1. Cloudflare Workerはあくまで「WebWorker」なのでその点は注意する
2. Cloudflare Workerのログ取得を検討する
3. Cloudflare Workerのサイズ制限には注意しつつ対策を検討する
4. Cloudflare Workerのみでアプリケーションを構築するとオリジンが存在しないのでオリジンへのリクエストキャッシュとほぼ同様の仕組みを考える必要がある
5. ServerlessのようなオートスケールするアプリケーションとDBの接続はConnection Poolingなどの機構を確認しておく

1点目はCloudflare Workersを使った方は知っているとは思うのですが、 **Cloudflare WorkersはNode.jsではありません** 。ですのでNode.jsに依存するライブラリは使用できないので注意が必要です。
2点目はCloudflare Workersのログ取得はCloudflareの機能的には構築した当初はあまりよい手法がありませんでした。ですのでアプリケーション内でログ取得するロジックを自作する必要がありました。しかし、今週のCloudflareのイベントで発表された[Logpush](https://blog.cloudflare.com/logpush-for-workers/)があるので使える場合は使ってログを取得することをおすすめします。（Enterprise契約のみの提供に見えます）
3点目はCloudflare Workersはサイズ制限があります。具体的には **ビルドしたファイルは1MB以内** に抑えないといけません。もしサイズを超える場合は[Service Bindings](https://blog.cloudflare.com/service-bindings-ga/)を使用してWorkersを分割したり、サイズ上限をあげる申請（どこかにURLがあったと思うのですが失念しました）をして1MBの制限をクリアする必要があります。
4点目はhttpリクエスト自体のキャッシュを使用している場合に限ります。例えばRemixの場合にはそのまま動かすと毎回SSRするようになります。正直計算リソースの無駄が頭によぎります。なのでレンダリングコストを抑えるためにhttpリクエストをキャッシュすることがCloudflareでは可能です。ただ、Cloudflare Workersはそのキャッシュ設定より先に動作するのでhttpリクエストをキャッシュするにはWorkers内の処理で定義する必要ありますが、そもそもRemixはオリジンという概念ではなくもうWorkers自体で動いているのでhttpリクエストは単純に設定できないということになります。Service Bindingsを使用してRemixが動作しているCloudflarre Workersをオリジンに見立ててhttpリクエストのキャッシュを試してみたのですが、それは有効化されませんでした。この点は別途Cloudflareに仕様なのか確認しているのもあり、Service Bindigsの記事を書くときがあれば詳しく記載します。
最後にDBへの接続です。Cloudflare Workersはリクエストが増加するとスケールすることが予想されます。その場合にDBへのコネクションも増えるわけです。DBへの接続数は無限に存在するわけではありません。こういうスケールするアプリケーションは常に考えておく必要があります。Supabaseの場合は[PgBouncer](https://www.pgbouncer.org/)を使用した[Connection Pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool)が提供されているのでそういった接続する場合はこちらの接続を使用します。ただし、**SupabaseのDBマイグレーションなどを行う場合はこちらの接続先からは行えないので通常の接続先から行う必要があります** 。

## 改善点

実際作ってみてイマイチな作りや仕様になっているかなと思っている箇所です。

1. Supabaseの `moddatetime` を有効化を[コード化](https://github.com/ateam-dev/web-speed-hackathon-2021-leaderboard/blob/main/scripts/leaderboard/prisma/migrations/20220414081100_/migration.sql#L5)
2. イベント終了1時間前の全チームスコア表示の抑制機能
3. アプリケーションの動作チェック用のE2E作成
4. 1計測ごとに計測URLを持つ

まず1つ目ですが、`moddatetime`を有効化するにはSupabaseに接続するpogtgresユーザでは権限が足りず行えませんでした。ですのでSupabaseのコンソール上から流すしか方法が見つけられませんでした。ここを自動化できればいいなと思っています。
2つ目と3つ目は単純な機能不足です。なくてもイベントは開催できますが、あった方がいいです。
4つ目に関してはスキーマの設計をちょっと失敗したなと思っているところです。計測するURLは計測ごとに異なる可能性があるのでそれを考慮したDB設計にしておいた方が後々ログとしては見やすいかなと思った次第です。他にも細かいところがあるかもしれませんが、冒頭に書いたように何かあればissueにでも投げておいてください。

## 最後に

ISUCONはほぼバックエンドエンジニアのためのイベントですが、フロントエンドエンジニアのイベントも開催したいということで作ってみました。社内でイベントとして実施してみましたが、結構な盛り上がりが感じられましたので良ければ使ってみてください。