株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

医療系スタートアップのバックエンドをモノレポ化した話 〜技術編〜

こんにちは、ヘンリーの SRE の戸田と Wildcard Engineer の岩永です。 弊社ではレセコン一体型クラウド電子カルテHenry を開発・提供しています。

前編の Henry のバックエンドをモノレポ化した戦略やプロセスに続いて、後編のこちらの記事ではモノレポ化の技術的手法を解説します。 dev.henry.jp

実際のモノレポ化の流れに沿って、ポイントを3点説明します。

  1. 2つの git リポジトリのマージ
  2. アプリケーション・ワークフローのモノレポ対応
  3. モノレポへの切り替え当日に向けた手順書の作成

1. 2つの git リポジトリのマージ

今回のモノレポ化においては、もともと存在していた henry-general-api と henry-receipt-api という2つのマイクロサービスのリポジトリを、1つのリポジトリにマージし、それぞれのマイクロサービスがサブディレクトリとして管理できることを目指していました。

henry-general-api/
├── .git/
└── ...

henry-receipt-api/
├── .git/
└── ...

↓↓↓

henry-backend/
├── .git/
├── general-api/
|   └── ...
└── receipt-api/
    └── ...

コアコンセプト

1つのリポジトリにマージするといっても、その後のコード変更のトレーサビリティを確保するために git の履歴が失われれないようにするための方法を考えなければいけません。

今回は git-filter-repo というツールを使用しました。これは、単一リポジトリの履歴を書き換えるためのツールです。例えば、条件に一致するファイルを履歴から完全に抹消したり、逆にそのファイルに関連する履歴だけを抽出したりすることができます。

このツールを使うと、前述したようなリポジトリのマージは大きく3ステップで実現できます。

  1. git-filter-repo を使い、マージ元の各リポジトリ (henry-general-api, henry-receipt-api) で、すべてのファイルをサブディレクトリのパスに移動させる

     cd /path/to/henry-general-api
     git filter-repo \
       --path-rename ":general-api/" \ # すべてのファイルをサブディレクトリに移動する。
       --tag-rename ":general-api-"    # Tag にもプレフィックスを付ける。
                                       # どちらも「(書き換え前):(書き換え後)」という記法。
    
     cd /path/to/henry-receipt-api
     git filter-repo \
       --path-rename ":receipt-api/" \
       --tag-rename ":receipt-api-"
    
     henry-general-api/
     ├── .git/
     └── (application code)
    
     henry-receipt-api/
     ├── .git/
     └── (application code)
    
     ↓↓↓
    
     henry-general-api/
     ├── .git/
     └── general-api/
         └── (application code)
    
     henry-receipt-api/
     ├── .git/
     └── receipt-api/
         └── (application code)
    
  2. 1 で履歴を書き換えたリポジトリを remote としてマージ先のリポジトリ (henry-backend) に登録する

     cd /path/to/henry-backend
     git remote add general-api /path/to/henry-general-api
     git remote add receipt-api /path/to/henry-receipt-api
    
  3. マージ先のリポジトリ (henry-backend) で、2 で登録した remote の branch を両方1つの branch に取り込む

     cd /path/to/henry-backend
     git merge --allow-unrelated-histories general-api/develop
     git merge --allow-unrelated-histories receipt-api/develop
                                         # ^^^^^^^^^^^ ここは remote の名前
    
     henry-backend/
     ├── .git/
     ├── general-api/  # general-api/develop から merge されたもの
     |   └── ...
     └── receipt-api/  # receipt-api/develop から merge されたもの
         └── ...
    

こうするとサブディレクトリ単位で見たときは元の履歴と全く同じ、親ディレクトリで見れば2つの履歴が混ざったようなものになります。

cd /path/to/henry-backend
git log               # 2つのリポジトリの履歴が混ざった1つの履歴になる
git log ./general-api # マージ前の henry-general-api の履歴と同じものが見れる

いきなりモノレポにすると困ること

コアコンセプトはシンプルなものですが、git-filter-repo は不可逆操作であるという問題があります。git-filter-repo はすべての commit を新しく作り直すので、前後で commit sha1 が完全に変わってしまいます。

つまり一度リポジトリをマージしてしまうと、マージ元のリポジトリで追加の commit があったときにその差分をマージ先のリポジトリに取り込むことができなくなってしまうのです。

