re:Invent 2024: Amazon/AWSが語る分散システム統合パターン
はじめに
海外の様々な講演を日本語記事に書き起こすことで、隠れた良質な情報をもっと身近なものに。そんなコンセプトで進める本企画で今回取り上げるプレゼンテーションはこちら!
📖 AWS re:Invent 2024 - Integration patterns for distributed systems (API306)
この動画では、分散システムにおける統合パターンの基本的な概念について、AmazonのPrincipal EngineerとAWSのPrincipal Solutions Architectが解説しています。Coupling、Control Flow、メッセージの順序と配信セマンティクス、エラー処理という4つの重要な柱に焦点を当て、それぞれの実践的な適用方法を説明しています。特にAmazonの支払いシステムにおける実例を用いて、FIFOキューやDead Letter Queue、Back Pressureなどの具体的な実装パターンを詳しく解説しています。また、Amazon SQS、Amazon SNS、Amazon EventBridgeなどのAWSサービスを活用した実装方法や、マルチテナント環境でのNoisy Neighbor問題への対処方法など、実務で直面する具体的な課題への解決策も提示しています。
※ 画像をクリックすると、動画中の該当シーンに遷移します。
re:Invent 2024関連の書き起こし記事については、こちらのSpreadsheet に情報をまとめています。合わせてご確認ください!
本編
Integration Architectureの重要性:Balaji Kumar GopalakrishnanとDirk Fröhnerの自己紹介
おはようございます。こんなに早朝から多くの皆様にお越しいただき、大変うれしく思います。新しいアイデアと実践的なソリューションについて、一緒に学んでいく準備はできていますでしょうか?私はBalaji Kumar Gopalakrishnanと申します。AmazonのPrincipal Engineerとして12年以上、様々なチームで働いてまいりました。現在はAmazon Finance Technologyチームに所属しています。本日はお越しいただき、ありがとうございます。皆様ご存知の通り、基調講演と時間が重なっているにもかかわらず、このセッションを選んでいただき、大変感謝しております。
こんにちは、私はDirk Fröhnerと申します。ドイツのAWSチームでPrincipal Solutions Architectを務めています。普段の仕事では、ソフトウェア企業の多次元的な変革をサポートしており、Integration Architectureは私の得意分野の一つです。これは本日のテーマにぴったりですね。今日は大量の情報をお伝えすることになりますので、しっかりと準備していただければと思います。
Integration Architectureの基本概念とトレードオフ
では、なぜこのテーマを取り上げるのでしょうか?それは、Integration Architectureが全ての人に関係するからです。モノリシックなアプリケーションで作業している方も、モノリスから最初のコンポーネントを切り出し始めた方も、すでにDivide and Conquerアーキテクチャを採用している方も関係なく、 Integration Architectureは誰にとっても重要なのです。常にシステム同士を統合する必要があります。そして、図のオレンジ色の円で示されているように自分たちの管理下にあるシステムもあれば、青い円で示されている外部の第三者システムやソリューションのように、管理外のシステムもあります。
Integration Architectureを考えるとき、それは常に一つの旅のようなものです。その旅に向けて準備をする必要があります。凸凹道を走る時に、車高が高く四輪駆動の車両を用意するように、 Integration Architectureの旅路においても、概念やパターンをしっかりと理解して準備する必要があります。 ここで2つの気づきがあります。1つ目は、私が学会で講演をするのは、素敵な休暇の写真を見せびらかしたいからだということです。2つ目の気づきは、クラウドアプリケーションにおいても、モダンなアプリケーションにおいても、あるいはアプリケーション全般においても、統合は後付けの考えであってはならず、アーキテクチャの本質的な部分だということです。
これはMicroservicesやSelf-contained Systemsのような分割統治型のアーキテクチャスタイルだけでなく、レガシーアプリケーションにも当てはまります。なぜなら、常に何かと統合する必要があるからです。では、2つのボックスやシステムを接続するのは、実際にはどれほど難しいのでしょうか?統合のために使用できる概念的かつ基本的な技術を見てみましょう。すでにここで、いくつかの曖昧さに直面することになります。例えば、 MessagingとStreamingを比較した場合、それぞれの概念を実装する製品が提供する機能が収束してきているため、その曖昧さは常に増大しています。
基本的なアプローチについて結論を出したとしても、考慮すべき多くのフォローアップの質問や側面があります。そしてそれぞれの側面には、個別のトレードオフが存在します。トレードオフ - これが重要なキーワードです。そしてこれが次の洞察へと導く決定的な用語となります。なぜなら、アーキテクチャの決定にはトレードオフが伴わないものはないからです。私はこれも好きなのですが、「信仰療法士」の原則に注意を払うべきです。私たちは皆、悪影響なしに問題を解決してくれる何かを求めています。しかし、厳しい現実として、アーキテクチャやテクノロジーアプローチについて何を決定しても、必ず何かしらの欠点が存在するのです。
最初は別として、どのようなアーキテクチャスタイルであっても、統合に関する決定には認識し管理する必要のあるトレードオフが伴います。冒頭でも述べましたが、分散システムの側面はあらゆる種類のアプリケーションに関連しています。そのため、このトークでは分散システムの基礎という観点から統合パターンについて見ていきたいと思います。統合は、後付けの考慮事項から、初日から行う重要なアーキテクチャ上の決定へと進化してきました。
分散システムにおけるCouplingの重要性
今日は4つの基本的な概念について議論します。これらは分散環境でシステムを接続する際に非常に重要です。まず、分散システムにおけるCouplingとその次元についてです。Couplingは、システムAがシステムBと連携するために必要な知識の量を定義します。それはデータフォーマットや場所などかもしれません。
この接続を理解することで、より良いアーキテクチャの決定を下すことができます。次に、システム間のPullingとPushing、Traffic Shaping、Back Pressureなどの制御フローとフロー制御のニュアンスについて議論します。また、Exactly-once delivery、At-least-once delivery、FIFO Channels、Competing Consumers、Deduplicationなどのメッセージの順序と配信セマンティクスについても取り上げます。最後にエラー処理について取り上げます。完璧なシステムは存在しないからです。多くの可動部分が故障を引き起こす可能性がある場合、それらにどう効果的に対処するかを理解する必要があります。
Couplingは、堅牢で柔軟なソフトウェアを構築するための重要な概念です。複雑なLEGO構造を作っているところを想像してみてください。この構造の各ブロックは、ソフトウェアのコンポーネントを表しています。これらのブロックを接着剤で固定すると、安定した構造が得られますが、特定のブロックを交換したり、構造の一部を再配置したりしようとすると悪夢のようになります。これは、ソフトウェアにおけるTight Couplingに似ています。一方、ブロックを接着する代わりに接続するだけなら、安定性を犠牲にすることなく柔軟性を得ることができます。これこそが、ソフトウェアアーキテクチャで私たちが目指すものなのです。
Couplingは二元的なものではありません。つまり、単純にCoupledかUncoupledかというわけではないのです。それは一つのスペクトラム上に存在します。 コンポーネント間の相互依存関係は、非常に低いものから非常に高いものまでさまざまです。私たちはこれらをLoosely CoupledとTightly Coupledと呼んでいます。このスライドでご覧のように、システムAとBには矢印で示される依存関係があります。依存関係がある場合、必ず何らかのレベルのCouplingが存在することを覚えておいてください。2つのシステムが完全にUncoupledであれば、依存関係はなく、この矢印も必要ありません。Decouplingは柔軟性をもたらしますが、この柔軟性にはコストが伴います。システム内のすべてのコンポーネントを単純にDecoupleすることはできません。なぜなら、Decouplingは複雑さと多くの可動部分をもたらすからです。
「構造は、Cohesionが高くCouplingが低い場合に安定する」という基本原則があります。Cohesionはモジュール内部の内容がどの程度関連しているかを表し、Couplingはモジュール同士がどの程度依存し合っているかを定義します。今日は、より良い安定性を実現するためにCouplingを低く保つための様々なテクニックを検討していきます。 Couplingはシステム間の依存関係に関するものであり、考慮すべき多くの側面があります。 私たちは、日々のアーキテクチャに関する議論で最も関連性の高い、Location、Format、Temporal、Domainというサブセットに焦点を当てていきます。
同期・非同期通信パターンとQueueの役割
いくつかのIntegration Patternを見て、これらの側面とどのように関連しているかを見ていきましょう。まずは、同期と非同期の考察から始めます。 Werner Vogelsの基調講演で学んだように、世界は非同期であり、私たちはその現実に対処する必要があります。非同期性は、システム間のTemporal Dependencyに焦点を当てています。説明のために、先ほどの2つのボックスの図に戻ってみましょう。 コミュニケーションパターンを見ると、送信者が受信者にメッセージを送信する一方向通信が最もシンプルに見えます。しかし、Webブラウザのユーザーとして、あるいは同期的なRequest-Responseモデルや関数呼び出しを扱うソフトウェア開発者として、私たちは異なるパターンに慣れています。
APIを使用したRequest-Responseモデルは、通常、同期パターンに従います。これを具体的にするために、通信にHTTPコネクションを使用することができます。ここで決定的な点は、レスポンダーがリクエストを処理している間、リクエスターが全時間にわたって積極的に待機するということです。
システムのシステムやマイクロサービスのランドスケープでこれがどのように機能するか見てみましょう。これはAdrian CockcroftのGitHubページからの依存関係グラフの例です。これは、外部からリクエストが入ってきて、この依存関係グラフを通過するパスを取る分割統治アーキテクチャでよく目にする例を示しています。これを同期的なRequest-Responseで実装すると、プロセス全体が実行されている間、このリクエストパスに沿ってTemporal Couplingが発生します。負荷がかかった際に課題が生じる可能性があります。なぜなら、リクエストパスに沿ってリソースを拘束し、下流のシステムで問題が発生した場合、上流のシステムに影響を与え、アプリケーション全体の安定性を損なう可能性があるからです。これが、そもそもCircuit Breakerのような緩和パターンが存在する理由の一つです。
これは実際、Microservicesのような分割統治型のアーキテクチャスタイルから得られた約束に反することになります。その約束の一つは、周囲のシステムに依存することなく、各システムを個別にスケールできるというものでした。しかし、同期的なRequest-Responseモデルを使用する場合、そのリクエストパス全体でリソースが束縛されるため、この約束は実現できません。下流のシステムに問題が発生したという理由だけで、上流のシステムを水平スケーリングしなければならない可能性があるのです。
では、どうすれば良いのでしょうか? APIを使用した非同期のRequest-Responseパターンを検討することができます。ここでの違いは、リクエスト元がリクエストを送信し、 下流のシステムに対してリクエストを送った後、接続を閉じて他の作業を行うことができる点です。最終的に、レスポンダーがリクエストの処理を完了すると、結果を送り返すことができます。これには、以前学んだようにトレードオフが伴います。リクエスト元は、どのリクエストを送信したかを覚えておく必要があるため、状態を保持しなければなりません。レスポンダーは応答をどこに送信すべきかを知る必要がありますが、これはリクエストのリターンアドレスというメタ情報で処理されます。同様に、Correlation IDによって、リクエスト元は受信した応答を元のリクエストと関連付けることができます。
同期的な統合に依存させてはいけません。APIを使用したRequest-Responseパターンでは、実行時に問題を引き起こす可能性のある時間的な結合が依然として残ります。下流のシステムがオフラインになっているか問題を抱えている場合、APIを介してそのシステムにリクエストを送信することは困難になります。ここで、特にQueueを使用したPoint-to-Pointメッセージングが役立ちます。
Queueはプロデューサーとコンシューマーを複数の方法で分離し、私たちの問題に対する決定的な効果は、Queueがメッセージをバッファリングすることです。ピーク負荷を平準化したり、コンシューマーがメッセージ負荷に追いつけない場合や一時的な問題が発生した場合でもメッセージを保持することができます。Queueのもう一つの興味深い特徴は、複数のコンシューマーを接続できることで、これをCompeting Consumerパターンと呼びます。
このパターンにおけるQueueの特徴は、各メッセージがそれらのコンシューマーの1つに送信されることです。そのため、メッセージ負荷に応じてコンシューマー側で簡単にスケールアウトすることができます。 このPoint-to-Pointパターンを実装する最も関連性の高いAWSサービスは、クラウドネイティブでServerlessなメッセージキューイングシステムであるAmazon Simple Queue Service(Amazon SQS)です。また、移行シナリオでよくあるように、JMSやAMQPなどの業界標準プロトコルに縛られている場合は、管理型のRabbitMQやApache ActiveMQシステムであるAmazon MQを使用することができます。
Amazon Finance Technologyチームの支払いフロー事例
それでは、Queue を使って Asynchronous Request-Response パターンがどのように実装できるのか見ていきましょう。 論理的なチャネルによって、一時的な分離だけでなく、場所の依存関係も分離されます。なぜなら、リクエスト側はもはやレスポンダーの場所を知る必要がないからです。 このことから、Loose Coupling は Lousy Coupling よりも「ほとんどの場合」優れているという洞察が得られます。ここで「ほとんどの場合」という点が重要です。冒頭で学んだように、アーキテクチャの決定に「常に」という言葉はありません。状況次第であり、トレードオフが伴います。高いスケール要件があり、これらの要件が必要な場合は、Loose Coupling が間違いなく正しい選択です。一方、スケール要件がなく、実行時の依存関係を許容できる場合は、余分な労力をかける必要はありません。
これらすべてを、AWS のお客様である Amazon の実例で説明してみましょう。Amazon がサプライヤーやサービスプロバイダーにどのように支払いを行っているのかを見ていきます。金融取引には常に2つの当事者が関与します: 支払い側(Payer)と受取側(Payee)です。Payer は送金する側、Payee は受け取る側です。 今回の例では、Amazon のビジネス部門が Payer となります。彼らは Digital Author、Vendor、App Developer など、さまざまな Payee に送金を行います。今日は、Vendor Payment Flow を通じて Amazon のビジネス部門が Vendor に支払いを行う方法に焦点を当てます。
Vendor は Amazon に商品を供給し、その商品は顧客が購入できるようウェブサイトで販売されます。商品を供給した後、Vendor は Retail Invoicing System に請求書を提出し、このシステムが請求書と Amazon の在庫からの受領書とを照合します。請求書と受領書が照合されると、これらの照合済み請求書は Account Payable System に送られ、 このシステムが会計処理と、税金や源泉徴収税の計算などの追加情報による請求書の拡充を担当します。Vendor との支払い条件に基づいて請求書の支払い準備が整うと、Account Payable System は支払い指示を Payment Disbursement System に送信します。その後、Payment Disbursement System は Amazon の取引銀行に対して、Amazon の銀行口座から Payee の口座への送金を指示します。
Vendor への適時かつ正確な支払いにおいて重要な役割を果たすため、Payment Disbursement System の仕組みを詳しく見ていきましょう。 ここでは、上流のサービスである Amazon のビジネス部門が Payment Disbursement Service に支払い指示を送ります。この指示には、Payee(Vendor)、Payer(どの Amazon ビジネス部門か)、金額、通貨などの情報が含まれています。次に、 取引銀行が必要とする可能性のある追加情報でこの指示を拡充します。情報を拡充した後、Validation Service を呼び出して検証を行います。このサービスは Fraud Detection Service などのサービスと連携します。
拡充と検証が完了すると、Payment Routing Service に送信され、 このサービスが取引銀行にデータを送信します。取引銀行からレスポンスを受け取ると、それを上流に返送します。ご覧の通り、 1件の支払い処理を完了するためには、複数のサービスが確実に機能する必要があります。このアーキテクチャには、カスケード障害のような制限や課題があります。チェーン内のいずれかのサービスが応答しなくなると、 取引全体が失敗してしまいます。
上流サービスのトラフィックパターンは、もう1つの課題を提示します。上流サービスのトラフィックパターンは、日次や月次で動作するものもあれば、リアルタイムで動作するものもあり、それぞれ異なります。チェーン内の各サービスは、スパイク状のトラフィックに対応できるようにスケールする必要があります。これは特に、内部システムとは異なるスループットやレイテンシーを持つ外部APIと統合する際に問題となります。この状況では、上流は銀行パートナーからの即時応答を期待していないため、最終的な応答があれば許容されます。
システムの制約をより深く理解した後、 私たちはDisbursementプロセスを2つのステップに分割することを決定しました。ステップ1では、Disbursementリクエストを受け付け、基本的なバリデーションを実行します。 ステップ2では、実際のDisbursementを処理します。私たちはAWS Step Functionを使用しています。これにより、銀行パートナーの要件に基づいてステップの追加や削除が柔軟に行え、障害発生時の復旧も効率的に行えます。
これら2つのステップをQueueで連携させることで、いくつかの利点が得られます。 ProducerとConsumerは、お互いの場所や可用性を意識する必要がありません。Consumerの可用性に関係なく、Producerはメッセージを送信し続けることができます。さらに、処理速度の違いによってProducerとConsumerは異なるスピードで動作でき、それぞれが独立してスケールできます。
しかし、まだ重要なパズルのピースが1つ解決されていません。 銀行パートナーからの応答がPayment Disbursementサービスに到達した時、このサービスは上流に応答を返す必要があります。 もし上流サービスごとにQueueを使用すると、Payment Disbursementサービスは統合の変更とコードの修正が必要になります。理想的には、Payment Disbursementサービスを上流から完全にデカップルしたいと考えています。
Queueのみに依存すると、特に場所とドメインの次元で、アーキテクチャに不要なCouplingが課されることになります。 3つのDownstreamシステムに対して3つのQueueを使用して同じペイロードを複数のレシーバーに送信することは可能ですが、これはProducer側でドメインCouplingを生み出します。 Request-Responseパターンでは、メッセージの送信者はレシーバーのドメインを理解する必要があります。さらに、3つの場所を管理することは労力の重複を招きます。
これは、Topicを使用したメッセージング(Publish-SubscribeやFan-outとも呼ばれます)が役立つ場面です。Topicはキューと2つの点で異なります:メッセージをバッファリングせず、登録されたすべてのSubscriberにメッセージを送信するのです。これは、キューについて学んだことに影響を与える可能性があります。というのも、各Subscriberがすべてのメッセージを受信することになるため、スケールやロードを管理するために単純にSubscriberを追加することができないからです。
Publish-Subscribeパターンとメッセージバスの活用
Publish-Subscribeメッセージングでは、PublisherはSubscriberから完全に切り離されます。PublisherはSubscriberのドメインモデルを理解する必要がなく、代わりに各SubscriberがPublisherのドメインモデルを理解する責任を持ちます。これによってPublisherの負荷が軽減され、管理すべき場所が1箇所だけになるため、場所への依存性も最小限に抑えられます。
Publish-Subscribeメッセージングを実装する最も関連性の高いAWSサービスは、クラウドネイティブでServerlessなAmazon Simple Notification Service(Amazon SNS)、あるいはマネージド型のRabbitMQやApache ActiveMQを提供するAmazon MQです。ただし、これは先ほど議論した問題をどのように解決するのかという疑問を投げかけます。Apache ActiveMQは、メッセージのバッファリング機能とスケールアウト機能が不足している問題の解決を支援します。
ここで、Topic-Queue-Chainingと呼ばれるシンプルな複合パターンを適用することができます。 Subscriberを直接Topicに接続する代わりに、その間にキューを配置することができます。これは、安定性と信頼性を高めるために、アーキテクチャに対して私が一般的に推奨することです。これにより、Durable Subscriber PatternとCompeting Consumer Patternによるスケールアウトの両方の利点を得ることができます。
ここで、メッセージバスについてはどうかと疑問に思われるかもしれません。Event Driven Architectureについて語るとき、一般的にメッセージバスが使用されますが、 バスとTopicのどちらを使うべきかについては、少なくとも60分は議論できるでしょう。簡単に言えば、メッセージバスの範囲は通常、アプリケーション全体、あるいはアプリケーション間にまで及びます。Pub-Subシナリオでは役割も異なります。Topicの場合、通常、Publisherのみの役割を持つ一方と、Subscriberのみの役割を持つ他方がおり、Balajiが先ほどの例で共有したように、その範囲はユースケースの一部となります。
Message BusやEvent Busは、アプリケーション全体や複数のアプリケーションにまたがるスコープを持つEvent-Driven Architectureで使用されます。追加の機能を提供する一方で、特定のペイロードフォーマットに制限されるなどの制約もありますが、商用システムとの統合も可能です。Message Busパターンを実装するAWSサービスがAmazon EventBridgeです。ご存知の通り、これはCloud-NativeでServerlessです。Fan-outやPub-subの概念を理解する上では、TopicやBusのどちらを議論するかは重要ではありません - 重要なのはこの概念そのものです。
実際の例に戻って、Pub-subが実際にどのように機能するかを見てみましょう。約束通り、最後のパズルのピースを解決します:上流にレスポンスをどのように返すかということです。ここでは、Fan-outモデルを使用しました。この場合、Subscriberである上流サービスは、TopicからのすべてのイベントをSubscribeする必要はありません。Amazon SNSのフィルタリングポリシーを使用して、特定のイベントセットをSubscribeすることができます。デカップリングによって複雑さと多くの構成要素が導入されたため、デバッグやモニタリングのために、リクエストをEnd-to-endで追跡するより良い仕組みが必要になります。そのために、Correlation IDまたはEnd-to-end IDを使用します。
上流がDisbursementサービスにペイロードやリクエストを送信すると、DisbursementサービスはユニークなCorrelation IDまたはEnd-to-end IDを作成し、それを上流に返します。この同じCorrelation IDは、複数のコンポーネントを通じてリクエストと共に引き継がれ、上流に返されます。これによって、複数のコンポーネントにまたがるリクエストを追跡することができます。その後、成功レスポンスに対してメールやSMSを送信するRemittance Messaging Serviceなど、より多くの機能やユースケースを追加していきました。このモジュールは同じTopicをリッスンしますが、フィルタリングポリシーを使用して成功レスポンスのみをフィルタリングします。
Bank Account Status Trackerは、不正確な銀行口座情報によって支払いが失敗する状況に対応します。次の支払いサイクルまで受取人を待たせる代わりに、すぐに検証のための通知を送り、次の支払いサイクルが正常に進むようにします。このモジュールは、不正確な銀行口座が原因で失敗したすべてのイベントをリッスンします。Bank Response Processorは、成功と失敗だけでなく、さまざまなレスポンスコードを処理します。このモジュールは成功以外のすべてのイベントをリッスンし、システムがどのようなアクションを取るべきかを判断します。
デカップリングの概念についてさらに議論を続けることもできますが、他にも取り上げるべき概念があります。次の章はControl FlowとFlow Controlです。ここに言葉遊びの意図はありません - これらは検討すべき2つの重要な側面です。
Control FlowとFlow Controlの重要性
これまで見てきたアーキテクチャ図を含め、ほとんどの図ではデータフローについて説明していますが、実際にはコントロールフローは異なる場合があります。 場合によっては、まったく逆方向を指すこともあります。Pullモデルを見ると、実際には受信側が会話を開始します。 これは、Queueを見るとより明確になります。送信側とQueueの間はシンプルで、送信側がメッセージをQueueにプッシュするPushモデルです。
しかし、QueueとReceiver(受信側)の間では逆になります。これは必然的なことです。なぜなら、Queueにメッセージをバッファリングさせたいからです。 Queueが単純にConsumerにメッセージをプッシュしてしまうと、それはできません。そのため、Consumerが会話を開始する必要があります。このように、データフローとコントロールフローが逆方向を指すことがあり、これは重要な気づきをもたらします:コントロールフローは、レイテンシー、 スケーリング、バッチ処理、タイムアウトなどの動的なシステム動作に影響を与えるのです。
例えば、Queueを使用する場合、Receiver側のPullモデルによって若干のレイテンシーが発生することは容易に想像できます。しかし、これはQueueのメリットを得るために受け入れなければならないトレードオフの一つです。 既に述べたように、Queueはピーク負荷を効果的に平準化できるため、非常に有益です。高負荷の状況下では、 たった一つのことだけを考慮すればよいのです:常に高速なProducerと遅いConsumerがいる場合、Queueのサイズが増加し続け、 それが問題につながる可能性があります。
この問題に対しては、基本的に2つのアプローチがあります。一つ目はTime to Live、二つ目はBack Pressure(Producer Flow Controlとも呼ばれます)です。メッセージの処理が追いつかない場合は、Producerに対して減速するよう信号を送る必要があります。これは、Queueの前段にあるAPI Gatewayで適用されるRate Limitingという形を取ることもあります。この実例は、Expoで展示されているServerlesspressoで見ることができます。
Serverlesspressoは、コーヒーの注文とそれを作るBaristaを管理するServerlessアプリケーションです。このシナリオではBack Pressureが発生します。なぜなら、ボトルネックはBaristaであり、一度に作れるコーヒーの数には限りがあるからです。全員が忙しい場合、次の注文は待たなければなりません。Back Pressureは、特に人間であるConsumerにとっては嫌なものですが、リソースに制限がある場合は、これが最適な方法です。Serverlesspressoを管理するStep Functionsのワークフローでは、キャパシティの有無を確認する明示的なChoice Stateがあり、キャパシティがない場合は次の注文を受け付けません。これは次の重要な気づきにつながります:Queueはコントロールフローを分離しますが、フロー制御が必要になります。
次のチャプターに移りましょう:メッセージの順序と配信のセマンティクスです。まずFIFOチャネルについて見ていきます。挑発的な言い方から始めましょう:メッセージの順序は頻繁に要求されますが、正当な理由がある場合もたまにあります。もちろん、これが絶対に必要な状況もありますが、私たちの精神的な怠慢さから、トレードオフを無視してこれを常に求めてしまいがちです。原則として、FIFOキューと単一のConsumerパターンでは非常にシンプルです - メッセージが同じ順序でConsumerに送信されることを確実にしたいだけなら、それほど複雑ではありません。
並行Consumer(Concurrent Consumer)を考えると、状況は難しくなります。そのために実際には、すぐに見ていくMessage Groupsと呼ばれるヘルパーパターンが必要になります。これはメッセージに付加する識別属性です。Amazon SQS FIFOキューの実装方法は、Message GroupsとMessage Group IDsを使用することです。これは前述の通り、すべてのメッセージに含まれる識別属性です。ここで見られるように、青、緑、オレンジの3つのグループにすべてのメッセージを分割すると-
Message GroupsとMessage Group IDsは、Amazon SQS FIFOキューでのメッセージの消費と配信を管理するために使用されます。メッセージを正しい順序で配信しますが、ここではConsumerをコントロールすることはできません。あるメッセージの処理が他のメッセージより長くかかる可能性があり、Bの処理がAより早く終わるため、全体の順序が乱れてしまいます。メッセージCが配信されると予想されますが、実際にはメッセージDが配信されます。仕組みとしては、特定のMessage Groupのメッセージが処理中(Consumerに送信され、まだ確認または削除されていない状態)である限り、SQSは同じMessage Groupの別のメッセージをConsumerに送信しません。そのため、Dに進むことになります。最終的にAも完了し、Cに進むことができます。
最終的に見ると、メッセージの全体的な順序は乱れていますが、Message Group内の順序は保持されています。これが重要な洞察です。分散システムにおけるメッセージの順序は、定義されたスコープに対して相対的です。すべての時間の始まりから終わりまでの完全なメッセージ順序が必要だと言うなら、物理的にはただ一つの方法しかありません - 逐次処理に縮退する必要があります。並行処理がある場合、これは機能しません。Amazon SQS FIFOキューのような優れた機能があっても、ビジネス要件によっては追加のエンジニアリングが必要になる場合があります。
ベンダー支払いフローに戻りましょう。ここでは特に税務システムに焦点を当て、源泉徴収税の計算方法について説明します。請求書は様々な理由で更新される可能性があります - 単に明細の説明を更新するだけの場合もあります。これらの変更が源泉徴収税額に影響を与えることもあります。これらの変更を発生した順序通りに処理することが非常に重要です。これらの変更を処理する際に、数日、数週間、あるいは数ヶ月かかることもありますが、そのような長期間、メッセージングシステムへの確認応答を保留にすることはできません。そこで、Consumer側でAmazon DynamoDBを使用したカスタムソリューションを構築しました。
メッセージの順序と配信セマンティクスの課題
このスライドには多くの動きのある要素が含まれているので、例に入る前に説明させていただきます。4つの変更があります:変更AとCは請求書1に属し、同じMessage Group IDを持っています。変更Bは請求書2に属し、異なるMessage Group IDを持ち、変更Dは請求書3に属し、これも異なるMessage Group IDを持っています。Workflow Execution Tableの目的は、システムが現在処理している有効な変更を保存することです。システムが請求書の変更を処理している場合、このテーブルにそのレコードが表示されます。この変更が正常に処理され、源泉徴収税が計算されると、このレコードはテーブルから削除されます。
Event Audit Tableの目的は、すべての請求書で発生したすべての変更を保存することです。パーティションキーは請求書ID、ソートキーは変更IDのステータスです。ステータスには3つの可能性があります:Active(システムがこの変更を積極的に処理している状態)、Completed(源泉徴収税の計算が完了すると、ステータスがActiveからCompletedに変更される)、そしてPending(特定の請求書に対してActiveな変更がある場合、他の変更はPendingステータスでこのテーブルに追加される)です。興味深いのはLSIです。LSIの性質上、同じパーティションキーを使用し、このLSIでは変更を時系列順に保存します。FIFO Queueと比較してみましょう:特定のMessage Group ID内では、すべてのメッセージが到着した順序で維持されます。同様に、特定のパーティションキー内では、すべての変更が時系列順に保存されます。
例に戻りましょう。FIFO Queueでは、メッセージを消費して正常に処理すると、そのメッセージは他のコンシューマーには見えなくなります。同様に、私たちが目指しているのは、この変更を正常に処理した後、LSIでこの変更が見えなくなるようにすることです。これをどのように実現するのでしょうか?単純に値をnullに更新します。値をnullに更新すると、この変更はこのLSIでは見えなくなります。
例を見てみましょう。ここでは変更A、B、Dがコンシューマーに見えています。変更Cは同じMessage Group IDに属しているため、見えません。変更Aが正常に処理され、メッセージングシステムに確認応答が返されるまで、変更Cは見えません。ここで、コンシューマーが変更Aを処理しているとします。請求書1に対してアクティブな変更がないため、この変更がアクティブな変更となります。Workflow Execution Tableにレコードを挿入し、Audit Tableにはアクティブとして追加します。Audit Tableにアクティブとして挿入すると、Amazon DynamoDB Streamsが発生します。Lambda関数にはカスタムロジックがあり、これが実際に源泉徴収税の計算を行うワークフローを呼び出します。
ここで、変更Cが見えるようになっていることに注目してください。これは、Change Consumerがテーブルにレコードを挿入した後、メッセージングシステムに変更が完了したことを確認応答したためです。変更Cは見えるようになりましたが、実際の源泉徴収税の計算はワークフローを通じて行われています。次に、変更Bを処理してみましょう。これは変更Aと同様です。これは新しいアクティブな変更なので、Workflow Execution Tableに請求書2の変更Bを挿入し、Event Audit Tableにステータスをアクティブとして挿入します。アクティブになると、DynamoDB Streamが源泉徴収税を計算するワークフローを開始します。
次に Change C がやってきます。Workflow Execution テーブルを確認すると、Invoice 1 に対して Change A がアクティブな状態で存在しているため、Change C は処理できません。そこで、Event テーブルに Pending ステータスで登録します。これらの書き込み操作はすべて、競合状態を避けるためにトランザクショナルな方法で実行されます。Pending ステータスでこのレコードが登録されても、ワークフローは開始されません。なぜなら、私たちが作成した DynamoDB Stream Lambda は、ステータスが Active の場合にのみワークフローを開始し、Pending の場合は開始しないからです。
ここで、Change B のワークフローが源泉徴収税の計算を完了したとしましょう。ワークフローは戻ってきて、2つの重要な処理を行います。まず、Change B はもはやアクティブではなく完了したため、Execution テーブルからレコードを削除します。次に、この Change を LSI から削除する必要があります。先ほど説明したように、LSI からこの Change を削除するには、null に更新してステータスを Completed に変更します。これにより、コンシューマーから見ると Change B は完了した状態となります。
次に Change D を処理してみましょう。同様に、Invoice 3 に対する新しい Change が Execution テーブルと Event Audit テーブルに Active ステータスで追加されます。その後、DynamoDB が源泉徴収税計算のワークフローを開始します。完了すると、Execution テーブルからレコードを削除し、この Change を LSI から削除して、ステータスを Completed に変更します。これで Change D は完了です。
ここからが面白いところです。長い時間が経過した後、Change A が完了します。このとき、ワークフローはこのキューの中で次にアクティブにすべき Change を特定する必要があります。そこで LSI を確認し、キュー内の次の Change が Change C であることを特定します。そしてワークフローは、Execution テーブルを Change A から Change C に更新します。Change A は完了したので、ステータスを Completed に更新して LSI から削除し、Change C のステータスを Pending から Active に変更します。Change C が Active になり Change A が完了すると、コンシューマーの視点からは、Change C に対して源泉徴収税を計算するワークフローが開始されます。Change C が完了すると、Execution テーブルからこのレコードを削除します。
Audit テーブルからこのレコードを削除し、最終的に Change C は完了します。このように、たとえ Change がここで長い時間(数ヶ月になることもある)かかったとしても、コンシューマー側での Change の順序を維持することができています。
次のチャプターに進みましょう。メッセージの順序と配信のセマンティクスについて、いくつかの側面を見ていきます。最初は重複についてです。まず、少し挑発的な発言から始めましょう:メッセージの重複排除は時々要求されることがあり、それなりの理由もあります。しかし、ここでもスコープを意識する必要があります - Producer側とConsumer側で何ができるのか、あるいは特定のパターンでProducerとConsumerにどのような影響を与えられるのか、という点です。Amazon SQS FIFOの場合、受信メッセージの重複排除には5分間の確認期間があります。時の始まりから終わりまでこのような処理を行うことは事実上不可能だということは容易に想像できますよね。
同様に、メッセージの消費に関しても、Message Visibility Timeoutのような概念があり、これは有限である必要があります。そうでないと、確実にスターベーション問題に陥ってしまいます。重要な点は、分散システムにおけるメッセージの重複排除は、定義されたスコープに対して相対的だということです。また、メッセージの配信頻度についても、アーキテクトや開発者がよく議論する話題です。彼らはよく「Exactly-once配信処理が必要だ」と言います - これを「Exactly-onceの信奉者」と呼びましょう。ProducerとConsumerのすべての重複排除オプションを使用したとしても、何かを処理している途中で例外が発生する可能性は常にコード内に存在します。そこで大きな疑問が生じます。本当にそのメッセージを二度と見たくないのでしょうか?すべてを手動で管理して、ロールバックして再処理するようにしたいのでしょうか?
通常、この背景にあるのは、アーキテクトや開発者がべき等な処理を恐れているということです。しかし、厳しい現実として、失敗のケースを避けることはほとんど - というよりまったく不可能なのです。そのため、べき等な処理は誰にでもお勧めします。皆さんが考えているほど難しくはありませんし、サポートしてくれるツールもあります。例えば、Lambdaを使用している場合は、べき等な処理をサポートするPower Tools for Lambdaをお勧めします。
失敗について触れましたが、分散システムにおいて失敗やトラブルの処理は非常に重要です。私たちのCTOの言葉を借りれば:「すべてのものは常に失敗する」のです。Poison PillsやDead Letter Channelのような少し怖そうなパターンが役立つかもしれません。失敗を受け入れる必要があります。なぜなら、それによってはじめて失敗を管理できるからです。まず、Dead Letter Queueとは何かを見てみましょう。この例では、いくつかのメッセージがQueueに入り、消費されます。そして、メッセージCを処理する際に繰り返し例外が発生します。これを永遠に続けたくはないので、Dead Letter Queueのパターンを採用し、このメッセージをそちらにリルートすることで、本番環境での心臓手術のような状況を避けながら調査することができます。
例に戻りましょう。他の条件でもこのソリューションが機能すると考える人はどれくらいいますか?誰も自信がないようですね?同じ例を別の条件で再現してみましょう。ここで、この変更を処理すると仮定して、何らかの理由で例外が発生したとします。事前に定義された回数まで再試行を続け、その後諦めます。そうすると、この変更、つまり私たちのメッセージはDLQに移動します。次に、その変更を取り上げて、同じ処理に戻ります。Invoice 1に対して現在アクティブに処理されている変更がないため、Execution TableとAudit Tableの両方にステータスをアクティブとして挿入します。
テーブルにレコードがアクティブになると、Amazon DynamoDB Streamのラムダがトリガーされ、源泉徴収税を計算するワークフローが開始されます。ワークフローの終了時に、このレコードをLSIから削除し、ステータスを完了に変更します。これらの失敗したメッセージを再処理しようとすると、その変更が再度コンシューマーによって検知されます。今回は例外が発生しないとします。ここで、Invoice 1に対してアクティブな変更が存在しないため、システムはこの変更をアクティブな変更とみなします。この記録を実行テーブルとイベント監査テーブルにアクティブとして挿入しようとします。
これは、最新の変更であるChange Cを既に処理している場合は、その変更を処理したくないという要件に違反します。この問題に対処するため、実行テーブルにいくつかのカラムを追加しました。ステータスカラムとタイムスタンプカラムを追加しました。このタイムスタンプは、メッセージがFIFOキューに到着した時刻や処理された時刻ではなく、Invoiceで変更が実際に発生した時刻であり、これはメッセージ情報の一部としてソースシステムから提供されます。
これらの変更で何が起こるか見てみましょう。同じ例外が発生した場合、事前に定義された回数だけ再試行してから諦めてDLQに移動します。ここでChange Cを処理し、ワークフロー実行テーブルと監査テーブルにステータスをアクティブとして挿入します。Streamsがワークフローを開始し、源泉徴収税を計算し、完了すると、両方のテーブルのステータスを完了に更新し、LSIから削除します。この失敗したChangeのメッセージを再処理する際、コンシューマーは実行テーブルを確認し、最近完了した変更があることを確認します。タイムスタンプを比較し、この変更がタイムスタンプT1を持っていることから、より新しい変更が既にシステムによって処理されていることがわかります。
このユースケースでは、この変更をスキップしつつ、監査目的で監査テーブルにスキップとして記録したいと考えています。スキップとして追加されると、ワークフローは開始されません。次に、アーカイブと再生について、そしてAmazon EventBridgeをどのように活用できるかを見てみましょう。中央にメッセージバスがあり、プロデューサーとコンシューマーがいると仮定します。前述のように、両者ともプロシューマーになり得ますが、この例では左側にメッセージのプロデューサー、右側にコンシューマーがいます。これらのコンシューマーが適用するルールは、最初のコンシューマーが紫のメッセージを受け取り、2番目のコンシューマーが青のメッセージを受け取るというものです。
正常なメッセージはすべてバスを通過し、処理のためにコンシューマーに配信されます。レジリエンスのために、間にキューを追加することもできますが、最終的にはすべてのメッセージがシステムを通じて処理されます。2週間後、コードにバグが見つかってメッセージを再処理したいと思っても、メッセージは既に消えてしまっています。ここで、Amazon EventBridgeやSNS FIFOトピックでサポートされているアーカイブと再生パターンを活用できます。メッセージをアーカイブに保存しておき、後で再処理が必要になった時に、バスを通じてコンシューマーに送信することができます。色を少し変更しましたが、ここでメッセージIDが変更されることに注意が必要です。このソリューションはその問題に対処していますが、特定のメッセージングシステムでこれがサポートされるまでは、メッセージの再生はストリーミングの独自のセールスポイントだったため、より多くの曖昧さを生み出しています。
分散システムの課題とリソース
これにより、どのような場合にどちらを使用するかの判断が難しくなりますが、消費モデルが異なることは確かな違いとして残ります。メッセージを再処理する必要があるため、べき等性のあるConsumerを用意する必要があります。 このケースでは、メッセージを可能な限り高速に送信するため、下流のシステムが実際にその負荷に耐えられることを確認してください。
分散システムでマルチテナントアーキテクチャを採用している場合に重要となる問題の一つが、 Noisy Neighborの問題です。ただし、これはマルチテナントアーキテクチャに限った問題ではありません。共有リソースを扱う際には常に発生する可能性がある問題です。ここではメッセージングについて話しているので、キューを例にとってNoisy Neighborとは何かを見てみましょう。 ここでは3つの異なるテナントがあるとします。これらのテナントの代わりにメッセージがキューに送信され、企業が最初に採用する典型的なアーキテクチャは、すべてのテナントに対して1つのマルチテナントキューを使用することです。
しかし、あるテナントが大量のバックログを作り始めるとどうなるでしょうか? メッセージのバックログは、大規模な新規メッセージの入力によって発生する場合もあれば、メッセージ処理側で何らかの例外が発生し、それらのメッセージが大規模にキューに戻される場合もあります。この例では、テナント1が このキューで支配的になり、大きなバックログを作り出しています。これが問題になる場合、いくつかの緩和パターンがあり、 これらについて何時間も話し合うことができますが、ここでは典型的な緩和パターンについてのみ言及します。 以前学んだBack PressureやTTL(Time to Live)を使用したLoad Sheddingを適用することができます。
その他の緩和 パターンについては、追加のエンジニアリングが必要になります。例えば、テナントごとの専用のSingle-Tenant Queue や、ハイブリッドアーキテクチャ、Cell Shardingなどです。これらは、問題が関連する場合に必要となる、より大きなエンジニアリング作業となります。このような問題点がない限り、これらの緩和技術を実装することはないでしょう。なぜなら、これらにはトレードオフが伴うからです。確かに利点はありますが、追加のエンジニアリング作業が必要となり、これは最初の洞察に戻ることになります:アーキテクチャの決定には 必ずトレードオフが伴うのです。
最後に、今日議論した分散システムの 基本事項を簡単に振り返ってみましょう。4つの柱について見てきました:Couplingとその次元、Control FlowとFlow Control、メッセージの順序と配信セマンティクス、そして障害とトラブルの処理です。さらに詳しく学びたい方は、 s12d.com/api306-24のリソースページからダウンロードできる資料をまとめています。またはこのQRコードをスキャンしてください。スキャンできるよう、しばらくこの画面を表示しておきます。また、これらの関連セッションもご覧いただくことをお勧めします。最初のものはすでに終了していますが、Breakoutセッションの録画をご覧いただけます。
サーバーレスについてさらに学びたい方は、無料コースを受講してバッジを獲得できるこちらのサイトをご覧ください。先ほども申し上げましたが、AWS Power Tools for Lambdaを特にお勧めします。これについても、写真を撮っていただければ後ほどご紹介させていただきます。本日はご参加いただき、ありがとうございました。大変光栄です。ご質問などございましたら、LinkedInでお気軽にご連絡ください。この後も会場におりますので、ご質問をお受けいたします。ありがとうございました。
※ こちらの記事は Amazon Bedrock を利用することで全て自動で作成しています。
※ 生成AI記事によるインターネット汚染の懸念を踏まえ、本記事ではセッション動画を情報量をほぼ変化させずに文字と画像に変換することで、できるだけオリジナルコンテンツそのものの価値を維持しつつ、多言語でのAccessibilityやGooglabilityを高められればと考えています。
Discussion