モノリシックなRailsアプリを、モジュラーモノリスを経てマイクロサービスに分解する
はじめに
Railsアプリをマイクロサービスに移行する専門チームにjoinして3ヶ月が経ちました。
これまでの学びをまとめておこうと思います。
マイクロサービスに移行するモチベーション
- 外部サービスからの利用性向上
例えば、ECサービスにおける「商品」や「顧客」といったマスタデータは異なるアプリケーション間で共通して使いたいモジュールです。これらのモジュールをマイクロサービスとして分離することで、外部アプリケーションから直接利用が可能となり、アプリケーション間の依存を減らして開発効率やユーザ体験が向上します。 - アーキテクチャの最適化
切り出したマイクロサービスに最適化した言語やフレームワーク、DBを選定できます。
特にRDBはスケールアウトが難しく、各ユースケースに最適化した分散DBを選択できることは大きなメリットになります。 - 開発速度と開発体験の向上
コードベースが小さくなり、コードの見通しが良くなり開発速度が向上します。テストも小さく済むのでCIの待ち時間も減ります。 - デプロイ頻度の向上
コード変更の影響範囲が狭まり、デプロイサイクルが小さくなります。デプロイサイクルが小さくなれば、デグレの原因特定やロールバックも容易になります。
マイクロサービスに移行するデメリット
- トラフィックの増加とパフォーマンスの劣化
サービス間のトラフィックが増え、通信のオーバーヘッドが大きくなります。これによりパフォーマンスが劣化し、クラウド利用料も増加します。 - 観測性の低下
各サービス間でのバグやエラーの特定が難しくなります。サービスメッシュやAPMなどの対策が必須となります。 - 強い整合性を保てない
サービスを跨ぐトランザクションの設定は難しく、サービス間でデータ整合性の担保が難しくなります。また、通信相手のサービスが落ちている可能性を前提にした設計が必須となります。 - サービス間でテーブルのjoinができない
仮にサービス間でDBを共有している場合でも、サービス感でテーブルのリレーションを切る必要があります。
このとき、例えばテーブル間のリレーションを利用してソートすることができなくなります。リレーションを使わない代替案は可能ですが、パフォーマンス要件が厳しくなってしまいます。 - インフラの複雑化
インフラのアーキテクチャが複雑化し、学習コスト、運用コスト、クラウド利用料などが増加します。
マイクロサービスの切り出し
ソフトウェアアーキテクチャ・ハードパーツにマイクロサービスの切り口やトレードオフが詳しくまとめられています。
どう言った切り口や粒度でマイクロサービスを切り出すかは本当に難しいです。自分は経験が浅く、うまく語ることはできませんが、下記を特に意識する必要があると感じています。
- DDDの文脈での集約や境界づけられたコンテキスト
- トランザクションがサービスを跨がない
移行手順
モノリシックなRailsアプリからマイクロサービスへの移行手順の概要をまとめておきます。
大きく3つのステップがありますが、実際には同時に実装を進めたり、機能ごとに小さく分割してリリースすることになります。
モノリシックなRailsアプリ
例として下記のような簡単なECアプリを想定します。
Active Recordが本当に優秀で、associationを自由にたどって簡単にViewを組み立てることができます。
例えば、@item = Item.find_by(xx)
をControllerからViewに渡してあげるだけで、下記のようなJSONを構築できます。
{
item_name: @item.name
producer_name: @item.producer.name
producer_account_number: @item.producer.account.number
costomer_name: @item.purchase.customer.name
}
Step1: モジュラーモノリスへの移行
Railsの中でドメインを分割し、モジュラーモノリスを作ります。
今回の例ではCustomer Domain
Account Domain
Item Domain
に分割することにします。
このとき、ドメインを跨ぐassociationを切る必要があります。
実際にmodelのassociationを切る必要はないでのですが、ドメインを跨ぐassociationを無いものとしてリファクタする必要があります。
目的のJSONを構築するためには、Controller側で3つのActive RecordをViewにに渡す必要があります。
@item = Item.find_by(xx)
@customer = Customer.find_by(xx)
@account = Account.find_by(xx)
{
item_name: @item.name
producer_name: @item.producer.name
producer_account_number: @account.number
costomer_name: @customer.name
}
上記は参照の例ですが、全てのactionでドメインを跨ぐassociationを切るリファクタを行います。
また、移行の前後でドメインを跨ぐassociationが新たに実装されてしまうことを防ぐために、arproxyやpackwerkをつかった静的解析をCIに組み込むことを検討する必要もありそうです。
Step2: Serviceクラスの作成
ControllerとActive Recordを分離します。
ControllerとModelの間にServiceクラスを設置し、ServiceクラスからControllerへのデータの受け渡しのためのAPIモデルを定義し、ActiveRecordから取得した情報をAPIモデルに詰め直します。
ActiveModel::Model
やActiveModel::Serialization
をincludeしたこのAPIモデルを定義することで、Active Recordと同様の挙動を与えてViewでのロジック修正を最小限に抑えることができます。
また、ドメイン間のやり取りも全てこのServiceクラスを通して行います。
module ApiModel
class Item
include ActiveModel::Model
include ActiveModel::Serialization
attr_accessor :id, :name, :producer_id
...
def method_used_at_view
...
end
end
class Customer
...
end
end
最終的にはドメイン分離の一環として別ドメインに対するbelongs_toやhas_one、has_manyを削除していきます。
Step3: マイクロサービスへの移行
Modelの中にあったドメインをマイクロサービスに移行します。
マイクロサービスではユースケースに最適な言語やフレームワークを選定できます。
マイクロサービスとのインターフェースをServiceクラスで吸収し、Controller目線では同じ挙動が担保されることが必須です。
さいごに
マイクロサービス化において難しいのは、もちろん実装面だけではありません。
バグを出さずに確実に切り替えを行うために考慮点は多くあり、深い技術的知見と共にビジネススキルも大切です。
- 依存関係を整理しつつ細かくFeature Flagを設定し、機能ごとに少しづつリリースできること
- 開発やQAのスケジュールを調整し、チーム間のalignが取れること
Discussion