Microservices Patterns を読んで(1)

Chris Richardson 氏の Microservices Patterns を読んだ。

Microservices Patterns: With examples in Java

Microservices Patterns: With examples in Java

マイクロサービスという言葉が出て来て数年経ちます。

私もマイクロサービス的な複数のサービス間でデータのやり取りを頻繁にするようなシステムを構築したことがあります。 その際にデータの整合性は最重要ではなかったのでトランザクション的なものは使いませんでしが、 お金を扱うようなシステムをマイクロサービスにした場合ちゃんとしたトランザクションはどうするのかは気になっていました。

本書にはその疑問に対する現実的な回答が載っています。

著者は CloudFoundry の創設者で、また以下のマイクロサービスのパターンを集めたサイトの管理者でもあります。 microservices.io

また本書のサンプルコードに度々登場するマイクロサービスのための evantuate-tram フレームワークとその開発を行なっている evantuate の代表でもあります。

eventuate.io

というわけで著者の知見を盛り込んだ本書は、マイクロサービスを導入するために必要な、設計・実装・インフラといったあらゆる知識を包括的に学べる一冊だと思いました。

なお、本書で掲載されているソースコードは以下で公開されているようです。

Eventuate example applications

本書は全部で 13 章から成っています。それぞれの章の内容を簡単にまとめてきます。 3週間くらいかけて読んだので一部曖昧だったり、私見もあるかもしれないけどそこはご容赦を。

1 章 モノリシックヘルを脱出する

Uber Eats のような、注文・レストラン・配達 を提供するシステムを題材にしている。 複数の機能を単一の Spring Boot アプリケーションでデプロイしている。 Jenkins による CI もあるし、ヘキサゴナルアーキテクチャもあるしで、決してレガシーではないが モノリシックゆえに理解しづらく、機能追加が難しく、新しいライブラリは言語が投入できず、スケールもしないという欠点を抱えているため、 これをマイクロサービスに移行したいというところから始まる。

マイクロサービスは上記の欠点を克服し、開発スピードの向上が期待できるとしつつも、一方で分散構成ゆえの困難も抱えることになる。

残りの章はその困難に対する対策となる。

2章 マイクロサービスアーキテクチャの定義

マイクロサービスの設計に関する章。

マイクロサービスの一つ一つはヘキサゴナルアーキテクチャで作り、サービス間のデータは 定義した API 経由でしかやり取りしない。 また、サービスごとに DB を持つ。

マイクロサービス全体の設計は次の3つを行うことになる。

  1. サービスの分割
  2. 各サービスの機能の定義
  3. サービスのAPIの定義とサービス間の関連を定義

サービスの分割の手法として、 business capability と DDD の2つが挙げられている。

前者は業務の価値を生む出す機能を抽出し、それをグルーピングしていくような感じだろうか。 ユースケース分析とか要求分析に近い感じだろうか。

後者は DDD の流儀に従って粗めのドメインモデルを作り、境界づけられたコンテキストを作ることでサービスを分割する。 またこの章だけではなく、随所に DDD の手法を適用していくことになる。

どちらの手法でも大事なのはビジネスの観点に従って分割をすることで、技術的な理由による分割を行なってはいけないということ。 また疎結合な分割を行うこと。適切でないサービス分割は、マイクロサービスアーキテクチャではなく、ただの分割したモノリス (Distributed Monolith) になってしまう。

3章 プロセス間通信

マイクロサービス間の通信手段に関する章。

同期通信の手段として、 REST, gRPC の2つがある。 REST は汎用的に使える一方でマイクロサービスの文脈では通信効率の悪さや、API と HTTP へのマッピングの負荷が大きいという点が述べられている。 gRPC は通信効率の良さやスキーマがあること、多言語対応などがマイクロサービスに適しているが、HTTP/2 が必須となる。

またどちらであっても、同期通信は通信相手が必ずいないといけないので、可用性を下げる要因となる。 そのために、Circuit Braker や Service Discovery を導入するとよい。

