Cloud Intelligence™Cloud Intelligence™

Cloud Intelligence™

AWSで実践するイベント駆動アーキテクチャ 第2回:発展編

By Vlad KhononovDec 16, 20246 min read

このページはEnglishDeutschEspañolFrançaisItalianoPortuguêsでもご覧いただけます。

前回は、AWSマネージドサービスを使ったイベント駆動アーキテクチャ(EDA)の基本要素について解説しました。今回取り上げるのは、いわば「発展編の基本」です。「発展編」と呼ぶのは、私のコンサルティング経験上、これからご紹介するプラクティスを実際に取り入れている企業がごくわずかだからです。それでもなお「基本」と位置付けるのは、これらが任意ではなく、信頼性の高いメッセージングベースのシステムを構築するうえで欠かせないものだからです。

本記事ではAWSサービスを例に取り上げますが、内容はインフラを問わずあらゆるシステムに当てはまります。実装の方法は変わっても、根底にある原則は同じです。

本記事のテーマは「パターン」です。パターンとは定義上、繰り返し発生する問題に対する再利用可能な解決策のこと。そこでまずは、EDAベースのシステムで見落とされがちな問題から話を始めましょう。

問題

前回は、イベント駆動システムでメッセージを発行する手段としてAWS SNSをおすすめしました。実際の発行コードは、たとえば次のような形になります。

...

sns.publish(
    TopicArn=users_topic_arn,
    Message=json.dumps({
        'event_type': 'user_registered',
        'event_id': str(uuid.uuid4()),
        'user_id': 'USER12345',
        'name': 'John Doe',
        'email': '[email protected]',
        'source': 'mobile_app',
        'registration_date': '2024-10-11T20:01:00Z'
    })
)

...

上の例では、user_registeredというタイプのイベントをSNSトピックへ発行しています。しかし、このイベントはどこから湧いて出たのでしょうか。サービスの仕事は、本当にメッセージを発行するだけなのでしょうか。もちろん違います。このイベントはより大きなビジネスプロセスの一部であり、通常は外部コンポーネントへ通知する前に、業務用データベースの状態を更新する処理が伴います。実態をより正確に表すと、次のようになります。

...

# 状態変更を永続化
users_table.put_item(
    Item={
        'user_id': user_id,
        'name': name,
        'password_hash': password_hash,
        'email': email,
        'source': source,
        'registration_date': registration_date,
        'created_at': datetime.utcnow().isoformat()
    }
)

# 対応するイベントを発行
sns.publish(
    TopicArn=users_topic_arn,
    Message=json.dumps({
        'event_type': 'user_registered',
        'event_id': str(uuid.uuid4()),
        'user_id': user_id,
        'name': name,
        'email': email,
        'source': source,
        'registration_date': registration_date
    })
)

...

まず新しいユーザーを永続化し、その後に通知を発行する。一見するとごく素直なコードですが、ここで次の3つの問いを考えてみてください。

  1. このコードでは何が起こり得るか?
  2. その結果、どのような影響が生じるか?
  3. このコードは信頼できると言えるか?

先を読み進める前に、ぜひ一度立ち止まって考えてみてください。

では、答え合わせをしましょう。

データベースへの書き込みとメッセージ発行の間で何かしら問題が起きると、システムは不整合な状態に陥ります。ユーザー側にはエラーが返り、処理全体が失敗したと受け取られるはずですが、レコードはすでにデータベースに作成済みです。一方で、メッセージは発行されていないため、user_registeredイベントの購読者には通知が届きません。なぜそんなことが起こるのか。サーバーの再起動、Lambdaのタイムアウト、ネットワーク分断など、特にクラウド環境では原因は数え切れないほどあります。

イベント駆動システムの整合性は、構成要素間でメッセージを確実に届けられるかどうかにかかっています。しかしこの例では、それが実現できていません。一見シンプルに見えても、上記のコードは信頼性が高いとは言えないのです。

では、どうすればよいのでしょうか。データベースへの書き込みとイベント発行を、ひとつのアトミックなトランザクションでまとめられるでしょうか。答えはノーです。過去にはそうした試み(例:DTC)もありましたが、うまくいきませんでした。2フェーズコミットはどうかと言えば、これも同様の障害シナリオを抱えており、解決にはなりません。

信頼性のある解決策は、2つのトランザクションを1つにまとめることです。どうやって?ここからはOutboxパターンについてお話ししましょう。

