Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

回復性の高いMicroservicesアーキテクチャを支える技術

メルカリバックエンドエンジニアの@yagi5です。
Mercari Advent Calendar 2018の23日目を担当します。

モノリシックなシステムは、障害が発生するとシステムが全停止してしまうことが一般的です。
しかし、Microservicesアーキテクチャでは様々なテクニックを用いて、サービス全体が停止するような障害に対処することができます。

この記事では、Microservicesにおけるシステムの回復性を高めるための技術について書いていきます。

回復性とは、障害が起こらないことを意味しません。
高い回復性を備えたシステムは、障害が発生するということを前提に、システム全体のダウンを避け、データのロスが回避されるように設計されています。

Microservicesの世界では、システムは自律的に動作する複数のサブシステムによって構成されます。
ひとつのサービスに障害が発生しても、それ以外のシステムは正常に動作することを期待されます。
つまり、各サービスは自分がアクセスするサービス(隣のサービスなどと呼称します)が常に正しく動くことを期待せず、 エラーを返してきた際にも、それが原因で自分が死なないことを意識して実装されなければなりません。

Microservicesアーキテクチャにおいて、回復性を高めるための戦略はいくつか存在します。

非同期通信

まず、リクエストをなんらかのメッセージブローカーを用いた非同期通信にするという選択があります。

Microservicesの世界では、一つのサービスをなるべく小さく保ち、 なるべくひとつの仕事だけを行うように設計します。

同時に、複数の仕事を行わなければいけないAPIエンドポイントは、 内部で複数のサービスをリクエストする場合があります。

その際、リクエストの連鎖を全て同期呼び出しで行っていると、各サービスが自律的でなくなり、パフォーマンスにも影響します。

非同期通信を使用することで、サービス間の疎結合を保ち自律性を維持することができます。

非同期通信を採用するときのポイント

非同期通信を採用するときは、操作が結果整合性になることを意識する必要があります。
Publishに成功してクライアントにレスポンスした段階では、まだSubscribeは完了していない可能性があるためです。

また、クライアントへのレスポンスは200 OKではなく、202 Acceptedの使用を検討しましょう。

メッセージングを実装する際は、emitter-io/emitterなどの実装があるほか、GCPのCloud Pub/Subや、AWSのAmazon Simple Queue Service(SQS)、AWS Lambdaなどのマネージドサービスを使用することもできます。

また、メッセージングによって行われる処理は冪等に行われる必要があります。 Cloud Pub/SubAmazon Simple Queue ServiceのスタンダードキューAWS Lambda、いずれも At least once です。 (SQSについては、FIFOキューを使用することでExactly onceが実現できます。2018年11月に東京にも来ましたね!)
At least once とは、 2回以上配信されることもあり得る ということを示すため、処理は冪等に実装しておきましょう。

リトライ with バックオフ

エラー時に適切にリトライを入れることも重要です。
リトライを入れる上で考えるべきことはいくつかあります。

リトライしていいエラーなのか?

まず、全てのエラーをリトライするのは無駄です。
そのエラーは、Invalid Argumentかもしれないし、Internal Server Errorかもしれません。
前者の場合は、おそらく何度リトライしても成功はしないはずです。

しかし、Internal Server Errorの場合は、状況によってはリトライに意味がある可能性があります。

メルカリでは、サービス間通信にgRPCを採用していますが、gRPCにはレスポンスのステータスを示すCodeが定義されています。
これらを使って、呼び出し元はリトライして良いのかを判断します。

重要なのは、実装するときに、呼び出すサービスがどのCodeを返してくるのかを知ろうとしないことです。
相手のサービスがどのCodeを返すのかわからないと実装できないじゃないか!と考えるのは間違いです。
相手のサービスがどのCodeを返すのかを知ろうとすることは、相手のサービスとの依存を強めてしまいます。 具体的に言うと、相手のサービスの実装が変更になった際にこちらのサービスも変更しなくてはいけません。
通信はgRPCというプロトコルの上で行われているのだから、gRPCで許されるどんなレスポンスが返ってきても正しく動くように実装しなければならない。という考え方をします。

バックオフは入れるか?(どう実装するか?)

また、バックオフの実装も重要です。

隣のサービスの障害は、復旧に時間を要するものである可能性もありますが、 一時的なネットワーク遅延によるタイムアウトや、負荷が高くて処理できない場合などは、少し時間をおいてリトライすることで正常に処理を完了できることもあります。

このような場合には単純なリトライに加えて、エクスポネンシャルバックオフアルゴリズムを使用することで、効果的にフロー制御が行えます。