非同期的手段として、kafka などの非同期メッセージングがある。 マイクロサービスの可用性を向上にするには、サービス間のデータの送受信を非同期メッセージにするのが望ましい。 (これは主に更新系操作の話で、同期通信ももちろん使っていいはず)

例えば、サービス間で非同期でデータを送りその結果を応答して欲しいなら、送信用のチャネルと返信用のチャネルを用意して 送信用のチャネルにデータを流す際に id を格納して、その id を付与したデータを返信用チャネルに流してもらう。

この際にデータの整合性を保つために Transactional Message というものが必要となる。 簡単に言うと DB の更新とそれに対応するメッセージの送信が必ずアトミックになると言うものだ。

evantuate tram はこういったメッセージ送受信を簡単に行うためのフレームワークで、 Transactional Message は 直接メッセージを送る代わりに、 DB の OUTBOX (送信箱)テーブルに送信メッセージ内容を書き込む。 そして、非同期で OUTBOX テーブルの内容をポーリングするか、DBのトランザクションログ を tailing して OUTBOX の内容をkafka に送る。

単純だけど、これはいいアイデアだ。 アプリケーションコード内にメッセージ送信を行うコードを書くと、DBトランザクション失敗時のメッセージ送信の扱いは面倒になりがちだ。

他にメッセージングの送信は最低一回保証なので同一メッセージが再度届くことも考慮した設計にする必要があることも述べられている。

なお、トランザクションログを監視して kafka に投げるライブラリは他に debezium も紹介されている。 ぜひ試してみたい。

4章 Saga によるトランザクション

本書の一番のポイントは多分ここ。実際マイクロサービスをやるにあたって最も気になるのはトランザクションだろう。 2フェーズコミットはスケーラビリティを大きく損なうため、Saga というパターンを使う。

Saga は非同期メッセージングをベースにしたサービス間の協調の仕組みだ。 トランザクションの ACID の4つの特性のうち、 ACD だけを保証する。 Isolation についてはいくつかのテクニックである程度は保てるが、基本的には結果整合性になる。

Saga は 以下のような手続きで実現する。

  • Transactional Message を使って、まずは自分のデータを処理中(pending) にして他のサービスのメッセージを投げる。
  • 他のサービスはメッセージを受け取って自身のデータを更新し、応答メッセージを投げる。
  • 応答メッセージを受け取ったらその内容に従って、ステータスを完了や失敗に更新する。

また失敗時に更新したデータを取り消す(または打ち消す)ために、補償トランザクションを用意しておく。

Saga は f 型と Orchestration 型の2つがある。

前者は送信したメッセージの扱いと返信を投げた先のサービスに任せる感じで単純な反面、トランザクションの内容が複雑だと全体の把握が難しくなる。
後者は主となるサービスが全てのメッセージの送受信の流れを決める感じで、複雑なトランザクションの制御に向く。 基本的に Orchestration で行うのがよく、evantuate tram は Orchestration を DSL として定義可能な API を持つ。

両者のソースコードは前述のサンプルコードにあるので両方を見て比較すると良いと思う。 Choreography 型は投げたメッセージについてどういった処理を行い、どういう返信メッセージをするのかを確認するためには、そのサービスのソースを見るしかないので、実際わかりづらい。

Isolation を保つにはトランザクション中のデータのステータス管理が肝になる。ステータス管理を適切に行うためにステートマシンを定義すると良い。

他に、Isolation を確保するためのテクニックとして https://dl.acm.org/citation.cfm?id=284472.284478 という 複数データベース環境におけるACIDに関する論文からいくつかが紹介されている。

5章 マイクロサービスのビジネスロジック

DDD の手法を適用してビジネスロジックを構成するクラスを作る。 主な登場人物は、 Service, Repository, Entity, ValueObject の他、マイクロサービス固有の SagaOrchestrator (SagaのDSL), MessageConsumer, MessageHandler などがある。

適切なサービス分割を行うために、 Aggregation(集約)を定義するのが重要。 他の集約への参照は、オブジェクトではなく ID を保持することが望ましい。

こうすることで、集約一つが適切なトランザクションの単位となる。 また、NoSQL を使う場合でも集約を決めることは重要となる。 それは、集約一つを NoSQL のドキュメントとすることで、トランザクションが提供されない NoSQL でもデータの整合性が保てるためだ。

