マイクロサービスとメッセージングのなぜ [概要編]

レッドハットでインテグレーションのためのミドルウェアのテクニカルサポートを担当している山下です。 最近はマイクロサービスでシステムを開発しているという話もよく聞くようになってきました。ではそこでメッセージング、そしてKafkaを使ってますでしょうか?マイクロサービスでは何故かRESTばかりが世の中に注目されてしまうことも多いために、今回はメッセージング推しの内容にしています。

マイクロサービスではメッセージングを用いたコマンドやイベントこそ中心であって不可欠です。マイクロサービスの中でメッセージングはどのように利用され、そしてなぜ必要なのでしょうか。今回は「マイクロサービスとメッセージングのなぜ [概要編]」と題してそれを概観していきます。

Kafkaの簡単なおさらい

f:id:tyamashi-oss:20190716232556p:plain

ストリーム処理のためのデファクト、といえるとても優秀なプロダクトです。クラスタリング前提の設計で、高スループットで水平にスケールします。それから、データはレプリカにコピーされ高い可用性と耐障害性を持ちます。そして、ページキャッシュやSendfileといったLinuxの機能を利用して効率よく処理を実現します。

どこでメッセージングは利用されるのか?

それでは、そもそもマイクロサービスによるシステムのどこでメッセージングが利用されるのでしょう。マイクロサービスでは、各サービスが独立して稼働します。そしてそのサービス間では、データの問い合わせを行うクエリ、何らかの指示を行うコマンド、状態変化を通知するイベントという3種類の連携が行われます(CQRS+ES)。

しかしRESTを用いるのは、クエリと一部のコマンドのみです。残る多くのコマンドとイベントはメッセージング(つまりはKafkaですよ)を用いて実現します。

次のマイクロサービスの全体図を見てどこでメッセージングが利用されているかみてみましょう。なお説明のために単純化したマイクロサービスの一例に過ぎないことに注意してください。

クライアントへの表示を担当するビューサービス(query-side service 後述)、そしてその背後に各サブドメインごとのサービスが存在します。RESTによる通信は、主としてシステムのエッジであるクライアントからのクエリとコマンドで利用します。一方でその背後で行われるサービス間の通信は、Kafkaを用いたメッセージングによる、コマンドやイベントを利用します。

マイクロサービスで利用される最も基本的なKafkaのトピックは、各サービスごと(正確にはサービス内の集約ごと)に作成する、コマンドのためのトピックと、イベントのためのトピックです。

メモ: 実際にはこれ以外にもシステムの要件に応じて様々なトピックが作成されることがあります。例として、システムの境界に配置してイベント形式の差異を吸収するトピックや、コマンドの実行結果を専用に受けるトピックや、複数のトピックをジョインするためのトピックや、一時的なデータ変換のためのトピックなどです。しかしまずはこの2つの基本のトピックを抑えることが大切です。

サービスはコマンドのトピック①を順次処理して②、ドメインオブジェクトの状態変化を起こし③、そしてイベントを発行します④。そして、そのイベントを購読している他のサービス⑤は、イベントから取り出した情報を自身のサービス内にキャッシュしたり、あるいはそれをトリガとして何らかの処理を行うことを繰り返しながら、全体としてビジネスプロセスが実現されます(ビジネスプロセスについては別記事で説明)。

このように、クライアントからの通信ではRESTを使う一方で、サービス間の通信ではメッセージングを利用し、各サービスはメッセージングを介して全体としてビジネスプロセスを実現します。

RESTはお手軽な解決策?

そもそもメッセージングよりもRESTによる同期通信のほうがお手軽と思えるかもしれません。もちろん各サービス間でRESTを利用した通信を利用できないわけではなく、実態として多用されている現実もあります。しかし本当にお手軽な解決策といえるのでしょうか?

RESTでは通信先サービスやさらにその先すべてのサービスが正常に稼働していなければ処理は行えません。そしてこのためにどこかで障害が発生すると連鎖的に機能不全のサービスが下流に向かって生まれてシステム全体にまで問題が波及してしまうことさえあります。さらにはそうしてどこかで取りこぼされてしまったリクエストは単に失われてしまいます。

またRESTベースの構成ではリクエストはダイレクトにWEBサーバーへの負荷となります。高負荷下ではシステムを構成する最も弱いサーバーを狙い撃ちにするでしょう。

