この記事ではマイクロサービス(ここでは、「複数台の『一つのドメインについての複数の機能を提供するAPI/RPCサーバー』によって構成されるシステム」と定義する)を構築する際の 様々なパターンについて書く。 それらは、FactoryやFacadeといったアプリケーションコードのデザインパターンではなく、 負荷分散やサービスディスカバリなど、アーキテクチャにおけるデザインパターンである。

Design for failure

まず、「Design for failure」について説明したい。 これは文字通り、「失敗のための設計」という意味である。

システム開発にパブリッククラウドを用いることの多い現代の開発者は、クラウドコンピューティングネットワークはしばしば不安定であり、データセンターでは可能であったスイッチングやルーティングの最適化が不可能であることをよく知っているはずである。 加えて、パブリッククラウドが提供する潤沢なコンピューティングリソースを手にした我々は、マイクロサービスが自動的にスケールすることを夢見るものである。それは、実際には予測不可能なサービスの起動や停止をもたらす。 これらは、我々がマイクロサービスを実装する際に、十分に障害・失敗について検討しなければならない主な理由のひとつである。 マイクロサービスの系が巨大になると、常にシステムのどこかが壊れていることは珍しいことではない。エラーハンドリングを諦めると、巨大なサービスは決して賢く動くことはない。 サービスの数が多いと、ソフトウェアは複雑になり、複雑なソフトウェアでは、開発者はミスを犯しやすくなる。それでもシステムを止めないために、我々はDesign for failureを強く意識する必要がある。システムが停止したとき、悪いのはミスをした開発者ではなく、ミスをしたくらいで動かなくなるシステムの設計なのである。

パターン1. Event Processing

最初に紹介するのはEvent processingパターンである。 例えば、メールを送信するために外部のAPIをキックする場合、それらは同期的に行われる必要はない。 なぜなら、外部のAPIに問題が発生した際にアップストリームがブロッキングされてしまうためである。 このようなケースでは、「Fire and forget」というアプローチが有効である。つまり、高可用なat least once配信を保証するキューにプッシュし、あとはキューに任せるというものである。 ユーザー体験の観点から言えば、このアプローチを採用するとユーザーはメンバーシップ登録を行っても、即時にwelcomeメールを受け取らなくなる。しかし、メールは本来そこまで即時に動くことが期待されるものではないため、特に問題はなく、多少の遅延は許容される。

Event Processingパターンは、メッセージングキューを使って複数のマイクロサービスを切り離し、疎結合にするためのものである。 データベースの更新処理やメール送信など、同期的に行われる必要がないものは積極的に非同期処理を行うように実装することはしばしば大きなメリットをもたらす。

メッセージングキューはメッセージがプッシュされると、それが正常に受け取れた場合にACKを返す。 メッセージングキューは分散されかつ高可用で、スケーラブルに実装するべきだ。それにより、メッセージングキューの利用者であるコンシューマーを担当する開発者にとっては、キューにメッセージをプッシュしたあと、それが処理されない可能性を検討する必要はない。むしろ、検討すべきではなく、コンシューマーの責務はキューへのプッシュと、ACKの確認のみにとどまるべきである。

しかしキューを実装する開発者にとっては話は別である。当然ながら、コンシューマーのバグや、メッセージフォーマットの間違いなどにより処理が失敗する可能性はある。そのため、どのようにエラーハンドリングを行うかは重要である。

余談だが、Fire and Forgetとは、本来軍事用語である。 ミサイル自体が追尾能力を持つため、人間が照準を合わせる必要がないことを、しばしばメッセージングキューに例える。

Event ProcessingパターンにおけるエラーのappendとDead Letter Queue

エラーハンドリングの基本的な考え方は、エラーの保存 -> Dead letter queueへの移動である。 メッセージの中身がvalidにもかかわらず処理が失敗した場合は、エラー情報をメッセージに付加し、バックオフ付きでリトライを行う。

メッセージはこんな感じになる。