これではモノレポ化に伴う変更 (アプリケーション・CI/CD も含めて) をすべて一気にやりきるまでは、普段のプロダクト開発を止めるしかなくなってしまいます。

モノレポ化対象のリポジトリは変更頻度や規模がかなり大きいリポジトリであり、変更がそれなりに大変なことが分かっていましたが、プロダクト開発が長期間止まることは許されません。

submodule から始める

そこで、普段のプロダクト開発を止めることなくモノレポ化に伴う変更を管理できるように、まずは2つのリポジトリを submodule にした状態で、アプリケーションや CI/CD がすべて動くようになることを目指すようにしました。

henry-backend/
├── .git/
├── .gitmodules
├── general-api/ (submodule)
|   ├── .git
|   └── ...
└── receipt-api/ (submodule)
    ├── .git
    └── ...
  1. まずマージ元の2つのリポジトリにそれぞれ、モノレポ化用のブランチを作ります

     cd /path/to/henry-general-api
     git checkout develop
     git checkout -b develop-monorepo
    
     cd /path/to/henry-receipt-api
     git checkout develop
     git checkout -b develop-monorepo
    
  2. そして、マージ先のリポジトリに 1 のブランチにチェックアウトした submodule を追加します

     cd /path/to/henry-backend
     git submodule add -b develop-monorepo https://github.com/bw-company/henry-general-api.git general-api
     git submodule add -b develop-monorepo https://github.com/bw-company/henry-receipt-api.git receipt-api
    

こうすることで以下のことが実現できるようになりました。

  • プロダクト開発で入る変更差分にいつでも追従することができる。
    • 履歴の書き換えは行っていないため、開発ブランチからモノレポ化用のブランチに git merge することができます。
  • モノレポ化のための変更差分 (diff) が確認しやすい。
    • 実際マージ元にも PR を作り差分をレビューしていました。

submodule からモノレポに切り替える (finalize script の作成)

すべての準備が整った最後には、submodule をやめて3つのリポジトリ (henry-backend, henry-general-api, henry-receipt-api) を1つにマージする作業が残っています。

基本的には「コアコンセプト」で示した内容をやることになるのですが、この際 submodule の存在が邪魔になります。例えば、submodule のディレクトリは普通のディレクトリとは git 内部の管理の仕組みが異なるため、submodule のディレクトリと同じパスに対して、普通のディレクトリへの変更 commit がある状態は作れません。(commit 一つ一つで conflict の resolve が必要になってしまいます)

そのため、まずはマージ先のリポジトリ (henry-backend) の履歴から submodule の存在を抹消する必要があります。具体的には以下のことをやりました。

  1. 各サブディレクトリを submodule の管理から外して git rm したコミットを作成する
  2. git-filter-repo を使い submodule のパスに一致する過去の commit (submodule の更新など) をすべて書き換えてなかったことにする
  3. 「コアコンセプト」の手順を行う

全体のフローはそこそこ複雑なので、いつどういう状態でも、誰でもできるように、初期の段階でスクリプトを作成しました。処理が途中で失敗したり止まってしまっても、再度実行したときに失敗したところから再開できるような工夫もされています。

具体的な処理を解説すると長くなってしまうので、gist に実際のスクリプトを上げていますので良かったら見てみてください。

Merge two git repositories while perfectly preserving all commit histories · GitHub

2. アプリケーション・ワークフローのモノレポ対応

前編でも説明したとおり、今回モノレポにする目的は、マイクロサービス間をまたがる作業を生産的に行えるようにするためでした。そのため、一般的なモノレポ導入でも同様かと思いますが、アプリケーション・ワークフローをモノレポに適応させることが重要でした。

  • 1つのIDEでモノレポが管理する全てのコードベースで快適に作業ができるようにする
  • モノレポ化以前のデプロイプロセスを踏襲する

これらの要件を実現するために、大きく3つの変更を行いました。

  1. ビルドシステムを multi-project 構成に変える
  2. IDE やエディタの設定を共通化する
  3. CI/CD を再構築する

ビルドシステム (Gradle) を multi-project 構成に変える

