LoginSignup
0
0

Legacy Migration Pattern : Strangler Fig Pattern, Data Migration

Posted at

Strangler Fig Pattern

バックエンド開発を4年以上続けていると、常にレガシーと共にあり、それを改善しながらソフトウェアが進化するのを見てきました。サービスと製品が進化する際、開発者も成長する存在であるため、レガシーの発生は必然的であり、製品の成長に合わせてソフトウェアを変化させ、進化させることも必然的な業務の一つです。

レガシーはコードレベルからデータベースに保存されたデータに至るまで、その形態は非常に多様です。もしコードレベルのレガシーであればリファクタリングを行い、データが問題であれば再正規化またはデータの再定義が必要です。ビジネスが古くなっている場合はUX改善、企画の再定義などが必要でしょう。

これまでレガシーを改善しようとする際、最初の主要な意思決定は次の二つに分かれます。

  • すべてを一度に行う(cut-over, big bang)
  • 徐々にマイグレーションする(Gradually)

一般的に総コストが最も低いのは、すべてを一度に行う方法です。デッドラインを設定し、その時点までにマイグレーションを完了させる目標を立てて進めます。マイグレーション対象によってこの規模とコストは非常に異なり、もし計画通りに進行すれば最良の方法でしょう。ただし、すべてを一度に行うということは、ロールバックや中断が発生する可能性があり、またマイグレーション対象の規模がますます大きくなることも無視できる戦略でなければなりません。

もし徐々にマイグレーションする方法を選んだ場合、上記のようにロールバック、中断、レガシー対象の規模から自由になりますが、徐々にマイグレーションも結局マイルストーンという単位に分かれ、次の二つの選択肢に直面します。

  • ビジネスのMission Critical領域から徐々にマイグレーション
  • ビジネスに影響の少ないNon-Criticalから徐々にマイグレーション

私がこれまでに述べたすべての選択肢は、状況によって有効である場合もあれば、そうでない場合もあります。すべての選択肢はビジネスインパクトを考慮して決定されるべきです。したがって、まずMission Critical領域とNon-Criticalがそれぞれどのように効果的であり、注意すべき点について経験的に述べると以下の通りです。

Mission Critical

重要な部分からマイグレーションを行うことで、マイグレーション対象の規模と変更がほとんど新たに構築したシステムで行われ、自然に他の部分もマイグレーションされます。ただし、初期マイグレーションコストが高くなり、カットオーバーのような副作用が発生する可能性があります。

Non-Critical

重要でない部分からマイグレーションを行うことで、マイグレーション対象で「共通的に」必要なものを優先してプロビジョニングすることができます。ただし、スケジュールが遅延した場合、Mission Critical領域までマイグレーションできず、システムが分離されてスノーフレークシステムになる可能性があり、これは長期的にチームにコスト損失をもたらします。

Fowlerはこのような戦略をStrangler Fig(絞殺無花果)Application Patternと名付け、無花果の木をメタファーに次のように語っています。

They seed in the upper branches of a tree and gradually work their way down the tree until they root in the soil. Over many years they grow into fantastic and beautiful shapes, meanwhile strangling and killing the tree that was their host.

結局、宿主の枝で種をまき、徐々に成長して宿主を殺す無花果の木のように、レガシーシステムのマイグレーションもそうあるべきだという意味です。Fowlerはまた、この戦略の重要点について次のように述べています。

The most important reason to consider a strangler fig application over a cut-over rewrite is reduced risk. A strangler fig can give value steadily and the frequent releases allow you to monitor its progress more carefully. Many people still don’t consider a strangler fig since they think it will cost more — I’m not convinced about that. Since you can use shorter release cycles with a strangler fig you can avoid a lot of the unnecessary features that cut over rewrites often generate.

結局、徐々にマイグレーションする理由はリスクを減らすためであり、この戦略はカットオーバーと比較してもコストが大きく異なるわけではないか、むしろ減らすことができるという文脈です。そしてFowlerはリファクタリングを頑張れと励まし、文を締めくくります。