エクスポネンシャルバックオフは、リトライ間の待機時間を指数関数的に長くしていくアルゴリズムです。
(AWS SDKはエクスポネンシャルバックオフを実装しています。)

もしクライアント側のリトライに処理にエクスポネンシャルバックオフを実装していない場合、全てのリクエストは一定間隔で送られることになります。
サーバーから見ると断続的に集中的なリクエストを捌かなくてはならなくなり、あまり効率が良いとは言えません。

エクスポネンシャルバックオフ/Jitter

エクスポネンシャルバックオフを使うことで、全体のリトライ回数が抑えられ、効率良いリトライが実現できます。

エクスポネンシャルバックオフは、cenkalti/backoffなどの実装がある他、gRPCでもサポートされています

また、バックオフアルゴリズムにはJitterというものもあります。

Jitterにはいくつかタイプがあるのですが、Random Jitterが有名です。
Random Jitterでは、エクスポネンシャルバックオフのようにリトライ間隔を指数関数的に増やすのではなく、ランダムにすることで、全体のリトライ間隔を分散させて負荷を軽減することを目的としています。
ぜひ採用を検討しましょう。

どのレイヤーでリトライするのか?

また、リトライをアプリケーションで行うべきか?についても検討する必要があります。
Cloud Pub/Subのようなマネージド・サービスにおいては、リトライはマネージド・サービス側で行ってくれるため、特別実装は不要です。

また、リトライ処理はサービスメッシュで行うという選択肢もあります。(Istioの例)

Istioを既に導入している場合などは設定を追加するだけでリトライができるので、検討しましょう。

サーキットブレーカー

サーキットブレーカーは、分散システムでのエラー処理のデザインパターンです。

例えば、隣のサービスに大規模な障害が発生し、リトライしても無駄なケースを考えます。
このようなケースでは、リトライすること自体が無駄なため、無意味なリトライを避け、すぐにリクエストを失敗させるような動きが理想的です。

また、呼び出し元の実装によっては、リトライをしてしまうとその間ブロックが発生し、リクエストが詰まって、システムリソースの枯渇につながることもあります。

理想的な動きは、 隣のサービスが死んでる場合はリクエストが即失敗する。成功する可能性があればちゃんとリクエストを送る。 というものです。

サーキットブレーカーはこれを実現します。
(詳しくはMicrosoft Azureのサーキット ブレーカー パターン のドキュメントがとてもわかりやすいのでご覧ください。)

サーキットブレーカーの中身

サーキットブレーカー自体は、Closed(正常)、Open(異常)、Half-Open(ClosedとOpenの中間)という3つの状態を取りうるState Machineです。
特定の回数リクエストが失敗すると、サーキットブレーカーは自身をClosedからOpenに切り替え、一定時間経過するとHalf-Openに切り替えます。
Openになっている間は隣のサービスは死んでいるものとして、リクエストを常に失敗させます。
Half-Openのときは、操作が失敗すればOpenに、一定回数成功すればClosedに遷移します。
この動きを繰り返すことで、失敗しそうなリクエストを即失敗させる、という動きが実現できます。

Goでは、go-kit/kit/circuitbreakerなどの実装があります。
また、サーキットブレーカー自体はシンプルな動きなため、自前実装しても良いでしょう。

また、リトライ同様、サービスメッシュにやらせるというパターンもあります。(Istioの例)

フォールバック

隣のサービスのエラー時にできることは、クライアントにエラーを返すことだけではありません。

例えば、ユーザーIDを受け取って、ユーザーにおすすめのコンテンツをレスポンスするレコメンドサービスを考えます。
レコメンドサービスのエラー時はユーザーにコンテンツを表示できない、というのはもったいないです。
このような場合は、代わりにランキングサービスにリクエストして、ランキング情報を表示する、という選択肢が考えられるでしょう。

このように、エラーが発生した際の代替手段を考慮することを フォールバックパターン と呼びます。
実際は、代わりのサービスをリクエストする以外にも、キャッシュを返す、静的な値を返す、などのバリエーションも考えられます。

フォールバックは大抵、他のソリューションと一緒に用いられます。
(一定回数リトライしたらキャッシュを返す、など)

まとめ

Microservicesアーキテクチャにおける回復性を高める技術について紹介しました。 ここでは紹介しきれませんでしたが、Bulkhead patternや、そもそもKubernetesのReplicaSetなども回復性に貢献するでしょう。 Microservicesアーキテクチャはシステム構成が複雑になりがちですが、そのぶん障害に強いサービスを構築することができます。

明日は@sota1235が担当します!お楽しみに!