キャンペーンのマイクロサービスの高速化

_sskyu氏:では、「Pros and Cons of SSR and JAMstack」と題して発表させていただきます。

私はSasaki Yutakaと申します。メルペイには一昨年の9月1日に入社しまして、フロントエンドエンジニアをやっております。

今日話す内容ですが、去年の8月からキャンペーンのマイクロサービスの高速化について調査をしてきて、先月、本番リリースを一部したので、その過程というか、そこで得られた知見について発表させていただきます。

アジェンダはこんな感じです。

まずメルペイキャンペーンとは何なのか? こういった1枚のLPを作っていて、だいたい1ヶ月に1〜2本ずつ開発しています。

これはキャンペーンマイクロサービスに限らず、フロントエンドチームで運用しているマイクロサービスで使っている技術スタックです。Node.jsの上でNuxt.jsを動かしていて、リバースプロキシにNginxを使っていて、CI/CDにCircleCIとSpinnakerを使っていて、それをKubernetes環境で動かしています。

ここでキャンペーンマイクロサービスの問題があって、SSRのパフォーマンスが出ないという問題がありました。1Podあたり20rpsしか捌けないというのは、これは2019年の4月時点だったんですけれども、Podを横にどんどんスケールアウトさせて、40〜80Podデプロイしてキャンペーンの負荷を耐えている状態でした。とても単純なLPを配信したいだけなのに、すごくコスパが悪い状況になっていました。

SSRのパフォーマンスについて調査

そこで、SSRのパフォーマンスについて8月に調査していったのですが、まずはいろいろなパターンで負荷試験をしてみました。

パターンは5個あります。1つ目は簡単な素のNuxt.jsアプリケーション。次にコンテンツの量が少ないキャンペーン。その次にコンテンツの量が多いキャンペーン。campaign BをNuxt.jsのclient-only tagで囲んでみるパターン。これはSSRはしないでSPAモードみたいに動きます。最後に、ファーストビューはSSRしますが、それ以降スクロールで隠れるような部分はclient-only tagで囲むというのをやってみました。

こちらが結果をグラフにしたものです。

当然のように、素のNuxt.jsアプリケーションが一番rpsが出ています。興味深いのが、コンテンツの量によってrpsに差が出たことです。コンテンツの量が多くなるほどrpsが出ていなくて、SPAモードにするとSSRしないので当然のようにrpsは上がって、5のパターンだと中間ぐらいになるというグラフになっています。

ここからわかったことは、SSRするコンポーネント数に比例してパフォーマンスが落ちることがわかりました。client-only tagを使うと、SSRしない範囲を決めて負荷を減らすことができることがわかりました。なので、ファーストビューだけをSSRして、スクロールで見ない部分はclient-only tagで囲むのが良さそうかなと思います。

一方で、このアプローチには限界が見えています。client-only tagを使うことで数十パーセントの改善が見込めるのですが、飛躍的な改善は難しそうなことが分かります。

nuxt generateというアプローチ

そこで、nuxt generateというまったく別のアプローチを試してみることにしました。以降、nuxt generate=JAMstackという意味合いで使っていきます。

そもそもNuxt.jsをご存じない方もいらっしゃるかもしれないので軽く説明をすると、srcのpagesというディレクトリの下に.vueファイルを置くと、それがRouteComponentと呼ばれるものになって、このRouteComponentはそのままディレクトリ構造のままURLに露出するようなかたちで使うことができます。例えばsrc/pages/foo.vueであったら/fooでアクセスできます。

nuxt generateは何をやっているかというと、SSR時に出力しているRouteComponentのhtmlをあらかじめ出力しておいて、それを読み込むとクライアントサイドで、rehydrationと呼ばれる、もう1回SSRと同じ処理が実行される流れです。そのあたりのことは、https://ja.nuxtjs.org/guide/に詳しく書いてあります。

「すすメルペイキャンペーン」を例として持ってきました。ここでは4枚出していますが、これがユーザーエージェントを見て、PC、iOS、Android、メルカリアプリ上のWebViewで出し分けを行っています。

ここで問題になるのが、nuxt generate時にユーザーエージェントが取れないという問題です。

nuxt generateをやっている箇所というのは、下の図で言うと、例えばmasterブランチにdiffを取り込んだときに、CircleCIがそれをhookして、nuxt generateを走らせます。それをrsyncでGCSにアップロードしてクライアントからアクセスするという流れです。

