pitfallineventdriven

今回はイベントドリブンにおける、あとでハマったら痛い目に会う設計時の落とし穴4つとその対策をご紹介します。

イベントドリブンについてよく知らないという方は、前回の記事「クラウドネイティブアプリケーションにおけるイベントドリブンの重要性」も参考にしてみてください。

落とし穴1: 運用中にイベントの構造が変わる

例えば旅行業のシステムで「予約イベント」があったとして、そのイベントの構造に新しい属性「予約代行業者ID」が追加されたとします。イベントを消化する側の処理(この記事では”Consumer”と呼びます)ではこの「予約代行業者ID」を使いたいので改修することになるのですが、それ以前のイベントを処理するタイミングではこの属性が存在しないため、属性追加前後の分岐が必要になります。同様に属性が削除された場合や、属性のメタ情報が変わった場合の考慮も必要です。

対策

RDBの場合には改修に合わせて過去データをマイグレーションすることが一般的ですが、イベントストアが過去データ(イベント)のマイグレーションをサポートしているものは少なく(i.e. Akka)、過去データが更新されない前提のデータ構造周辺のアーキテクチャを採用しているものが多い印象です。例えば、過去データがコンパクション(圧縮)可能なデータ構造が採用されている場合、元データは削除されるため、コンパクション後のデータにパッチをあてるといった難易度の高い運用が要求される可能性があります。また、RDBの場合も同様ですが、元データをマイグレーションするだけでなく、元データを利用して作られた派生データ(i.e. Materialized View, 外部システムのデータ)にもパッチをあてていく必要があります。

どういった設計にするかは、Consumerのデータストアがどれだけ他機能に依存されているかや、開発・保守のコスト等さまざまな要因とのトレードオフ…と言ったらそこまでですが、筆者個人の経験からすると、使い捨てのようなシステムでない限り、以下のような設計にして保守コストを抑えることをしたくなります。

1. Consumerを動的に変更する

構造変更の都度分IF文を増やしてコードを増改築していくのではなく、構造変化のタイミングを発生日時やバージョン番号で管理し、それに合わせたConsumerの差分管理をしておき、イベントの構造に合わせて動的に選択されたConsumerを生成してからイベントを渡すような手段が考えられます。

2. Consumerのデータストアをスキーマレスにする

イベント発生元のデータ構造をConsumer側がコントロールできない(例えば開発チームが分かれている)ケースにおいて、Consumerのデータストアのスキーマをガチガチに固めることは、構造の変化に柔軟に対応できなくなり初期リリース直後の改修スピードについていけなくなるリスクがあります。特にアジャイルな開発ではスキーマが変わる前提で設計しチームのアジリティを維持できるかは技術選定において重要度の高いパラメータです。

3. イベントのリプレイ機能を用意する

構造がかわることを事前にConsumerの開発者にお知らせするとはいえ、全てのConsumer開発チームが完璧に対応してくれることを期待するはやめておいたほうがいいでしょう。そのため、特定のConsumerに再度イベントを配信できるよう、イベントのリプレイができることも重要です。

イベントのリプレイとは、過去のイベントを再度時系列に古い順から辿っていくことです。なにが嬉しいかというと、例えばConsumerに不具合があったとき、Consumerを修正後、再度障害発生時点からイベントの消化を再開することが可能です。このときもしリプレイ機能がなかったら、アプリケーションのログやDBからマニュアルもしくは一時的なスクリプト等でConsumerのデータストアを再構築することになります。そうすると障害発生時だけに利用するロジックが作成されることになり、通常用と障害対応用の2パターンの処理のメンテナンスが必要になります。このような戦略を取っていると、障害対応用のコードは往々にしてメンテナンスされなくなります。

また、ステージングやQA環境で動作検証をするようなとき、リプレイ機能があれば、再度UI等からイベントを発生させるといった面倒な手順を踏むこと無く、Consumerのテストをすることが可能です。このような障害からの復旧速度が重要な高いSLAが要求されるシステムにおいては特に検討に値するでしょう。

落とし穴2: イベントが多重配信される

イベントは1回以上(At least once)受け取ること可能性があることを考慮する必要があります。Kafkaに代表される永続化機能を備えたストリーミングプラットフォームは、イベントを必ず1度だけ(Exactly once)配信する機能を持っていることがあります。

Kafkaの場合、以下にあるようにほとんどのアプリケーションにおいては気にする程のオーバーヘッドではないため有効にしておけば問題にならないのではと思われるかもしれません。

For 1 KB messages and transactions lasting 100 ms, the producer throughput declines only by 3%, compared to the throughput of a producer configured for at least once, …

1KBのメッセージと100msのトランザクションにおいて、At least once(…)と比べたときに、Producerのスループットはわずか3%しか減少しません(2017年6月時点)。

https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/ 

とはいえ、ストリーミングプラットフォーム側がExactly onceだからといっても他の箇所で発生する障害やオペレーションミスが原因で1度以上配信される可能性を無視するわけにはいきません。そんなときに最悪Consumerが処理を続行できずエラーになれば良いのですが、警告ログも吐かずに正常終了し、データだけがおかしくなってしまったときにはなかなか気付くことができず大きな問題に発展しかねません。

対策

イベントに一律Incrementalな番号を用意し、Consumerは最後に消化したイベントの番号と受取ったイベントの番号を比較し、すでに消化済みであればスキップする。

落とし穴3: 不正なイベントを発行してしまう

