モジュラモノリスに移行する理由 ─ マイクロサービスの自律性とモノリスの一貫性を両立させるアソビューの取り組み
大規模なソフトウェア開発においてモノリシックかマイクロサービスかというアーキテクチャの議論がありますが、近年は第3の選択肢としてモジュラモノリスが話題になっています。いったんマイクロサービス化に舵を切りながら現在はモジュラモノリスに取り組むアソビューの考え方や進め方について、VPoEの兼平大資(disc99)さんによる寄稿です。
アソビューでは、現在の事業状況にマッチしていることや過去の経緯から、モジュラモノリスを中心としたアーキテクチャを採用しています。 今回は、なぜその選択をし、どのように実現しているかを紹介します。
記事の前半では、アソビューが提供する事業や、アーキテクチャに対する考え方、開発組織の歩みなどを説明します。 中盤以降は、アソビューにおけるモジュラモノリスへの取り組みの詳細について解説します。
- アソビューの事業の特徴とこれまでの歩み
- モノリスとマイクロサービスの特徴、そしてモジュラモノリス
- モジュラモノリスの特徴 ─ モノリスおよびマイクロサービスとの比較
- 組織とアーキテクチャの適合性
- アソビューにおけるモジュラモノリスへのアーキテクチャ変更
- モジュラモノリスを維持するためのルール設定
- モジュラモノリスの欠点への対応
- DBの分離に関する考察
- モジュラモノリスを運用してみて
アソビューの事業の特徴とこれまでの歩み
アソビュー株式会社では、大きく分けて2つの事業を運営しています。
1つは一般の利用者に向けて、遊びの予約サイト「アソビュー!」などを運営するサービスEC事業です。もう1つは、レジャー事業者に電子チケット・予約管理サービス「ウラカタ」などを提供するSaaS事業です。
イメージとしては、楽天やAmazonのようなモール型EC事業と、ShopifyのようなD2C事業の両面を1社で運営している状態です。 他にも、主軸の事業で培ったアセットや蓄積したデータを生かし、さまざまな周辺事業やサービス、外部連携を展開しています。
それぞれの事業で業務内容や向き合うユーザーは異なりますが、扱うマスタデータは同じものをベースにしています。 そのため、表向きはそれぞれの事業が独立して動いているように見えますが、裏側では情報を共有・連携して事業を展開しています。
世の中に複数の事業を展開する企業は数多く存在しますが、それらも弊社と同じように、ナレッジやデータを社内で横展開しつつサービスを提供する場合が多いのではないでしょうか。
変化し続ける環境・開発組織・アーキテクチャ
アソビューで立ち上げるさまざまな事業には、柱になるものもあれば、別事業と統合されたり終了するサービスもあります。 新型コロナウイルスの流行のように外部環境が一瞬にして変わったり、M&Aなども内部要因によって組織や技術要素が短期間で大幅に変化することもあります。
こうした変化に適応し続けながら事業を成長させるには、開発組織とアーキテクチャの関係性を適切な状態に保つ必要があります。 コンウェイの法則が示すように、組織とアーキテクチャは密接に関係するためです。
しかし、ここで注目しなければいけないのは、両者のライフサイクルの違いです。 組織やチーム配置は、その気になれば会社の方針次第で翌日から変更できます。 しかしアーキテクチャやシステムは、組織のようにすぐ変更することが困難です。
この課題を解決するには、何らかの方法でアーキテクチャやシステムの変更容易性を高めることが必要になってきます。 変化の激しい市場環境や事業状況の中で、技術的な制約により成長が鈍化することは避けなければなりません。 また、事業や組織の成長に対して素早くシステムを適用させることや、その過程で発生する技術的負債をリファクタリング・リプレイスできることも重要な要素になってきます。
アソビューのアーキテクチャの変遷とそこから学んだこと
前掲の情報を踏まえて、これまでアソビューがどのようなアーキテクチャを選んできたのか、そしてこれからどのようなアーキテクチャを目指しているのかを解説します。
アソビューのシステムは、モノリスなアーキテクチャから始まりました。 会社が成長するに伴い新規事業を立ち上げる際には、システムも新規構築してきました。 この選択によって、事業やチーム、システムが独立性を保ち、自律自走できたのです。 その結果、複数のサービスが事業の柱として成長しました。
こういった成果と組織拡大に伴い、よりアジリティを上げて自己組織化を進めるため、2017年ごろからマイクロサービス化へと舵を切りました。 このアーキテクチャ変更には、当時のマイクロサービスの流行も影響しています。 マイクロサービス化は、素早いサービスの立ち上げや、短期的な成長には効果的でした。
しかし、長期的な観点で見ると課題も残りました。 サイロ化の促進や、全体最適化の難易度の上昇、組織体制とのアンマッチ、オーバーヘッドなどの問題が多々発生することになったのです。
私たちがモノリスとマイクロサービスの運用を経験して学んだことは「どのように集約と分散をコントロールするかが重要」ということでした。 この詳細は、「マイクロサービスによって起こったサイロ化に対する取り組み」として弊社のテックブログにまとめています。ご興味あればご覧ください。
これからのアソビューが目指す姿とアーキテクチャの選択
事業会社において事業の成長はもちろん重要ですが、そのためにはプロダクト、組織、そして関わる一人一人のメンバーの成長も重要です。
しかし環境や組織が変化し続ける中で、初めから正解が分かっていることはめったにありません。 成長を続けるには、チャレンジを繰り返すことができる環境を作り、PDCAのサイクルの中で正解を見つけていく必要があります。 このサイクルを実現するには、組織が拡大してもそれぞれのチームが必要以上のコミュニケーションコストを支払うことなく、自律自走できる状態が重要です。
また組織が拡大しても、個々の開発者には企業の歯車のような状態になってほしくありません。 広い領域に関わって能力を最大化し、イノベーションを起こしたりサービスを作り変えたりできるだけの大きな権限と裁量を、全てのメンバーに与えたいと考えています。
一方、アソビューのシステム的な特徴として、事業が多角化してもサービス間でデータの共有・連携が多いということがありました。
こうした経験を踏まえてこれから目指す姿を考えると、マイクロサービスの自律性と、モノリスの一貫性や全体適用のスピードという双方のメリットを取り入れることが理想でした。 そのため有力なアーキテクチャ候補として、両方のメリットを生かすことができるモジュラモノリスが挙がりました。
そこで私たちは、モジュラモノリスへの取り組みを2020年ごろに始めたのです。
モノリスとマイクロサービスの特徴、そしてモジュラモノリス
アソビューにおけるモジュラモノリスへの取り組みの前に、モノリスやマイクロサービス、そしてモジュラモノリスにはそもそもどのような特徴があるのかについても触れておく方がよいでしょう。 アソビューの話からいったん離れて、一般論としてアーキテクチャの解説をしていきます。
モノリシックアーキテクチャのメリット
モノリシックアーキテクチャ(以降、モノリス)は、他の選択肢と比べて実装が簡単なアーキテクチャです。 開発にフレームワークを利用し、意図的にシステムを分離するアーキテクチャを適用しなければ、モノリスな構成になることがよくあります。
創業当初のアソビューがそうだったように、ほとんどのサービスでは開発の初期フェーズにおいて、少人数の開発者だけがプロジェクトに関与します。 そして、短期間でシステムを構築することが重要ですし、事業として安定するまでに多くの破壊的変更を繰り返すことが多いでしょう。
モノリスはこういった、少人数で素早く大規模な変更を繰り返す開発スタイルの場合に、非常に有力な選択肢です。 ソースコードが1つのリポジトリに集約されますから、すぐに全体を検索できます。 開発環境の構築も容易ですし、サービス全体の理解・変更も最小限のオーバーヘッドで行えます。
テストやデプロイなども、単一のアプリケーションに対して実施するだけですし、DevOpsなどの事業成長に直結しにくい作業の時間も、最小限にしやすいです。 データベース(以下、DB)も単一になることが多く、シンプルなクエリーで必要な情報を扱うことができます。
モノリスの大きなメリットとして、APIを介した通信を必要とせず、他のコンポーネントを直接呼び出すことができます。 これにより、APIのバージョン管理や後方互換性、ネットワークのエラーハンドリングなどを意識する必要がありません。 データの一貫性も、DBのトランザクションなどで容易に実現できます。
モノリシックアーキテクチャのデメリット
さまざまなメリットがあるモノリスですが、大きな欠点は全てが密結合になることです。
容易にコンポーネントを再利用できるメリットは、その反面、結合度が自然と高くなってしまうデメリットをもたらします。 そのため、アプリケーションや組織がスケールしてくると、1つの変更による影響範囲が大きくなり、どのような副作用が生じるかを見通しづらくなります。 結果、ソースコードベースで全てを理解する必要性が高くなってしまい、開発速度の低下やバグ発生のリスクなどが急速に増加します。
こうした課題は、機械的に解決できるものばかりではありません。 複数のステークホルダーが情報連携しなければ、うまく解決できない課題も多いです。 そのため、必然的にチームや組織間をまたいだコミュニケーションが増えますし、集団的な意思決定を必要とするため自律性の低下にもつながります。
また、システムが単一のアプリケーションとして実行されているため、局所的なバグや負荷が全体に波及しやすくなります。 たった1機能の障害が全サービスダウンを引き起こしてしまうなど、大規模障害に陥りやすくなります。
マイクロサービスのメリット
モノリスの課題を解決するための選択肢に、マイクロサービスアーキテクチャ(以降、マイクロサービス)があります。 マイクロサービスでは小さなアプリケーションを独立して開発・デプロイし、APIによって相互の連携を行うことで協調動作します。
1つ1つのアプリケーションがランタイムレベルで分離されているため、各アプリケーションで任意の技術を利用可能です。 実現したい要件や開発者のスキルとニーズに合わせて、適切な技術選定を行えます(ただし、特定企業内の各開発チームであまりにもバラバラな技術選定をしてしまうと学習コストや運用コストが増大しますし、全社横断的な技術施策を実施するハードルも高くなります。 そのため実際には、マイクロサービス化を推進している企業であっても、採用する技術に一定の制約を設けるケースが多いです。 アソビューもそうでした)。
また、各アプリケーションを独立してデプロイできるのも利点です。 APIなど外部に公開されたインタフェースに変更がなければ、他のアプリケーションへの影響を気にすることなくデプロイが可能であり、特定機能の障害が全体に波及する可能性が低いです。 理想的にはDBなども分離することで、変更の影響度を最小化し、アプリケーション単位で柔軟にスケールできます。
アプリケーションという明確な境界ができ上がることにより、必然的に疎結合な設計を強制されるという傾向もあります。 結果的に、チームや組織で自律性が高まり、アジリティの上昇が期待できます。
マイクロサービスがはやりはじめた当初は、各社が手探りで適切な構築・運用方法を模索していました。現在ではナレッジやエコシステムが広く浸透し、経験者も増えているため、構築・運用のコストは以前より圧倒的に下がっています。
マイクロサービスのデメリット
一方で、マイクロサービスに移行することで発生する問題もあります。
APIによる通信を行うと、ネットワークを経由するためパフォーマンス劣化や信頼性の低下が発生し、通信のエラーハンドリングも必要になります。 API間の整合性を担保する必要もあるため、ソースコード上の関数呼び出しと比べて圧倒的に高い技術と高コストな作業が求められます。
また、DBなどのトランザクションも使えないため、アプリケーションレイヤーで分散トランザクションを実現する必要性もあり、DBを分離している場合には必要なデータを参照するコストも高まります。
各アプリケーションのデプロイも分割されるため、依存関係や互換性を意識したリリースやテストを行うには、パイプラインの作り込みやモニタリングの拡充などのDevOpsの負担も増します。
開発組織全体として一定以上のサービス品質や開発効率を実現するには、特定の機能を提供するアプリケーションやライブラリ、サービスメッシュの導入などが必要になり、サービスの価値へ直結しない作業に要する時間が増える傾向にあります。
全体のアーキテクチャを理解する難易度も高まります。 E2Eの機能を確認するための、開発環境の構築やデバッグなども難しくなります。 特にアプリケーションをまたぐような修正は、修正難易度が高いだけでなく、複数のチーム間の合意やデプロイ作業の調整が必要となります。
これらのデメリットを最小化するには、自律性を高められる最適なアプリケーション境界を見極める必要があります。 しかし、変化する環境や事業、組織の中で適切な答えを出すのは相当な難易度です。 アプリケーション境界を誤ってしまうと、モノリスとマイクロサービスの悪いとこ取りをした「分散モノリス」というアンチパターンに陥ります。
モジュラモノリス=モノリスの一貫性+マイクロサービスのモジュール性
モジュラモノリスとは、モノリスのような一貫性と、マイクロサービスのようなモジュール性を持ったアーキテクチャです。 デプロイするアプリケーションとしては1つですが、システム内部は複数のモジュールに分割されています。
有名な事例としては、ECプラットフォームのShopifyが挙げられます。 モノリスだったRuby on Railsのアプリケーションをモジュラモノリスに移行し、2019年時点で1,000人以上の開発者が迅速かつ継続的に新機能を構築できる環境[動画]を支えています。
また、書籍『モノリスからマイクロサービスへ』(2020年、オライリー・ジャパン)では、以下のように紹介されています。
モノリスをモジュールに分割して独立して開発できるようにすることで、マイクロサービスアーキテクチャの課題の多くを回避しつつ、多くのメリットを提供できる。これは、多くの組織にとってスイートスポットになる可能性がある。
最終的にはマイクロサービスアーキテクチャに移行することを目的としてモノリスをモジュラーモノリスに分解し始めたものの、モジュラーモノリスで問題のほとんどを解決できることが分かったという話を、これまでに複数のチームから聞いたことがある。
モジュラモノリスの特徴 ─ モノリスおよびマイクロサービスとの比較
モジュラモノリスは優秀なアーキテクチャですが、モノリスやマイクロサービスの課題を全て解決するわけではありません。 そのため、モジュラモノリスを有効活用するには、どういった特徴を持っているかを理解する必要があります。
開発容易性
1つ1つのアプリケーションがランタイムレベルで分離されたマイクロサービスと比較すると、モジュラモノリスは同一のランタイムで動作するため、モジュール間のアクセスではモノリス同様、以下のようなメリットがあります。
- ネットワークを利用しない単純な関数呼び出し
- 対象モジュールが常に利用可能
- 呼び出し時の通信がないため、セキュリティなどの考慮が不要
- モジュールを超えた変更が一度に可能
マイクロサービスの場合、ソースコード上もネットワーク処理を意識するような処理を記述する場合が多く、REST APIやgRPCのような専用のプロトコルを利用する必要があります。 また、呼び出し先のモジュールやネットワークがダウンする可能性、セキュリティなども考慮が必要となります。
そのため、これらを満たすためのインフラやミドルウェアなどが必要となる場合も多く、複雑度が高まる傾向にあります。 複雑度が高いと、保守性、可読性、可観測性が低下し、高いスキルセット、高度なインフラ、維持するためのカルチャーなどが必要になります。
また、モジュラモノリスはコードベースが単一になるため、システム全体のローカル環境実行が容易です。 そして、素早く変更やデバッグをしたり、モジュールをまたいだリファクタリングしたりといったことを、IDEの機能で安全に実施できます。 E2Eテストなどのシステム全体の動作検証も、モノリス同様に1つのアプリケーションを起動するだけで実施できます。
自律性
モノリスは多くの場合、それぞれの処理が密結合になり、成長過程で変更コストが増大し、自律性を大幅に下げていく傾向にあります。 この特徴は、組織が拡大してチームが自律自走する上で大きな足かせとなります。
モジュラモノリスではマイクロサービスと同様に、チームが自律するための論理的な境界をアプリケーション内で作成し、モジュールとして分割します。 適切にモジュール分割すれば、各チームの担当箇所が明確になり組織のメンバーが自律的に動ける状態になります。 それに加えて、境界を利用することで影響範囲を最小化でき、ソースコード修正のコンフリクトも回避できます(ただし、この状態を実現するにはモジュール境界を適切に定め、共有部分をできるだけ作らないという設計上の注意が必要になります)。
オプションの選択肢として、トランザクションやDBなどを分離すれば、さらなる自律を促すこともできます。 また、マイクロサービスと違い、モジュラモノリスの境界は同一のコードベースに存在するものであるため、分割をやり直すことも容易です。
一方で、マイクロサービスのように強い分離を強制できません。そのため境界の管理が曖昧だと、それぞれの処理が密結合な状態を生み出す可能性もあります。
パフォーマンス
モジュラモノリスは1プロセスや1スレッド上の関数呼び出しで処理が完結するため、マイクロサービスと比べてパフォーマンスは非常に高くなります。
マイクロサービスでは、ネットワーク通信、シリアライズや暗号化など複数のオーバーヘッドが発生するだけでなく、ネットワーク障害なども含めたリトライ処理、サーキットブレイカーなども必要となります。
モノリスと比較すると、モジュールが分かれる分パフォーマンス面ではモジュラモノリスの方が不利になりますが、同一のランタイム内なので極端にパフォーマンスへの影響が出る場合は少ないです。
デプロイ
デプロイに関してはモノリスと同様です。 システム内部的にはモジュール分割されていても、パッケージング後は単一のアプリケーションとなるため、実行環境に容易に展開できます。
全てのモジュールが同時にデプロイされるため、当然ながらマイクロサービスのようにモジュール間の互換性を意識したり、高度なデプロイパイプラインなどを作ったりする必要はありません。
また、デプロイ作業が一度で完了するため、開発者ごとやPull Requestごとに環境を作るなど、テスト環境の構築も容易になります。 結果的に、DevOpsのような事業価値に直結しにくい時間を大幅に短縮できます。
スケーラビリティ
モジュラモノリスでは、モノリス同様に水平または垂直にスケール可能です。
ただし、ランタイムには全てのモジュールが展開されるため、マイクロサービスのように特定のモジュールのみスケールさせることは困難です。
マイクロサービスでは、特定のアプリケーションだけをスケールさせることが可能なため、リソースを効果的に使用可能です。
可用性
モジュラモノリスは単一のランタイムで実行されるため、バグが混入していたり、ランタイムのリソースを使い果たすモジュールがあったりする場合には、システムの全体障害へと発展しやすくなります。
マイクロサービスではランタイムが分離されているため、依存モジュールがあらかじめ切り離しできるように設計されていれば、障害点を局所化可能です。 ただし注意が必要なのはマイクロサービスの依存設計で、この設計にあまり強く依存してしまっている状態になると、それぞれのアプリケーションの稼働率が掛け合わされてしまい、単一のアプリケーションの可用性より下回ってしまいます。
モジュラモノリスでも水平スケールなどにより可用性を高めることはできますが、マイクロサービスのような機能単位の分離は困難です。
組織とアーキテクチャの適合性
アーキテクチャの選択にあたっては、それぞれの特徴や開発組織の規模やステージなどによっていくつか考慮すべきことがあります。
ごく少人数での開発や仮設検証を繰り返すタイミングでは、スピーディーに修正できて処理の再利用や破壊的変更が容易で、DevOpsやインフラ管理なども単純なモノリスが効果的な可能性が高いです。
しかし、開発人数やチームがある程度増えてきたタイミングでは、モノリスは修正やデリバリーのコンフリクト、認知負荷の向上などが発生しやすくなります。 モジュラモノリスはそういった、自律性や影響範囲の局所化の重要性が高まった場合に、効果的な選択肢になりえます。
またShopifyのように、モノリスの課題を解決するための移行パスとして選択される場合もあります。 マイクロサービスへの切り出しをモジュール単位でしやすくなるため、組織やシステムの状態に合わせて柔軟な変更がしやすいのも特徴です。
技術的自由度
モジュラモノリスは同一のランタイムで実行されるため、マイクロサービスのようにモジュールごとに任意の言語やフレームワーク、ライブラリなどを使うことが困難です(複数言語を利用できるようなVMなどを利用すれば一部緩和はできますが、それでも制約は多いです)。
特定の課題に特化した技術が必要な場合、この制約が問題になることがありえます。 また、超大手企業のように人材が豊富なケースにおいては、技術スタックの自由度を高くした方がイノベーションにつながりやすくなる場合も存在します。
しかし、マイクロサービス化を進めている企業であっても、実際には技術スタックや環境を統一してナレッジの共有をしていることが多いです。 自由度を高めることにはコストがかかることを理解した上で、技術を選択する必要があります。
モジュール分割と自律性
モジュラモノリスではモジュール性を維持するために、いくつか重要な要素があります。 まず、マイクロサービスのように自己完結したビジネスドメインに合わせたモジュールを作ることが重要です。
モノリスなアーキテクチャでよく利用されるのは、UIやビジネスロジック、データアクセスなど技術的な機能をもとにした水平方向へのモジュール分割です。 レイヤードアーキテクチャやクリーンアーキテクチャ、ヘキサゴナルアーキテクチャなども同様に、役割によって水平に分割されています。
こういった分割のメリットは、ビジネスロジックと技術要素を切り離し、技術的変更に対する影響や責務を明確にしやすいことです。 一方で、ビジネス的な機能追加や変更がある場合には、それぞれのモジュールに追加や変更が発生します。
実際の開発では、技術的な変更よりビジネス的な変更の方が圧倒的に多く発生するため、こういった変更がそれぞれのモジュールに影響を与えます。 複数モジュールに影響を与えずに単一のモジュールで修正を完結させるため、モジュラモノリスでは必要な技術要素を垂直方向に全て含んだモジュールとして定義します。
このように分割することで、各モジュールがビジネス的変更に対して自律的な状態を維持し、安定した変更が可能になります。
モジュールの独立性(高凝集、疎結合)
システムをモジュールとして分割する上で、重要なのが“独立性”です。 この条件を満たすには、いくつかの要素があります。
依存関係の数
それぞれのモジュールを完全に独立させるのは現実的に不可能ですが、依存関係は最小限に抑える必要があります。 多数の依存が発生するようなモジュールは、それだけ独立性が低くなっており、外部の変更に影響を受けたり与えたりしやすくなります。
依存の強さ
モジュールの独立性を維持するには、モジュール間の結合度も重要になります。 モジュール間の呼び出しが高頻度だったり、双方向で呼び出しが発生する場合には、密結合なモジュールとなっている可能性が高いです。
こういったモジュールは単体で機能を完結させにくく、変更の影響度が大きくなります。 このようなモジュールは境界を誤っている可能性があるため、モジュールのマージや切り出しなどを検討する必要があります。
依存モジュールの安定性
開発を進めていく過程で、モジュールの変更は常に発生します。 変更頻度が少ないモジュールと比べて、変更頻度の多いモジュールに依存した他のモジュールは、独立性の影響を受ける可能性が高くなります。 つまり、依存元のモジュールに変更が加わると、依存先のモジュールにも変更やテストが必要となります。
モジュールの公開インタフェース
モジュラモノリスでは、モジュール間での契約となる公開インタフェースが必要になります。 この契約を定めずに開発した場合、モジュラモノリスは最終的にモノリスと同様に1つのランタイムに集約されますから、モジュール内部へのアクセスが容易に可能になり、自律性を維持できなくなります。
そのため各モジュールでは、そのエントリーポイントとなる公開インタフェースを定義します。 これはいわゆるカプセル化であり、モジュールではこの定義を安定させることで、インタフェースが変わらない限り、内部変更を自由に行うことが可能です。
公開インタフェースにはいくつかのパターンがあり、単純な関数呼び出しや、REST APIなどを利用した同期的なもの、Pub/Subなどを利用した非同期のものなども含まれます。
どのアーキテクチャを選ぶのか
モジュラモノリスが銀の弾丸ということはなく、モノリスやマイクロサービスと比べてもさまざまなトレードオフが存在します。
しかし、Shopifyほどのユーザー数やトラフィックを抱えているサービスが、1,000人以上の規模の開発を実現できていることを考えると、モノリスでの開発に課題があり、完全なマイクロサービスへ移行するオーバーヘッドが高いと考える企業にとって、モジュラモノリスは有力な選択肢になります。
また、モジュラモノリスかマイクロサービスかの2元論で考える必要はなく、適切に融合させることも可能です。 実際にShopifyでも、一部のアプリケーションに関しては、モジュラモノリスから切り離して開発している事例が紹介されています。
これらの選択肢や実績、過去作り上げてきた資産を生かし、アソビューではモジュラモノリスを中心としたアーキテクチャに取り組んでいます。 ここからは、アソビューで具体的にどのような構成でモジュラモノリスを利用しているかを紹介します。
アソビューにおけるモジュラモノリスへのアーキテクチャ変更
アソビューでは、これまでマイクロサービス化を進めてきた歴史があるため、ある程度はアプリケーションが分離された状態となっています。 またマイクロサービスの運用を支えるため、Kubernetesによる実行環境や、API Gatewayによる集約レイヤーを利用してきました。 そのため、モジュラモノリス化を行う上で有利に働く技術的基盤が、ある程度は存在します。
現在、モジュラモノリス化を進めているのは、主にAPI Gatewayのバックエンドとなる、コアロジックを支えるアプリケーションです。 一度に全てを統合するのではなく、緩やかにコア機能をモジュラモノリスの方に集約していく方向性で、アーキテクチャ変更を進めています。
アソビューのWebフロントエンドはSPA化が進んでいるため、モジュラモノリスアプリケーションではHTMLなどのレンダリングを行わず、gRPCで通信し、API GatewayでJSONへと変換しています。 UIに関わる集約処理などは、API Gatewayやフロントエンドで直接行うことが多くなっています。
モジュラモノリス内に集約用のUIレイヤーを構築する設計は用いていません。 そうした設計にしてしまうと、過去に構築してまだアーキテクチャ変更を行っていないバックエンドアプリケーションなど、“モジュラモノリス外”の要素に依存し、集約箇所が点在することになるためです。
モジュールの設計と構成
ここからはアソビューのモジュラモノリスの内部構成について紹介していきます。
まず、モジュールをどのように構成しているかについてです。 アソビューではメイン言語としてJava、フレームワークとしてSpring Boot、ビルドツールとしてGradleを利用しています。 Javaの場合、モジュールを作る方法にはいくつかの選択肢があります。
- Maven/Gradleのマルチモジュール機能
- Java 9のモジュールシステムを利用
- パッケージレベルで適切なルールを設定し分割
アソビューでは、3つめの「パッケージレベルで適切なルールを設定し分割」を選択しています。
Gradleのマルチモジュール機能は社内でも過去に広く使われていました。 モジュールの管理や依存関係のコントロールなどが標準で組み込まれているため便利です。 しかしそれ故に、全体への影響を理解しないままライブラリの追加や設定をしてしまうケースが増加し、結果的に運用コストが高まる傾向にありました。
加えて、SpringはDIやAOPなどランタイムで実行される処理が多いため、設定が分散すると実行時に起きる問題が予測しづらくなり、誤った設定によって全体への影響が生じる問題も抱えていました。 モジュラモノリスではモジュールを分割しても最終的には1つのランタイムに集約されるため、全体に影響する設定が隠蔽されやすい技術は、なるべく採用したくありません。
また、Java 9のモジュールシステムも依存関係のコントロールとしてはGradle相当のことができ、クラスレベルで依存のコントロールができるというメリットはありましたが、やはりランタイムの問題が解決できないため見送りました。
ルールベースによるパッケージの分割
前述の背景を踏まえて、最終的にはGradleのシングルモジュールを用いて、パッケージをルールベースで分割することにしました。
ルートには、modulesとsharedという2つのパッケージが存在しています。modules配下には、分割された各モジュールが格納されています。 このモジュールは、マイクロサービスで言えば1つのサービスになるような構成になっており、DDD(ドメイン駆動設計)の境界づけられたコンテキストを意識しています。
sharedパッケージは、マイクロサービスで利用していた技術的共通レイヤーや、開発をサポートするコンポーネントを定義しています。 このsharedパッケージが肥大化するとモノリスへ巻き戻ってしまうため、できるだけ利用しないようにして、各モジュールに依存する処理は配置しないようにしています。
またJavaの場合、publicメソッドはアプリケーション内から自由に参照できてしまうため、各モジュールの公開インタフェースにのみアクセスさせ、内部処理にはアクセスしないようにしています。
アプリケーション全体に影響するような設定は、ルートのクラスに一括で集約しています。 1ファイルあたりの記述量は増えますが、分割されてランタイムで結合するより、冗長でも見通しはよくなると考えています。
ArchUnitによるルールの検証
一方、こういったルールを開発者全員が理解し、常に意識しながら開発するのはかなりコストがかかります。
そのためアソビューでは、こういったルールを維持するため、ArchUnitを利用しています。 ArchUnitはJavaのソースコードを解析し、適切なルールを守っているかをユニットテストによって検証してくれます。
public class ArchitectureTest { String root = "com.asoview.modularmonolith"; String shared = "com.asoview.modularmonolith.shared"; String modules = "com.asoview.modularmonolith.modules"; JavaClasses CLASSES = new ClassFileImporter() .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) .importPackages(root); @Test void moduleパッケージが公開インタフェース以外の外部のmoduleに依存しないこと() { noClasses() .that().resideInAPackage(modules + "..") .should() .dependOnClassesThat(new DescribedPredicate<>("moduleパッケージに別moduleパッケージの依存が存在するか") { @Override public boolean apply(JavaClass clazz) { // ... return result; } }) .check(CLASSES); } @Test void module間の同期的依存は単方向に保たれていること() { // ... } // ... }
こういったテストをpre-commitやCIで実行することにより、シングルモジュールでも全ての開発者が適切なルールを維持できることを保証しています。 また、Javaのユニットテストであるため、GradleやJava 9のモジュールと比べて、柔軟で細かなルール設定が行いやすいということもあります。
その他にも、シングルモジュールであるため、モジュールをまたいでリファクタリングなどを行う場合にも特殊な仕組みを意識する必要はなく、通常のパッケージをまたいだコード修正で完結します。 逆にルールを変える場合は、ユニットテストを変更するだけで済むというメリットもあります。
コンパイラレベルで依存を定義できないという弱点はありますが、Springでは実行時にリフレクションによって解決される領域も多いため、現時点ではトータルで見てArchUnitの機能で必要十分だと考えています。
モジュラモノリスを維持するためのルール設定
モジュラモノリスでは、一定のルールをもとにモジュールを維持する必要があります。 ここでは、アソビューでどのようなルールを設定しているかを紹介します。
ここから紹介する設定は、前述したように全てArchUnitによってルール定義し、ユニットテストによって整合性を保証しています。 まず、分割したパッケージの詳細から説明します。
modulesパッケージの構造と設計
modulesパッケージは、各モジュールのルートパッケージです。 この配下には、それぞれのモジュールごとに独立したパッケージを用意しており、現在アソビューでは20程度のパッケージ(=モジュール)が存在しています。 それぞれのモジュール内では、小さなアプリケーションを作れるようになっています。
各モジュールでは、現在デフォルトの構造として、レイヤードアーキテクチャ+DIPをベースに、CQRSを取り入れた構成を採用しています。 レイヤードアーキテクチャ+DIPにしている理由は、アソビュー社内でDDDに取り組んでおり、メンバーの共通認識を持ちやすかったためです。 また、CQRSに関しては、イベントソーシングを利用するような高度な設計は要求しておらず、CommandとQueryを分割することを前提としています。
この設計には、これまでDDDやレイヤードアーキテクチャを利用してきた結果として、私たちが学んだ知見が反映されています。 これらの手法には詳細が隠蔽されるというメリットはあるものの、読み込み側のパフォーマンス意識の低下や設計にかかるコストの増加などのデメリットがあり、それらのマイナス面が軽視できなくなってきたのです。
ただし、ここまでリッチな構成にする必要がない場合には、別のルールを定義することで、モジュール内で任意の設計を選択できます。
また、modules内の各モジュールは基本的にフラットな関係性になっており、Shopifyの事例で紹介されているようなモジュール内でさらに役割をグルーピングする高度な設計にはしていません。 API Gatewayで既にUIレイヤーを構築できていることと、こういったレイヤー設計に失敗すると後からの学習コストや変更コストが高まることから、現時点ではシンプルな構成にしています。
sharedパッケージの使いどころ
sharedパッケージは、モジュール内で利用する共通コンポーネントを扱っています。 可能な限りこのパッケージを利用しないことを推奨していますが、モジュラモノリスの特性上、共有が必要なものも存在します。
例えば、モジュラモノリスでも負荷分散のために水平スケールを行いますが、その際にDBのコネクション数など外部リソース側の都合で制約が発生する場合もあります。 このときモジュール内で自由にコネクションを消費する設定になっていると、いざというときにDB側のコネクション制限に達してしまい、スケールできなくなる問題が発生します。 そのため、アプリケーション全体として見たときに適切なコネクション管理ができている必要があります。
また、アソビューではメインのクラウドとしてAWSを利用していますが、AWSのAPIは自由度がかなり高いため、セキュリティや組織統制の観点からある程度のルールを守る必要がある場合にも使用しています。
他にも各種のモニタリングなど、モジュール内で実装すると抜け漏れや手数が増える技術要素に関しては、sharedパッケージに定義しています。
パッケージ間の依存関係
ルートパッケージは、アプリケーションで利用する設定やプロパティ、エントリーポイントとなるクラスのみで構成しています。 設定やプロパティは全体に関わるものが多く、プロパティも環境変数などの外部から一括で設定されることが多いため、入力箇所は一箇所に限定し、内部での参照はコンパイラのサポートを得られるようにしています。
ここまで説明した特性を踏まえて、それぞれのパッケージでは依存方向を限定しています。 まず、ルートパッケージでは設定などが必要となるため、modulesやsharedに依存する必要があります。
sharedはルートに依存する必要はなく、またビジネスロジックであるmodulesにも依存すべきではないため、外部へ依存しないようにしています。
modulesもルートに依存する必要はありませんが、sharedに依存することがあるため、この方向の依存は許可しています。 また、modules内では独立性を維持するため、publicメソッドであっても、公開インタフェース以外で直接の参照はできないようにしています。
その他にも、モジュール内でレイヤードアーキテクチャやCQRSを維持するルールも定義しています。
protobufによる公開インタフェース
モジュール間を疎結合に保つには、依存関係を最小限に抑える必要があります。 モジュラモノリスでは、マイクロサービスの公開APIと同じように、外部モジュールからアクセスするための公開インタフェースを専用で定義します。
アソビューでは、マイクロサービス化した際にメインの通信プロトコルとして、gRPCを採用していました。 モジュラモノリスの公開インタフェースとしても、その資産を生かしてProtocol Buffers(以降、protobuf)を採用しています。 protobufは特定の言語に依存しない独自のIDLを持ち、定義からコード生成が行えるため、生成されたコード公開インタフェースの定義として利用しています。
単純なJavaのコードで公開インタフェースを定義するのと比べると手間が増えますが、事前に言語やプロトコルに依存しない定義をすることで、将来的にモジュールをマイクロサービスに切り出した場合にも、最小限の変更で切り替えが可能になります。 そのため、現在モジュール間でのアクセスとして、同期・非同期が存在しますが、いずれもprotobufによって定義・生成されたコードを利用しています。
モジュール間の同期アクセス
モジュール間の同期アクセスには、gRPC用のServer、Stubコードを利用しています。
gRPC Javaでは、Server、Stubのようなアプリケーションレイヤーのコードと実際の通信プロトコルは実装上分離されており、Channelクラスの実装を切り替えることで任意の通信方式を選択できます。
モジュラモノリスでは、マイクロサービスではネットワークを挟んだ通信だったものを、内部の関数呼び出しにできることが大きなアドバンテージです。 そのため、この通信レイヤの実装ではネットワークを返さず、直接StubからServerのコードを呼び出す単純なChannel実装に切り替えています。
// 直接外部モジュールのメソッドを呼び出すためのChannel実装 public class DirectChannel extends Channel { BindableService service; @Override public <RequestT, ResponseT> ClientCall<RequestT, ResponseT> newCall(MethodDescriptor<RequestT, ResponseT> methodDescriptor, CallOptions callOptions) { return new ClientCall<>() { // ... @Override public void sendMessage(RequestT message) { StreamObserver<ResponseT> responseObserver = new StreamObserver<>() { // ... }; try { // ネットワークを経由しないServiceのメソッド呼び出しの実行 String methodName = UPPER_CAMEL.to(LOWER_CAMEL, extractBareMethodName(methodDescriptor.getFullMethodName())); Method method = service.getClass().getMethod(methodName, message.getClass(), StreamObserver.class); method.invoke(service, message, responseObserver); } catch (Exception e) { // ... } } }; } // ... }
// 外部のモジュール呼び出し処理サンプル public class OrderService { PaymentServiceGrpc.PaymentBlockingStub stub; OrderService(PaymentService paymentService) { // Channelの実装を指定(通常はDIで解決する) DirectChannel channel = new DirectChannel(paymentService); stub = PaymentServiceGrpc.newBlockingStub(channel); } void Payment() { try { // Channelの実装に依存しない通常のRPC呼び出し Payment payment = stub.getPayment(PaymentRequest.newBuilder() .setName("123") .build()); // ... } catch (Exception e) { //... } } }
この実装により、モジュラモノリス内では単純な関数呼び出しを実現できるだけでなく、将来的にモジュールをマイクロサービスに分割した場合にも、公開インタフェースは同じまま、Channelの実装を切り替えるだけでネットワーク越しのアクセスも可能になります。
モジュール間の非同期アクセス
モジュール間の非同期アクセスには、SpringのApplicationEventを用い、同じくprotobufで定義したクラスから生成したJavaのコードをPayloadとして利用するようにしています。
// 非同期モジュール間連携用のインタフェース public interface EventPublisher { <T extends GeneratedMessageV3> void publish(T event); }
// 非同期モジュール間連携用の実装 public class DirectEventPublisher implements EventPublisher { ApplicationEventPublisher applicationEventPublisher; @Override @Async public <T extends GeneratedMessageV3> void publish(T event) { applicationEventPublisher.publishEvent(event); } }
もともとマイクロサービス化を進めていたころに、社内でKinesisとprotobufを利用したアプリケーション間連携用のEventBusを構築していました。 先ほど解説した同期アクセスのくだりと同様に、将来的にモジュールを分離した際には、スムーズにこれらへの切り替えをできるようにしています。
アプリケーション内に限れば、Pub/Subの連携を同期的にも行えますが、上記の理由により現時点では非同期のみの連携をしています。
モジュール内の依存方向の管理
モノリスの大きな問題が密結合になることでした。
モジュラモノリスによってモジュール分割しても、近い状態に陥ってしまう場合があります。 その原因は相互参照や循環参照です。 この状態になったモジュール同士は密結合になっており、見た目上は分離されているものの、実態としては1モジュールになってしまっています。 こういったモジュールに変更を加える際には、影響範囲の見通しが悪くなり、理解しなければならない領域が広がることで認知負荷が高まり、モジュールの自律性が低下して変更コストが増加します。
Shopifyではこれらの問題に対して、モジュール間の依存関係を事前検知することで対処しています。 どうしても相互の依存が必要になる場合には、Pub/Subを利用し依存関係を逆転する方法もあります。
これはObserverパターンを利用した方法です。 モジュールAからB、BからAの双方向の依存があった場合、BからAの参照をやめ、代わりにBからは処理に従ったイベントを発行し、Aはそのイベントをサブスクライブします。 この構成により、BはAの存在を知る必要がなく、依存が発生しなくなるため双方向依存が解消できます。
アソビューでも、このアプローチを参考に同期処理は相互参照や循環参照を禁止し、非同期のPub/Subでのみ逆方向の参照を可能にするルールを定義しています。 また、この発想はDDDのドメインイベントとの相性もよいため、境界づけられたコンテキストとしてモジュール間の連携にも適しています。
例外的に共有しているモジュール
モジュラモノリスにおいて、共有モジュールはモジュール間の結合度を高め、モジュールの自律性を低下させるため、なるべく使用を避けるようにしています。 しかし、その前提がある中でも、アソビューでは以下のモジュールを共有モジュールとして定義しています。
- モジュール内の公開インタフェースへのアクセスクライアント
- DBなど外部リソースへのアクセスクライアント
- トランザクションなどアプリケーション内で共通制御が必要なコンポーネント
- アプリケーションのモニタリング
また共有モジュールではありませんが、アプリケーション全体に及ぶ設定として以下を定義しています。
- AOPを利用した共通的なロギングやエラーハンドリング
- DIするコンポーネントの管理
- アプリケーション内で利用する環境変数の管理
これらは、ビジネス要件で変更されることがほぼなく、技術要件として統一が必要であるため、共有モジュールとして定義しています。
モジュラモノリスの欠点への対応
マイクロサービスと比較したモジュラモノリスの欠点として、内部的にいくら分離されても最終的には単一のランタイムで実行されることがありました。 この問題に対応するため、アソビューで行っている取り組みを紹介します。
可用性の分離
現代的なアプリケーション設計の1つとして、同一のバイナリであっても環境変数を利用して設定を切り替えるというものがあります。 アソビューのモジュラモノリスでも、環境変数を利用して起動時に設定を切り替えられるようにしています。 この設定には、本番や開発環境用の設定だけではなく、アプリケーションが持つ特定の機能の有効化なども含まれます。
# Spring Boot application.yml asoview: modules: order: event-handler: enabled: true # ... payment: cache: enabled: false # ... point: # ...
これらを利用することで、同一のバイナリであっても起動設定の切り替えが可能になり、マイクロサービスのように各機能が別々のアプリケーションとして振る舞うことができます。 また、ローカルや検証環境など、利便性や開発・運用コストの低さなどを重視する環境では、従来通りモノリスな状態で起動させて一通りの検証も可能です。
似たような仕組みとして、Grafana LokiのMonolithic/Microservices modeやConsulのClient/Server modeが存在します。
特にアソビューでは、事業成長の過程でアプリケーションが分離される単位が変わる可能性があるので、起動モードのように大きなユースケース切り替えのフラグを用意するのではなく、機能単位で細かく有効化できるようにしておき、必要なときに必要な方法で利用できるようにしています。
現時点では、APIとWorker用の2ランタイムで起動することで、リアルタイム処理とバックエンド処理という特性の違う処理を単一のソース・バイナリで実現しています。 他にも、高トラフィックが想定されるワークロードや、高可用性が重要視で外部からの影響を分離したい機能分離なども想定しています。
アプリケーション分割の構築と注意点
アソビューではマイクロサービス化を行った際に、アプリケーション実行のためにKubernetesの環境を構築・整備してきました。 この技術基盤があるため、Deploymentとして切り離し、API Gatewayでルーティングすることで、可用性の分離されたアプリケーションを素早く構築できます。
こういった構成を用いると、バイナリの中に本来必要ない処理や設定が含まれることになるため、徹底したリソースやパフォーマンスの最適化は難しくなります。 ですが、マイクロサービス化した場合と比較すると、開発効率がよくなり全体最適を行いやすくなるため、十分ペイすると考えています。
注意点としては、アプリケーション分割するということは、起動設定で切り替えられるとはいえ関数呼び出しからネットワーク越しの通信へと変わるということです。 つまり、マイクロサービスでの課題がまた生まれることになるため、それを許容してでも分割を進めるべきなのかの判断が必要になります。
また、特定のモジュールによって全体のリソースを使い果たす危険性があり、同一のランタイムで起動しているアプリケーションが影響を受ける可能性があります。 こういった問題に対応するため、今後はバルクヘッドを導入して実行スレッドなどにモジュール単位で制約をかけ、特定のモジュールの影響でアプリケーション全体のリソースが枯渇しないように対処する予定です。
変更とデプロイのコンフリクト
モジュラモノリス化したことで、マイクロサービス同様にモジュールが疎結合な状態になったことから、複数のメンバーが同系統の機能開発をしない限りはコンフリクトが発生しにくくなりました。 また、環境変数によるランタイム分離を可能にしたことによって、デプロイの柔軟性も増しています。
デプロイに関連した技術的要素についても触れると、アソビューではCIにCircleCIを、CDにArgo CDを利用しています。 Argo CDのsyncPolicyを組み合わせることで、特定のアプリケーションはCIをトリガーに自動でリリースし、修正内容やリリースタイミングなどに制約のある機能は手動承認によるデプロイを行うこともできます。
リリース制約を回避したり、チームが自律的に動くために特定の機能だけを素早く切り離したりといったことも可能になっています。
プログラミング言語やライブラリのバージョンアップ
モジュラモノリスでは、プログラミング言語やライブラリのバージョンアップを行う場合にはサービス全体に影響するケースが多く、こういった施策にそれなりのコストが掛かるのは事実です。
ただし、そうしたバージョンアップなどの対応は、全社横断で実施したい施策である場合が多いです。 そのため、マイクロサービスのように分離されたアプリケーションを1つ1つバージョンアップして検証するよりも、トータルで見た場合にはモジュラモノリスの方が圧倒的に低コストでバージョンアップを実施できると考えています。
DBの分離に関する考察
マイクロサービスでは、サービスごとにDBを分離する方が望ましく、共有DBにするのはアンチパターンだとされています。 これと同様に、自律性やスケーラビリティを考えると、モジュラモノリスでもDBの分離は有効な選択肢です。 また、DBを事前に分離しておくことで、後からマイクロサービスへ切り替える場合の移行コストも下げることができます。
一方で、DBを分離することで生じるデメリットもあります。 モジュール境界に影響するようなリファクタリングやモジュールをまたいだデータの結合、トランザクションなどの管理が高コストとなってしまうのです。 メリット・デメリットを天秤にかけて、何を重視するかを考えた上で方針を決める必要があります。
アソビューでは今のところ、もともとマイクロサービス化していた箇所以外に関しては、DBに大きな変更を加えていません。 DBの運用方法やモジュール境界を見直す可能性を考えると、具体的に問題が顕在化していない段階で物理レベルでのDB分離を急ぐと、結果的に運用コストが高くなってしまう可能性があるためです。
今後は、物理レベルでのDB分離ではなく、論理レベルでスキーマを分割する方向で検討しています。 論理レベルで分離していれば、将来的に物理レベルの分離時のコストも下げることが可能です。
その他にも、こういった前提条件を踏まえ、従来のRDBだけでなくNewSQLなどを用いることで、DB側でスケーラビリティを向上できるような選択肢も検討を進めています。
モジュラモノリスを運用してみて
モジュラモノリスの運用が始まって1年以上がたちますが、現在までクリティカルな問題は発生していません。 開発・運用も効率的な体制を実現できています。
未だにコンテキスト境界に迷うようなモジュールも存在しているのですが、もしもそうしたモジュールの分割に失敗しても、リファクタリングの範囲内で容易に後戻りできることは非常に大きな利点です。
以前は各アプリケーションで設定が異なる箇所もあり、開発環境の構築にも苦労していましたが、そうした問題が発生することもなくなりました。 複数の機能を横断してのテストなども実施しやすくなっています。
また、モジュール境界が明確になり、各開発チームで担当モジュールも決まっていることから、マイクロサービス同様、低い認知負荷で開発を素早く、自律して行うことができています。 それだけではなく、ソースコードを全体見渡したりコード変更やデバッグをしたりも容易にできるため、開発者が望めばより広い範囲に関わるようなチャレンジも可能です。
今後の展望
今後もアソビューでは、モジュラモノリスを中心としてアーキテクチャを成長させていく予定です。
一方で、全てのシステムを完全にモジュラモノリスに統合しようとしているわけでもありません。 認証や通知など、初めからマイクロサービスとして分離した方が費用対効果が高い機能も存在しますし、モジュラモノリスへの移行が難しい既存の技術的資産なども存在します。
またこの先、モジュラモノリスから分離されるアプリケーションが出てくることも考えられます。 さらにフルリモート・フルフレックスへのシフトといった時代の変化に合わせて、分散した働き方でも成長し続けるアーキテクチャのあり方を考える必要もあります。
そのため将来的には、モジュラモノリスを中心に据えつつ、その周りを独立した複数のマイクロサービスが取り巻く、惑星系のようなアーキテクチャに進化していくのではないかと想定しています。
アソビューではマイクロサービス化したからこそ得られたナレッジや資産を生かしつつ、モジュラモノリス化に取り組んでいます。 この記事が、モノリスからの移行に悩んでいる方だけではなく、マイクロサービスに課題を感じている方にも参考になれば幸いです。
編集:中薗 昴
制作:はてな編集部