そこで今回のポストでは、マーティン・ファウラーのStranglerパターンのように、徐々にサービスを進化させ、変化させる方法について、コードレベルからデータレベルまで取り扱ってみたいと思います。インフラでの徐々に展開(Rolling, Canaryなど)や徐々にトラフィックを移動するポストは扱わず、サービスとバックエンドシステムの観点からのみ取り扱う予定です。

参考として、AWSマルチリージョン環境でリージョン間のトラフィックを徐々に移行した経験について、このポストに記載してありますので、興味のある方はご覧になることをお勧めします。


Data Migration

一般的にTopDown形式の文章を書くのが好きなので、Clientの観点、コードレベルから書こうかと思いましたが、最近行ったことがデータの観点でのレガシー改善とマイグレーションなので、データの観点から書いてみたいと思います。

まず、システムの基盤となるデータがどのような形をとっているかによって、コードレベルやAPIに多くの影響を与えるものです。過度に正規化されたデータはJoinなどのオーバーヘッドによりAPIまで分離させる現象を引き起こしたり、過度に逆正規化されたデータはデータ整合性や同期の問題を引き起こします。一般的にシステムが拡張されると、正規化されたデータと逆正規化されたデータが混在して元データソースに保存される場合が非常に多いです。このような場合、元データはよく正規化された状態で保存しながら、複雑なReadを行うためのデータを別途[CQRS]で管理するのが一般的です。また、ビジネスが拡大する中で、類似したビジネスが異なる形でデータが蓄積されたり実装される現象もよく見られます。このような場合、データを一方に統合するか、新しいドメインモデルを導出してデータをマイグレーションし、ビジネスを一か所で拡張可能な形で運用することでコストを削減することができます。

データの量や種類が少ない場合は、データベースのダンプデータを持って一度にcut-overすることもできますが、これは非常に多くのビジネス的な意思決定を必要とし、リスクが非常に高い作業となる可能性があります。また、レガシーデータソースのトランザクションがなくなるという前提も含まれなければなりません。リアルタイムサービスで既存のトランザクションをすべて処理しながら、さまざまなデータソースを一か所に集約してマイグレーションしたり、大きなデータソースを分離してバックエンドシステムを構成しようとする場合、必然的に最終的な一貫性(Eventual Consistency)を持つことになります。マルチデータソースでの強い一貫性(Strong Consistency)を持つ分散トランザクションは障害伝播のようなリスクが高まり、SPOF(Single Point of Failure)になる可能性があります。

結局、データマイグレーションは別途のシステムを必要とし、この実装はデータ処理(Data Processing)の代表的なパターンである(1)Batch(2)Lambda Architecture(3)Kappa Architectureに分類されます。今回のポストでは、これらのパターンを実務で実装して感じたことを紹介したいと思います。


Context

データレベルの下位互換性を保ちながら、新しいデータベースへの構造変換とマイグレーションを行う作業は、既存データと新規データの両方を含み、ダウンタイムなしで準リアルタイムに行われる必要があります。データ処理ロジックは冪等性を持っているため、最低でも一度は実行を保証することで、データは失われることなくマイグレーションされることを前提とします。

(1) Batch

一般的にバックエンド開発者が経験するバッチは、一定の時間ごとに動作するシステムを意味します。バッチの構成は、一般的にBatch Jobを実装することと、Jobを実行するSchedulerで構成されます。Batch Jobの実装は、Spring Frameworkを使用する方であれば通常spring-batchのようなライブラリを使用し、SchedulerはJenkins、Argo Workflow、Kubernetes Jobなどさまざまなものを利用できます。または、両方の構成を同時に可能にするAriflowやDatabricksなどのプラットフォームを使用しても実装できます。

一般的にバッチを実装する際に重要なポイントは、バッチが動作するアプリケーション環境の隔離と分散可能性です。バッチは負荷の高いワークロードであるため、他のアプリケーションと同じコンピューティング資源で動作すると、他のアプリケーションのリソースをすべて占有し、深刻な障害を引き起こす可能性があります。一般的にデータ処理は並列で行われるため、並列作業が分散できるかどうかも重要です。それがコンピューティング資源レベルであれば最良ですが、隔離されたインスタンスでスレッドレベルで分離されても、一般的にバッチはうまく動作することがわかります。バッチは基本的にStatefulなアプリケーションであるため(以前のバッチ作業がどこまで動作したかを知る必要があるため)、状態管理をどのように行うかについても多くの考慮が必要です。そのため、spring-batchのようなライブラリを使用すれば、このコストをかなり削減できます。

