リリース後に落ちないように、新規サービスで備えておいたこと

こんにちは、エンジニアの@tarrです。
前回の連載記事ではマルチクラウドなどを使い、Blocksでは最大限落ちないようにリスクヘッジをしながらシステムを構築しているという記事を書きました。

今回の記事は、リリース前の状態で、どのように負荷に対応するシステムを構築したかという話です。

リリース前のシステムで、スパイクやユーザー数の急激な増加を予測して、スケールするシステムを作るのは非常に難しいです。
どのように使われるのか、どのような量のリクエストが来るのかを正確に予測することがしづらいからです。

一方で、リリースのタイミングでいきなり落ちてしまって、機能しないものを提供するわけにもいきません。
また、無限にコストをかけることもできないため、コストにも配慮した落とし所を探る必要もあります。
今回は、Blocksがリリース前にスパイクに対してどのような準備をしたかを紹介します。

リリース前はある程度の経験則なども含めて意思決定をしていき、リリース後にチューニングをしていくという考え方をしていました。
そのため、記事内で疑問点が浮かぶ箇所もあるかと思います。
この記事は、β版のリリースまで時間がない中で70点くらいを目指すという部分を書いており、なるべく生々しい情報や考えを載せることを重視しているため、その点はご留意ください。
また、この記事の調査内容は、2020年の7月ごろに行われたものとなっています。

このエントリーは次の流れで展開されます。

  • ボトルネックになる部分の選定
    • Blocksのcomponent単位でどこがボトルネックになるかを探っていきます。
  • 実行環境をどうするか
    • スパイクに対抗するためにはGAEかGKEのどちらを採用するかを探っていきます。
  • 実装言語をどうするか
    • Node.jsとGoでのApplicationの運用経験もありません。のどちらを採用するべきかを探っていきます。
  • Stress Testで最適な設定を見つける
    • Stress Testを行い、スパイクにも強い設定を探っていきます。

ボトルネックになる部分の選定

スケーラブルなシステムを設計するにあたって、まずはじめに考えなければならないのは、ボトルネックはどこになるのかということです。
Blocksのボトルネックはどこになるのでしょうか?

アーキテクチャ図

アーキテクチャ図より、次の5つのcomponentで分けて考えていってみます。

  • admin component(管理画面)
  • builder component(builder.jsを生成するcomponent)
  • builder.js(builder.jsというJavaScriptを配信するcomponent)
  • log-collector(エンドユーザーから送信されるeventを収集するcomponent)
  • log-aggregator(収集されたeventを使いやすい形に加工するjob component)

admin component

管理画面から考えていきます。
管理画面のadmin componentは、クライアントが触る部分です。
ここにアクセスが集中することは考えづらいです。
社内でスタンダードな構成に載せて、GKEで提供します。

builder component

管理画面でブロックの操作をすると、builder.jsが生成されます。
このbuild処理はbuilder componentで行われます。
buildは管理画面を操作するクライアントによって行われるため、大量のリクエストを処理するということにはなりません。

builder.js

builder componentから生成されたbuilder.jsはS3に格納され、CloudFrontによって配布されます。
エンドユーザーからのリクエストがダイレクトに来る部分なため、負荷的な意味でのスパイクはここが一番大きいです。
しかし、CloudFrontから配布すればいいので、ここのスパイクは考えなくても良さそうです。

log-collector

builder.jsを読み込んだエンドユーザーは、ブロックの書き換えや表示などが行われるたびにeventを送信します。
そのeventの受け先として、log-collectorというcomponentを用意しています。
クライアントのサイトをユーザーが表示をするたびに複数のeventが飛ぶため、ここは大量のリクエストが飛んできます。

クライアントのサイト数 * クライアントのサイトのPV * PVあたりのevent数

