ソースコードのハッシュ値を利用したCIの高速化

こんにちは、kintoneチームの川向です。 ソースコードハッシュ値計算ツールであるsverを導入してCIの高速化を行ったので、その紹介をさせてください。

この仕組みにより、通常は1時間かかるCIの実行時間が最善のケースでは20分程度に短縮可能になりました。

導入前の課題

kintoneのCIの大まかな構成は以下のようになっています。

kintoneのCI概要。始めにバックエンドとフロントエンドのビルドと単体テストが実施される。その後E2Eテストが実行され、最終的にリリース候補アーカイブのアップロードが行われる
kintoneのCI概要

E2EテストはAPIテストとSeleniumテストの2種類があります。 またkintoneは2種類のインフラ(国内顧客向けオンプレ基盤、グローバル顧客向けAWS基盤)で運用しているので、 E2Eテストもそれぞれの環境で実施しています。

このE2Eテストに問題がありました。

まず第一に時間がかかることです。特にSeleniumテストは40分ほどかかります。これは件数が多いことが一因です。現状、Seleniumテストが4000件、APIテストが3300件ほどとなっています。

第二に、不安定という問題があります。 不安定なテストは少しずつ修正しているのですが、テストの追加や製品コードの変更によるメンテナンスは継続的に必要なため、完全に解消することは困難です。 また、2つのインフラ基盤で同じテストを同時に実行しており、 どちらか一方でも失敗するとリリース候補アーカイブは出来上がらないようようなCI構成にしており、不安定さが増しています。

第三に、E2Eテストのリソースに限りがあり、複数のCIが同時に多数実行されると律速されるという問題もあります。

解決方法の検討

この問題の解決策の検討では、既存の仕組みを大きく変えないことを一つの条件にしていました。

まず、E2Eテストへの依存度の高さが問題であることはわかっているのですが、 この改善は試験戦略の見直しなどが必要で時間がかかるため、保留しました。 フロントエンドの刷新活動が並行して行われており、 そちらで新しい試験戦略も実践されているのですが、 刷新前の画面に対しては利用できないなどの理由もあります。

また、ビルドの仕組みも大きくは変えないことにしました。 現在逆コンウェイ戦略に基づいてチーム分割を行っており、 各チームごとにビルドの仕組みも選択できることも期待されると思われるため、統一的なツールの新規導入は行いませんでした。 bazelpleaseの調査を簡単に行ったのですが、同様の理由により採用していません。

最終的には、ソースコードハッシュ値計算ツールのsverを使用することにしました。 既存の仕組みをそのまま維持しながら、可能な場合にはテストをスキップするCIが実現可能なためです。なお、sverはYakumo(グローバル顧客向けAWS基盤)で使われているソースコードのハッシュ値計算ツールのOSS版です。

sverを使ったテストのスキップによるCI高速化

sverを使ってどのようにCIの高速化を行ったのかをAPIテストを例に説明します。

kintoneでは前述のようにバックエンドとフロントエンドを分けてビルドし、その後APIテストを実行しています。

kintoneのワークフロー。ビルドジョブはフロントエンドとバックエンド用の2つがあるが、APIテストはバックエンドのビルドジョブにのみ依存している

この依存関係を見てもわかるように、フロントエンドのジョブはAPIテストには影響がありません。 つまり、フロントエンドのコード変更を行っても、APIテストの結果は変わらないということです。 言い換えると、以前APIテストが成功しており、その時点のAPIテストに依存するソースコードと変わっていなければ、そのAPIテストはスキップできるはずです。

この「依存するソースコード」を宣言的に列挙して、その内容のハッシュを計算してくれるツールとしてsverを使っています。

APIテストが成功したとする。この後、JSの変更のみのコミットが行われてCIが起動したとき、APIテストのソースコードのハッシュ値は変わらない。そのため、APIテストはスキップできる
APIテストがスキップできる例

ここではAPIテストを例として説明しましたが、Seleniumテストや単体テストなどのジョブでも同様のスキップが行なえます。

