📝

Monorepo について調べてみた

2024/11/30に公開

気になっていた Monorepo についてとうとう仕事でも考える時がきたので、を使い調べた内容をまとめておく。

まずは読んだとこ。
https://circleci.com/ja/blog/monorepo-dev-practices/
Monorepo のメリットやデメリット、また誤解されることなどが簡潔に分かりやすく記載されていた。

次に ChatGPT を使って、自分が想定するケースをインプットにサンプルのディレクトリ構成などをアウトプットしてもらった。
ちなみに自分が想定したケースは以下です。

  • EC サービス
  • サーバーサイドレンダリングを行う Web
  • モバイルアプリケーションに機能提供するバックエンドAPI
  • Web とバックエンド API それぞれがそのサービスで定義されているコンテキストによってデプロイができる状態にする

ディレクトリ構成とそのポイント

ディレクトリ構成例

ecommerce-monorepo/
├── README.md                     # プロジェクトの説明
├── Gemfile                       # 共通のGemfile(必要に応じて)
├── Gemfile.lock
├── docker-compose.yml            # ローカル開発用設定
├── infrastructure/               # インフラ関連の設定(Kubernetes、Terraformなど)
│   ├── k8s/
│   │   ├── products/
│   │   ├── orders/
│   │   └── customers/
│   └── ...
├── contexts/                     # 各コンテキスト(ビジネス領域)を管理
│   ├── products/                 # 商品関連サービス
│   │   ├── api/                  # 商品API(Railsアプリケーション)
│   │   │   ├── app/
│   │   │   ├── config/
│   │   │   ├── db/
│   │   │   ├── Gemfile
│   │   │   ├── Dockerfile        # コンテキストAPI用のDocker設定
│   │   │   └── ...
│   │   ├── web/                  # 商品Web(Railsアプリケーション)
│   │   │   ├── app/
│   │   │   ├── config/
│   │   │   ├── db/
│   │   │   ├── Gemfile
│   │   │   ├── Dockerfile        # コンテキストWeb用のDocker設定
│   │   │   └── ...
│   │   └── tests/                # 統合テストやE2Eテスト
│   │       ├── e2e/
│   │       └── ...
│   ├── orders/                   # 注文関連サービス
│   │   ├── api/                  # 注文API(Railsアプリケーション)
│   │   ├── web/                  # 注文Web(Railsアプリケーション)
│   │   └── tests/                # テスト関連
│   └── customers/                # 顧客関連サービス
│       ├── api/
│       ├── web/
│       └── tests/
├── shared/                       # 共通モジュールやコンポーネント
│   ├── gems/                     # 再利用可能なGem
│   │   ├── ecommerce-core/       # ドメインロジックや共通処理
│   │   │   ├── lib/
│   │   │   ├── ecommerce-core.gemspec
│   │   │   ├── Gemfile
│   │   │   └── ...
│   │   └── ...
│   └── ui-components/            # 再利用可能なUIコンポーネント(Rails Engine)
│       ├── app/
│       ├── config/
│       ├── lib/
│       └── ...
├── scripts/                      # 開発・運用用スクリプト
│   ├── build.sh                  # ビルドスクリプト
│   ├── deploy.sh                 # デプロイスクリプト
│   └── ...
└── docs/                         # プロジェクトのドキュメント
    ├── architecture.md           # アーキテクチャ概要
    ├── deployment.md             # デプロイ手順
    └── ...

構成のポイント

1. コンテキストごとに独立したRailsアプリケーション

  • 各コンテキスト(products, orders, customers)ごとに WebAPI を分離。
  • これにより、特定のコンテキストのみを個別にデプロイ可能。

2. 共通モジュールの再利用

  • shared/gems 内で共通ロジックを Ruby Gem として切り出し、各アプリケーションで利用。
    • 例: ecommerce-core Gemにドメインロジックを集約。
    • Gemfileでの利用例:
      gem 'ecommerce-core', path: '../../shared/gems/ecommerce-core'
      

3. Web と API の独立したデプロイ

  • 各Railsアプリケーションはそれぞれ独立してDockerイメージを作成可能。
  • Dockerfiledocker-compose.yml を使用し、ローカル開発環境でも同じ構成を再現。