上記式のような量のeventが飛んでくる上に、スパイクが発生することも予想ができます。
スパイクにシステムが耐えられなかった場合、Blocksで提供している計測機能が使えなくなってしまいます。
Blocksのシステムのボトルネックはここになるでしょう。
ここは慎重な技術選定をしていく必要があります。

log-aggregator

log-collectorで集められたeventは、log-aggregator componentによって、集計処理が行われます。
ここでは実際に管理画面に表示されるデータへと加工されます。
これはjobサーバーによって、数十秒に1回、処理が行われます。
データ量によっては一度の処理時間が長くなり、終わらなくなったりすることが予想できます。

クライアント単位で分割するなど、適切に分割して並列処理できるようにしておくことや、負荷をかけないSQLを書くことで、スケールできるようになります。
ここは、データ量が増えるにしたがって、中間テーブルを作るなど、今後メンテナンスをする必要があるかもしれません。

実行環境をどうするか

Blocksのシステムでボトルネックになりそうな部分はlog-collectorになりそうだということがわかりました。
log-collectorにくるスパイクが、どのような特性を持っているのかを考えてみます。

Blocksのクライアントのサイトのリクエストが、サービスの負荷に直結するサービスの特性のため、スパイクが予測できないというものがあります。
多くのサービスでは、自社サービスのスパイクのタイミングがある程度予想できます。
たとえば、セール開始のタイミングや、プッシュ通知を送ったタイミングなどには、大量のリクエストが発生するためスパイクになりやすいと予想できます。

通常なら、そのタイミングでインスタンスを準備しておくなど、スパイクに備えることをします。
しかし、Blocksのようなサービスでは、予期できないスパイクに対して、取りこぼさないようにスケールするという設計しなければなりません。
このスパイクの特性に加えて、通常のリクエスト数も大規模になることが予測できるため、コストを考えて、通常のリクエストをさばく効率性も求められそうです。

GKE vs GAE

スケールのスピードが重要な場合、実行環境には何を採用するのがよいのでしょうか。
PLAIDでは大半のシステムをGKE(Google Kubernetes Engine)かGCE(Google Compute Engine)上で運用されていることが多いです。
このlog-collectorも、開発コストをかけずに作ろうとしたら、GKEかGCEで作ってしまうのが、もっとも簡単です。
しかし、予測のできないスパイクに対応するためには、より素早くスケールする仕組みが必要でした。

社内で相談をしていたら、GKEなどに比べてGAE(Google App Engine)のほうがスケールが速いということを聞きました。
GKEとGAEで実際に負荷をかけて試してみると、GAEのほうがスケールは速く、コスト面も概算をしてみるとビジネスに影響を与えるレベルではなさそうでした。
そのため、リリース後にコストが問題になったら再考するとし、log-collectorはGAEで運用をしてみることにしました。
ただ使うだけではなく効率よくスケールさせるためには、GAEのチューニングをやる必要があります。

実装言語をどうするか

また、スケール時のパフォーマンスを上げるために、Cold Start時のパフォーマンスを良くするというアイデアがありました。

BlocksのcomonentはNode.jsで開発されており、チームメンバーもNode.jsに習熟しています。
log-collectorをNode.jsとGo言語を使い実装し、パフォーマンスを比較してみることにしました。
実際のパフォーマンスの比較には、GAEで動かしたNode.jsとGoの実装に対して、loader.ioを使ったstress testをしました。

Node.js vs Go

Node.jsに負荷をかけたときの図

上図はNode.jsのシステムに対して負荷をかけたときのloader.ioの結果です。
青のラインがresponse timeを表しており、スパイク時に一度6000ms付近まで上がりましたが、30sほどでインスタンスがスケールして、最終的に1000ms付近で返しているのが見て取れます。

Goに負荷をかけたときの図

次に、Goのシステムに対して、同じ負荷をかけてみます。
青のラインを見てみると、Goではスパイク時には、5000msまでresponse timeが上がったものの、スケールにしたがってすぐにresponse timeが下がっていき、最終的に150ms付近で落ち着いていることがわかります。