こうしたことから信頼性を高めたり高負荷下で障害を広げないための仕組み(クラスタリング、ロードバランサ、サービスメッシュ、サーキットブレーカーなど)や、スパイクアクセスを考慮したリクエスト数やバックエンドの処理時間の予測、リソース使用量の分析、スレッド数やタイムアウト時間の調整、オートスケールの入念な設計、そしてこれらの十分な計画とテストが必要となります。

しかしどれだけ信頼性を高めたとしても、障害が発生しないことを前提としたシステム設計が許されるわけではありませんし、実際に障害やエラーは必ず発生します。データのクエリであればリトライで済むかもしれませんが、処理途中でエラーとなったコマンドをリトライすれば重複した処理やデータの不整合、あるいはタイムアウトなどのさらなるエラーを起こして状況はより複雑になり、その設計は困難かつ高価になります。

結果としてRESTではシステムが複雑になればなるほど、巨大なドメインモデルによるローカルトランザクションとモノリシックなシステム構成とする圧力が高まり、マイクロサービスから遠ざかってしまうこともあります。

なぜマイクロサービスにメッセージング(Kafka)が必要なのか?

一方でメッセージングではどうなのでしょう?メッセージングにはどのようなメリットがあり、なぜ必要とされるのでしょうか?

安定したシステムを支えるメッセージング

プロデューサ側(送信側)では、メッセージの実行結果を待つことはないため、応答時間が向上し、少ないサーバで大きなスループットを実現できます。そして処理時間の予測や関連するタイムアウト調整なども最小限の考慮で済みます。

またコンシューマ側(受信側)は、メッセージングによるバッファにより、スパイクアクセスによっても高負荷状態に陥ることはなく、決められたスレッド数の中でメッセージを継続的に処理し続けます。常に安定した状況下でメッセージを処理することができ、リソース利用効率が高く、リソース割当の設計も容易です。そして負荷テストもある程度簡略化することが可能です。さらにメッセージが送られない時間帯に一時的にコンシューマ側(受信側)をすべて停止させてしまうことや、溜まったメッセージの状況に合わせてオートスケールさせることも比較的容易で、それほどシビアな利用予測や設計が求められることもありません。

メッセージングはエラーや障害にも強く、システムの可用性を保つのに役立ちます。サービスの協調の中で発行されるメッセージがKafkaのトピックに保存されていることで、障害やエラーによっても処理の中途状態は常に明確となり、それが失われることもありません。障害に陥っても復旧後にメッセージが処理されて、データの不整合や2重処理なども発生させず(重複配信の対処法は疑問編にて説明)に、引き続きビジネスプロセスが進行します。このため処理途中でサーバーが落ちることにも危険はなくなり、個別サーバーに過度な信頼性をもたせる必要もありません。

またメッセージングを通してサービス間が接続されることで、サービスの障害がシステム全体へと波及することは防がれ、仮に障害下であっても限定的なサービスの提供も可能です(バルクヘッド)。

このようにして、メッセージングによりシステム全体の安定性が向上するのです。

イベントは履歴になる(イベントは状態の変化を表し、イベントの蓄積が状態を再生する)

とはいえ、一般的なアプリケーションでは、そもそもイベントなんか使ってないことが多いですね。わざわざイベントをシステムに追加するっていうのは至極面倒に思えます。

しかし、そもそもイベントは他のサービスやシステムへと情報を送るためだけの仕組みではありません。サービスの内部でもイベントは重要です。むしろイベントさえあれば本来はオブジェクトをDBに保存する必要さえありません。

一般的なアプリケーションでは、データベース上のレコードに最新の状態のみを保存します。そしてこれを中心に捉えると、オブジェクト状態の変更(レコードの変更)の結果として、イベントは追加で発行するものと考えがちです。

しかしこれを逆に考えれば、発行されたイベントの蓄積からオブジェクトの状態を復元できるということでもあります(イベントソーシング)。この考え方の場合には、データベース内にイベントのみが保存/蓄積され、オブジェクトそれ自体は保存されません。もしもオブジェクト状態を保存する場合には特定時点のスナップショットとして扱います。