4. テストの分離

  • 各コンテキストに固有のテスト(ユニットテスト、統合テスト)を配置。
  • 共通テストやE2Eテストはコンテキストに依存しない形で tests/ にまとめる。

5. インフラ設定の分離

  • infrastructure/ にインフラ関連の設定を集約。
  • コンテキストごとのKubernetesマニフェストやTerraform設定を個別に管理。

6. スクリプトで効率化

  • モノリポ内のアプリケーションを対象にするビルド・デプロイ・テストスクリプトを作成し、自動化を促進。

ローカル開発の工夫

  • docker-compose を活用: 各サービスをまとめて起動可能に。
  • git sparse-checkout の利用: 特定のコンテキストだけをクローンして作業。
    • Git の一部だけをクローンする機能を利用します。
      必要なディレクトリやファイルだけをチェックアウトできるため、リポジトリサイズが大きい場合でも効率的です。

メリット

  1. 効率的な管理

    • 単一リポジトリで全コンテキストを管理でき、全体の一貫性を保ちやすい。
  2. 柔軟なデプロイ

    • コンテキストごとに独立してデプロイ可能なため、変更の影響範囲を限定。
  3. 再利用性の向上

    • 共通ロジックやUIコンポーネントを一元管理でき、コードの重複を削減。

shared ディレクトリを参照するには

shared の参照方法のパターン

1. RubyGem として管理する

  • shared 配下のコードを独立したGemとして構成し、各コンテキストの Gemfile で参照します。

手順:

  1. shared/gems/ecommerce-core をGemとして管理。
  2. 各コンテキストの Gemfile にパス参照を追加:
    gem 'ecommerce-core', path: '../../shared/gems/ecommerce-core'
    
  3. Bundlerでインストール:
    bundle install
    

利点:

  • 再利用可能なコードを明確に切り分けられる。
  • テストやバージョン管理が容易。

2. モジュールのパスを指定する

  • shared 配下のコードをモジュールとしてロードし、アプリケーションのロードパスに追加します。

手順:

  1. shared/modules に再利用可能なコードを配置:
    shared/modules/common_utils/
    ├── lib/
    │   └── common_utils.rb
    
  2. Rails アプリの config/application.rb にロードパスを追加:
    config.autoload_paths << Rails.root.join('../../shared/modules/common_utils/lib')
    

利点:

  • 簡単な設定でコードを共有可能。

注意点:

  • autoload_paths の設定はディレクトリ構造に依存するため、複雑になると管理が難しくなる。

3. Git サブモジュールとして利用

  • shared ディレクトリを別リポジトリとして切り出し、サブモジュールとして利用します。

手順:

  1. shared を別リポジトリに分割。
  2. 各コンテキストでサブモジュールとして追加:
    git submodule add <shared-repository-url> shared
    
  3. 必要なコードをパス参照。

利点:

  • モノリポの一部でありながら、独立性を保てる。

どの方法を選ぶべきか

  • Gemとして管理: 長期的に保守が必要な再利用可能なコードやドメインロジック。
  • モジュールパス: 軽量な設定で簡単に共有したい場合。
  • サブモジュール: モノリポの一部でありながら別リポジトリで管理したい場合。

注意点

  1. 適切な依存関係管理

    • 各コンテキストがsharedディレクトリに過剰に依存しないように設計。
    • 必要最低限のモジュールやロジックのみを共有。
  2. テストとデプロイの整合性

    • shared の変更が各コンテキストの動作に影響を与えるため、CI/CDで変更の影響を確認する仕組みが必要。
  3. 循環参照の回避

    • shared が特定のコンテキストに依存する形にならないよう注意。

各コンテキストでバージョンの異なる Ruby を使う方法

方法 1: コンテキストごとの Ruby バージョン管理

各コンテキストで異なる Ruby バージョンを使用するためには、rbenvRVM などの Ruby バージョン管理ツールを利用して、各コンテキストごとに異なる Ruby バージョンを設定できます。

