monorepoのCI/CDで変更された部分だけをビルド/デプロイする
2020-07-11: Cloud Buildでの記述が誤っていたので修正しました。
はじめに
今年のゴールデンウィークは暇があり、勤務先で複数のリポジトリを使っているのが辛く感じてきていたため、monorepoについて調べてみました。monorepoについての説明やメリットについては他の記事に譲ります。
この参考記事でmonorepoの本当の課題として挙げられている以下の4点のうち、3点目に相当する「CIで変更によって影響を受けた部分だけをビルドする方法」を調査・検討しました。
- トランクベース開発は、より一段と重要になります
- すべてのサービスがモノレポで上手く動くわけではありません
- より精巧なCIセットアップが必要です
- あなたは大規模な変更について考える必要があります
この参考記事ではnxが挙げられていますが、nxは主にJavaScriptのプロジェクトを対象としたツールのようなので、言語に依存しない方法を検討しました。
前提
以下の前提で話を進めます。
- monorepoのローカル開発環境でのビルドは既に構成されている。
- 実装例としてGradleのマルチプロジェクトビルドを使いますが、特定の言語やビルドツールに限定した内容ではありません。
- ソースコードのバージョン管理にはGitを使っている。
- masterブランチとfeatureブランチによるPull Requestベースの開発を行っている。
- developブランチを使っていてはダメというものではありません。
どうやって変更された部分を特定するか
まず、変更された部分を特定する方法を考えます。
案1: ビルドツールの差分ビルドを活用する(不採用)
GradleやMake、Bazelなどの依存関係を考慮するビルドツールでは、変更された部分だけをビルドする機能が備わっていますが、CI環境でこれに依存するのは得策とは言えません*1。CI環境に前回のビルド結果がキャッシュとして残っていたとしても、そのキャッシュとの差分が実際の変更の差分と一致する保証はないためです。
CIの場合、仮に変更の差分と一致しなくても最終的な成果物をビルドできれば大きな問題はないかもしれません。一方CDの場合、本当は変更がないのにキャッシュの都合でビルドが実行され、デプロイまでされてしまうのは困るというケースがあるかもしれません。
案2: Gitの差分から特定する(採用)
そこで、Gitの差分から変更された部分を特定するのが良さそうです。 git diff --name-only <commit>
を使えば現在のHEADと <commit>
の間で変更されたファイル名一覧を取得できるので、それで判断するイメージです。
ただ、比較対象とする <commit>
の選び方については一考の余地があります。単純な方法としては、featureブランチではmasterブランチとの差分を、masterブランチでは直前のコミット HEAD^
との差分を取る方法があります。masterブランチは絶対にマージでしか更新しない運用であれば大丈夫かもしれませんが、masterブランチに複数のコミットをプッシュしてしまった場合に差分から漏れてしまうので、ちょっと心許ないです。
理想的には、ビルドのトリガーとなったプッシュ(マージ含む)に含まれる差分から判断したいです。この差分を取得できるかどうかはCIツールに依存します。
CIツールでGitの差分から変更された部分を特定する
Travis CIでは、環境変数 TRAVIS_COMMIT_RANGE
*2を見れば、そのビルドが対象としているコミットの範囲がわかります。 TRAVIS_COMMIT_RANGE
は新しいブランチを新しいコミットなしでプッシュした場合には空になりますが、この場合には差分がないはずなので、特に影響ないと思います。
GitHub Actionsの場合、環境変数などではわからないものの、ワークフローのトリガーで on.push.paths
*3を指定すれば、指定したパスが変更された場合のみ実行されるので、ビルド内で git diff
する手間なく簡単に実現できます。
CircleCIでは、パイプライン変数 pipeline.git.revision
と pipeline.git.base_revision
*4を比較すれば近いことができそうですが、新しいfeatureブランチをプッシュしたときには pipeline.git.base_revision
が空になってしまうので、もう一工夫必要です。
個人的によく使っているCloud Buildだと、ビルドトリガーの「含まれるファイルフィルター」を設定することで、指定したパスが変更された場合のみビルドを実行できます。GitHubアクションと似ていますが、新しいfeatureブランチをプッシュしたときには、最新のコミットに含まれる差分のみがフィルターの対象になるようで、もう一工夫必要です。
以下のリポジトリに、GitHub Actions, Travis CI, CircleCIの挙動を試した結果を残してあります。参考にしてください。
https://github.com/orangain/understand-github-actions-on-paths
具体的な実装例
というわけで、GitHub Actionsを使ってGradleマルチプロジェクトビルドのプロジェクトで変更があった部分だけビルドするサンプルを作りました。
次のような2つのアプリが2つのライブラリに依存する構成です。
それぞれのアプリに対応するワークフローの設定を含めており、 apps/account-app
の設定は次のとおりです。on.push.paths
にはアプリのディレクトリに加えて、アプリが依存するライブラリや、Gradleの設定ファイル *.gradle.kts
も含めています。これによって、アプリのコードが変更された場合だけでなく、依存関係が変更された場合にも再ビルドできます。
name: Build account-app on: push: paths: - "apps/account-app/**" - "libs/greeter/**" - "libs/profile/**" - "*.gradle.kts" jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-java@v1 with: java-version: '11' - name: Run gradle build run: ./gradlew :apps:account-app:build - uses: actions/upload-artifact@v2 with: path: apps/account-app/build/libs/*.jar
最後に
上記のサンプルプロジェクトは実際に運用したものではありませんが、変更があった部分だけCI/CDの対象とすることができそうな感触が得られました。
最初は単純に差分にファイルが含まれているかどうかで判断するのでは不十分で、依存関係を考慮しないといけないと考えていたため、ちょっとしたツールを実装していましたが、実装してる途中でそのような考慮は不要であることに気付きました。
結果的にこのゴールデンウィークは車輪の再発明をしただけでしたが、GradleのマルチプロジェクトビルドやGitHub Actionsを試したり、トポロジカルソートを実装したりと、いろいろ学べたので良しとします。この記事にも大した新規性はありませんが、少しでも検索に引っかかって再発明する人が減れば幸いです。