スケジューラはバッチを動作させる要素で、バッチが期待通りの正確な時間に動作するためには、スケジューラの構成も重要です。スケジューラはどのバッチをいつ回すかに関する状態管理が必要なため、ストアのDurability保証が必要です。また、スケジューラアプリケーションのHA構成が可能かどうかも重要です。代表的なスケジューラであるJenkinsの場合、マスターノードの冗長化ができないため、マスターノードが中断された場合、スタンバイ(パッシブ)ノードが再起動されるまでバッチを動作させることができない障害が発生します。

バッチは基本的に準リアルタイム性を持ちにくいです。しかし、Spark Streaming、Apache Flinkのように小さなバッチを高頻度で少しずつ実行することで、マイクロバッチと呼ばれる形でインクリメンタルデータをマイグレーションすることができます。バッチをパラメータに応じて異なる動作をさせた後、全データとインクリメンタルデータを一つのバッチで処理できるようにすれば、管理ポイントを一つにしてすべてのデータをマイグレーションすることができます。

しかし、バッチを使用してインクリメンタルデータを処理するのは非常に難しいです。まず、バッチの動作時間を正確に測定できないため、スケジューラでバッチの動作時間を正確に設定することが難しい点があります。もし、最後のバッチ動作時点から動作するロジックを作成するなら、最後のバッチ動作がどこまで完了したかの状態管理が必要です。そして、このように最後のバッチ動作の状態管理が必要なら、バッチワークロードの分散環境構成が難しくなります。一般的に、このようなバッチの動作はスーパーバイザープロセスのように無限ループの形態をとることが多いです。

上記の理由から、バッチを使用してデータ処理を実装するのは、バッチの動作間に新しいデータを処理するのが難しいという問題があります。しかし、多くの企業が既にバッチインフラを構築して使用しているため、実装が容易であり、一回限りのマイグレーションや準リアルタイム性を必要としないシステムであれば、バッチを通じて実装するのも良い方法です。

(2) Lambda Architecture

データマイグレーションを経験したことがある方なら、一度は実装してみたことがあるアーキテクチャだと思います。バッチなどを利用して定期的に一貫性を保ちながら、ドメイン/CDCイベントなどを利用してインクリメンタルデータをマイグレーションします。これにより、バッチでは実現が難しい準リアルタイム性を確保できます。また、Batch Layerを事前に実装するため、システムが進化したり再マイグレーションが必要な時に簡単に再動作させることができる利点があります。

Layer-of-Lambda-Architecture.jpeg

Lambda Architectureを実装する際に考慮すべきは、Batch LayerとSpeed Layer(上の図のStream Layer、以下Stream)を同時に維持するのが非常に難しいということです。

コードで例を挙げると、Lambda Architectureでは一般的にバッチはDBMSを基準にロジックが作成され、StreamはKafka、MQのようなソースを基準にロジックが作成されます。Zero-Payload Patternなどを利用してBatchとStreamを単純なトリガー用途で使用するなら同じ実装を再利用できますが、これは既存のソースが持つデータを十分に活用できず、リソースを浪費する形で実装されることになります。また、一般的にBatchとStreamは別のサーバーインスタンスで動作します。これにより、デプロイの不一致が発生する可能性があります。上の図のように、データマイグレーションはServing Layerのデータソースを触る作業であるため、デプロイの不一致によってデータの整合性が異なる二つのシステムに合わなくなると、アプリケーションエラーが発生する可能性があります。もしNoSQLデータベースやalias/viewをサポートするデータベースなら、このようなエラーを事前に防ぐシステム構築が可能です。また、Lambda ArchitectureはBatchとStreamを同時に使用するアーキテクチャであり、二つのシステムの長所と短所をすべて持ちます。