手順

  1. rbenv を使う場合

    • 各コンテキストのディレクトリに対して異なる Ruby バージョンを設定できます。

    例えば、contexts/products というディレクトリで Ruby 2.6 を使い、contexts/orders で Ruby 3.0 を使いたい場合、それぞれのディレクトリで次のように設定します。

    • contexts/products/.ruby-version に 2.6 を設定:
      2.6.0
      
    • contexts/orders/.ruby-version に 3.0 を設定:
      3.0.0
      
  2. RVM を使う場合

    • 同様に、RVM でも各コンテキストごとに .ruby-version ファイルを置くことで、特定のバージョンを自動的に使用できます。
    rvm use 2.6.0 --create
    rvm use 3.0.0 --create
    

効果

  • 各コンテキストで異なる Ruby バージョンを設定することで、それぞれのコンテキストで必要な Ruby バージョンを使い分けることができます。
  • rbenvRVM は、ディレクトリごとに設定を上書きするため、プロジェクト内の複数の Ruby バージョンを共存させることが可能です。

方法 2: Docker を使って Ruby バージョンを分ける

もしコンテナを使って開発している場合は、Docker でコンテナごとに異なる Ruby バージョンを設定する方法が便利です。これにより、コンテキストごとに完全に独立した環境を作成できます。

手順

  1. コンテキストごとの Dockerfile 作成

    • 各コンテキストのディレクトリ内に Dockerfile を配置し、必要な Ruby バージョンを指定します。

    例: contexts/products/Dockerfile では Ruby 2.6 を指定:

    FROM ruby:2.6
    WORKDIR /app
    COPY . /app
    RUN bundle install
    

    例: contexts/orders/Dockerfile では Ruby 3.0 を指定:

    FROM ruby:3.0
    WORKDIR /app
    COPY . /app
    RUN bundle install
    
  2. docker-compose.yml の設定

    • 複数のコンテキストを一緒に起動する場合、docker-compose.yml を使ってそれぞれのコンテナを定義します。

    例:

    version: '3'
    services:
      products:
        build: ./contexts/products
        volumes:
          - ./contexts/products:/app
      orders:
        build: ./contexts/orders
        volumes:
          - ./contexts/orders:/app
    

効果

  • 各コンテキストが完全に独立した環境を持つため、異なる Ruby バージョンを使用する際に発生する依存関係の衝突を回避できます。
  • 開発環境を Docker コンテナで統一できるため、開発者ごとの環境差異を減らすことができます。

方法 3: CI/CD パイプラインで Ruby バージョンを指定

もし複数の Ruby バージョンを使いたいが、ローカルの環境での管理が煩雑になる場合、CI/CD パイプライン(例: GitHub Actions, GitLab CI, CircleCI など)で Ruby バージョンを指定し、ビルド・テストを異なる Ruby バージョンで実行することができます。

手順

  • CI/CD パイプライン内で、コンテキストごとに異なる Ruby バージョンを指定してビルド・テストを行います。

例: GitHub Actions の workflow.yml で Ruby バージョンを指定する方法

jobs:
  products:
    runs-on: ubuntu-latest
    steps:
      - name: Set up Ruby 2.6
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 2.6
      - name: Install dependencies
        run: bundle install
      - name: Run tests
        run: bundle exec rspec
  orders:
    runs-on: ubuntu-latest
    steps:
      - name: Set up Ruby 3.0
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.0
      - name: Install dependencies
        run: bundle install
      - name: Run tests
        run: bundle exec rspec

効果

  • CI/CD パイプラインで異なる Ruby バージョンを指定することで、コードが複数の Ruby バージョンで正常に動作することを確認できます。
  • ローカル開発環境に依存せずに、プロジェクト内で異なる Ruby バージョンを使い分けることができます。

注意点

  • **バージョン管理ツール(rbenv, RVM, Docker)**を適切に設定しないと、異なる Ruby バージョンがうまく切り替わらなかったり、依存関係の問題が発生することがあります。
  • バージョン依存の Gem やライブラリがある場合、それらが各 Ruby バージョンで動作することを確認する必要があります。
  • Docker や CI/CD の設定において、Ruby バージョンの違いが原因でビルドが失敗しないよう、適切にテストを行うことが重要です。

大規模なチームでの開発における問題