エラーレートはGoとNode.jsはほとんどかわらず、最初の数秒だけ少しエラーが出ている状況でした。
緑のラインはclient数で、秒間6000のclientが立ち上がります。
1秒以内に処理ができていれば、6000付近で落ち着くはずですが、goはその基準も満たしています。

これより、次のようなことがわかりました。

  • スパイク時の性能をみると、このconfigではそこまで大差がないように見える(あまり判断できない)
  • 安定しはじめると、Goの実装の方が優秀(結局、このときの検証では7.23倍パフォーマンスに差がありました)

この結果から、Goを採用して、GAEの設定をチューニングして、スパイク時に対応するという方針で詰めていくことにします。

Stress Testで最適な設定を見つける

GAEの設定はどのようにチューニングをしていけばよいのか、あたりをつけてみます。
GAEのapp.ymlに書く設定項目としては次のようなものがあります。

  • instance_class : インスタンスの種類
  • max_concurrent_requests: 1インスタンスで受けつける最大のリクエスト数
  • min_idle_instances: アイドル時の最小インスタンス数
  • target_throughput_utilization: リクエスト数でのauto scaleの設定
    • リクエスト数 / (max_concurrent_requests * インスタンス数) > target_throughput_utilization のときに新しいインスタンスが作られる
  • target_cpu_utilization: CPU使用率でのauto scale設定
    • 値が小さいほどスケールするがコストは増加

このstress testの方針として、下記を検証すれば、スパイクに耐えられるシステムを作ることができると考えました。

  • 1インスタンスあたり、concurrentで何requestさばけるかを知る。
    • ここでわかった値を元に、max_concurrent_requestsを決める
  • それを元に target_throughput_utilization などの数値の調整によって、スケールの最適化を目指す。

上記の目的に沿って、stress testを行います。
stress testはloader.ioというサービスで、6000qps(Queries Per Second)の負荷をかけて行いました。
6000qpsという値は、初期のリリース時に入る予定のスパイクを想定して、出した数字になります。

色々なパターンでの負荷テストを検証し、その中から、結論をだす根拠の流れが見えるようにピックアップして結果を載せてみます。

max_concurrent_requests を決める

まず、1インスタンスあたり、どれくらいのrequestをさばけるのかを探っていきます。
max_concurrent_requestsという設定が、1インスタンスで受けつける接続数になっています。
ここを変更しながら、だいたいどのくらいのリクエストがさばけるのかを見ていきます。
最初にmax_concurrent_requestsを70に設定してみます。

instance_class: F1
automatic_scaling:
  max_instances: 1000
  max_concurrent_requests: 70

max_concurrent_requests:70でのresponse time

上のグラフを見てみると、スパイク時からパフォーマンスがあがるまでのタイムラグが増えていることが見て取れます。
また、スパイク後は安定してリクエストをさばけています。

max_concurrent_requests:70でのerror rate

また、こちらのグラフからは、スパイク時にエラーが返ってきているのがわかります。
つぎに、max_concurrent_requestsを40に設定してみます。

instance_class: F1
automatic_scaling:
  max_instances: 1000
  max_concurrent_requests: 40

max_concurrent_requests:40でのresponse time

max_concurrent_requestsを40に下げると、70よりもはやくスケールして安定化しているのが見て取れます。

max_concurrent_requests:40でのerror rate

スパイク時のエラーも少しは出ていますが、かなり減っています。
この設定はCPUを富豪的に使う設定なので、これを下げていけば究極的にはさばけるようになります。
しかし、安定したらmax_concurrent_requests:70でも十分さばけていました。

そのため、ある程度スパイクでもエラーが出ない設定値をさがしつつ、少量のエラーは他の設定値でカバーするという方針が良さそうです。
何度か数値をかえてテストをしていった結果、今回は、max_concurrent_requests:50で調整を詰めることにしました。

