この記事は、Merpay Tech Openness Month 2023 の16日目の記事です。
こんにちは。メルペイのバックエンドエンジニアの@panoramaです。
今回はメルカードのバックエンドにおいて「外部APIへのリクエストの流量制御を実現するためにCloud Tasksを導入した話」をご紹介します。
背景
メルカードのバックエンドでは提携している企業さまのAPIをさまざまな処理で呼び出しています。(以降このAPIを外部APIと呼びます。)
メルカードをご利用いただいているお客さまが増えるにつれ、通常のご利用時やカスタマーサポートでこの外部APIを呼び出す処理も増え、急激に負荷がかかることも発生するようになりました。
もし、時間当たりの処理件数が外部APIの処理速度を上回ってしまうと処理が失敗してしまいます。
しかし外部APIは自社内のマイクロサービスとは異なり、自分たちで自由にスケールすることはできません。
対策
外部APIの呼び出しは同期的でなくても良いケースがあります。
また今回の高負荷時の調査で「同期的でなくても良い呼び出し」が同時に複数起こっているケースを観測していました。
これらを非同期の呼び出しに変更し、処理レートを設定することで、非同期化できた部分の負荷を一定以下に制御することができます。
今回の課題は瞬間的な高負荷(スパイク)への対処です。
負荷が上がっている場合の最もシンプルな対策は外部APIのスケールを依頼することですが、平常時は現在の処理速度で問題なく、一瞬の高負荷な状態さえなだらかなものに変えることができれば解決できます。
実現手段
上記の実現方法としては、例えば以下のような案が考えられます。
- アプリケーションレイヤーで非同期化する
- 定期実行バッチで非同期化する
- 流量制御が可能なキューイングのマネージドサービスで非同期化する
それぞれ
- 言語の並行処理・並列処理の機能を用いて非同期化し、非同期化された処理全体のレートが一定以下になるように連携する
- 処理対象をデータベースに一時的に記録し、その記録をもとにバッチ処理で回収する
- マネージドサービスがサポートする設定項目で自サービスの求める流量制御を実現する
ことによってその処理レートを設定します。
メルペイではGo/k8s/GCPを使用しているので、1はgoroutine、2はCronJob、3はCloud Tasksが該当します。
1に関して「複数のpodで複数のgoroutineが動く環境全体の外部API呼び出しのレートを一定以下にする」ということをアプリケーションレベルで実現するのはかなり実装コストが掛かります。また「非同期化の対象が増えたときに容易に追加できる」「対象毎に処理レートの設定が可能な」汎用的な仕組みである必要があります。
2の方法はシンプルですが、高負荷時以外の多くの場合でバッチに拾われるまでに無駄な遅延が発生します。(CronJobのスケジュールの間隔は最短でも1分)
一時的なアクセス増においてのみ外部APIの呼び出しをなだらかにしたいという目的だったので、それ以外ではほとんど即時に処理されてほしいです。
3のCloud Tasksを選択した場合はその特徴から上記1、2の問題点は解消されます。
- フルマネージドサービスなのでキュー管理を意識する必要がない
- メルペイではGoogle CloudのリソースをTerraformで管理しているため、キューの追加やレートの変更はtfファイルの変更で容易にできる
- 一定のレートを超えないように流量制御するが、それ以下のときはほとんど即時実行される(つまりスパイクのみなだらかにしてくれる)
また標準でリトライ機構があるため、一時的に外部APIが不安定になり失敗した場合でも、自前でリトライ処理を実装する必要がありません。
今回は使用していませんが、タスクを実行する時間を指定するスケジューリングの機能や重複排除などもサポートしています。
一方で、今回は非同期化対象が外部APIなので、
- 外部APIの認証情報をCloud Tasksに持たせたくない
- リクエスト/レスポンス時のログを自社サービス内で落としたい(ロジックに通したい)
という事情がありました。
よってこれらを解決しつつ、Cloud Tasksの利点を活用することにしました。
前提知識
先程の「実現手段」でCloud Tasksの特徴を挙げましたが、ここでCloud Tasksについて説明しておきます。
Cloud Tasks
Cloud TasksはGoogle Cloudが提供する非同期タスク実行を行うためのフルマネージドサービスです。
タスクと呼ばれる単位をキューに送信すると、非同期に取り出されてワーカーに送信されます。(タスクをワーカーに割り当てることをディスパッチと呼びます。)
ディスパッチのレートにはトークンバケットアルゴリズムという流量制御のアルゴリズムが使われており、大量のトラフィックが来てもバケットサイズ、レート設定で定めた基準以下に抑える(均一にする)仕組みになっています。
ディスパッチ後、ワーカーから2xxのHTTPレスポンスが返ってくるとタスクは完了されたとして消去されます。2xx以外のレスポンスコードが返ってきた場合やリクエストがタイムアウトした場合はリトライに移行します。
Cloud Tasksのユースケースはたくさんありますが、公式のガイドには”Managing third-party API call rates”が含まれています。
このようなキューイングのサービスは他にもあり、AWSだとAmazon SNS、AzureだとAzure Queue Storageなどがあります。
今回の構成
通常であれば以下のように直接外部APIを呼び出す構成になると思います。
しかしこの場合はCloud Tasksが外部APIの認証情報を持ち、ログはCloud Tasksの実行ログとして落ちます。
そこで今回は次のように一度自分のサービスをproxyのように経由させて外部APIの認証を乗せています。
これによって
- 外部APIの認証情報をCloud Tasksに入れる必要がない
- リクエスト/レスポンスをロジックにかけて処理することができる
- ログを自サービスに落とせる
- 失敗した場合に記録したり、失敗の詳細をSlackに通知したりできる
などのメリットがあります。
つまりCloud Tasksを純粋にタスクの非同期タスク実行管理の目的で使っています。
このやり方のデメリットとしてはCloud Tasksからの呼び出しで自サービスを経由する分だけ、自サービスのトラフィックは増加します。
今回非同期化の対象となった部分は全体を通してリクエスト数はそこまで多いわけではなく、平常時と高負荷時の差がかなり激しいケースだったためこの部分はあまり大きな問題ではありませんでした。
また副次的な効果としてリトライ機構があるので、サービスが不安定になったりメンテナンスに入ったとしても非同期タスクが失われることを考慮する必要がありません。
まとめ
今回は非同期化可能な外部API呼び出しの流量制御においてCloud Tasksを使った例を紹介しました。
一般的なユースケースに比べて少し特殊な例だったかもしれませんが、今後非同期タスク実行を検討するときの選択肢として本記事の知識がお役に立てば幸いです。
私は今まで非同期実行についてそこまで深く考えることはなく、Cloud Tasksを使用するのも初めてだったのでとても勉強になりました。
こういうパターンで他に良い方法や面白い知識があれば、教えていただけるとうれしいです👀
それでは、ありがとうございました。
明日の記事は@krisさんです。引き続きお楽しみください。
※ 追記: Cloud Tasksからメルカードバックエンドへの通信経路や認証については省略しています