My reflection on microservices
マイクロサービスアーキテクチャでの設計・開発を経験を振り返りです。次の設計や技術的な意思決定に資するようにするための個人的な考えの整理です。自身が思い出せれば十分なので最低限の説明で図解もしておらず、分かりにくい点はご容赦ください。
成功だった点
障害の分離
カード決済時に外部のパートナーから利用通知(オーソリ)が送られた際に、利用者の残高に基づいて決済可能か判断し、結果を応答する必要がある。オーソリは24時間365日任意のタイミングで通知されるので、オーソリを処理するサービスは高い可用性が求められる。実際にその機能を持ったサービスをマイクロサービスとして独立させることによって、他のサービスで障害の影響を受けることなく稼働させることができた。
また、境界づけられたコンテキストに基づいてサービスを分割したが、ある程度は(後述するように問題もあるが)開発が進んでも境界は守られており、分散トランザクションが必要になることもなくサービス内で主な処理が完結できるようになった。
サービス間通信は非同期で行い、受信側のサービスの応答を待たずに送信側は次の処理に移ることができるため基本的にはサービス間は疎結合になった。一方で、バッチ処理で他のサービスのデータが必要になることがあるが、冪等かつ次の実行に後回しができる場合は同期的にデータを取得していた。
ソースコードの関心の分離
モノレポを採用し、ルートのディレクトリごとにマイクロサービスの実装を分けた。そのため、業務ドメインに関する知識が各ディレクトリに閉じ込めることができた。その結果、ある程度の規模の開発までは他の業務ドメインに登場する概念や実装を気にせず開発ができるようになった。
例えば、与信ロジックの変更は与信サービスに変更を加えれば良いし、カード利用額の口座引落を代行する外部パートナーとの連携は請求サービスに変更を加えれば済んだ。また、カード発行時の審査条件の変更はカード管理サービス内に変更が閉じることになった。
一方で、ユーザーとカードの対応関係に紐づく権限管理や事業所とカードの1:1対応の関係から1:Nへの変更など、抽象度の高い概念やその関係に変更が生じると全サービスを横断した変更が必要になった。
再利用よりも複製
サービス間が疎結合になり、小さなコードベースになったのは再利用・結合よりも複製させるという思想が前提になっている。開発前に業務ドメインへの理解を深め、ドメイン駆動設計 (DDD)の境界付けられたコンテキストという概念に基づいてサービスを分割した。境界付けられたコンテキストとは、特定のモデルを適用する範囲を明確にしたもの。例えば、物理的には同じ1つのカードであっても決済時に必要になるカードの属性と発行時に必要になるカードの属性は異なる。つまり以下のような違いがある。
カード(決済コンテキスト) | カード(発行コンテキスト) |
---|---|
有効化済みかどうかのフラグ | 発行状況(申し込み~有効化まで) |
月当たりの上限額 | 暗証番号 |
取引当たりの上限額 | 有効期限 |
… | … |
このように業務ドメインを理解し、エンティティ、ドメインサービス、集約を特定し、それらが適用される範囲から境界付けられたコンテキストを決めサービスを分割した。結果的にコンテキストごとに適用されるモデルが生まれるので、個々のモデルはサービスの責務に閉じることになる。ただし、サービス間でモデルが重複することになるので、同期も必要になるというトレードオフも存在する。例えば、カード発行コンテキストでカードが有効化されると、そのカードでの決済に応じられるように決済コンテキストの対応するカードの属性も更新する必要がある。
これらの 2 つのサブシステムに対して 1 つのモデルを作成しようとすると、不必要に複雑なモデルになります。 また、変更を行う場合に複数のチームが個々のサブシステムに対して作業を行う必要があるため、時間の経過と共にモデルの進化が難しくなります。 そのため、多くの場合、現実世界のエンティティ (ここではドローン) を 2 つの異なるコンテキストで表す個別のモデルを設計することをお勧めします。 各モデルには、特定のコンテキスト内に関連する機能と属性だけが含まれます。
また、分散トランザクションを生み出さないように少なくとも集約より大きい単位でサービスを分割した。
https://learn.microsoft.com/ja-jp/azure/architecture/microservices/model/tactical-ddd
マイクロサービスのアーキテクチャで特に興味深いのは、エンティティと集約のパターンです。 これらのパターンを適用すると、アプリケーション内のサービスの自然な境界を識別できます (このシリーズの次の記事を参照)。 原則として、マイクロサービスは集約よりは大きく、境界付けられたコンテキストよりは小さくする必要があります。
サービス間通信の設計
メッセージの喪失や重複、順序の逆転などのケースを想定して、不整合やエラーが起きない、またはエラーが起こってもリカバリーできるような設計を実現できた。
具体的にはTransactional outboxパターンを用いてメッセージの発生を伴う処理とメッセージの(outboxテーブルへの)永続化を単一のトランザクション内で実行し、ワーカーが定期的にoutboxテーブルから未送信のメッセージを読み込みメッセージブローカーへと送信するようにした。
そして、メッセージには必ず送信元のサービス名とoutboxテーブルのプライマリキーを含めることにした。これによって受信に失敗しているメッセージを確認し(AWS SQSであればデッドレターキューから確認できる)、送信元のoutboxのレコードを未送信ステータスに更新すれば再送できるようにした。
また、メッセージブローカーへの送信は複数ワーカーで行っていたので、重複した送信を少なくするような工夫もした。データベースの排他ロックは範囲検索の場合、ギャップロックやテーブルロックになり、注意深く実装しないとデッドロックのリスクもあるので利用しなかった。アプリケーション側で楽観ロックのようなロックバージョンを取り、変更されていないバージョンのレコードを送信するようにした。
// ランダムな文字列を生成しversionとロック期限を更新する
UPDATE
messages
SET
version = 'abcd',
released_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE)
WHERE
status = 'unsent'
AND released_at <= now();
// 生成したversionのレコードを取得して送信する
SELECT * FROM messages WHERE version = 'abcd';
// 送信に成功した場合はステータスを送信済みにする
UPDATE message SET status = 'sent' WHERE version = 'abcd';
受信側でもメッセージを重複排除用のテーブルに保存するようにした。このテーブルには、再送の場合と同じように送信元のサービス名とoutboxテーブルのプライマリキーをユニーク制約とした。これによって受信時にトランザクションを開始し、まずメッセージをこのテーブルに保存するようにすることで、同じメッセージを処理してコミットするとロールバックされるようにした。
まとめると、まずはシンプルな実装から始め、想定されるエラーの発生頻度やメッセージの流量を確かめながら、さらなる作り込みを検討しようとしたのは建設的だった。
課題
複雑な依存関係
まず、マイクロサービスの利点としてデプロイの独立性や障害の分離、スケーラビリティが挙げられる。これらは単独のサービスとしてプロセスが割り当てられていることに起因する。
しかし、いくつかサービスはバッチ処理が主な責務となっており、サービスとして分割した利点を享受できていない。具体的には請求サービスは外部パートナーから共有されたクリアリングをAmazon S3から取得し、請求に必要なデータ構造に変換し、永続化して口座引落を担う外部パートナーに連携する。また与信サービスは会計アプリケーションから口座残高の遷移や明細データを取得し、事業所ごとに限度額を算出する。もちろん、データベースは分けられているので異なる性能を採用したり、パフォーマンスや排他制御の問題から分離できたり、スキーマ変更の独立など利点はある。
一方で、システム全体としては多くのサービスを連携させる必要があるので開発やテストが困難になった。特に動作確認は複数のデータベースに整合性があり、動作パターンを実現するデータを持たせる必要があった。そのためには結局各サービスで連携されるデータや実装を確認しなければならない。また、非同期で連携させるにはpublishとsubscribe用のそれぞれのワーカーを起動する必要があり、メッセージブローカーであるAWS SNS・SQSも動作させなければならない。
また、サービス間の依存関係も複雑になり、いつ何がどのサービスに連携されているべきか把握するのが困難になった。実際に途中から通知サービスでユーザーへのカードの割り当てを管理するようになった。ユーザーに通知サービスが割り当てられているカードの識別子を返却することで特定のカードのみ操作が可能になる。そのためにはカード発行時に通知サービスにカードが連携される必要があった。しかし、それまでは発行後のアクティベーション時に連携するようになっていたので、カードを発行しても閲覧できない状態が発生した。カードを発行するサービスはカード割り当てに関知せず、通知サービスはただ受け取ったカードの割り当てを管理するだけなので、この問題に事前に気づくには双方のサービスへの理解が必要だった。
複数の責務を持ったサービス
開発当初はサービス間の連携は疎であり、各サービスは1つの役割を持っていた。しかし、開発の増加で機能も増え、要件が変更・追加されていくと次第にサービス間の連携は増え、役割が肥大化していく。
例えば、途中から事業所あたり複数枚のカードを発行できるようになり、カードをその所有者としてユーザーに割り当てることができるようになった。これにより特定のカードだけを所有者に利用させることができる。またカードに関する通知は所有者にだけ送るなど、通知ごとに割り当てに応じた送信対象の選定が必要になった。そこで元々あった通知サービスがユーザーとカードの割り当てを管理するようになった。
しかし、割り当てられたカードに関する情報のみを取得し画面に表示するためには、各サービスへリクエストする際のパラメーターに割り当てカードの識別子の一覧を付与する必要がある。そのため、各サービスへのリクエストの前に必ず通知サービスにそれを問い合わせることになった。しかし、これは本来通知を行うサービスがアクセス制御も担っている状態になり、システム全体の単一障害点になってしまった。通知サービスが応答できなくなるとユーザーからの他サービスへのリクエストができなくなってしまう。
単一障害点の問題を解消するにはユーザー管理と通知のサービスを分けて、ユーザー管理サービスはユーザーとカードの関係を管理し、ストレージにキャッシュすることで一時的にサービスがダウンしても他のサービスへのリクエストができる構成にすべきだった。セッションキャッシュによる SPOF(単一障害点)問題の解消という記事が参考になる。そして、通知サービスは非同期で受け取ったメッセージからメール本文を組み立て、ユーザー管理サービスに問い合わせ、メールサーバーにリクエストをするようにすれば、通知サービスに通知の責務だけを任せることができた。
可逆性のある変更から取り入れることが重要だという学びになった。予測できないビジネス要件が生まれるので最初は探索できる余地を残しておく。もちろん保守的になりすぎても動機付けられないし、学びも少ないのである程度の挑戦はあるべき。今回のケースだとまずはモジュラモノリスで1つのサービス内でカード発行、与信、通知に論理的に分割して凝集度の高いモジュール性を追求すべきだった。そして、後からユーザー管理を切り出せる余地を残しておくべきだった。
トランザクションの並行制御の問題
非同期で通信するためには、サービスごとにpublishとsubscribeをそれぞれ担うワーカーが必要になる。複数ワーカーによるファントムリードが発生した。ファントムリードとはクエリの結果が既に過去のものとなっており、実際の結果と異なっている現象のこと。
具体的にはトランザクションを開始し、事業所ごとの残高の集計額を読み取ってカードAの利用額を加算した結果を保存する際に、別のワーカーでカードBの利用額を残高に加算して保存していたので、カードBの利用額を反映することができなかった。元々1事業所あたりカードは1枚だった時の実装が複数枚になり、かつ必ず集計時に発生するわけではなかったので発覚が遅れた。
他にもまず対象事業所の存在を確認し、取得できなければ事業所テーブルにINSERTするという処理があったが、複数ワーカーが同時にトランザクションを開始し存在確認し、結果が取得されなかったので同じレコードを挿入しようとして失敗した。
それ以外にも受信側でメッセージの重複排除のために専用のテーブル(inbox)にメッセージを保存していた。そこには送信元のサービス名とoutboxのidのユニーク制約があり、DuplicateEntryのエラーの場合はメッセージを破棄して処理を終了するようにしていた。しかし、そのテーブル以外で同じエラーが発生してメッセージを破棄してしまった。受信後のトランザクション開始時にまずinboxにメッセージを保存するようにすべきだった。既にレコードがあればその時点でDuplicateEntryのエラーになるし、トランザクションが終了するまでこのレコードはロックされる。
メッセージの分割の単位が小さく、近いタイミングで送信されるようになるとトランザクションの同時実行制御に関する問題が起こりやすくなるという学びになった。根本的にはマイクロサービスによる連携ではなく、複数プロセスが同じ集約に属するレコードを操作すると不整合のリスクが高まるということだと思う。
まとめ
- 再利用よりも複製が有効な場合もある
- 再利用による結合よりもモデルの重複を許すことで責務を明確にし、変更の影響範囲を小さくすることができる
- 可逆性のある変更から取り入れる
- 予測できないビジネス要件が生まれるので最初は探索できる余地を残しておく
- トランザクションの並行制御の問題に注意する
- 複数プロセス・スレッドが同じ集約に属するレコードを操作すると不整合のリスクが高まる