コマンドの実行時など、オブジェクトが必要となる場合には累積されたイベントを順に再生(applyメソッド)しながらオブジェクトの状態を復元します。 そして、コマンドの実行の結果として、(オブジェクトそれ自体の状態変更は行わずに、)新しいイベントを追記します。 サービスの処理は通常、コマンドを受け取ってイベントを返すメソッド(processメソッド)として実装され、(アーキテクチャにもよりますが)データを書き換える処理はありません。イベントによる事実が追記によって積み重ねられていきます。

イベントはビジネス上も重要なデータです。イベントそれぞれの中にはオブジェクト内で何が起こったのかが記録されており、これらすべてが履歴として保存されていることで、各種の分析や監査で利用したり、任意の時点のオブジェクト状態を復元することさえ可能です。

イベントは何らかの付属物ではなく、他のサービスのために発行するものでもありません。むしろイベントさえ存在していれば後のものはどうとでも導出できます(極論ですが)。イベントはそれ単体で存在するべきものです。他のシステムのために追加で設計が必要な面倒なものと考えるのは適切ではありません。

様々なサービスやシステムと連携したり情報を統合するためのデータハブ

イベントは単体としても存在するべきものですが、(部分的にでも)それを他のサービスに公開すれば通知やデータ転送の役割を担うようになります。

メッセージングはマイクロサービスのような小さく独立したサービスをイベントを通して連携させるために必要なのはもちろん、データウェアハウスや分析のシステムへのデータ転送のためにも必要です。

それから、現実的な問題として画面表示のデータを提供するのに特化したビューサービス(query-side service)を作成することも多くあります。マイクロサービスでは個別のサービスが分断される一方で、クライアントに対して様々な検索条件やサービスを横断して組み合わされたデータを一度に提供できる必要があります。つまり、(サービス内のドメインモデルとは別に、)表示や検索といったクエリのみに特化した参照用のモデルが必要です(CQRS)。このために、各種のサービスから発行されるイベントを集めて、統合された表示用のデータを作成する専用のビューサービスが必要になるのです。ビューサービスは、ユーザが求める様々な要件に応じて、それに適した複数のデータベースとビューを組み合わせて利用します。

そしてこれらの実現を支えるのがイベントを転送するデータハブとしてのKafkaの役割です。Kafkaは分散システムでのコミットログあるいはトランザクションログと表現される場合もあります。

イベントを利用することでサービス間の秩序ある関係を維持できる

イベントを利用しないと、サービス間の秩序ある関係を維持することはできません。RESTによる同期通信は基本的にプル型(順方向の依存)なので、サービスがRESTのみを利用すると通信上の関係がそのままサービスの依存関係となって独立性を阻害します。

各サービスは独立しつつも、上流や下流といった秩序ある関係性を維持しています(コンテキストマップ)。サービスに対してコマンドを送ると、結果としてイベントを出力します。サービスはコマンドを受け取ることによっても、イベントを発行することによっても他サービスに依存することはありません。

しかしそれとは逆に、サービスがコマンドを送る、あるいはイベントを受けるためには、当該のサービスとドメインの知識に対して依存することになります。このため上流や下流の関係によって、通常は各サービス間でクエリやコマンドを送る側、あるいは逆にイベントを発行する側は決まります。

例えば、受注管理サービスからの在庫管理サービスへのコマンド発行は許されても、その逆の方向からのコマンドの発行まで許せば、相互の依存関係となるために独立したサービスの関係を阻害してしまいます。しかし、在庫管理サービスが状態変更に応じてイベントを発行し、受注管理サービス側でイベントを購読することで、依存関係を破綻させることなくサービス間の協調を実現することができます。

RESTによる同期通信は基本的にはプル型(順方向の依存)なので、上流や下流といった秩序あるサービスの関係性を維持するためにも、メッセージングを利用したイベント(逆方向の依存)は必要になります。

ところで、サービス間でコマンドを利用せずに、イベントのみでつないで協調させるという設計も、無秩序にサービスを関係づけることになります。これもまたサービスの独立性を阻害し、分散モノリシックなシステムとなるためにお勧めすることはできません。

後編で説明するように、ステートマシンベースでSAGAを行うプロセスマネージャーを利用すれば、そこで依存関係の調停を行うことができ、適切なサービスの関係を維持したままに協調させて、結果整合性を保ったトランザクションを実現することも可能です。

マイクロサービスでの責務の明確化が促進される

一般的なアプリケーションではオブジェクトをまるごとCRUDによりデータベースに出し入れするような実装(CQRSではない実装)とすることがあります。しかしマイクロサービスではこれにならってコマンドにまるごとオブジェクトを渡してサービスに更新を依頼するような汎用コマンドを設計することはできません。