また、sver作者のブログ記事にはより詳細な説明が書かれているので、そちらもご参照ください。

kintoneでのsverの利用方法

それでは実際にsverをどのように利用したかを説明していきます。

sver設定ファイルの書き方

sverではソースコードの依存関係の記述にsver.tomlという設定ファイルを使用します(参考)。 この中ではプロファイルという単位で複数の依存関係を記述することができます。 また、この設定ファイルはモノレポ構成を考慮して複数配置できるようになっています。

kintoneのレポジトリも複数のコンポーネントからなるモノレポ構成のため、 設定ファイルも各コンポーネントごとに配置しました。

# 設定ファイル配置のイメージ
.
├── README.md
├── frontend
│   └── sver.toml
├── frontend-sub
│   └── sver.toml
├── backend
│    └── sver.toml
└── etc
    └── sver
        └── sver.toml // コンポーネントに分けられないジョブの設定ファイル。後述します

例えばfrontendコンポーネントの設定ファイルは以下のようになります。

# frontend/sver.toml
[frontend-build]  # *1
dependencies = [
    ".github",  # *2
    # "frontend/", # *3 明示的に指定しなくてもsver.tomlのあるディレクトリ以下のものは依存に含まれる
]

[frontend-test]
dependencies = [
    ...
]

工夫した点の一つは、CIジョブとプロファイルを1対1対応させたことです(*1)。 この例ではfrontend-buildジョブに対応するプロファイルがfrontend-buildとなっています。 これにより、ジョブ単位で依存ファイルを管理できるようになり、 ジョブ単位でのスキップが行いやすくなりました。 プロファイルがジョブと対応するので、CIスクリプトも追加で依存に含める形になっています(*2)。 なお、sver.tomlのあるディレクトリ以下のファイルはすべてデフォルトで依存に含まれます(*3)。

コンポーネントに分けられないようなジョブに対応するプロファイルは、sver専用のフォルダを作ってそちらで記述しました(*4)。 専用フォルダを作った理由は、ルートディレクトリにsver.tomlを配置してすると、すべてのファイルが依存関係に含まれてしまい、不要なものを一つずつ除外設定するのが困難だったためです。

# etc/sver/sver.toml # *4
[selenium-test]
dependencies = [
    "frontend:frontend-build", # *5
    "frontend-sub:frontend-sub-build", # *5
    "backend:backend-build",  # *5

    ".github",
    ...
]

そして、ジョブ間の依存関係はプロファイルの依存関係として記述するようにしました(*5)。 こちらについてはもう少し説明します。 例えば、E2Eテストがスキップ可能であると判定するには、テストスクリプトとテスト対象が前回の成功したCIから差分がないことを調べる必要があります。 テスト対象は、通常上流ジョブで作られた成果物になるため、それを生成している上流ジョブ(に対応するプロファイル)も依存関係に含めています。

なお、E2EテストではGitHubの環境変数や利用するインフラの設定によっても結果が変わる場合があります。 kintoneの場合は環境の設定が変わるケースが少なく、そういった情報もテストスクリプト内で表現されることが多いので特別な配慮は行っていません。

キャシュの保存先(GitHub Actions Cache、Amazon S3)

sverを使ったスキップ処理を実装する場合、 sverのハッシュ値をどこかに保存する必要があります。

kintoneのCIはGitHub Actionsとなっており、 当初はすぐに使える actions/cache を使用しました。 artifactを使う方法もありますが、 kintoneではジョブ数が多く、CIのSummary画面でartifactが大量に表示されると見にくいという懸念もあり採用しませんでした。

なお、GitHub Actionsのキャッシュはactions/cacheで利用するとブランチごとのスコープになってしまうので、 ブランチを跨いでテストをスキップできるように、キャッシュの存在確認にはAPIを利用しました。

# APIの利用例
hit=$(gh api -X GET -F "key=$key" "/repos/{owner}/{repo}/actions/caches" --jq '.total_count > 0')