{ 
 "id": "ABCDERE2342323SDSD", 
 "queue" "registration.welcome_email", 
 "dispatch_date": "2016-03-04 T12:23:12:232", 
 "payload": { 
 "name": "Nic Jackson", 
 "email": "mail@nicholasjackson.io" 
 }, 
 "error": [{
   "status_code": 3343234,
   "message": "Message rejected from mail API, quota exceeded",
   "stack_trace": "mail_handler.go line 32 ...",
   "date": "2016-03-04 T12:24:01:132"
 }]
}

エラー情報を保存する理由はいくつかある。まずは人間が読み、デバッグに使用する。そして、ある閾値を超えた時点で、Dead letter queueに移動させるためだ。

Dead letter queueは、メインの処理を行うキューとは別で持つ、処理できなかったメッセージのためのキューである。 Dead letter queueはメイン処理のキューと1対1で存在する。例えば、 order_service_emails というキューに対しては、 order_service_email_deadletter というキューを作る。 Dead letter queueに入ってくるメッセージは、前述の通りエラーの情報をすべて保持しているため、人間が読んで対応することもできるし、machine-readableなエラーコードで扱えるならば、プログラマティックに対応しても良い。

Event processingとDead letter queueを使って非同期処理を行う際には、いくつかポイントが有る。

  • キューの利用者は、結果整合性について理解し、了承している

キューの利用者は、メッセージをプッシュしACKが帰ってくることまでは自分で確実に確認しなければならない。しかし、ACKが帰ってきた後、実際に処理がいつ実行されるのか、すでに完了しているのか、失敗してはいないだろうか?などとは考えない。 キューにプッシュしたのだからいつか実行されるはずである、という事前の了解が両者にあるべきだ。 これが許容できない(つまり、即座に処理の成功/失敗をハンドリングしたい)のであれば、キューを採用するという選択を見直すべきだろう。

  • Dead letter queueにメッセージが入ることは「異常事態」だ

Dead letter queueは、「予想外の失敗が’起きた際に」「念の為」メッセージを保存する場所であるべきだ。 例えば、バリデーションエラーが起きたなどの理由でDead letterを使うべきではない。バリデーションエラーが発生するのであればACKを返してはいけない。ACKを返すのは、「処理が可能であるとき」だけだ。 Dead letterにメッセージがプッシュされたら、開発者に通知を行うべきだ。その後、速やかにエラー情報を確認し、必要に応じて対処する。

  • キューを提供するチームは、処理にかかる時間をSLOとして提供する

キューに入ったメッセージが実際に処理されるまでの時間をSLOとして他チームに提供しよう。 これは、他チームが非同期処理を使うべきかどうかを検討する材料になる。

冪等性、そしてメッセージの順序

メッセージ配信には「at least once」(最低一回配信、ロストしないが重複し得る)、「at most once」(最大一回配信、重複しないがロストし得る)、「exactly once」(一回のみ配信、重複もロストもしない)の種類がある。

メッセージが重複する可能性がある以上、メインの処理は基本的に冪等性を保たなければいけない。 冪等性を保てない場合のアプローチはいくつかあるが、メッセージのIDをデータベースに (ロック付きで) ストアするなどして、同じメッセージが二度処理されないようにすることが考えられる。

また、順序の問題もある。なんらかの理由で、メッセージの順番が入れ替わってしまい、データ不整合が起こる可能性がある。 例えば、ユーザが自分自身のプロフィールをアップデートしたいケースを考えよう。 ユーザが非常に短い間隔でプロフィール (例えば、メールアドレスを) を2回更新して、何らかの理由で (ネットワークなどが考えられるが) その更新処理のメッセージの順番が入れ替わってしまった場合。 ユーザにとっては1回目の更新のデータ (つまり、古いデータ) が後に届くことになり、2回目の更新が行われていないように見えてしまう。 実際のところ、これはサーバーサイドでは防ぐことができない。なぜなら、本質的にメッセージの順序を知っているのはクライアントだけだからだ。 つまり、クライアントやゲートウェイといったレイヤで、datetimeの属性を付与し、メッセージを処理する際に、メッセージの時系列を正しくハンドリングするなどの対応が必要になる。