nuxt generateするときはreqやresという変数が使えません。「req.headers[‘user-agent’’]」みたいなOptional Chaningをしないで書いていると、ここで実行エラーになって、nuxt generate時にエラーが出ます。回避策としては、常に「ユーザーエージェントがある」と想定しないコードを書く必要があります。

次にv-ifです。ここにサンプルコードがありますが、v-ifの中でユーザーエージェントをもとに判定するような評価式を書くと、SSRだとユーザーエージェントが取れないので常にこのothersというのが出力されるのですが、クライアントサイドでrehydrationが起こるとユーザーエージェントが取れるのでこのisAndroidかiOSかどっちらかに入って、サーバサイドで出力したhtmlとクライアントサイドで計算したhtmlが不整合を起こしてDOMエラーが出るみたいなことが起こりました。これを解決するためには、v-ifをv-showに変えると一旦は収まるのがわかりました。

一部のキャンペーンを本番に出さないようにする仕組み

次に、キャンペーンは並行で開発されるものであるので、一部のキャンペーンは本番に出したくないという機能を作る必要がありました。

もともとはNuxt.jsのmiddlewareで実装されていて、この実装を見るとmiddlewareをRouteComponentで読み込んで、IncomingなRequestが来るたびに、都度判断して、404ページを返すようになっています。

これをそのままnuxt generateすると、generateした結果が404ページを出力するみたいになってしまって、これはあまり意図しない動きだったので使えませんでした。

そこでnuxt generate用のモジュールを独自で作りました。やりたいこととしては、本番にキャンペーンを出ないようにするということだったので、nuxt.configで除外したいキャンペーン一覧のリストを正規表現で列挙して、次にnuxt generate時のbuild:extendRoutesをhookしてRouting情報から除外します。このままだと画像や静的なassetは本番に出る可能性があったので、generate:doneをhookしてassetsの中から正規表現にマッチする画像名を削除するというコードを書きました。

あとはいくつかのnuxt lifecycleがブラウザ上で使えないという問題もあります。middlewareやfetchはnuxt generate時に実行されるので、これはCircleCI上で実行されます。赤枠で書いてある部分ですね。これらはブラウザ上で実行されないことに注意が必要です。

キャンペーンにおけるfetchで使うAPIは、ユーザーのステータスを取得するAPIがほとんどで、これをCircleCI上から叩くと、何をもってユーザーを識別するかが判断できないので、これはクライアントサイドにJSがロードされてからAPIを叩くようにソースコードを編集しました。

JAMstackの開発とデプロイフローも参考までに書きましたが、ローカルでは普段通りnuxt dev(universalモード)で開発します。

メルペイでは「dev」「test」「production」という3つの環境があり、dev環境はfeatureを気軽に開発するための環境で、testは、QAエンジニアと一緒になってクオリティを担保していくステージングみたいな環境です。

branchをpushすると、dev用のGCSバケットにnuxt generateしたものをデプロイして、Pull Requestのコードレビュー前に確認できる環境を用意してもらいました。

キャッシュレスキャンペーンをJAMstack環境に移行

キャッシュレスキャンペーンをJAMstack環境に最初に移行しようとしました。キャッシュレスキャンペーンはすごくシンプルなLPで、APIを叩く箇所はありません。キャンペーン期間が2019年の10月〜2020年6月末と今も開催しているキャンペーンで、「これを先に移行しないことにはどうしようもないな」という感じで移行することにしました。

ほかに動いているキャンペーンは長くても1〜2ヶ月で期限が切れてしまうので、今のマイクロサービス上で動かして、終わったらそのまま終了にしました。このキャッシュレスキャンペーンを先行して移行して、運用が問題なさそうであればそのまま新規開発はJAMstack環境で行う、としました。

移行に関して3つのステップ踏んでいます。まずJAMstack環境を作る。ドメインを決めて、マイクロサービス版とJAMstack版のキャッシュレスキャンペーンを並行で2箇所で動かすようにしました。当然ながら本番に出ないようにIP制限をかけて、社内からのみアクセス可能にしています。

2つ目に、本番環境にキャッシュレスキャンペーンをデプロイする。これは主にデプロイスクリプトの検証とマイクロサービス版のキャッシュレスキャンペーンとのコンテンツの差異がないことをQAにて保証するためにやりました。

