キーポイント
- イベント駆動型アーキテクチャは強力であり、適切なトポロジーを選択すれば、実装とサポートは非常にシンプルになる可能性がある。
- 耐久性のあるワークフローとオーケストレーションのためのオープンソースのフレームワークは、信頼性の高いシステムの構築に役立ち、カスタム開発の工数を節約することができる。
- KEDAは様々なメトリクスをサポートしており、従来のCPUベースのスケーリングでは効率的でないような高度なオートスケーリングルールを設定できる。
- 些細なビジネスケースでも、要件を満たすために高度なアーキテクチャ設計が必要な場合がある。
- イベント駆動型アーキテクチャは、オーケストレーションアプローチでも弾力的なスケーラビリティを可能にする。
今回は、Event-Driven Architectureをメディエーター・トポロジーで使用し、弾力的なスケーラビリティ、信頼性、耐久性のあるワークフローなど、興味深い実装内容を実現したビジネスケースの話を紹介したいと思う。全てはKubernetes、KEDA、AWS、.NETの技術を使って構築した。
ビジネス上の問題
まず、ビジネスケースから説明しよう。我々の製品では、ユーザーがファイルをアップロードし、デューデリジェンスプロセスの一環として、後でオンラインで共有したりレビューしたりする。しかし、その裏側では、もっと複雑なことが行われている。各ファイルは処理されなければならない。つまり、基本フォーマットに変換してブラウザで閲覧できるように最適化したり、プレビューを生成したり、言語を判別して画像上のテキストを認識したり、メタデータを収集したり、といった作業を行う。ファイルには、文書、写真、技術図面、アーカイブ(.zip)、そして動画も含まれる。
1日に何十万ものファイルがアップロードされることもあれば、アクティビティのない日が続くこともある。それでも、一般的にユーザーは、ファイルをアップロードしたらできるだけ早く共同作業を始めたいと考えている。そのため、弾力的に拡張でき、費用対効果に優れたアーキテクチャが必要だ。
また、それぞれのファイルには、お客さまにとって必要不可欠で機密性の高いビジネス情報が入っている。どこかでファイルを紛失するようなことは許されない。
全体で何十万、何百万というファイルがある場合、問題が発生したときに素早く特定し、解決するためには、システムの観測性が高いことが重要であることは明らかである。
アーキテクチャ設計に影響を与えるもう一つの重要なディテールは、1つのファイルの処理に十数個のステップが含まれることがあるということである。各ステップは数秒から1時間続くこともあり、CPUとRAM(とIO)を大量に消費する。また、ファイル処理プロセスを簡単かつ迅速に変更できるようにしたい。
ファイル処理にはサードパーティーのSDKを使用しているが、これは必ずしも信頼できるものではなく、時にはメモリを破損してメモリアクセス違反エラーやスタックオーバーフローなどでクラッシュすることがある。
実装について
では、どのように実装したかを見ていこう。
スケーラビリティの要求から、イベントに基づくソリューションを構築することを考えた。しかし同時に、信頼性、観測性、システムサポートの容易さにも妥協は許されなかった。
私たちは、メディエーター・トポロジーパターンを用いたイベント駆動型アーキテクチャを選択した。イベント・メディエーター(内部ではオーケストレーターと呼んでいる)と呼ばれる特別なサービスが存在する。これは、ファイルを処理するための最初のメッセージを受信し、ワークフローと呼ぶファイル処理スクリプトを実行する。ワークフローとは、特定のファイルに対して何をしなければならないかを、個別のステップの集合として宣言的に記述したものである。各ステップタイプは、個別のステートレスサービスとして実装されている。パターン用語では、イベントプロセッサと呼ばれるが、我々はコンバータと呼ぶ。
最初の図は、一般的にどのように動作するかを示している。ユーザーがファイルをアップロードしたら、メディエーターにコマンドを送り、ファイルを処理させる。ファイルの種類に応じて、メディエーターは必要なワークフローを選択し、それを開始する。注目すべきは、メディエーター自身はファイルに触れないということだ。その代わり、特定の操作タイプに対応するキューにコマンドを送り、応答を待つ。このオペレーションタイプを実装するサービス(コンバータ)は、キューからコマンドを受け取り、対応するファイルを処理し、処理が完了したことと処理結果の格納場所をメディエーターに返送する。メディエーターはその答えを受け取った後、ワークフロー全体が終了するまで同じように次のステップを開始する。あるステップの出力は、次のステップの入力になることもある。最後に、メディエーターはシステムに対して、処理が完了した旨のコマンドを送信する。
さて、このソリューションの仕組みがわかったところで、必要なアーキテクチャの特性がどのように実現されているのかを見てみよう。
スケーラビリティ
まず、スケーラビリティから見ていこう。
すべてのサービスはコンテナ化され、Amazon内のKubernetesクラスターで実行される。また、Kubernetesクラスタには、KubernetesベースのEvent-Driven Autoscaler(KEDA)コンポーネントがインストールされている。コンバーターは、Competing Consumersパターンを実装している。
その上で、Converterの種類ごとにキュー長に応じたスケーリングルールが設定されている。KEDAはキューを自動的に監視し、例えば、キューに100個のテキスト認識コマンドがある場合、クラスタに100個の新しいポッドを自動的にインスタンス化し、後でキューにあるコマンドが少なくなったらポッドを自動的に削除する。キューの長さに基づくスケーリングを特に選んだのは、古典的なCPUスケーリングよりも信頼性が高く、透過的に動作するためである。私たちは多くの異なるファイル処理オペレーションを同時に実行しており、その負荷は必ずしもCPU負荷と線形に相関していない。
もちろん、新しいPodを実行するには、クラスタのノードを増やす必要がある。Cluster Autoscalerは、これを助けてくれる。クラスタの負荷を監視し、KEDAがPodをスケールさせるのに合わせてノードを追加したり削除したりする。
ここで1つ興味深いニュアンスがあるのは、スケールインの際、処理ファイルの途中でコンテナを停止してやり直したくないということだ。幸いなことに、Kubernetesではそれに対処できる。KubernetesはSIGTERMを送信して、終了の意思表示をする。コンテナは、応答を遅らせることで、メッセージの処理が完了するまで終了を遅らせる。そこでKubernetesは、レプリカを終了させる前に、terminationGracePeriodSecondsの値までSIGTERMのレスポンスを待つことにする。
さて、コンバーターは弾力的にスケールするが、メディエーターはどうだろう?システムのボトルネックになりはしないか?
そう、メディエーターとワークフローエンジンは水平方向にスケーリングできる。KEDAもスケールするが、今回はアクティブなワークフローの数に応じてスケールする。ワークフローエンジンが内部で使用するRedisのいくつかのリストのサイズを監視し、アクティブなワークフロー数と現在の負荷に相関させている。
先ほど述べたように、メディエーターはプロセスのオーケストレーション以外のオペレーションを行わないので、リソースの消費は最小限である。例えば、数千のアクティブワークフローがあり、200台のコンバーターまでスケールアウトする場合、メディエーターのインスタンスは5台程度しか必要ない。
クラスタは均質で、コンバーターとオーケストレーターのインスタンスを実行するために別々のノードタイプを持つことはない。
保守性・拡張性
このシステムの実装と保守がいかに容易であるかについて説明しよう。
コンバーターは、キューからコマンドを受け取り、指定されたファイルの処理を実行し(サードパーティライブラリのメソッドを呼び出す)、結果を保存し、レスポンスを送信するという、非常にシンプルなロジックのステートレスサービスである。
ワークフロー機能の実装は非常に難しいので、自分でやるのはお勧めしない。市場には、何年も何百万ドルも投資された、かなり成熟したソリューションが存在する。例えば、Temporal.io、Camunda、Azure Durable Functions、AWS Step Functionsなどである。
私たちのスタックは.NETで、AWSでホストされているため、そして他のいくつかの歴史的な理由から、私たちはDaniel GerlagのWorkflow Coreライブラリを選択した。
軽量で使いやすく、我々のユースケースを完全にカバーしている。しかし、Workflow Coreはアクティブな開発中ではない。代替案として、Chris Patterson氏によるMassTransitのState Machineに目を向けるとよい。
メディエーターの実装も簡単で、ソースコードは主に、ファイルの種類ごとに一連のステップの形で宣言的に記述されたワークフローのセットである。
システムのテストは、多くのレベルで可能である。コンバーターの実行やオーケストレーターサービスのインスタンス化を必要としないため、ユニットテストでワークフローをカバーすることは容易である。また、すべてのステップが期待通りに起動されているか、リトライやタイムアウトポリシー、エラー処理が期待通りに動作しているか、ステップがワークフローの状態を更新しているかなどを確認することも有効だ。Workflow Coreライブラリは、そのためのサポートを内蔵している。最後に、すべてのコンバータ、オーケストレータ、データベース、Redis、キューを起動するエンドツーエンドの統合テストを実行できる。Docker composeを使えば、ローカル開発でもワンクリックやコマンドラインで簡単に実行できる。
そのため、変更が必要な場合は、ワークフローの記述を変更したり、時には新しいコンバータサービスをシステムに追加して新しいオペレーションをサポートしたり、代替ソリューションを試したりするだけでいい。
信頼性
最後に、このシステムの最も重要な側面である「信頼性」について説明しよう。
どんなサービスがいつダウンするかわからない、システムの負荷がシステムの拡張性を超えて大きくなる、一部のサービスやインフラが一時的に利用できなくなる、コードの欠陥でファイルの処理がおかしくなり再処理が必要である。
信頼性に関して最も分かりやすいケースは、コンバーター・サービスである。このサービスは、処理を開始するときにメッセージをキューにロックし、処理を終えて結果を送信するときにそれを削除する。サービスがクラッシュしても、メッセージは短いタイムアウトの後、再びキューで利用可能になり、コンバーターの別のインスタンスで処理できる。新しいインスタンスが追加されるよりも負荷が大きくなったり、インフラに問題があったりすると、メッセージはキューに溜まっていく。それらは、システムが安定した直後に処理される。
メディエーターの場合、重い仕事はすべてWorkflow Coreライブラリによって再び行われる。実行中のワークフローとその状態はすべてデータベースに保存されるため、サービスの異常終了が発生しても、ワークフローは最後に記録された状態から実行が続けれる。
また、失敗したステップを再試行する設定、タイムアウト、代替シナリオ、並列ワークフローの最大数の制限なども用意されている。
さらに、システム全体が冪等であるため、あらゆる操作を副作用なく安全に再試行でき、重複してメッセージを受信する懸念も軽減される。AWS S3ポリシーにより、一時ファイルを自動的に削除し、失敗した操作によるゴミの蓄積を回避できる。
Kubernetesのもう1つの利点は、各サービスの制限と最小リソース要件、例えば、最大でどれだけのCPUやRAMリソースを使用でき、起動に必要な最小値を設定できることだ。同じクラスタノードで複数のPodが動作しているときに、いずれかのインスタンスでメモリリークや無限ループが発生するなど、ノイジーネイバーの問題を心配する必要がない。
メディエーターのアプローチと耐久性のあるワークフローのおかげで、我々は非常に優れたシステム観測性を持っている。どの瞬間でも、各ファイルがどの段階にあるのか、何個のファイルがあるのか、その他の重要な指標を完全に把握できる。不具合が発生した場合、履歴データを確認し、影響を受けるファイルの処理を再開するなど、必要に応じて対応できる。
AWSのインフラやアプリケーションの重要なメトリクスをすべて網羅したダッシュボードを構築した。
全体として、大規模な障害が発生しても復旧できるようになっている。
まとめ
最終的には、拡張、変更、テストが容易で、観測性と費用対効果に優れた、拡張性の高いシステムを実現した。
最後に、興味深い統計データをいくつか紹介しよう。既製品のコンポーネントを使用したため、システムのウォーキングスケルトンを構築するのにわずか2ヶ月しかかからなかった。ピーク時のスループットは、1時間あたり約5,000ファイルであった。これは容量制限ではなく、不具合によるプロセスの暴走など、予期せぬインフラ投資を避けるために、意図的にオートスケーリングを制限し、現在は徐々に解除している。1日にユーザーからアップロードされた最大のバッチは25万ファイルだった。新しいソリューションに切り替えてから、合計ですでに数百万ファイルを処理したことになる。
しかし、いくつかの失敗やインシデントもあった。その多くは、高負荷時のエッジケースにのみ現れるサードパーティライブラリの不具合に関連したものだった。責任あるエンジニアとして、私たちは診断情報と修正に貢献しようと最善を尽くした。何か問題が発生したときに、負荷を減らしてシステムを安定させ、回復させ、修正作業をしながらスループットが低下した状態でオペレーションを継続できるように、簡単に設定できる制限を設けることが、今回の教訓のひとつである。