分散トランザクションとAtomicity

以前、Transactional Integrityのエントリ内で、Transaction ManagerによるRollback/Staged commitについて書いた。 Transaction Managerのアプローチは、コミット/ロールバックを備えていることにより不整合な状態が短くできるというメリットがある。しかし、実装が複雑になることがデメリットと言える。

書籍マイクロサービスアーキテクチャには以下のような記述がある。

分散トランザクションは正しく実行するのが困難で、実際にはスケーリングを妨げることがあります。(中略)現在単一トランザクション内で起こっているビジネス操作があったら、本当に必要かどうかを自問してください。それらを異なるローカルトランザクション内で実行し、結果整合性の概念に頼ることができるでしょうか。このようなシステムのほうが構築やスケーリングがはるかに簡単です。

分散トランザクションはシステムアーキテクチャ・実装が複雑になりがちなため、システムをシンプルに保つために、可能であれば 分散トランザクションを使わない ことが重要である。複数の、Atomicityを必要とする操作は、結果整合性の概念を採用してアーキテクチャを検討すべきだと考えている。

例として、ECサイトのように、注文を受け付けたら注文情報をデータベースに保存し、メールを送る、というユースケースを考える。 メールの送信を責務とするマイクロサービスとデータの永続化を責務とするマイクロサービスがあるとしよう。 この場合、それぞれがロールバックとコミットをサポートするやり方も考えられるが、よりシンプルに、メッセージキューを使うことを検討できる。

[Order Service]
↓ Add message to queue
[Queue]

まずこう。

で、

[Queue]
↑ Read
[Email service]

こう。 で、

[Queue]
↑ Read
[Data persistence service]

こうなる。 このやり方が十分にシンプルである。失敗した際はリトライを行う。 成功した場合はキューからメッセージを取り除く。

Transaction Outbox Pattern

Transaction outbox patternは、microservices.ioで紹介されるデザインパターンである。 上記ページの図の通り、OrderテーブルにOrder情報を保存すると同時に、Outboxテーブルにも保存し、それを Relay process がReadし、Publishするパターンである。 これも実現したいことはメッセージングキューを使うケースと同じで、シンプルな形で複数の処理のAtomicityを担保したいときに有用である。

タイムアウト

タイムアウトは、ダウンストリームのサービスが利用できないことを知るための方法だ。 別のサービスにアクセスして、レスポンスが帰ってくるまでの時間に時間制限を設けることで、 リクエスト全体がブロックすることを防ぐことができる。

重要なのは、ダウンストリームのサービスから応答がないままタイムアウトしたとしても、こちらが送信したリクエストをサービスが受け取っているか受け取っていないかはわからないということである。 タイムアウトはあくまでfail fastのため、呼び出し元に失敗を知らせることが目的だ。

また、負荷や、サーバリソースの観点からもタイムアウトは有用といえる。 アクティブなコネクションをタイムアウトなしに繋ぎっぱなしにしておくと、サーバリソースを消費したまま解放されないことになる。 前述の通り、多くのコンポーネントを内包する巨大な分散システムでは、常にどこかが壊れているものである。 もし運悪く誤作動を起こしているサービスに繋がってしまった場合、そのサービスのせいでリクエストすべてがブロックされてしまう。 タイムアウトが実装されていれば、代替となる別のマイクロサービスをコールするなどの選択肢が取れる。

実装する際は、何秒でタイムアウトさせるか?ということを検討する必要がある。 LBのタイムアウトを超えないようにした上で、10秒など任意の値にしておき、プロダクション環境で実際どの程度リクエストに時間がかかっているか、スパイクの発生頻度やスパイクの際の時間をもとに設定するのが良いと思う。(そのため、タイムアウトの時間は設定として注入できるように実装しておくと良い。)

バックオフ