大規模な開発チーム(例えば 50 人以上)でモノリポを使用する場合、コミットの数が多くなり、いくつかの問題が発生する可能性があります。その中でも、特に以下の点が懸念されることが多いです:

1. コミットの関連性の判断が困難になる

チームが異なるコンテキストに責任を持っている場合、どのコミットが自分たちの担当範囲に関わるのかを判断するのは確かに難しくなることがあります。特に、他のチームが作業しているコンテキストで何が変更されたかを確認することが手間になるため、プロジェクト全体での協調が難しくなる場合もあります。

2. コードレビューやマージの負担

モノリポ内でのコードレビューが増え、どのレビューが重要で、どれが自分たちの担当範囲に関係あるかを把握するのが難しくなります。また、大規模なチームだとレビューが滞ったり、同じ部分の変更に対する意見が分かれやすくなることもあります。

3. Git リポジトリのパフォーマンスの低下

コミット数が多くなると、Git リポジトリ自体のパフォーマンスに影響が出ることがあります。例えば、git clonegit pull が遅くなる、ブランチの切り替えに時間がかかる、などの問題が発生することがあります。

4. コンフリクトの増加

大勢の開発者が同時に同じファイルやディレクトリを変更することが多くなるため、マージコンフリクトが頻繁に発生する可能性が高まります。これにより、コンフリクト解消にかかる手間が増え、開発速度が遅くなることがあります。


これらの問題に対処するための戦略

1. チーム間の責任範囲を明確にする

チームが担当するコンテキストをしっかりと区分し、責任範囲を明確にすることで、他のチームのコミットが自分たちの作業に影響を与えることを避けやすくなります。

  • コミットメッセージの規約: 各チームがコミットメッセージに「どのコンテキストに関わる変更か」を明記することで、他のチームの作業範囲を把握しやすくなります。例えば、コミットメッセージのプレフィックスに products:orders: を使うことができます。
    git commit -m "[products] Add product image upload feature"
    

2. Git ブランチ戦略の整備

ブランチの運用ルールを定め、各チームが効率的に作業を進められるようにします。例えば、以下のような運用方法を採ることが考えられます:

  • コンテキストごとのブランチ作成: 各コンテキストごとに独立したブランチを作成して開発する方法。
    • 例: products, orders などのブランチを作成し、各コンテキストの変更はそれぞれのブランチで行う。
  • Feature ブランチ戦略: 各チームは自分たちの作業用のブランチを作成し、その後 maindev ブランチに統合する。

3. Git サブモジュールやサブツリーの活用

もし特定のコンテキストが他のコンテキストと強く分かれている場合、そのコンテキストをサブモジュールサブツリーとして分割することも選択肢の一つです。

  • Git サブモジュール: sharedorders などの大きなコンテキストをサブモジュールとして管理し、それぞれを独立したリポジトリにすることで、各チームが作業するリポジトリの規模を縮小できます。
  • Git サブツリー: サブモジュールのように別リポジトリにするわけではなく、リポジトリ内で部分的に分ける方法です。これにより、チームは必要な部分だけを扱いやすくなります。

4. コードレビューと CI/CD の自動化

  • コードレビューのフォーカス: コードレビューの際に、レビュー対象のコードがどのコンテキストに関わるかを明確に示すことが重要です。CODEOWNERS ファイルを使って、特定のファイルやディレクトリの担当者を指定することもできます。
  • CI/CD: 各コンテキストごとに独立した CI/CD パイプラインを設定することで、無関係な部分のビルドやテストを避け、パイプラインの実行を効率化できます。例えば、products の変更に対しては products 用のパイプラインのみを実行し、他の部分には影響を与えないようにすることができます。

5. Git のパフォーマンスを最適化

  • リポジトリのスプリット: リポジトリが大きくなりすぎた場合、定期的なアーカイブ不要な履歴の削除を行い、Git のパフォーマンスを維持する方法もあります。git filter-branchgit gc を使用して、古い履歴を整理したり、不要な大きなファイルを削除したりできます。

快適な Monorepo 開発生活を送るには、色々と手間がかかりそうだけど、うまくやれば幸せになりそう!

Discussion