min_idle_instances

スパイク時のエラーを下げるアイデアとして、min_idle_instance を設定して、事前にインスタンスを待機させることを試してみました。
数字を5から段階的に50まで上げてためしてみたところ、スパイク時のエラーの数は減らず、スパイクに影響はないようでした。
これは今回のテスト規模からして、想定しているスパイクだと丸められるからだと推測しています。
あまり多くのインスタンスを待機させておくのはコストの面で良くないと考えたため、min_idle_instancesの設定はしないことにしました。

target_throughput_utilization と target_cpu_utilization

target_throughput_utilizationは、次の式が成り立つときに、スケールするという設定です。

リクエスト数 / (max_concurrent_requests * インスタンス数) > target_throughput_utilization

これは、許容リクエストの何%を超えたらスケールさせるかという話と置き換えることができます。
単純に考えると、request数でスケールさせればよいように感じますので、ここを調整したくなります。
しかし、target_throughput_utilizationはデフォルト値が0.6で、最低値が0.5になっています。
0.50.6の2つでテストをしたところ、差が見られませんでした。
この数字は低ければ低いほど余裕を持ってスケールするため、0.5で差が見られなかったことで、他の設定値で調整をする必要がありそうです。

次に着目したのが、target_cpu_utilizationで、スケールする際のcpuのしきい値を設定します。
target_cpu_utilizationのデフォルト値は0.6で、最低値は0.5となっています。
たとえば、0.6の場合はCPU使用率が60%に達したら、新しいインスタンスが作られる設定になります。

過去のテストで、スパイク時にCPUが限界に達することがわかっていたため、「CPUの使用率でスケールさせるほうがうまくいくのでは」と考えました。

instance_class: F1
automatic_scaling:
  max_instances: 1000
  max_concurrent_requests: 50
  target_cpu_utilization: 0.6

target_cpu_utilization: 0.6でのerror rate

target_cpu_utilization: 0.6でのresponse time

target_cpu_utilization: 0.6でのインスタンス数

上のグラフをみてわかるのは、次のようなことです。

  • スパイク時にエラーがでている
  • 2分近くかけてスケールをしている
  • スパイク時に余分なインスタンスを含めて、657インスタンスくらいまでスケールしている

これを参考に、target_cpu_utilizationを0.5に減らし、CPU率が50%に達したらスケールするように設定しました。

instance_class: F1
automatic_scaling:
  max_instances: 1000
  max_concurrent_requests: 50
  target_cpu_utilization: 0.5

target_cpu_utilization: 0.5でのerror rate

target_cpu_utilization: 0.5でのresponse time

target_cpu_utilization: 0.5でのインスタンス数

target_cpu_utilizationを0.5に減らした結果、次のような結果が得られました。

  • スパイク時にエラーのエラーがなくなった
  • スパイク時のスケールが1分くらいに短縮された
  • スパイク時に余分なインスタンスを含めて、550インスタンスくらいで、下がった

この設定値で6000qpsまでのスパイクに対応できそうです。

設定を少し変更して、追加でテストする

ここで、一旦リリース時の設定値は決まりました。
これに加えて、この値が通用しなくなったときにどのように変更していくかの方針を決めるために、いくつか値をずらしたテストを追加で行いました。
多角的に特性を理解しておき、今後の参考にします。

結果を軽く共有だけしておきます。

  • 10000qpsに増やすとどうなるのか
    • スパイク時に多少のエラーが出るようになりましたが、スケールのスピードは変わりませんでした。
  • 設定値をそのままで、instance classをF1からF2に変更するとどうなるのか(パフォーマンスと料金が2倍のインスタンス)
    • 上位のインスタンスなのに、逆にエラーが出るようになりました。
    • instance_class上げるなら、cpuをきちんと使うところまでmax_concurrent_requestsをあげる必要がありそうです。
  • max_concurrent_requestsを55や60に変更して、最適値をさらにさぐってみました
    • エラーやスケールのスピードに問題が出始めました。