分散システムでは、別のシステムへのリクエスト時に、ビジネスロジックではなくネットワークを理由としてエラーが発生することがある。 このようなケースでは、リトライすれば処理が成功する可能性が高いため、リトライを実施すべきであるが、その一方で、リトライ時にはバックオフを行うべきである。 バックオフとは、リトライする前に最大数秒程度の待ち時間を設けることである。 一切バックオフせずにリトライし続けると、ネットワークの輻輳や、自身のシステムの高負荷につながる。リクエスト先のシステムのレートリミットに到達してしまう可能性もある。 適切にバックオフすることで、これらの問題を解消できる。 バックオフのアルゴリズムには種類があり、以前会社のブログに書いたので参照して欲しい。

ヘルスチェック

マイクロサービスアーキテクチャにおいては、どのサービスもヘルスチェックのエンドポイントを提供すべきである。 それは、Consulなどのサーバーサイドサービスディスカバリや、モニタリングなどに使用される。 ヘルスチェックでなにを確認するか?は、各サービスの特性により異なる。 例えば、以下のようなものが考えられる。

  • データベースへのコネクションのチェック
    • 接続ができること
    • コネクションプールの状態
  • レスポンスタイム
  • コネクションの数

例えば、データベースへのコネクションが貼れないとなにもできないサービスであれば、ヘルスチェックでそれをチェックすべきである。 しかし、データベースへのコネクションがなくても内部でキューを持っている、というケースであれば、キューが正常に機能している限り、コネクションのチェックは不要だと言えるだろう。

レスポンスタイムは、システムのキャパシティを図る指標のひとつになる。 例えば、プロダクション環境で負荷試験を行い、正常に可動できるしきい値を計測し、それを設定ファイルなどに書いておく。それをヘルスチェックに使う、という手法もある。 例えば、事前の負荷テストで

  • 秒間5000リクエストを50msで捌ける
  • 秒間6000リクエストを150msで捌ける

ことがわかっている場合、これらをヘルスチェックのしきい値に利用できるだろう。 仮にSLOとして「レスポンスタイムが100ms以内である」ことを保証しているのならば、現在の同時接続数が6000を超えている場合にはヘルスチェックを通さない、などの実装が可能になる。

ヘルスチェックを行う際には、ハンドシェイクを行うか?という議論も存在する。 それぞれのクライアントが、ダウンストリームに接続する際にハンドシェイクを送信することで、リクエストを処理できることを確かめることができる。 しかし、一般的にこれでは通信量が大きくなり無駄が多い。また、ロードバランシングをクライアントサイドで行っていることを前提とする。 ヘルスチェックは、クライアントサイドではなくダウンストリームが自分自身で行う方がメリットが多い。 ダウンストリームが処理の前にヘルスチェックの状態を読み取る処理を必ず実行する。 ヘルスチェックが通らないことをアップストリームがすぐに知ることができるため、別のノードにリクエストを送信する選択ができる。 ダウンストリームも、ヘルスチェックの状態を読み取るだけなので、処理時間はほとんど追加されない。

Kubernetesのヘルスチェックには、Readiness Probe, Liveness Probeという2つの概念がある。 Livenessが失敗する際はPodが再起動される (設定による) が、Readinessはアプリケーションの失敗を示し、Podの再起動はしない。トラフィックを流さないことは設定可能である。 これらをうまく使えば、ヘルスチェックをKubernetesに任せることも可能である。

スロットリング

スロットリングは、サービスが処理できる同時接続数を考慮してコネクションの数を制限するパターンである。 スロットリングエラーの際は、429 Too Many Requestsを返す。 スロットリングの実装自体は簡単であるが、しきい値を決めることは簡単ではない。 負荷試験を行って、SLOも考慮した上で決める必要がある。

サービスディスカバリ

マイクロサービスでは、アプリケーションは仮想化されたOSやコンテナの上といった環境に配備されていることが多い。 そのため、サービスの数やその場所は一刻一刻、動的に変化する。 これによりサービスにスケーラビリティが生まれるが、問題もある。別のサービスがどこにいるのかをどのように知ればよいのだろうか? このようなケースには、サービスディスカバリと動的なサービスレジストリを使う。 サービスディスカバリには高可用性と、強い整合性保証が必要になる。Consuletcdなどが利用できるだろう。