イベントは更新しないのが一般的です(多くのストリーミングプラットフォームは更新を許可していません)。過去の事実を更新したいなら別の更新イベントを発行すべきでしょう。ですが、もしイベント発行側の処理に不具合があり、誤って不正なイベントを発行してしまった場合にはどうすれば良いでしょうか?仮にストリームに溜まったイベントの修正ができたとして、すでにConsumerによってイベントが消化されてしまっている場合は?あるいは別途更新イベントを用意してなかった場合には?

このよう事態がクリティカルな障害に発展しそうなシステムでは、事前のテストで100%排除することは不可能と考え、万が一に備え対策を用意しておく必要があります。

対策

1. イベントをトラッキングする

イベントがこれ以上消化されないよう一刻も早くシステムを停止したとしても、きっといくつかのConsumerは不正なイベントを消化済みでしょう。そのため、まずはだれがどのイベントを消化したかをトラッキングし、適切に障害発生後の対応ができるようにしておきます。

Consmerの開発者に異常時の対応方針を伝える

Consumerが外部システムや自分の管轄外の場合も想定すると、Consumer側にデータのマニュアルメンテナンスを依頼することは現実的ではないかもしれません。Consumer側が手の届かない範囲にある以上、Consumer側と事前に「こういうケースではこういう処理をしてください」と約束をしておく必要があります。

例えば、「万が一不正なイベントを発行してしまったときは、それを打ち消すためのキャンセルイベントを24時間以内に送信します。」といった具合です。そうしておけばConsumerはそれに備えてロジックを組んでおき、データストアを自動でメンテナンスするよう対策しておくことも可能です。Consumerがクレジットカード決済をするようなケースでは、可能であれば24時間待ってキャンセルイベントが来ないことを確認から実際に決済したり、待てない場合には先に決済しておき、キャンセルイベントを受取ったら決済システムに対してキャンセル処理を依頼するロジックを事前に組んでおくこともできます。

落とし穴4: 似たようなイベントが乱立してしまう

例えば旅行業のシステムで予約に関連する以下6つのイベントがあるとします:

  • パッケージツアーの予約イベント
  • オプションの予約イベント
  • オプショナルツアーの予約イベント
  • 飛行機の予約イベント
  • ホテルの予約イベント
  • バスの予約イベント

このときConsumerの実装者は以下のようなことを考えることでしょう:

  • パッケージの予約イベントに飛行機やホテルの情報も入っているのでしょうか?それとも飛行機の内容は飛行機の予約イベントをハンドリングしないといけないのでしょうか?
  • 飛行機の予約イベントが到達する前に、パッケージの予約イベントが必ず届くのでしょうか?ですが、予約されたものがパッケージツアーではないときには飛行機の予約イベントだけ届きそうです。
  • 1パッケージに飛行機が複数ある場合には飛行機の予約イベントは2回届くのでしょうか?
  • オプションでホテルのアニバーサリー特典が追加された場合、その情報はホテルの予約イベント側に追加されるのでしょうか?
  • オプショナルツアーでバスに乗る場合、オプショナルツアーの予約イベントにある金額と、バスの予約イベントの金額は合算すべきでしょうか?
  • etc…

1つでも認識を間違うと不具合の原因になってしまいます。

対策

どのタイミングでどのイベントが発行され、イベントにどのような情報が入っているかは、Consumerのロジックに大きく影響を及ぼします。

上の例であれば、予約されたときは必ず1つ「予約イベント」が発行され、その属性として飛行機やホテル、オプショナルツアーの情報が入っていると良いかもしれません。そうなのであれば、元のデータモデルも予約エンティティを集約ルート(参考: Amazon / ドメイン駆動設計)として、子Entityにホテル予約Entityが存在した方が良いでしょう。といった具合にイベントを考えたことでデータモデルが整理されていくこともあります。このようになにをイベントとして発行する必要があるのかは、システムが対象としているドメインを良く理解していないと決められません。データモデルの設計時からイベントの設計も同時にして明確にイベントを定義しておくことで上記のような混乱を未然に防ぐことを心がけていくと良いでしょう。

おまけ: イベントが循環参照してしまう

イベントが乱立し、イベントが巡り巡って循環参照してしまうことは “Event hell”“ピンボールアーキテクチャ” と呼ばれたりもします。イベントドリブンな設計がコンポーネント間を粗結合にしてくれることの負の側面をよく表現してれています。

対策

DDDの集約ルートをイベントの属性(※)とし(= 集約ルート以外のEntityやValue Objectの振る舞い単位でイベントを作らない)、集約ルート同士の依存関係をデータモデル設計時に定義しておくことは良いプラクティスと考えています。これにより、循環参照を未然に回避でき、イベントの乱立を防ぐことが可能です。

※ 補足: イベントに集約ルートからその配下のEntityやValue Objectをまるまる入れると、イベントの名前に則さない属性も全て入っきてしまうため、イベントの内容に則した属性だけを入れるような考慮も必要です。ただ、理想的な設計を実装に落とし込めるほど予算や余裕のある組織はないでしょうから、全てまるまる入れるといった選択は現実的だとも考えています。

おわりに

アーキテクチャを設計していると、そんなの後で良いよと言われがちだけど最初に設計しておかないと痛い目に会う重要なケースをご紹介しました。

なにが起こるか分からないのがソフトウェア開発です。ここに上げたようなことも参考に、安心安全なイベントドリブンアーキテクチャを設計していきましょう!