解決策:Outboxパターン

Outboxパターンの考え方は非常にシンプルです。まず、状態の変更と発行予定のメッセージを、ひとつのアトミックなトランザクションで業務用データベースに同時に永続化します。両方が成功するか、両方が失敗するかのどちらかで、中途半端な状態は発生しません。次に、外部の仕組み(発行リレー)がコミット済みのメッセージを取得し、非同期でメッセージバスに発行します。

図1:Outboxパターン

実装:全体像

Outboxパターンには、万能の実装方法は存在しません。具体的な実現方法は、利用する技術スタック、特にデータベースに大きく左右されます。

第一に、更新データと発行予定メッセージをアトミックなトランザクションでコミットできるかどうかは、データベースの機能に依存します。マルチテーブルトランザクションをサポートするデータベース(リレーショナルデータベースやDynamoDBなど)であれば、メッセージを「outbox」と呼ばれる専用テーブルに永続化できます。サポートしていない場合は、更新後の状態とメッセージを単一のレコード内にまとめて永続化することになります。

第二に、永続化したメッセージを確実に取得する仕組みも必要です。プッシュモデルに対応するデータベースもあり、その場合はデータベース自身が発行リレーを呼び出して新しいメッセージを渡してくれます。たとえば、DynamoDBのLambdaトリガーや、リレーショナルデータベースの変更データキャプチャ(CDC)機構などです。

最終的に、メッセージの永続化方法と取得方法によって、同じメッセージが不必要に再発行されないようにするための仕組みが決まります。

実装:具体例

冒頭の例に戻りましょう。コードがDynamoDBを使っているため、このケースでOutboxパターンを実装する最も手軽な方法は、マルチテーブルトランザクションを活用し、発行予定のイベントを専用テーブルに追記することです。

...

with dynamodb.meta.client.transact_write_items(
    TransactItems=[\
        {\
            'Put': {\
                'TableName': users_table.name,\
                'Item': {\
                    'user_id': user_id,\
                    'name': name,\
                    'password_hash': password_hash,\
                    'email': email,\
                    'source': source,\
                    'registration_date': registration_date,\
                    'created_at': datetime.utcnow().isoformat()\
                }\
            }\
        },\
        {\
            'Put': {\
                'TableName': outbox_table.name,\
                'Item': {\
                    'event_id': event_id,\
                    'data': {\
                        'event_type': 'user_registered',\
                        'event_id': event_id,\
                        'user_id': user_id,\
                        'name': name,\
                        'email': email,\
                        'source': source,\
                        'registration_date': registration_date\
                    }\
                }\
            }\
        }\
    ]
)

...

次に、イベントを実際にSNSトピックへどのように発行するかを決めます。最もシンプルなのは、outboxテーブルにDynamoDB Streamsを有効化し、新しいレコードごとにLambda関数をトリガーして、そのイベントをSNSトピックへ発行する方法です。

最後に、発行済みメッセージの扱いをどうするかを決める必要があります。発行用のLambdaは、SNSへの発行成功を確認した時点でoutboxテーブルからレコードを削除する方法もありますし、レコードを残したまま実際に発行したタイムスタンプで更新する方法もあります。

図2に、ソリューション全体をまとめました。

図2:AWS DynamoDB、Lambda、SNSで実装したOutboxパターン

発行予定のイベントを単純にSNSトピックへ流すだけの素朴な方法に比べると、Outboxパターンの実装は構成要素が増え、システム全体は確かに複雑になります。しかし出来上がるのは、信頼性の高いソリューションです。元のトランザクションさえコミットされていれば、実行中に何が起きても、対応するイベントは必ず発行され、購読者に届けられます。

シリーズ記事一覧

  1. AWSで実践するイベント駆動アーキテクチャ 第1回:基本編
  2. AWSで実践するイベント駆動アーキテクチャ 第2回:発展編(本記事)
  3. AWSで実践するイベント駆動アーキテクチャ 第3回:難所編

本記事の初出は https://vladikk.com です。

イベント駆動システムの整合性は、構成要素間でメッセージを確実に届けられるかどうかにかかっています。Outboxパターンを使えば、基盤インフラがサービス横断のトランザクションをサポートしていなくても、システムの状態更新と結果イベントの発行をひとつのアトミックなトランザクションとして扱えます。Outboxがもたらす信頼性は、実装に要する手間をはるかに上回る価値があります。