ユースケース記述やサービス間のインタラクションから適切にそれぞれのコマンドを設計し、実装へと同期させなくてはなりません。適切にコマンドの定義がなされない場合には、それによって発生するイベントのタイプも限定(例えば常にUserUpdatedイベントになる)されて、その後の適切なビジネスロジックの駆動や分析が困難になります。

例えば、一口にユーザオブジェクトを更新するコマンドといっても、メールアドレスを更新する場合、住所を変更する場合、広告メールを停止する場合、退会してアカウントを削除する場合、それぞれで異なるビジネス上の対応や異なる実行権限が必要になるはずです。このために、それらそれぞれのためのコマンドと、その実行結果に対応した適切なイベントが必要です。

コマンドとイベントは適切な設計を行って実装まで正確にマッピングされるべきものです。メッセージングを利用することにより、適切にコマンドとイベントの設計が行われ、マイクロサービスでの責務の明確化が促進されます。

コマンド実行順の保証と並列実行

Kafkaを利用してコマンドを配信することには更なるメリットがあります。並列実行によりスループットが向上し、処理順も保証され、そしてサービス内の排他制御を省略することができます。

一般的なメッセージングシステム(Apache ActiveMQなど)ではコンシューマ(受信側)を1つにすれば1つずつ順番に処理することが可能ですが、複数のコンシューマを利用すると、それらへの配信順自体は順番どおりであっても、並列に処理されるために処理順は前後するのが一般的(MQ次第ですが回避策もあります)です。

一方で、Kafkaではトピック内のパーティションごとにコンシューマは固定されます。このため、トピック全体として見た場合には並列処理させてスループットを向上させる一方で、各パーティションのメッセージは割り当てられたコンシューマの中でシングルスレッドで1つずつ順番に処理(Single Writer)させることができます。

このため、オブジェクトのID(正確には集約ルートのID)をパーティションのキーとして利用すれば、各オブジェクトに対してコマンドは一度に1つずつのみが処理されます。そして複数のスレッドで同時に実行されるとデータの不整合が発生するようなの箇所(クリティカルセクション)での、悲観的ロックや楽観的ロックといったような排他制御を省略することができます。これはサービス実装をシンプルにし、スループットを向上させ、利用可能なデータベースの選択肢(排他制御の弱いNoSQLなどを含む)を増やします。またオブジェクトごとのメッセージ処理順(配信順ではなく)を前提としたシステム設計が可能となります。

まとめ

今回は概要編として、マイクロサービスでなぜメッセージングが必要なのかを説明してきました。まずマイクロサービスによるシステムにおいて、どこでメッセージングが利用されるのかを説明しました。そしてRESTを利用した場合に発生しうる各種の課題を確認する一方で、メッセージングによってシステム全体としての安定性が向上することも説明しました。もちろんマイクロサービスでもRESTは利用するのですが、サービス間ではメッセージングを利用することを検討してください。

またその後、イベントは履歴となり、またサービス間を連携し、秩序ある関係を維持するためにも重要な役割を担っていることを確認しました。そして最後にKafkaによってコマンドの実行順が保証され並列実行にも役立つことを説明しました。マイクロサービスにおいて、メッセージングによるコマンドやイベントは設計上も実装上もなくてはならないものです。

今回は概要編としてメッセージングの良い面ばかりに焦点を当てましたが、次の疑問編ではat least once(一回以上)の配信によるメッセージの重複配信にどう対処するのかとか、データ更新とメッセージ送信をどのようにアトミックに行うのかといったような、メッセージングでよく疑問とされることを説明したいと思います。

マイクロサービスとメッセージングのなぜ [疑問編]」はこちらです。

Special Thanks

勉強会での議論がこの記事の基になっています。参加者の方々、そしてレビューしてくださった方々、どうもありがとうございました!

参考文献

[PR]

  • オープンソースのApache Kafkaをベースとした製品として、Red HatからAMQ Streamsが提供されています。AMQ Streamsにはさらに、KubernetesやOpenShiftの上でApache Kafkaの運用の自動化を実現する、オープンソースのStrimziをベースとした機能も含まれています。Red Hat製品に興味ない方もKubernetesを利用している方はStrimziをぜひ使ってみてください。

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。