Gradle にはネストした複数のプロジェクト(モジュール)を持つ「マルチプロジェクト」という仕組みがあり、モノレポ化対象のプロジェクトは以下のように、既にマルチプロジェクトとなっていました:

  • henry-general-api (root project)
    • app (sub project)
    • utils (sub project)
  • henry-receipt-api (root project)
    • app (sub project)
    • utils (sub project)

これを以下のように既存のプロジェクトルートを新プロジェクトのサブプロジェクトにする形を採りました。

  • henry-backend (root project)
    • general-api
      • app (sub project)
      • utils (sub project)
    • receipt-api
      • app (sub project)
      • utils (sub project)

この判断は前述の git submodule を利用した移行プロセスともよく噛み合い、人間にとっても理解しやすい利点がありました。今後同じようなことをやる場合も、この手法を採るでしょう。

なお buildSrc ディレクトリと settings.gradle.kts ファイル、Gradle wrapper スクリプトはプロジェクトルートに置く必要があるため、これだけは各サブプロジェクトから移動しました。gradle.properties ファイルも各プロジェクトの設定をマージしたファイルを作成しています。

Version Catalogによる依存バージョンの管理

各サブプロジェクトでばらばらにバージョン管理をする必要が無かったため、モノレポ化にあわせて Version Catalog による依存の中央管理を導入しました。

もともと弊社ではRenovateを使い、ライブラリなどの依存を極力最新に保つようにしています。Renovate は Version Catalog もサポートしていますので、特に運用に変更は生じませんでした。

モノレポ化によるビルド時間の変化

モノレポ化によってビルド時間はどう変化したでしょうか。

CIに限った話としては、サブプロジェクトをビルドする際に他のサブプロジェクトのビルドを実行しないように注意すればほとんど影響はありません。Gradleは -pオプションでプロジェクトディレクトリを指定できますので、例えば ./gradlew -p general-api shadowJarとすれば general-api 以外のサブプロジェクトによる影響は最小化できます。

実際に移行前は約20分で終わっていたデプロイが、移行後も約20分程度で終えられています。とはいえこのプロジェクトにおけるデプロイのボトルネックがもともと Gradle ではなくデータマイグレーションにあったことには注意が必要でしょう。

IDE やエディタの設定を共通化する

IntelliJ の設定ファイルは、Gradle プロジェクトができていれば IntelliJ が自動的に生成してくれます。ここでは自動生成では補えない部分について述べます。

.gitignore

.gitignoreには gitignore.io で配布されている IntelliJ 用ものをほとんどそのまま利用しています。さらにGradleによるプロジェクト管理をしているため、Gradle が自動作成してくれるファイルも加えています。

コードスタイル設定

移行前のプロジェクトあった.idea/codeStyleディレクトリは手動で移行する必要がありました。弊社では *を使った import を抑制したり、Trailing Comma を許容したりといったカスタマイズを施していたため、 .idea/codeStyles/Project.xmlファイルをコピーして対応しました。

Detekt プラグイン

弊社では Detekt プラグインを利用しているため、その移行も行いました。

IDEA プラグインの移行自体は .idea/detekt.xmlに必要な情報を書くだけで済みますが、Detekt の設定は丁寧にマージする必要があります。今回は Detekt ルールはゆるい方に合わせ、モノレポ化を終えてから順次ルールを追加していくことにしました。

なおルールさえマージしてしまえば、baseline ファイルは ./gradlew detektBaselineで自動生成できます。

CI/CD を再構築する

CI/CD には CircleCI を使っていました。Docker image を build して、CloudRun + CloudFunction へのデプロイを行う流れです。

Henry のマイクロサービスは git-flow に従ったブランチ管理・デプロイ戦略を採用しています。デプロイ環境が4つあり、それぞれのブランチへの push で自動デプロイがされる仕組みです。

ブランチ デプロイ環境 存続期間
develop staging, sandbox 永久的
release/YYYYMMDD qa 一時的
master production 永久的

モノレポにおいてもこのデプロイフローを再現できる必要がありました。

もともと大きな機能変更をテストするための場として sandbox 環境が用意されていたので、モノレポ化に関しても、一時的にこの環境を専有することで実験を進めました。