当初はこの形で運用していたのですが、キャッシュの容量が上限を超える状態が続いており、 sverのキャッシュも1日程度で切れてしまい不便だったので、現在はAmazon S3に移行しています。 APIでのアクセスではキャッシュの有効期限が伸びないというのも原因の一つだったと思います。

sverを使ったジョブの書き方

次に、sverを使ったジョブの書き方を説明します。 sverを導入したジョブは以下のような構成となります。

テストジョブはsver情報生成ジョブに依存する。さらに別のジョブに依存するテストジョブはそちらへの依存も持っている。
sverを使った場合のジョブの構成

順に説明していきます。

sver情報生成ジョブ: ハッシュ生成とキャッシュの存在確認

このジョブは、sverを使ったハッシュ生成とそれに対応するキャッシュが存在するかの確認を行います。 今回は一つのジョブで複数のsverプロファイルを計算するようにしたので、outputsはjsonになります。 例えば、selenium-testとfrontend-testというプロファイルを同時に計算する場合で、前者にキャッシュがあり、後者にキャッシュがない場合、以下のようなjsonをジョブのouputsの一つとして出力します。

{
  "selenium-test": {
    "key": "sver-selenium-test-e0ff4e2dc244",
    "hit": true
  },
  "frontend-test": {
    "key": "sver-frontend-test-79117f3ac781",
    "hit": false
  }
}

keyがキャッシュのキーで、後ろの文字列がsverの生成したハッシュ値です。 hitはキャッシュが存在するかどうかを表します。

ビルドジョブ: 依存ファイル以外に依存しないことの確認

sverでプロファイルを指定したジョブでは、 チェックアウト後に依存ファイル以外を削除します(*1)。 これにより、暗黙的な依存がないことを保証できるようになります。 これはpleaseなどで行われているsandbox機能を参考にしたものです。

先日、sverのv0.1.18で実装されたsver exportでも同様の処理が行えると思います。

jobs:
  backend-build:
    steps:
      - uses: actions/checkout@v3
      - uses: mitoma/sver-actions/setup@v1
      - name: Delete unnecessary files
        shell: bash
        run: comm -23 <(git ls-files | sort) <(sver list "${{ env.target }}" | sort) | xargs -r -d '\n' rm -rf  # *1
        env:
          target: backend:backend-build
      - ....

テストジョブ: ジョブ成功後にキャッシュ保存

sverでスキップを行う対象のテストジョブでは、 ビルドのジョブのような依存ファイル以外の削除に加えて、 追加の処理が必要になります。

  selenium-test:
    needs: [
        sver-check-caches,  # *1
        frontend-build,
        frontend-sub-build,
        backend-build
    ]
    if: ${{ ! fromJSON(needs.sver-check-caches.outputs.caches).selenium-test.hit }}  # *2
    steps:
      - uses: actions/checkout@v3
      - uses: mitoma/sver-actions/setup@v1
      - name: Delete unnecessary files
        shell: bash
        run: comm -23 <(git ls-files | sort) <(sver list "${{ env.target }}" | sort) | xargs -r -d '\n' rm -rf 
        env:
          target: etc/sver:selenium-test
      - ....
      - name: Save sver cache
        uses: actions/cache/save@v3  # *3
        with:
          key: ${{ fromJSON(needs.sver-check-caches.outputs.caches).selenium-test.key }}
          path: /path/to/cache-file

まずはsver情報生成ジョブをneedsに加えます(*1)。 そして、キャッシュが存在する場合はジョブ自体をスキップします(*2)。 それ以降は依存ファイル以外を削除する点などはビルドジョブと同じですが、 ジョブ成功時にキャッシュを保存する点(*3)が異なります。

下流ジョブのifの書き方