調査結果と結論

最終的な設定値は下記です。

runtime: go112
service: rewrite-log-collector-go-eva

instance_class: F1
automatic_scaling:
  max_instances: 1000
  max_concurrent_requests: 50
  target_cpu_utilization: 0.5

また、次のような知見が得られました。

  • GoとNode.jsで比較してみると、通常運用時にさばける数がGoのほうが多かったため、Goを採用する
  • スパイク時の挙動がGoのほうがよいと言い切れなかったが、Goもチューニングしたことによって、6000qpsくらいのスパイクであればかなりエラーを少なくできた
  • Idle instanceを大きく置くのはありだが、大きいスパイクでは丸められるので、あまり効果がなさそうだった
  • Concurrent requestを減らす方向でもうまくスケールするが、通常時のコストが上がる
  • F1 instanceを使う場合、concurrent requestは50が限界だった(6000qpsのスパイクに対応するためには)
  • F2 instanceにする場合、concurrent requestなどをあげないと、cpuがうまく使われず、スパイク時にうまくスケールしない

また、今後スパイクの大きさが大きくなったときに取れる方針としては、instancetypeを大きくして、concurrent requestも少し増やすという方向で調整したら、うまくスケールしそうだということもわかりました。

おわりに

この結果を元に設定したlog-collectorを動かしており、β版のリリースから、本リリースした現在まで、設定を変えずに順調に動いております。
Blocksを導入しているクライアントからリクエストがもっとも多くなり、スパイクが発生する瞬間は2020/12/31から2021/01/01にかけての年越しの瞬間でした。
Event数が、いきなり2.83倍になるスパイクだったのですが、特に問題なくスケールして乗り越えました。

年越し時のspikeのqueue

年越し時のspikeのinstance数

一方で、実際に運用を重ねていくうちに、KARTE Blocks内のコストでGAEが大きい割合を占めることもわかってきております。
現在はビジネスに与える影響が少ないため、コスト削減をするフェーズではないですが、今後コスト削減をすることにしたときに、GAEのチューニングが考えられます。
リリース後に問題なく機能するという課題はクリアできたので、次は必要に応じて最適化をするということ意識していきたいです。

私はGAEをビジネスレベルで使うのは今回が初めてですし、GoでのApplicationの運用経験もありません。
今回の記事中の内容にはおそらく多くのツッコミどころや、もっと良くできるというアドバイスがあると思います。
もしよければTwitterなどでコメントいただければ、読ませてもらうので、反応をお待ちしております。


この記事は「KARTE Blocksリリースの裏側」という連載シリーズの9日目の記事でした。全10回を予定しています。
これから毎日記事を更新していくため、更新をチェックしたい方は@KARTE_BlocksのTwitterアカウントをフォローしてください!

「KARTE Blocksリリースの裏側」の記事の一覧は次のとおりです。

  1. KARTE Blocksを支える技術
  2. インクリメンタルに新しい技術を取り入れる方法
  3. セカンドパーティコンテンツをもつサードパーティスクリプトの作り方
  4. AWSが落ちてもGCPに逃がすことで落ちないシステムを作る技術
  5. ユーザーが自ら理解・学習するためのテックタッチなアプローチ
  6. 爆速で価値あるプロダクトをリリースするためのチームビルディング
  7. CSS in JSとしてVanilla-Extractを選んだ話と技術選定の記録の残し方
  8. 0→1のフェーズで複数のユーザー体験をつなぐUIデザインを考える|wagon|note
  9. リリース後に落ちないように、新規サービスで備えておいたこと ← イマココ
  10. KARTE Blocksにおけるポジショニングの考え方とその狙い
  11. 「KARTE Blocksリリースの裏側」の裏側 - 複数人で連載記事を書く方法