オーバープロビジョニングなマイクロサービスをモジュラモノリスに移行してコスト削減した話

本記事は[CyberAgent Group SRE Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ca-sre)の11日目の投稿になります。

## はじめに

こんにちは、株式会社CyberZでSREをしている[@toro_ponz](https://x.com/toro_ponz)です。

個人的には、2024年はコスト削減の年でした。事業のフェーズだったり時世の兼ね合いだったりして、インフラコストにテコを入れる必要が生じ、そういった部分に注力した一年間だった気がします。

この記事では、コスト削減の取り組みのうちの一つである、マイクロサービスをモジュラモノリス化した話をします。

## 前提

移行対象は以下の構成です。BFF的な役割を兼ねた外部公開APIからコールされる、内部APIコンポーネント群となっています。

- 言語: Kotlin (サーバーサイドKotlin)
- フレームワーク等: Ktor, Exposed
- 実行環境: Kubernetes (EKS)
- 通信: REST
- 開発: 単一チームで全マイクロサービスの開発
- DB: 1サービスにつき1DBスキーマ (クラスタは共用)

![前提の構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262177/05dda105-6ae2-6c79-c4b0-a85f6fa5abd6.png)

## 金食い虫のマイクロサービス

関わっているプロダクトでは、2019年ごろからマイクロサービス化に取り組み、いくつかの新規機能から導入が行われました。その結果、**主要ではない**機能を中心に取り扱うマイクロサービス群が生まれ、今日まで稼働していたのです。

これらの機能はサービスのコアロジックではないため、活用するユーザーが限定的だったり、キャッシュ前提の構成になっていて負荷がほとんどないという特徴がありました。JVM系の言語を用いて実装していたこともありリソース効率は非常に良く、サービス内の大きなイベントの時でさえスケールアウトの必要がないほどでした。

しかし、たとえトラフィックが少なくても、高可用性を維持するためには複数ノード・複数AZに分散して配置する必要があります。定められた最低コンテナ数は、リクエストが落ち込む夜間でさえも否応なく確保され続けます。それもサービスごとにです。明らかなオーバープロビジョニングにもかかわらず、可用性の観点から縮小することが難しい状況でした。

![嵩むコスト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262177/fcc969a5-1c97-6e38-2464-77a039fa5cd3.png)

## 高すぎる独立性

また、このマイクロサービス群は単一の開発チームが担当しているにもかかわらず、独立したコードベースで管理されていました。ベースとなるマイクロサービスをコピーすることで新規サービスの立ち上げを素早く行う形です。

そのため、独立したソースコードですが**同じ言語**、**同じフレームワーク**、**同じアーキテクチャ**を採用していました。いくつかある共通の処理も、それぞれのサービスにコピペされて管理します。

つまり、バージョンアップ作業などはそれぞれで必要になります。ものによっては記述方法やバージョンに差異が生まれていて、更新に大きく工数がかかる状況となっていました。例えば、JCenter廃止対応は多少難儀した記憶があります。

![バージョン差異.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262177/6af73639-8554-2590-459b-b86a9b867bcb.png)

よくあるマイクロサービスの幻想に陥った例だと思います。**ランニングコスト**と**メンテナンスコスト**の二重苦を抱えたものだったのは言うまでもありません。

## モジュラモノリスという光明

マイクロサービス群の課題は認識していたものの、コアコンポーネントに比べるとその変更の頻度は少なく、メンテナンス的な観点ではある意味許容していました。
一方で、他のコスト削減施策によってランニングコストが浮き彫りになり、抜本的な改善を検討する必要が生じたのです。

コスト削減の兼ね合いではあるものの、モジュラモノリスの導入をする絶好の機会だと思いました。幸いなことに、言語やアーキテクチャが統一されているコンポーネントです。大きな工数はかからないと期待して着手しました。

## 方針

今回はコスト削減が本懐ということで、可能な限り既存の構成を活用して設計しました。そのため新しいミドルウェアを採用したりコードを丸々書き換えたりすることはせず、少ない工数での移行を試みました。モジュラモノリス自体の説明は割愛いたしますが、今回採用したいくつかのポイントを抜粋してご紹介いたします。

### ディレクトリ構成

#### 旧ディレクトリ構成

旧構成では、各サービスは完全に独立して管理していました。実装コードからGradleの設定、Dockerfileまで、ボイラープレートをコピーして作成され、独自に改修されています。

また、Gradle Multi-Projectを用いて各レイヤーの依存関係を制御していました。サービス間の連携はOpenAPIのymlベースで生成しています。

```text:旧構成
└── services
    ├── serviceA
    │   ├── common              # 各種ユーティリティクラス等
    │   ├── dataaccess          # リポジトリ実装等
    │   ├── presentation        # コントローラー他
    │   ├── entrypoint&router   # Ktorの処理等
    │   ├── usecase             # ビジネスルール
    │   ├── buildSrc            # Gradle
    │   ├── build.gradle.kts    # Gradle
    │   ├── settings.gradle.kts # Gradle
    │   ├── gradle.properties   # 各種ライブラリのバージョン番号等
    │   └── Dockerfile          # コンテナイメージテンプレート
    └── moduleB
        ├── common
        ├── dataaccess
        ├── presentation
        ├── entrypoint&router
        ├── usecase
        ├── buildSrc
        ├── build.gradle.kts
        ├── settings.gradle.kts
        ├── gradle.properties
        └── Dockerfile

※ 説明のため、一部の構成や命名を改変しています。
```

#### 新ディレクトリ構成

新構成では、GradleやDockerfileなど共通になる部分をrootに置き、presentation以下のレイヤーをmodulesに配置しました。使用ライブラリやそのバージョンなどはここで一元管理されています。

rootのsrc配下には、アプリケーションの起動処理や、Ktorのrouting処理などが記載されています。srcは各モジュールのpresentationへの参照のみを持ち、APIパスによって呼び出すモジュールを振り分けます。usecaseやpresentationなどはほとんど手を加えず、そのままのコードを引き継ぎました。

また、依然として各モジュールは他のモジュールの実装を知らず、OpenAPIのymlベースでの連携になっています。

```text:新構成
├── common               # 各種ユーティリティクラス等
├── modules
│   ├── moduleA
│   │   ├── dataaccess   # リポジトリ実装等
│   │   ├── presentation # コントローラー他
│   │   └── usecase      # ビジネスルール
│   └── moduleB
│       ├── dataaccess
│       ├── presentation
│       └── usecase
├── src
│   └── main
│       └── kotlin
│           ├── router   # Ktorの処理等 (各moduleのpersentation呼び出し)
│           └── App.kt   # エントリポイント
├── buildSrc
├── build.gradle.kts
├── settings.gradle.kts
├── gradle.properties    # 各種ライブラリのバージョン番号等
└── Dockerfile

※ 説明のため、一部の構成や命名を改変しています。
```

### 切り替え方式

問題発生時にアプリケーションのデプロイが必要になるのは避けたかったため、ALBのルールベースでの切り替えを採用しました。マイクロサービス群は単一のALBに集約していたので、それを活用し、呼び出し側のアプリケーション実装を変えることなく切り替えを行いました。

```markdown
## 移行前

ルール1: HOSTが`service-a.example.com`のときServiceAに転送
ルール2: HOSTが`service-b.example.com`のときServiceBに転送

## 切り替え前

ルール1: HOSTが`service-a.example.com`のときServiceAに転送
ルール2: HOSTが`service-b.example.com`のときServiceBに転送
ルール3: HOSTが`*.example.com`のときModulerMonolithに転送 # 追加

## 古いルールを削除して順次切り替え

ルール1: HOSTが`service-a.example.com`のときServiceAに転送 # 削除
ルール2: HOSTが`service-b.example.com`のときServiceBに転送
ルール3: HOSTが`*.example.com`のときModulerMonolithに転送 # ルール1がなくなることでリクエストが到達

## 移行後

ルール1: HOSTが`*.example.com`のときModulerMonolithに転送
```

(今考えなおすと、ルール内でTargetGroupの重みづけをして徐々に移行する方が、より安全だった気がします。とはいえAWS Load Balancer ControllerのIngressGroupを活用し分散してルールを管理していたので難しいかもしれませんが……。)

### パス設計

これまで複数のコンポーネントで提供されていたAPIを一つにまとめるため、それをどこでどうやって振り分けるかを決める必要があります。ホスト名などで判別するか、あるいはパスやヘッダーなどでモジュールを指定するかです。

今回はパスでの振り分けを採用しました。APIのパスに `${module-name}` のprefixを入れ表現します。
例: `GET /users/:user_id` => `GET /account/users/:user_id`

また、切り替えの都合上prefixのついていないリクエストも考慮する必要がありました。幸いなことに、対象となるマイクロサービスでパスが重複したものはなかったため、一時的にどちらのパスでもリクエストを振り分ける形で実装しました。切り替えが完了してから、呼び出し側のパスにprefixを追加します。

```kotlin
fun Routing.root() {
    // TODO: 移行完了後削除 (ここから)
    // ServiceA
    getResourceA1() // GET /resource-a1
    getResourceA2() // GET /resource-a2
    // ServiceB
    postResourceB1() // POST /resource-b1
    postResourceB2() // POST /resource-b2
    // TODO: 移行完了後削除 (ここまで)

    route("service-a") {
        getResourceA1() // GET /service-a/resource-a1
        getResourceA2() // GET /service-a/resource-a2
    }
    route("service-b") {
        postResourceB1() // POST /service-b/resource-b1
        postResourceB2() // POST /service-b/resource-b2
    }
}
```

### モジュール間の連携

モジュールAからモジュールBの処理を呼び出す場合、これまではサービスBの外部URLでAPIコールを行っていました。しかし、これをそのまま移行すると、デプロイ中に新バージョンから旧バージョンにリクエストが到達し得ます。マイクロサービスの場合デプロイ順などで回避が可能ですが、モジュラモノリスの場合は考慮が必要です。

問題を回避する方法としては、

1. これまで通り外部URLを用い、サービスメッシュを用いて新旧のトラフィックを制御する
2. localhostを用いで自分自身にAPIコールを行う
3. コード上で別モジュールの処理を呼び出す

などが考えられます。サービスメッシュを使っていないため1は採りませんでした。また、3はほかのモジュールへの参照が必要になり再設計の工数がかかるため、今回は2の方式でモジュール間連携を実現しました。リクエスト側のコードにほとんど変更が必要なかったのも、採用の一つの理由です。

```diff:application.conf
apis {
    account {
-       baseUrl = https://account.example.com/
+       baseUrl = http://localhost:80/account/
    }
}
```

### その他モジュラモノリスのデメリットなど

モジュラモノリスを採用することで、いくつかの点でデメリットが生じます。例えば以下のようなものです。

1. 独立した開発およびデプロイができない
2. コンピューティングリソースの共用によるスケールの独立性欠落
3. データの整合性周りの考慮

上記については大きく課題になりませんでした。単一のチームで開発していること、突発的なスパイクのあるモジュールが少ないこと、もともとデータ整合性を担保する構成で設計されていたことなどが要因です。

今後可用性を分離したいモジュールが出てくるかもしれませんが、その際はDeploymentを分割してALBのルールで振り分ければ良いと思っています。少なくとも、移行前の構成が抱えていた課題よりはクリティカルでないという判断です。

## 切り替えを終えて

上記に挙げたポイントなどを考慮しつつ、コードを修正しトラフィックを切り替え、そして旧マイクロサービス群の削除を行いました。移行後は以下の構成になりました。

![移行後の構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262177/aec16a09-dd3a-112f-5d8b-256b0bbf10be.png)

実際のところ、コードの修正が想像以上に大変で、当初の見積もりの倍以上の工数を要したのが反省点です。特に、Ktorから呼び出すpresentationレイヤーの記述方法が統一されていなかったのが大きな要因です。テスト周りのライブラリバージョンも差異があり、なかなか苦戦しました。

多少工数はかかりましたが、コスト的には純粋にまとめた分だけの費用が削減できていて、おおよそ想定通りの結果が得られました。費用対効果は十分高かったと感じています。もし皆さんの環境に、こういったコンポーネントがあるのなら、ぜひご検討されてはいかがでしょうか。

## おわりに

このマイクロサービス群は、わたしが設計した一つ目のマイクロサービスをベースとして改修および運用が行われていたものであり、いつかテコ入れしたいと思っていました。かねてよりの負債をようやく一部返済することができ、個人的も非常に良い取り組みでした。

マイクロサービス導入のデメリットを経験し、コアロジックであるモノリスを積極的に分割するモチベーションが現在はなくなっていました。しかし、マイクロサービス群をモジュラモノリスという形で再構築したことで、また一つの選択肢が生まれたのではないかと考えています。このコンポーネントが未来の負債にならないよう、引き続き改善を続けていきたい次第です。