こんにちは、ヘンリーの SRE の戸田と Wildcard Engineer の岩永です。 弊社ではレセコン一体型クラウド電子カルテの Henry を開発・提供しています。
前編の Henry のバックエンドをモノレポ化した戦略やプロセスに続いて、後編のこちらの記事ではモノレポ化の技術的手法を解説します。 dev.henry.jp
実際のモノレポ化の流れに沿って、ポイントを3点説明します。
- 2つの git リポジトリのマージ
- アプリケーション・ワークフローのモノレポ対応
- モノレポへの切り替え当日に向けた手順書の作成
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ステップで実現できます。
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)
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
マージ先のリポジトリ (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 └── ...
まずマージ元の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
そして、マージ先のリポジトリに 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 の存在を抹消する必要があります。具体的には以下のことをやりました。
- 各サブディレクトリを submodule の管理から外して git rm したコミットを作成する
- git-filter-repo を使い submodule のパスに一致する過去の commit (submodule の更新など) をすべて書き換えてなかったことにする
- 「コアコンセプト」の手順を行う
全体のフローはそこそこ複雑なので、いつどういう状態でも、誰でもできるように、初期の段階でスクリプトを作成しました。処理が途中で失敗したり止まってしまっても、再度実行したときに失敗したところから再開できるような工夫もされています。
具体的な処理を解説すると長くなってしまうので、gist に実際のスクリプトを上げていますので良かったら見てみてください。
Merge two git repositories while perfectly preserving all commit histories · GitHub
2. アプリケーション・ワークフローのモノレポ対応
前編でも説明したとおり、今回モノレポにする目的は、マイクロサービス間をまたがる作業を生産的に行えるようにするためでした。そのため、一般的なモノレポ導入でも同様かと思いますが、アプリケーション・ワークフローをモノレポに適応させることが重要でした。
- 1つのIDEでモノレポが管理する全てのコードベースで快適に作業ができるようにする
- モノレポ化以前のデプロイプロセスを踏襲する
これらの要件を実現するために、大きく3つの変更を行いました。
ビルドシステム (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)
この判断は前述の 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人でレビューを繰り返しました。
- 当日何も考えなくて良いように実際に使うコマンドや確認方法も含め詳細に記載する
ステップを踏んで範囲を広げて行き、途中で問題があっても切り替えと顧客への影響が最小になるようにする
実際には以下のようなの順に確認をしていきました。
- master ブランチ + sandbox 環境 (finalize 前 = submodule の状態)
- master ブランチ + sandbox 環境 (finalize 後 = モノレポの状態)
- develop ブランチ + staging 環境
- master ブランチ + qa 環境
- master ブランチ + production 環境
- 当日の作業だけでなく、切り替え後の開発体験を想像する
- branch protection を有効化する等、設定や考慮漏れを事前に潰すことができました
実際の手順書の一部はこのようなものです。
- [ ] general-api と receipt-api で master 追従
- [ ] 差分を検知して反映して sandbox deploy
[ ] 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- [ ] deploy branch 変更して staging deploy
{general,receipt}-api/develop-monorepo
とmaster
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