📑

KotlinのCIをキャッシュと実行制御で60%削減した

2024/10/16に公開

はじめに

ログラスのソフトウェアエンジニアのmako-makokです!

最近バックエンド周りのCIをリプレイスしたので、その過程を記事にしました。
まだまだ理想には遠いCIですが、ワークフローファイルの整理とキャッシュと実行制御によって、実行時間を60%削減しました。
構成など、記事を見てくださっている方の参考になれば幸いです。

ログラスのバックエンドの技術スタック

CIに関連する技術スタックを簡単に紹介します。

  • フレームワーク: SpringBoot
  • 言語: Kotlin
  • テスト: JUnit5, mockk
  • Lint: ktlint
  • ビルドツール: Gradle
  • アーキテクチャ: オニオンアーキテクチャ
  • CIツール: GitHub Actions

プロジェクトはオニオンアーキテクチャに基づいており、presentationapplication servicedomaininfrastructureといった層ごとにGradleプロジェクトを分けています。

技術スタックについて更に詳細が知りたい方はぜひ以下の記事もご覧ください。
2023年末に執筆した記事から大きく変更はありません。

https://zenn.dev/loglass/articles/open-loglass-tech-stack-2023

また、ログラスのオニオンアーキテクチャについてより詳細に書かれている記事もご覧ください。

https://zenn.dev/loglass/articles/7f08be47c8c9ac

テストの構成

前述のモジュールの他、独自のコレクションライブラリなどを含む約20のモジュールに分かれています。
テストは、ユニットテスト、DB接続を伴うインテグレーションテスト、一部機能ではスナップショットテストを実装しています。

スナップショットテストについては以下の記事をご覧ください。

https://zenn.dev/loglass/articles/595a91af94ff27

GitHub Actionsの概要

Workflow, Job, Stepの単位について

GitHub Actionsの実行単位にはWorkflow、Job、Stepの3つがあります。

Workflow

最大の実行単位です。
ymlの単位と同じで、Workflowには1つ以上のjobを定義します。
PRがオープンされたとき、定期実行、マニュアル起動などWorkflow単位での実行制御などを行うことができます。

https://docs.github.com/ja/actions/writing-workflows/about-workflows

Job

JobにはStepを複数記述することができます。
Job1つにつき、1つのRunner(マシン)が付与されます。
特に何も制御していなければ、ワークフロー内に定義されたジョブは並列に実行されます。
needs 句を利用することで、特定のjobが成功したら〜等の実行制御も可能です。

https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/using-jobs-in-a-workflow

Step

GitHub Actionsにおける最小の実行単位です。
stepにつき、1つのshellが利用されます。
job内に記載されたstepは基本的に上から順に逐次実行されます。

コスト構造について

GitHub Actionsは、Workflowの実行時間(分単位) * OSごとの係数 * ランナーごとの単価 + ストレージコストで料金が決まります。
つまり、総実行時間を短縮し、ストレージの使用量を減らすことでコスト削減が可能です。

詳細は公式の記事をご覧ください。

https://docs.github.com/ja/billing/managing-billing-for-your-products/managing-billing-for-github-actions/about-billing-for-github-actions

ActionsのSummaryを確認すると、以下のような情報が表示されるかと思います。

  • Total duration: ワークフロー開始から全ジョブ完了までの時間
  • Billable time: 課金対象の実行時間

CIの現状と課題感

よくある話ですが、CI改善前に感じていた課題感は以下です。

  • PRを作成したときに流れる自動テストが遅い
    • CIの遅さは開発のサイクルが鈍化に直結する
    • トラブル発生時に、CIの待ち時間だけ解消が遅れる
  • コストがかさんでいる

まずは計測と既存のワークフローファイルがどうなっているかキャッチアップしました。
結果、以下のことが判明しました。

  • バックエンド関連のテスト実行のワークフローが5個ある
    • 最長のワークフロー: 25分程度
    • 総実行時間: 70〜80h
    • ワークフローの実行単位が散らかっている
  • コストの内訳として、バックエンド関連が大半を占めている
  • コードの可読性が低い
    • composite actionが乱用されている
      • そのため、実行したいテストには不要なjobやstepが実行されていることもある
  • キャッシュがほとんど有効活用されていない

以下はイメージですが、以下のようなワークフローが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 がある程度はよしなにやってくれます。

https://github.com/gradle/actions

追加で、無駄な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

サーバーの構築方法については以下をご覧ください。

https://docs.gradle.com/build-cache-node/

その他のGradleの設定

複数のGradle Projectがあるので、並列実行が有効です。
並列実行をオンにするには以下の設定を行います。

org.gradle.parallel=true

https://docs.gradle.org/current/userguide/performance.html#parallel_execution

結果

  • ワークフロー数
    • 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接続を行うインテグレーションテストが中心

ここからはかなり手探りになりますが、以下を考えています。

さいごに

この記事がどなたかの参考になれば幸いです。
CIに関してはまだまだ改善のスタートラインにたっただけだと感じています。
更に改善できた暁には、またどこかで記事にしようと思います。

参考資料

https://docs.github.com/ja/actions

https://gihyo.jp/book/2024/978-4-297-14173-8

https://docs.gradle.org/current/userguide/performance.html

GitHubで編集を提案
株式会社ログラス テックブログ

Discussion