モノレポ対応に際しては、なるべく移行時や切替時の設定ミスが少なくなり、長期に渡ってもメンテナンスしやすくなるように設定の共有化やリファクタリングを並行で行いました。具体的には以下のようなものが含まれます。

  • Reusable Config Reference Guide にあるプラクティスに従い、commands や parameters を活用し、なるべく可読性が確保できるようにした
  • Dynamic Configuration を活用し、設定ファイルを分割した。
    • constants.yml — Anchor/alias を使った定数系の定義
    • common.yml — 共有の設定や再利用可能な command や job の定義
    • {general,receipt}-api.yml — 各サービス固有の job と workflow の定義

それ以外にも Gradle Project の構造が変わったことによる変更や、キャッシュ効率を良くするために jar の生成を docker build 内でやっていたものを CI 上でやるように変えたりと、細かいところでいろいろな調整・改善をしましたが、長くなるのでここの知見はまた別の機会に共有したいと思います。

3. モノレポへの切り替え当日に向けた手順書の作成

モノレポ化の PoC が sandbox 環境で動くところまでできたところで、当日の手順書を作成しました。

マージ元リポジトリでの変更差分の反映や、設定が正しくモノレポ移行できているかの最終確認など、finalize script を実行する前後でやることがそれなりありました。

手順書を作るにあたっては、以下の点に気をつけながら3人でレビューを繰り返しました。

  1. 当日何も考えなくて良いように実際に使うコマンドや確認方法も含め詳細に記載する
  2. ステップを踏んで範囲を広げて行き、途中で問題があっても切り替えと顧客への影響が最小になるようにする

    実際には以下のようなの順に確認をしていきました。

    1. master ブランチ + sandbox 環境 (finalize 前 = submodule の状態)
    2. master ブランチ + sandbox 環境 (finalize 後 = モノレポの状態)
    3. develop ブランチ + staging 環境
    4. master ブランチ + qa 環境
    5. master ブランチ + production 環境
  3. 当日の作業だけでなく、切り替え後の開発体験を想像する
    • branch protection を有効化する等、設定や考慮漏れを事前に潰すことができました

実際の手順書の一部はこのようなものです。

  • [ ] general-api と receipt-api で master 追従
  • [ ] 差分を検知して反映して sandbox deploy
    • [ ] general-api と receipt-apimaster-monorepo branch に master を取り込む
    • [ ] monorepo repo で submodule を update する
    • [ ] monorepo repo 側で変更に追従
    • [ ] 反映漏れがないか一通りチェック
      • [ ] .github/workflows
      • [ ] groovy
      • [ ] lib versions 系
    • [ ] master に merge
  • [ ] general-api, receipt-api の repo で develop-monorepo branch を作る

      git checkout develop
      git checkout -b develop-monorepo
      git merge master-monorepo
      # conflict 解消
      git push origin develop-monorepo
    
  • [ ] ./poc-tools/finalize して sandbox deploy

    • 手順
      1. git checkout master
      2. ./poc-tools/finalize
      3. 目視で確認
        • submodule が消えているか
        • receipt-api / general-api が統合され、履歴も統合されているか
        • receipt-api / general-api と monorepo で最新のコミットが一致しているか
      4. CI を確認
        • sandbox デプロイが実行されること
        • 本番デプロイが動いていないこと
  • [ ] deploy branch 変更して staging deploy
    • {general,receipt}-api/develop-monorepomaster branch の3つをマージした develop branch を作成

        git checkout master
        git checkout -b develop
        git merge remotes/general-api/develop-monorepo
        git merge remotes/receipt-api/develop-monorepo
      
    • デプロイのブランチ設定を変更: staging (develop に変える), sandbox (develop に変える)

    • develop に push
    • CI を確認
      • staging デプロイが実行されること
      • 本番デプロイが動いていないこと

まとめ

プロセスの検討、アプリケーション・ワークフローのモノレポ対応、手順書作成、といった準備には、3人の Working Group による週1の活動でトータル2ヶ月間ほどかかりました。

しかしその入念な準備の甲斐あって、切り替えの作業とそれに伴うコードフリーズはたったの5時間で済みました。また、細かいミスはあったものも、顧客影響は一切なく、開発者もみんな翌日からすぐにストレスなくモノレポでプロダクト開発を再開できたことはとても素晴らしい結果でした。


最後まで読んでいただきありがとうございました!

ヘンリーでは一緒に働く仲間を絶賛募集中です。興味がある方はぜひお気軽にご連絡ください。 jobs.henry-app.jp