この記事は MIXI DEVELOPERS Advent Calendar 2022 18 日目の記事です。
普段はSREチックなことをやっているしがないエンジニアなのですが、とある日に「もしかしたら突発的に大量のアクセスが来るかもしれない」というお話が降って湧いたため、こりゃいかんと仕組みを作ったお話をしていきたいと思います。
要件
要件としては以下になります。
- プッシュ通知などの誘導により、特定のページに短時間に大量のアクセスが来る可能性
- ページへの誘導がいつ行われるのか事前の連絡は無し
- なるたけ新鮮な情報が表示されるようにする
ザックリとこんな感じですが、一つずつ検討していきましょう。
その前にサービス構成
と、検討に入る前に扱っているサービスの構成を見ましょう。
- フロントエンド:Next.js, TypeScript, Apollo Client, GraphQL Code Generator
- バックエンド:Ruby/Rails, GraphQL Ruby
- その他:GKE, NGINX
フロントエンドにはNext.jsでバックエンドにはRuby/Rails、その間の疎通はGraphQLで行い、GKEで運用しております。エンドユーザーからのリクエストはフロントエンド・バックエンドともにNGINXを通してPodにやってくるという流れですね。
では検討に戻りましょう。
特定のページに短時間に大量のアクセスが来る可能性
普通の考えだとフロントエンド/バックエンドともにPodを横展開しまくってスケールアウトしようぜ!となるのですが、バックエンドサーバに一つ問題があり、とある理由からスケールしてもあまりRPSが上がらないという状況です。これは困りました・・・
時間があればバックエンドの問題の根治にリソースをかければよいのですが、そこまで時間がないという状況でもありました。幸いにもフロントエンドはPod数に比例して性能が上がることは確認できましたので、ここはフロントエンドでどうにかするように舵を切りましょう。
Next.jsにはSSG(静的サイト生成)の機能があります。これを使って、バックエンドからのレスポンスをビルド時に取得し、サイト表示時には取得したものを使ってバックエンドへの負荷を軽減します。
ここで決まったこと
- Next.jsのSSGを使う
ページへの誘導がいつ行われるのか事前の連絡は無し
事前連絡無しでのページ誘導については自動スケーリングに頼ってもいいのですが、極短時間で大量にリクエストが来た場合にスケールが間に合わない可能性が高いです。事前にスケールアウトすることも対策として考えられるのですが、事前連絡もないため常にスケールアウトしておくのはちょっと費用的にもNGです。
これについては、SSGを使うという方針が決まりましたのでホスティング先が大量のリクエストを捌けるならOKそうです。今回は読み込みで秒間5000rpsを保証しているGoogle Cloud Storage(以下GCS)を使ってホスティングをすることにしましたので、問題はなさそうですね👌
さらに自動スケーリングも据えているので、万が一これ以上来ても最悪の事態は回避できそうです。
ここで決まったこと
- GCSでホスティングする
なるたけ新鮮な情報が表示されるようにする
SSGを使った場合、GraphQLからのレスポンスはビルド時に返ってきたものを使うので、ビルド後にDB上のデータが更新されたとしても反映されません。また定期的にビルドをするようにして、ある程度のフレッシュさを保つことはできますが、出来ることならもう少しリアルタイムな情報がほしいところです。
ISRの導入も考慮したのですが、SSGしてGCSでホスティングする場合にはStatic HTML Exportを行う必要があり、ISRはサポートされていないことで断念しました。Static HTML Exportは割と制限が多いので、もし使われる方は一度以下のドキュメントに目を通すと良いかもしれません。
この辺りで「普段はリアルタイムな情報を返し、過負荷時はビルド時に取得した鮮度に欠ける情報を確実に返す」というアイデアの芽が生えてきました。
ここで決まったこと
- 過負荷かどうかによって鮮度の違う情報を返す
決まったこと
ここまでで決まったことは以下になります。
- Next.jsのSSGを使う
- GCSでホスティングする
- 過負荷かどうかによって鮮度の違う情報を返す
①と②はNext.jsの領分、③は今回で言うとNGINXの領分になりそうです。
ちなみに、③については過負荷にならなければ新鮮なデータを返し、過負荷であれば鮮度に欠けるデータを返すのですが、こういった負荷制限をかけてそれを超えた場合には安定しているが不確実なデータなどを返すような仕組みをGraceful Degradationと呼んだりします。(SRE本を読んだ時に知りました)
※ 今のところ頭にあるザックリイメージ
さて、やることは決まりましたので実際に組んでいきましょう!
Next.jsのSSGを使う
それではNext.jsのSSGを使って組んでいきましょう。まずは仮に作成した土台のページはこちらになります。
なんの変哲もない、小さなページを作りました。 useTestQuery
というGraphQLのQueryを呼んで、中身を表示しています。これがバックエンドとやり取りしているところですね。
では、これをSSG対応していきます。
getStaticProps
を使うことで、事前にどういったデータを取得しておくのか?を定義することが出来ます。今回は TestQuery
のレスポンスを取得しておきたかったので、 getStaticProps
内で取得しています。
ここで、先程のコードで使っていた useTestQuery
を getStaticProps
の中で何故使っていないのか?ですが、理由はReactのhooks rulesに違反するためです。
なので、わざわざApollo Clientを生成して取得しているというわけです。
そして、取得したデータを関数コンポーネントの引数として渡せばSSG対応の出来上がりです。簡単ですね!
ここまで出来ましたら一度 next build
を実行してみて、キチンと通るかどうかだけ確認しておきましょう👌
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
↑ が表示されたらOKです!
GCSでホスティングする
次は、GCSでホスティングするところをやっていきましょう。
まずは next export
というコマンドを実行して、静的なHTMLとしてエクスポートをしていくのですが、その前にnext.config.js
に設定を足して特定ページのみエクスポートされるようにしてみましょうか。これは必ずやらなくても良いのですが、一応こういうことも出来ますよという例として参考にしてみてください。
さて、それでは next export
を実行して、ホスティングに必要なファイルをエクスポートしてみましょう。 next export
の実行が終わると out
ディレクトリが生成されているかと思います。これをGCSに載せてGCS側のホスティングの設定をすればOK・・・というわけもいかず。
out
ディレクトリに生成されたファイルを見てみると test.html
があると思いますが、これをGCS側でアクセスする際のURLは /test.html
になります。しかし出来れば /test
といった形でアクセスできるようにしたいですよね?
それを解決するために、trailing slashの設定も追加してみましょう。
これで再度 next export
をしてみると、 test
ディレクトリが生成されてその中に index.html
があることがわかりますね!しかし、まだこれだけではやりたいことは達成できず、GCS側の設定も必要になるのですがそちらは後述とさせていただきます。
Next.js側でホスティングの用意が出来ましたので、GCS側の設定に入っていきましょう。こちらはそう難しくないので、GCPに慣れている方は以下のドキュメントを参考に進めていくと良いかと思います。
設定するのは以下の3点です。
- バケットの作成
- 一般公開
- ウェブサイト構成の編集
バケットの作成
何はなくともバケットが無いと始まりませんので、バケットを作成しましょう。以下の画像のように設定し、作成してみてください。
一般公開
次に、一覧から作成したバケットを選択して、権限タブから一般公開できるようにしてみましょう。
そして、プリンシパルに allUsers
を選択し、Storageレガシーオブジェクト読み取り
の権限を付与します。
これで一般ユーザーにもバケットのオブジェクトが読み取りできるようになりました。
ウェブサイト構成の編集
最後に、ウェブサイトの構成編集をしましょう。この設定がtrailing slashの話で後述すると言っていた設定になります。
バケットの一覧画面からケバブメニューをクリック。
そして、ウェブサイトの構成の編集をクリックすると・・・
ここを以下のように設定してください。
これでGCS側の設定は完了しました!と言いたいところですが、最後にドメインを割り当てたり出来るように、Cloud Load Balancingを作成してバケットと繋いでみましょう。こちらもGCSに慣れている方は以下のドキュメントを参考に進めてみてください。
設定するのは以下の5点です。
- Cloud Load Balancingの作成
- フロントエンドの作成
- 静的IPアドレスの予約
- 証明書の作成
- バックエンドの作成
- フロントエンドの作成
Cloud Load Balancingの作成
Cloud Load Balancingのページへ行き、作成を行いましょう。
ここからロードバランサとそれに紐付くフロントエンド、バックエンドの設定を行います。
まずはロードバランサの名前を決めておきましょう。
フロントエンドの作成
次にフロントエンドの作成ですが、この設定中に別途、静的IPアドレスの予約と証明書の作成を行います。
ひとまず名前の設定と、プロトコルをHTTPSにしておきましょう。完了したらIP addressを選択し、作成してください。
証明書も同じく選択すると作成へのリンクがありますので、そちらから作成してください。
ここまで出来ましたら残りはHTTP -> HTTPSのリダイレクトをONにして、完了しましょう。
バックエンドの作成
バックエンドはフロントエンドに比べると簡単で、先程作成したバケットをバックエンドバケットとして設定するだけです。
これでバックエンドの作成も完了。
そして、あとはLoad Balancingも完了して終了となります。別途、必要なDNSのAレコード設定などはよしなに済ませておいてください。
ここまで出来ましたら、あとはバケットに out
ディレクトリの中身を置くだけです!置いた後に、 /test
でアクセスできるかどうか確認してみてください。
注意点として、証明書のプロビジョニングに時間がかかったりして、最終的にロードバランサが使えるようになるまで1時間ほどかかりますので、そこだけご注意ください。
過負荷かどうかによって鮮度の違う情報を返す
今回の最大の肝の部分にやってきました。
改めてやりたいことを振り返ってみますと
- 過負荷ではない時にはGKEからサイトを表示
- 過負荷の時にはGCSでホスティングしたサイトを表示
でした。それではまずは過負荷かどうかのチェックからやってみましょう。
過負荷かどうかのチェック
最初に述べたように、ここからはNGINXの領分ですので nginx.conf
を編集していきます。NGINXにはリクエスト数によって処理をハンドリングする ngx_http_limit_req_module
という便利なモジュールがありますので、そちらを利用して過負荷かどうかをチェックします。
まずは、どのくらいのリクエスト数を過負荷と判定するのか?を設定します。
今回は「ユーザー全体からのリクエスト数が20rpsを超えたら過負荷とする」という設定にしています。ちなみに limit_req_zone
は key_all
を指定するとユーザー全体のリクエスト数が対象となりますが、 $binary_remote_addr
を指定するとIPアドレス毎のリクエスト数になりますので1つのIPから大量にリクエストが来た場合の対処などに使えますね。
また、 zone=z_all:10m
はゾーン名の指定とゾーンの共有メモリ割当数を指定しています。共有メモリについてはワーカープロセスと共有しているメモリ領域で、limit_req_zoneでは各IPアドレスごとの状態と、limit_req_zoneが適用されたパスへのリクエスト回数が保持されます。大体、16000件分のIPアドレスで1メガバイトを消費するので10mの指定だと160000件分のIPアドレスが保持できます。
ちなみに今回のようにNGINXを扱う場合や、NGINX ingress Controllerを扱って流量制御する場合にはインスタンス毎のリクエスト数による制御となります。つまり、今回のように20rpsで設定した場合、インスタンスが3つあると全体で20 * 3 = 60rpsのリクエストを捌くことになるという計算になりますのでご注意ください。
さて、 limit_req_zone
を指定しただけではまだ過負荷判定が出来ているとは言えません。次はどこのパスに対してこの設定を適用するのか?を設定します。
これで /test
に対して過負荷かどうかの判定ができるように設定しました。
ですが、このままでは超過したリクエストが503になるだけです。ここから次は「過負荷の時にはGCSでホスティングしたサイトを表示」をやっていきましょう。
過負荷の時にはGCSでホスティングしたサイトを表示
ここでは少しトリッキーなことを設定し、GCSへリクエストを遷移させます。まず、 limit_req
モジュールには limit_req_status
というディレクティブがあり、これをlocation内で指定すると超過したリクエストのHTTPステータスコードを503から変更することが出来ます。これを使って超過した時に503から555を返すようにしてみましょう。
これだけでは何のこっちゃい?といった感じですが、併せて error_page
ディレクティブを使っていきます。これは指定したHTTPステータスコードに紐付くエラーページを設定するもので、よくあるのがカスタマイズした404や503ページを表示するのに使うと思います。
これを使ってHTTPステータスコードが555の時に○○を表示する、といったことをやってみましょう。
こんな風になりました。さてここまで来たら「〇〇を表示する」のところを「GCSでホスティングしたサイトを表示する」に変更したら実現できそうですね。
ここで必要となってくる新しい要素に named location
というものがあります。 named location
とは通常のリクエストでは使われず、内部リダイレクトのみで使われるlocationです。内部リダイレクトとはNGINX内で完結するリダイレクト処理で、グローバルネットワークに出ることなくリダイレクト処理を行うことが出来ます。
この named location
を使ってこんな風に書いてみましょう。
このような形で、過負荷判定だった場合にHTTPステータスコード 555を返し、 error_page
では named location
を使って内部リダイレクトを行い、 named location
では proxy_pass
をGCSのドメインにすることでGCSへの表示に切り替えることが出来ます。もう一つ追加しているのが、 server_name
で今回はGKEとGCSの2つのドメインが入り混じるので追加しておきました。
ちなみにこの手法の利点として、別ドメインのGCSへ飛ばしているのにURLは変わらないことにあります。これは proxy_pass
を使っているおかげではあるのですが、詳しい説明はドキュメントを参照してください。
この状態で一度負荷ツールを使って、負荷をかけつつ実際にGCSにリクエストが飛ぶかどうか確認してみてください。そのままだとGCSに飛んだかが分からないので、GCSにある test/index.html
を少し改造して、 <h1>Test Page in GCS</h1>
とすると分かりやすいかもしれません。
ここまででかなり形にはなりました。しかし、先程負荷をかけて確認した方は気付いたと思いますが、 /test
にアクセスしたら /test/index.html
にリダイレクトされたかと思います。これはGCSの仕様で、 /test
の場合には /test/index.html
へリダイレクト、 /test/
の場合には /test/index.html
の内容が表示されるといった挙動になっているためです。
これでは少し見た目的にまずいので、 named location
の中身を少し改造して /test
と来たら /test/
とリライトしてしまいましょう。
さて、これで過負荷の時にはGCSでホスティングしたサイトを表示するという要件は満たせましたので、次は過負荷ではない時にはGKEからサイトを表示にトライしましょう。
過負荷ではない時にはGKEからサイトを表示
本記事も長くなりましたが、最後に過負荷ではない時にGKEからサイト表示するようにしましょう。こちらはNGINXだけでなく、Next.jsにも手を入れていきます。
またまた振り返ってみるとGKEからサイトを表示をやりたいモチベーションは「なるたけ新鮮な情報が表示されるようにする」でした。では、現在はどうなっているかというと、 getStaticProps
を指定しているためビルド時に取得したデータを返すようになっています。これではいけません。
アレコレと頭を悩ました結果、「NGINXで何かしらのリクエストヘッダーを付与し、Next.js側のMiddlewareでリクエストヘッダーがあればCookiesを発行し、CookiesがあればGraphQLへリクエストする。無ければビルド時のデータを返す」という妙案が浮かびました。
まず、Next.jsでGraphQLへリクエストする/しないを判定するのに「NGINXを通っているかどうか」の情報が必要であり、それにはNGINXで何かしらリクエストヘッダーを付けてあげればいいと考えました。そして、Next.jsでリクエストヘッダーを受け取るにはMiddlewareが必要なので、Middlewareで受け取った後にクライアント側で判断できる"何か"を渡さないといけない。それを今回はCookiesでやってみようと思い至った次第です。
なので、やることとしては以下の3点になります。
それでは最後のパートをやっていきましょう。
NGINXでリクエストヘッダーを付与する
これは非常に簡単で、 /test
のlocationでリクエストヘッダーを付けるだけです。
しれっと追加しましたが、通常のappへの proxy_pass
を書くの忘れていたのでここで追加しておきました。(すいません)
で、その proxy_pass
先へ proxy_set_header
でリクエストヘッダーを設定しています。
Next.jsでMiddlewareを作成する
次にリクエストヘッダーをMiddlewareで受け止めてみましょう。Next.jsの src
ディレクトリの下に middleware.ts
を書きます。
Next.jsのmiddlewareはリクエストとレスポンスを参照し、編集することができます。まずリクエストの NextRequest
から渡ってきたヘッダーの X-Pass-Nginx-Request
を参照して、もし存在すればGraphQLのリクエストを行うためのCookiesを設定しなければいけません。そのため、今度はレスポンスの NextResponce
に対して RequestGraphql
を設定しています。
maxAge
をoptionとして付けているのは、消えるようにしておかないと過負荷時にGCSを見に行ったとしてもGraphQLへリクエストしてしまうので、それを防ぐためにやっています。
Cookiesの有無でGraphQLへのリクエストするかどうかを決める
最後に先程発行したCookiesを参照して、GraphQLへリクエストするかどうか決める処理を書きましょう。
やっていることとしては、「RequestGraphql Cookiesを取得」して、「存在すればuseTestQueryでGraphQLリクエストを行う」「無ければprops.dataを利用する」といった感じになります。これでGCSではビルド時の情報を返し、GKEでは表示時にリクエストして返ってきた情報を返すといったことが出来ました。
一旦完結
ここまで作ってきたもので一旦は完となります。試しに負荷ツールで負荷をかけつつ、チームメンバー総出で手動で負荷をかけてもらいましたが、難なく捌けて表示にも問題ないことが確認できました👌
とりあえず無い知恵を絞って仕組み作りをしてみましたが、如何だったでしょうか?もしかしたら穴があったり、もっと良い方法があったりするかもしれないのでそういったご意見ははてブの方で寄せていただけますと幸いです🙏
ちなみにこの話のオチですが、紆余曲折があり結局この仕組みは世に出ることはありませんでした。残念。
ただし、こういった云々かんぬん考えた間に得た知見は腐ることがないので、いい経験になりました。
さて一旦はここで完結ですが、最後にこの仕組みのCI対応のお話と、Next.jsで若干ハマった pages
以外でのSSG対応のお話を供養として載せておきますので、まだ記事を読む気力のある方は引き続き読んでいただけると幸いです🙇
CI対応
読んでいただける方、ありがとうございます。ここからは本題からは少し逸れたボーナストラックになります。
今回はNext.js側で色々と変更点を加えたことで、既存のCIの修正が必要となりました。どういった修正が必要だったかのお話をしていきます。ちなみにCircleCIを使っているので、コードはそちら準拠となります。
修正を加える点としては以下の2点でした。
out
ディレクトリの中身をGCSへアップロードtrailingSlash
を切り替える
CirlceCIの設定ファイル例を見る前に、 next.config.js
を少し変えておきます。
trailingSlash
をtrue -> falseにしておきました。
で、CircleCIの設定ファイル例はこちら。
out
ディレクトリの中身をGCSへアップロードするのは簡単ですね。単純にGCSへアップロードするだけなので、権限を与えたサービスアカウントのキーを使ってAuthし、 gsutil
を使ってアップロードするだけです。(最近は gcloud storage
がGAになったのでそちらを使うでもOK)
trailingSlash
を切り替える処理は sed
を使って、 trailingSlash
を false -> true にしてから next export
して、また戻しています。何故やっているのかというとGCSでホスティングする際には trailingSlash
を有効化して、GKE側では trailingSlash
を無効化にしておかないとGKE側で /test
のようにアクセスできなくなってしまいます。個人的にこれはあまりよろしくなかったので、こういう処理をしています。
pages
以外でのSSG対応
これ結構悩んだのですが、実際に仕組み作りをしている中で pages
ディレクトリ以外に存在するReactカスタムフックの中でGraphQLへリクエストしている箇所があり、これも「GCSではビルド時のレスポンスを返し、GKEではGraphQLへリクエストして取得する」という出し分けをしなければいけません。
しかし、getStaticProps
は pages
以下にあるファイルでしか用いることが出来ないため、また違った方法を探さなければいけません。
Googleを探し歩いた結果、「プリビルドで取得したレスポンスをJSONファイルとして保存し、GCSでそれを返す」といった方法を発見しました。割とやっている人が多かったので、これくらいしか解決方法がないのでしょう。(原始的な方法ですが・・・)
気を取り直してやっていきましょう。
まずはプリビルドで取得するところからです。プリビルドは npm script
の prebuild
を用い、レスポンスを取得してJSONとして保存するスクリプトを ts-node
で動かします。
こんな感じでApollo Clientを通して取得したレスポンスを data
ディレクトリにJSONファイルとして出力しています。
このJSONファイルをRequestGraphQLのCookiesがなかった場合に以下のようにして返してください。
testData = await import("data/test.json");
pages
以外でのSSG対応はこのような流れになります。これについてはもっと良い方法はないものかと思ってはいるのですが・・・うーむ。
本当に完
これにて本当に完結です。ここまで読んでいただけた方は改めてありがとうございます。
やっている中でもっと何か小ネタがあった気がするのですが、とりあえず思い付くだけ書いてみました。誰かの参考になれば幸いです🙏