サービスレジストリは、通常はIPアドレスとポート、またアプリケーションのバージョン情報や環境情報のメタデータなどを保持している。 そして、サービスを検索するクエリを受け付けることができ、バージョン情報などはクエリに使われる。 また、ヘルスチェックを行う機能を持っているものもある。その場合、サービスを検出するクエリへのレスポンスが、ヘルスチェックに成功しているサービスのみになる。

サービスディスカバリを実現する手法には2種類ある。サーバーサイドサービスディスカバリと、クライアントサイドサービスディスカバリだ。 サーバーサイドサービスディスカバリとは、サービスディスカバリをクライアントが意識する必要がなく、バックエンドのリバースプロキシに任せるものである。SOAの世界ではこの手法が使われており、マイクロサービスの世界ではAPI Gatewayパターンとしてrepresentationされている。 各サービスはURIとパスだけを知っていれば、後はバックエンドがサービスディスカバリを抽象化してくれる。 クライアントサイドサービスディスカバリでは、サービスディスカバリをクライアント自身が行う。つまり、別のサービスをコールする前に、サービスレジストリからサービスの場所を教えてもらうのである。

サーバーサイドサービスディスカバリ

サーバーサイドサービスディスカバリでは、サーバーサイドにサービスディスカバリを行うレイヤーが存在し、繋ぎたいサービスがどこにあるのか、そしてどこに繋ぎに行くかを抽象化してくれる。 一般的に、ゲートウェイのように働くリバースプロキシが存在し、ゲートウェイがダイナミックなサービスレジストリからデータを取得し、バックエンドに受け取ったリクエストをフォワードする。

クライアントは、サービスディスカバリのことは考えずに、あるURLとパスでゲートウェイに対してアクセスする。ゲートウェイはサービスレジストリからサービスの場所を教わり、そこり対してリクエストをそのままフォワードし、レスポンスをクライアントに返す。

リバースプロキシ自体がボトルネックになる可能性や、レイテンシの増加などがデメリットとして考えられるが、より大きな問題は、処理の失敗のハンドルをどこに実装するか? ということを選択しなければならないものである。リバースプロキシは言うまでもなくドメインロジックを持つべきではないため、エラーハンドリングは各クライアントで行うべきである。 後述するクライアントサイドサービスディスカバリでは、クライアントが行うことを強制できる。

クライアントサイドサービスディスカバリ

クライアントサイドサービスディスカバリでは、ダウンストリームにアクセスする前にサービスレジストリに自分でアクセスする。 これは、処理の失敗についてより細やかなハンドリングを行わせることができる。

ロードバランシング

サービスディスカバリをクライアントサイドで行うメリットを示した。 ロードバランシングにも同様に、サーバーサイド (ロードバランサ) で行うやり方と、クライアントサイドで行うやり方がある。 ロードバランサがロードバランシングを担うというアーキテクチャでは、SSL Terminationなどの付加的な処理もロードバランサに行わせることができるというメリットがあった。 しかし、サービスメッシュやゼロトラストネットワークという概念の登場も手伝って、インターナルなコネクションもセキュア化することはひとつの常識になりつつある。 ロードバランシングもクライアントサイドで行うアーキテクチャはよりサービスメッシュ・フレンドリーであり、マイクロサービスにおけるデファクトスタンダードになっていくのではと筆者は考えている。

所感

実際のところ、ここまで書いたことは「マイクロサービス」特有の話ではない。どちらかというと分散システムで考えなければいけないことである。 これらについて知識と対処法を持ち合わせることはもちろん、マイクロサービスの世界では、これらを誰が実装し、誰が利用するのか?依存先のサービスは果たしてこれらを考えているのか?など、考えることが増える。これがマイクロサービスの難しさだと思う。

また、ここに挙げたすべての問題を解消するような方法は実際のところ存在しない。 重要なことは、起こりうる問題と、その対処法についての知識を持つこと、そして、Design for Failureに対してベストを尽くし、それをモニタリングし、日々改善を行うことだと考えている。