上記のような短所にもかかわらず、Lambda Architectureは現在ほとんどのシステムで使用されている非常に優れたアーキテクチャです。これまでに何度も実装してきましたが、次に紹介するKappa Architectureに比べて低い学習曲線と実装の容易さから、依然としてよく使用されているアーキテクチャです。

(3) Kappa Architecture

Screen Shot 2024-06-07 at 9.20.06 AM.png

Kappa ArchitectureはLambda ArchitectureからBatch Layerを除き、Speed Layer(以下Stream)のみを使用するアーキテクチャで、Lambda Architectureが複数のレイヤーを同時に維持しなければならないという欠点を取り除いたアーキテクチャです。現在までの実装例を見ると、ほとんどがKafkaと共に実装されており、それだけKafkaの利点をうまく活用できるアーキテクチャです。

Kappa ArchitectureのSource Layerは一般的にKafka Clusterです。Kafka Topicに保存されたデータを処理してServing LayerのDatasourceに加工して格納するもので、これは一般に言われるKafka Consumerを意味します。Kafkaが提供するTopic Partitioningを利用して並列処理を容易に行うことができ、Consumer Offsetを利用して少なくとも一度の実行を保証することができます。

3-mirror-topics.jpeg

Kappa ArchitectureはTopicにあるデータのみを使用するため、Source DatasourceのすべてのデータがKafka Topicに保持されている必要があり、これをKafkaではSSOT(Single Source of Truth)と表現します。一般的にKafka Source Connectを利用して簡単に実装できます。ただし、この場合、すべてのデータをKafka Clusterで処理するため、高いディスク容量が必要です。これはソースデータの容量と圧縮アルゴリズムによって確認および予測することができ、Kafkaではデータ処理の過程でもトピックにメッセージを発行して処理するため、パイプラインの規模によって差が生じることがあります。

Kappa Architectureでは、パイプラインが進化して以前のデータ再マイグレーションが必要な場合、既存のTopicを最初から再処理する方法で解決します。これは現在、まだ完全に確立された実装レファレンスを見つけるのは難しいですが、従来のLambda ArchitectureでBatchとStreamが全く異なる形で実装されるのとは異なり、Kafkaのような単一ストリームを通じて一緒に処理できるため、システムの管理が容易になります。一般的にこのような実装は、Stream Processingのバージョンを同時に維持することと、Source Streamのデータをもう一度リプレイして発行する方法を使用し、Kafka Streamsでもこのような実装を容易にするためのチケットが発行されています。

また、もしKafkaでデータ処理を行う場合、Streaming LayerのアプリケーションはStateful状態である必要があります。Kafka StreamsのKTableのような実装はアプリケーションが状態を持つようにし、もちろんStateless状態でも運用可能ですが、RocksDBのデータを保持してKTable Recovery時間を短縮し、迅速なデプロイ時間を確保することができます。この場合、サーバーインスタンスはk8sのstatefulsetのようなStatefulなストレージを必要とします。

Kappa Architectureはデータ処理パイプラインの単一経路(Single Path)を維持することで管理が容易であり、Streaming Platform(Kafka)の高い可用性(HA)とスループット(Throughput)を確保することができます。実装経験から見ても非常に優れた強力なアーキテクチャですが、Streams処理ツールがまだ多くのレファレンスを確保および発展していないようです。おそらくApache Flink、Kafka Streams、KSQLのようなツールがさらに発展すれば、今後はLambda Architectureよりも多く使用されることになるでしょう。

まとめ

今回のポストでは、データマイグレーションに使用される一般的な3つのアーキテクチャについて説明しました。経験的に見て、データをマイグレーションする際に最も重要なのは、データの整合性を保つことと、再マイグレーションが可能かどうかでした。再マイグレーションが可能であることが重要な理由は、データの整合性が合わない場合に簡単に復元するためであり、データ処理アプリケーションが進化するにつれて追加のマイグレーション要件が発生したときに、過去のデータも再度処理する必要があるためです。再マイグレーションの過程で、リアルタイムで生成/修正/削除されるインクリメンタルデータについても考慮する必要があります。このように、データマイグレーションは多くのことを同時に考える必要があります。次のポストでは、レガシーシステムを新しいシステムに移行するためのコードレベルでのパターンを整理してみたいと思います。


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0