最後に本番の有効化ですが、IP制限を解除して、メルカリアプリの「メルペイ」タブのキャッシュレスキャンペーンのバナーのURLを変更して、JAMstack版のキャッシュレスキャンペーンにリクエストが流れるようにしました。

これが全体のアーキテクチャです。マイクロサービス版は、CIでnuxt buildを走らせると、Node.jsとNginxのDockerイメージができてGCRにpushされて、Kubernetes上でサービスを動かすようになっています。htmlはNode.jsが返すんですが、それ以外のstaticなassetsのJSとかCSSとかはFastlyでキャッシュされているような構成になっています。

JAMstack版は、CIでnuxt generateを走らせたら、GCSにアップロードされて、htmlも事前にプリレンダリングされて、リクエストは全てFastlyがハンドルするようなかたちです。クライアントにhtmlやサブセットがロードされた後に実行されるAPIリクエストはMerpay Gatewayを経由してKubernetes環境で動いているMerpay APIに向けるという構成になっています。

この2つの環境でキャッシュレスキャンペーンを動かしている状態です。

どうやってマイクロサービス版からJAMstack版を見分けるかということで、最初はドメインを別にしようか考えていたんですが、議論して「ドメインは同じでいこう」となりまして、pathの先頭に「s/」がついているほうをJAMstack版にするという結論に至りました。

このsは「static」のsです。今は移行期間で、sがついていることでURL的に違和感があるので、そのうち消す予定です。

別ドメインでいこうと思っていたのですが、同じドメインにしたことによって、nuxt core fileや画像が参照できないという問題が起きました。ですが、これはnuxt.configのrouter.baseに/s/を設定すると、htmlの内にタグでって埋め込まれます。

このタグは相対パスの基準を設定するタグなので、画像を読み込むときに先頭から「/」とやると絶対パスになって「s/」なしのほうから読み込んでしまう問題が起きたので、画像などのsrcから先端の「/」を外して相対パスにすることで、この問題は解決できました。

マイグレーションが完了して、SSRとJAMstack版のキャッシュレスキャンペーンは、コンテンツに差異がなく、今のところ運用できています。

最後にSSRとJAMstackの差についてです。この図は「Rendering on the Web」というブログ記事からの図です。今回細かくパフォーマンス面で、例えば「TTIがFCPよりすごくかかるよ」と書いてありますが、そういう説明はしませんでした。というのも、このブログを読むとパフォーマンスに関して網羅的に関して書いてあるので、ぜひ読んでみてください。

今日ずっと話していたSSRというのは、この表の真ん中の「SSR with (Re)hydration」で、JAMstackと言っていたnuxt generateに関しては「CSR with Prerendering」です。

SSRとJAMstackのメリット・デメリット

最後に、このタスクを通してProsとConsを個人的にまとめます。SSRはコードで表現できる自由度が高いのがとてもいいです。また、JAMstackではNode.js周りの機能を追加することはできないので、どうしても追加したい場合はSSRでやるしかないと思いました。

一方で、SSRは運用コストがとても高くて、サーバを運用しているためオンコール体勢が必要になってしまいます。また、メルペイのキャンペーンの場合はrpsが出ないので、スケールアウトで対応せざるを得ません。つまり、金銭面ですごくコストが高くなります。

JAMstackのProsとConsなんですが、一番は運用コストが低いことです。当初のコスパが悪いIssueに関して言うと、マイクロサービスと比べて10分の1くらいに削減できそうという試算が出ました。あと、JAMstackは静的なassetをただホスティングするだけなのでオンコール体勢が不要になります。

一方で、アプリケーションによっては向き・不向きがあります。LPのようなペライチのサイトであったり、ブログやLPとかには適しているんですけれども、管理画面のような、動的であったりページ数が多かったりAPIをバンバン叩くようなものには不向きかなと思います。

それと制約がSSRに比べて多く、SSRと同等の表現をするときに工夫が必要になって、これから知見が貯まっていくかもしれませんが、けっこう苦労するかもしれません。

キャンペーンの場合は、ユーザー情報を叩くAPIがSSRで叩くとおかしなことになるので、クライアント側のJS実行タイミングでやる必要がありました。

発表は以上になります。本日はありがとうございました。

(会場拍手)