また集約の処理結果を、DomainEvent というサービスで起きた変化を伝えるデータにして、さらにその DomainEvent をメッセージ送信することで、 他のサービスへの通知を行うことができる。

DomainEvent は様々な用途で使用できる。 Choreography 型 Saga の起因となるのも DomainEvent となるし、他に後述の EventSourcing や CQRS, AuditLog などでも使う。

また適切な DomainEvent の定義の仕方として、 Event storming について少しだけ触れらている。

6章 Event Sourcing

集約の更新と同時にイベントを投げるというのは、2つのデータを管理することになるし、もしイベントを投げることを失念してしまったら 見つかりにくいバグの原因になりかねない。 それならいっそのこと、イベントの蓄積だけを行おうというのが、 Event Sourcing の基本的な考え方。

銀行の入出金の例で言えば、従来の方法は入出金があるたびに現在の残高を更新していくが、 Event Sourcing では入出金を一件ずつ記録しておき 残高の算出が必要になった時点で全ての入出金のイベントを取得して残高の算出を行う。

Event Sourcing を行えば管理対象は イベントデータだけだから前述の懸念点は無くなる。 一方で従来のやり方とは大きく異なるやり方なので、学習コストがかかることに注意が必要。

他に、サマリ処理を軽減するスナップショットや重複メッセージの扱い、仕様変更後のマイグレーションといった実装上の懸念点について述べられている。

7章 クエリ

サービスを跨ったデータの取得方法に関する章。

一つ目は API Composition で、一つのサービスが他のサービスの API をコールしてデータをまとめるというもの。 単純だし、最も一般的に使われる。 一方で複雑な場合だと、 インメモリ join や多数の通信によって計算量や時間が増える。 後者は並列化などによって緩和可能だが、前者の解決には CQRS パターンによるデータ複製及びクエリに適したストレージを使うことが挙げられる。

CQRS (Command Query Responsibility Segregation) は クエリ専用のデータモデルやデータストレージ (ElasticSearch など) を用いる方法。 また Command 側はこれまでに出てきた DDD の手法で手厚く作り、 ドメインイベント経由で Query 側にデータを提供する。 Query 側はイベント経由でクエリ用のデータを作り公開する。

DB に限らず適した製品を選べたり、複数サービスに跨るデータを集約できることがポイントになる。 DB以外の製品として出てきたのは、地理情報の検索が可能な Elastic Search など。

また、 Event Sourcing をCommand 側で使った場合はイベントを集約した内容を Query として生成することで、 集約にかかる負荷を抑えたり、Queryの要求に応じて柔軟にQueryモデルの再生成が可能といったメリットがある。

CQRS を適用する上での欠点は、複雑性が増すこと。

一旦まとめ

長くなったので主に設計・実装に関わる前半が終わったこの辺で一回区切ります。

個人的に気になっていた、 Saga について実行可能なサンプルコードもある状態で解説されているのがとても良かったです。 OUTBOX を使用したトランザクションメッセージは個人的には目から鱗のアイデアだと思いつつも、 Saga パターンと補償トランザクションは真面目に書くと結構辛いなというのが正直な感想ではあります。

とはいえ、これより優れたアイデアというものも中々出てこないですし、 Saga を簡単に書き下す言語やライブラリの登場が今後あるかもしれないです。 個人的には、Saga を書くのに Scala + Akka は結構良いのではと思っていたりします。

あとはドメインイベントは必須だとして、 Event Sourcing をどうするか。 Event Sourcing を使うと CQRS も多分必須になるし。 なんとなく、 Event Sourcing がハマりそうな分野(会計とか)はあるものの、これを適用するのは勇気がいるなと思います。

とはいえ本書は全てのパターンを使うことを求めてはいないし、各パターンの利点・欠点もちゃんと述べている。 そのため、自分たちが作るものに合わせて最適なパターンを決めていけばいいし、 特定のサービスだけ Event Sourcing + CQRS にするというのももちろんありでしょう。

続きを書きました。 kencharos.hatenablog.com