上記の方式でテストのスキップが可能になります。 ただ、GitHub Actionsだと途中のジョブがスキップされると、それより下流のジョブが実行されません。 そのため、下流のジョブでは下記(*1)のような記述を行い、上流ジョブがスキップされていても処理を進められるようにしました。 kintoneのCIの場合「リリース候補アーカイブのアップロード」ジョブでこの対策を行っています。

  archive-upload:
    needs: selenium-test
    if: ${{ ! cancelled() && ! failure() }}  # *1

結果

この仕組みの導入の結果をいくつか紹介します。

まず、sverを使ったスキップ処理は、E2Eテストも含めて27件のジョブに導入しました。 依存関係にあるジョブ(ビルドを行うジョブ)も含めると43件のジョブに対応しました。

また、メインのブランチでのCIの実行状態も調べました。 このブランチにはトピックブランチの変更がマージされていきます。

まず、単体テストや静的解析チェックのジョブはスキップ率が高い事がわかりました。 あるジョブを調べたところ、スキップ率は6割程でした。 スキップ率が高いのは、これらのジョブはプルリクエスト上でも常に実行されており、マージ後もそのハッシュ値が変わらないことが多いためです。

E2Eテストについては、APIとSeleniumでスキップ率は大きく異なりました。 APIテストでは6割、Seleniumテストでは1割ほどでした。 E2Eテストは単体テストや静的解析チェックのジョブと異なり、プルリクエスト上での実行は手動起動となっており、キャッシュはそれほどされずスキップ率は低めになります。ただ APIテストはJavascriptやCSSの変更では依存関係のファイルが変更されることがないため、スキップ率が高くなったようです。

課題と今後の展開

sverの導入により改善は見られましたが、まだまだ課題があります。

まず、Seleniumテストのスキップ率が低い点です。 ただ、Selenimテストはほぼすべての依存を含むので、キャッシュ率を上げることは難しいのではと感じています。 キャッシュのキーの計算方法として、sverによるソースコードのハッシュだけではなく、成果物のハッシュ値を使い分けることも検討しても良いかもしれません。 成果物のハッシュ値を使うと、成果物に影響のないソースコードの差分(package.jsonのdevDependenciesの更新など)があってもキャッシュが利用できるようになります。

また、CIでテストが実行中にメインのブランチにプルリクエストがマージがされてCIがもう一つ起動する場合、 先行するCIジョブと同じソースコードハッシュであっても、 まだキャッシュが保存されていないためスキップを行えないという問題もあります。 特にSeleniumテストは時間がかかるのでこの状況が発生しやすいです。

また、今回導入したsverの設定ファイルで依存関係を正しく記述するのが難しいという問題があります。 特にジョブ間の依存関係が変わったときに、それに正しく追従するのが難しいです。 例えば、アーカイブ作成ジョブAに上流ジョブBができたとします。ジョブBの成果物をジョブAで使用する場合、その依存関係をsverの設定ファイルにも反映する必要があるのですが、それを忘れてもジョブは失敗せずに進んでしまいます。 対策としては、プロファイルに対応するジョブの依存関係が変わったときに、それに気がつけるCIを作る方法がありそうです。 ただ、今更ではありますが、kintoneのように最終的な成果物が複数のコンポーネントを組み合わせたものとなるモジュラモノリスにはsverは向いていないのかもしれません。

また、ビルド結果もsverの仕組みを使ってキャッシュできるとよりCIが高速化するので、取り組んでみたいです。 sverはYakumo(グローバル顧客向けAWS基盤)で使われているツールのOSS版なのですが、Yakumoではハッシュ値はイメージのタグとして使われています。 マージボタン1つで本番適用するための仕組みで「ソースコードのハッシュ値」として書かれているものがそれに該当します。同様の仕組みを作ることも検討したいです。

まとめ

CIにソースコードハッシュ値計算ツールのsverを導入しました。 これにより、CIジョブに関係あるファイルが変更されていないときにジョブをスキップ可能にしました。 スキップ率は、単体テストやAPIテストでは6割、Seleniumテストは1割です。

今後もCIの安定化、高速化を進めて行きます。