KotlinのCIをキャッシュと実行制御で60%削減した
はじめに
ログラスのソフトウェアエンジニアのmako-makokです!
最近バックエンド周りのCIをリプレイスしたので、その過程を記事にしました。
まだまだ理想には遠いCIですが、ワークフローファイルの整理とキャッシュと実行制御によって、実行時間を60%削減しました。
構成など、記事を見てくださっている方の参考になれば幸いです。
ログラスのバックエンドの技術スタック
CIに関連する技術スタックを簡単に紹介します。
- フレームワーク: SpringBoot
- 言語: Kotlin
- テスト: JUnit5, mockk
- Lint: ktlint
- ビルドツール: Gradle
- アーキテクチャ: オニオンアーキテクチャ
- CIツール: GitHub Actions
プロジェクトはオニオンアーキテクチャに基づいており、presentation
、application service
、domain
、infrastructure
といった層ごとにGradleプロジェクトを分けています。
技術スタックについて更に詳細が知りたい方はぜひ以下の記事もご覧ください。
2023年末に執筆した記事から大きく変更はありません。
また、ログラスのオニオンアーキテクチャについてより詳細に書かれている記事もご覧ください。
テストの構成
前述のモジュールの他、独自のコレクションライブラリなどを含む約20のモジュールに分かれています。
テストは、ユニットテスト、DB接続を伴うインテグレーションテスト、一部機能ではスナップショットテストを実装しています。
スナップショットテストについては以下の記事をご覧ください。
GitHub Actionsの概要
Workflow, Job, Stepの単位について
GitHub Actionsの実行単位にはWorkflow、Job、Stepの3つがあります。
Workflow
最大の実行単位です。
ymlの単位と同じで、Workflowには1つ以上のjobを定義します。
PRがオープンされたとき、定期実行、マニュアル起動などWorkflow単位での実行制御などを行うことができます。
Job
JobにはStepを複数記述することができます。
Job1つにつき、1つのRunner(マシン)が付与されます。
特に何も制御していなければ、ワークフロー内に定義されたジョブは並列に実行されます。
needs
句を利用することで、特定のjobが成功したら〜等の実行制御も可能です。
Step
GitHub Actionsにおける最小の実行単位です。
stepにつき、1つのshellが利用されます。
job内に記載されたstepは基本的に上から順に逐次実行されます。
コスト構造について
GitHub Actionsは、Workflowの実行時間(分単位) * OSごとの係数 * ランナーごとの単価 + ストレージコストで料金が決まります。
つまり、総実行時間を短縮し、ストレージの使用量を減らすことでコスト削減が可能です。
詳細は公式の記事をご覧ください。
ActionsのSummaryを確認すると、以下のような情報が表示されるかと思います。
- Total duration: ワークフロー開始から全ジョブ完了までの時間
- Billable time: 課金対象の実行時間
CIの現状と課題感
よくある話ですが、CI改善前に感じていた課題感は以下です。
- PRを作成したときに流れる自動テストが遅い
- CIの遅さは開発のサイクルが鈍化に直結する
- トラブル発生時に、CIの待ち時間だけ解消が遅れる
- コストがかさんでいる
まずは計測と既存のワークフローファイルがどうなっているかキャッチアップしました。
結果、以下のことが判明しました。
- バックエンド関連のテスト実行のワークフローが5個ある
- 最長のワークフロー: 25分程度
- 総実行時間: 70〜80h
- ワークフローの実行単位が散らかっている
- コストの内訳として、バックエンド関連が大半を占めている
- コードの可読性が低い
-
composite actionが乱用されている
- そのため、実行したいテストには不要なjobやstepが実行されていることもある
-
composite actionが乱用されている
- キャッシュがほとんど有効活用されていない
以下はイメージですが、以下のようなワークフローが5つありました。
name: api-db-test-admin-infra
on:
pull_request:
types: [opened, synchronize, reopened]
push:
jobs:
db_test_admin_infra:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-db
- uses: ./.github/actions/run-test
with:
command: ./gradlew :infra:test --tests "com.example.a" --tests "com.example.b" --tests "com.example.c"
実施した改善
composite actionなどが乱立していたり、その中で不要なjobやstepがあるということから、少しずつ改善していくのは難しく、全体的に設計を見直しフルリプレイスしました。
全体像は以下の図をご覧ください。
各Jobについて
PR作成毎に実行するWorkflow
ログラスには約20弱のGradle Projectがあり、最初に全モジュールに対してコンパイルとlintを行います。コンパイル結果はGitHub Actionsのローカルキャッシュに書き込みます。
gradle test
はcompile系のTaskに依存していますが、後続のジョブではlocal cacheからコンパイル結果を取得するので、コンパイルをスキップすることができます。
また、各ジョブはRemote build cacheを参照するようにし、キャッシュがヒットしたTaskはキャッシュの結果を利用します。
local cacheについては gradle/actions/setup-gradle
がある程度はよしなにやってくれます。
追加で、無駄なCIを回さない工夫として、以下の2点を実装しています。
- PR毎のCIではこの2つが成功した場合のみ各モジュールのテストを実行
- オニオンアーキテクチャの依存関係に則り、依存性がない部分のみが変更された場合には無関係なjobは実行しない
name: test
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
# 最初にcompileとlintを並列実行
compile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- name: compile test kotlin
run: ./gradlew compileTestKotlin
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- name: ktlint check
run: ./gradlew ktlintCheck
# compileとlintが通ったら各テストを並列実行
domain-test:
needs: [compile, lint]
runs-on: ubuntu-latest
- name: Checkout sources
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
- name: domain-test
run: ./gradlew domain:test
presentation-test:
needs: [compile, lint]
runs-on: ubuntu-latest
- name: Checkout sources
uses: actions/checkout@v4
- name: Setup
uses: ./.github/actions/setup
- name: domain-test
run: ./gradlew presentation:test
定期実行するWorkflow
クリーンビルドを実行して、Remote build cacheに書き込みます。
CIごとにRemote build cacheに書き込むことも試してみたのですが、どうにもCIを多重実行していると壊れたキャッシュが書き込まれてしまい、CIが落ちるという事象が多発したためこの形にしています。
on:
schedule:
- cron: '0 0,2,4,6,8,10 * * 1-5'
jobs:
api_db_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-db
- name: build and cache
run: ./gradlew clean build
Remote build cacheについて
Remote build cacheは、HTTP経由でキャッシュRead/Writeすることができます。
設定方法は settings.gradle.kts
にbuildCacheの設定を追加するだけです。
buildCache {
remote<HttpBuildCache> {
url = uri("https://example.com")
credentials {
username = username
password = password
}
// 読み込みの設定
isEnabled = true
// 書き込みの設定
isPush = true
}
local {
isEnabled = true
isPush = true
}
}
あとは gradle.properties
などでキャッシュを有効にすると利用できます。
org.gradle.caching=true
サーバーの構築方法については以下をご覧ください。
その他のGradleの設定
複数のGradle Projectがあるので、並列実行が有効です。
並列実行をオンにするには以下の設定を行います。
org.gradle.parallel=true
結果
-
ワークフロー数
- before: 5
- after: 1
-
実行時間
- before: 25分程度
- after: 15分程度
-
総実行時間
- before: 70〜80h
- after: 70〜80h
- ※但し、キャッシュと実行制御により変更箇所によってはこれより少なくなる
-
コードの可読性
- before: 複雑なcomposite actionと5つのワークフローで複雑だった
- after: composite actionは最低限に、1つのワークフローで見通しが良くなった
フル実行でも15分程度に短縮されました。
以前の25分から約10分の短縮です。
実際のところはオニオンアーキテクチャによる実行制御により、フル実行の回数が減少し、体感的にもさらに早く感じられます。
フル実行時の総実行時間はやや微減程度でしたが、実行制御やキャッシュによってある程度の削減が見込まれます。
このCIは稼働を始めたばかりで、実際コストへどう影響するかはまだ分かりませんが、来月が楽しみです。
今後の展望
ある程度改善されたものの、フル実行で15分かかるのはまだ伸びしろがあるなと感じます。
実行時間の内訳は以下です。
- コンパイルが7〜8分弱
- 最長のテストジョブが8〜9分
- DB接続を行うインテグレーションテストが中心
ここからはかなり手探りになりますが、以下を考えています。
- ヒープの調節
- テストのパラメータの調節
- https://docs.gradle.org/current/dsl/org.gradle.api.tasks.testing.Test.html#N3309B
-
maxParallelForks
,maxHeapSize
,forkEvery
あたりが効果的だと思われる
- テスト自体を早くする
- 共通データinsertの仕組みなど、SQL発行回数を減らす
- CI実行環境のマシンスペックの増強
さいごに
この記事がどなたかの参考になれば幸いです。
CIに関してはまだまだ改善のスタートラインにたっただけだと感じています。
更に改善できた暁には、またどこかで記事にしようと思います。
参考資料
Discussion