CircleCIの実行時間を削減するための小技

2024/01/24に公開

最近はもっぱらCIといえばGitHub Actionsを使っているのですが、CircleCIも使っています。今回はCircleCIのジョブ実行時間を削減するための小技をまとめておきます。なお、プロジェクト依存で手法は変わってくるので、これらをやれば必ず早くなるわけではないです、色々試してみてください。もし良い方法があったらコメントで教えてください。

以下、紹介する小技です。

  1. Gitリポジトリのクローンを効率化
  2. WorkspacesとCacheをうまく使う
  3. 必要のないテストとビルドはスキップする
  4. 依存度合いでテストを分割する

また、CircleCI公式ドキュメントに最適化手法も書かれているので、こちらも合わせて読むことをオススメします。

Gitリポジトリのクローン時間削減

CircleCIでは checkout という標準のGitリポジトリをクローンするアクションがあります。これは素朴に git clone するものですが、リポジトリが肥大化していると単にクローンするだけでもそれなりの時間がかかります。

理由は git clone が過去の履歴データも一緒に持ってくるからなのですが、CIで必要なのは対象ブランチの最新ファイルだけで、過去履歴は必要なかったりします。

そこでShallow cloneと言われる方法を使います。

    steps:
      - run:
          name: checkout (shallow)
          command: |
            git clone --depth 1 --branch $CIRCLE_BRANCH "$CIRCLE_REPOSITORY_URL" .  

--depth オプションで履歴の階層を1に指定して最新バージョンのみを取得してきます。当然のことながらgit logをしても最新コミット以外は出てきません。

フォークするスタイルの場合

GitHubの運用がフォークスタイルの場合は、プルリクのブランチを直接持ってくることができないので以下のようにする必要があります。運用スタイルに合わせて調整をしてください。

steps:
  - run:
      name: checkout (shallow)
      command: |
        git clone --depth 1 "$CIRCLE_REPOSITORY_URL" .
        # $CIRCLE_BRANCHがmainでない場合にのみfetchとswitchを実行
        if [ "$CIRCLE_BRANCH" != "main" ]; then
          git fetch --depth 1 origin $CIRCLE_BRANCH/head:PR_BRANCH
          git switch PR_BRANCH
        fi

この取り組みで、とあるリポジトリでは以下のように差が出ました。地味に嬉しいですね。

  • 通常のclone : 25sec
  • Shallow clone : 5sec

WorkspacesとCacheをうまく使う

CircleCIには2つのファイル共有方法があります。

  1. Workspaces
  2. Cache

Workspacesは単一のワークフロー内において、各ステップのジョブでファイルを共有する仕組みです。例えば、ビルドの成果物を次のジョブに渡したり、プロジェクト全体で使うライブラリを共有したりするのに使います。

Cacheは、異なるワークフロー…CircleCIプロジェクト全体で共有できるストレージです。Workspaceが単一の一回きりの共有に対して、Cacheは一度生成するとキャシュキーを指定すればどこでも呼び出すことができます。主にnpmやgemsなどインストールしたライブラリ群に使います。

どちらかWorkspacesとCacheは、どちらか一方というより組み合わせて使うイメージです。

保存するファイルは厳選する

このWorkspacesとCacheは非常に便利で、ジョブ実行時間の削減にもつながる機能ですが、安易に使うと逆に時間がかかってしまいます。特に保存するファイル数が多く、全体のファイルサイズが肥大化していると、保存時に時間がかかってしまいます。

本当はビルドの成果物だけ後続で必要なのに、ビルドに必要なライブラリ群も含んでしまっていたりすると、それだけで無駄な時間がかかってしまいます。例えば、1GBほどのデータを保存しようとすると1分以上かかってしまいます。

Workspaces vs Cache

似たような機能なので比較されがちですが、いわゆる速度という面においてはどちらも同じような性能です(僕が計測した限りでは)。保存速度もリストア速度もほぼ同じです。

使い分けとしては機能通り、ワークフロー内で限定的に永続化したいならWorkspaces、プロジェクト全体で共有したいならCacheを使うようにしましょう。

  • Gitリポジトリ:Workspacesが向いてる
  • ライブラリ:Cacheが向いてる(package-lock.jsonをキャッシュキーにするなど)
  • ビルド成果物:用途次第
    • 必ずビルドするワークフローならWorkspaces
    • 特定のリソースが更新されない限りビルドしないならCache

必要のないテストとビルドはスキップする

例えば、RailsとReactの組み合わせのアプリケーションの場合、Reactのコードを修正していなければ、Reactのユニットテストは不要というケースがあります。または、Railsが管理するJSやCSSファイルを修正していなければAssets Precompileが不要というケースも。

このように、特定のファイル群が更新されていなければ実行する必要のないジョブをスキップして時間を削減することができます。

やり方はいくつかありますが公式が出している path-filtering というOrbを利用するか、自前でBashスクリプトを使うかが代表的と思います。path-filtering を使う場合は設定ファイルの構成を書き換える必要があるので大掛かりになりますが、いわゆるモノレポなプロジェクトにとっては使い勝手が良いと思います。

この記事では手軽にBashスクリプトで実現する方法を紹介します。

  foo_build:
    docker:
      - image: cimg/node:18.17.1
    working_directory: ~/repo
    steps:
      - checkout-blob-less
      - run:
          name: create frontend build cache key
          command: |
            # ビルド対象のファイルが最後に更新されたコミット番号を
            # キャッシュキーにし、BUILD_VERSIONというファイルに書き出す
            git rev-parse $(git log --oneline -n 1 react/*.ts package-lock.json | awk '{{print $1}}') > BUILD_VERSION
      - restore_cache:
          key: build-{{ checksum "BUILD_VERSION" }}
      - restore_cache:
          key: npm-{{ checksum "package-lock.json" }}
      - run:
          name: npm run build
          command: |
            # 現在のバージョンとCacheから復元したバージョンファイルを比較する
            current_revision=BUILD_VERSION
            previous_revision=dist/BUILD_VERSION
            if [ ! -e $previous_revision ] || ! diff $previous_revision $current_revision; then
              npm run build
              # 最新キャッシュキーをCache保存して次回使えるようにする
              cd ~/repo && cp -f $current_revision $previous_revision
            else
              echo "ビルドスキップ"
            fi
      - save_cache:
          key: build-{{ checksum "BUILD_VERSION" }}
          paths:
            - dist/

やり方はシンプルで、まずgit logで特定のファイルが更新された最新のコミットIDを取得します。そしてそれをBUILD_VERSIONに書き出しておきます。その後、以前のビルドで保存されていたBUILD_VERSIONを取り出し中身を比較します。

同じであれば、特定のファイル群は以前から更新されていないということになるので、ビルドする必要がありません。差分があればなんらか更新されたということなのでビルドします。そしてビルド結果をCacheに保存します。

これだけでちょっとしたモノレポ対応ができるようになります。ただ複雑なワークフローをやるには最初に紹介した path-filtering を使ったほうが設定ファイルはキレイに書ける気がします。

依存度合いでテストを分割する

これはアプリケーション構成依存の話ですが、例えばRailsで作られており、一部のフロントエンドをVueのSPAで作っていたとします。自動テストはRailsのファイルだけで完結するもの(モデルテストやヘルパーテスト)もあれば、WebpackでビルドしたJSファイル群が必要なもの(リクエストテストなど)もありました。

素直に実行すると、全部素材を揃えてテストすることになるのですが、それだとnpm installしてWebpackビルドして、さらにbundle installして、Railsのassets precompileもするためテスト前に必要なジョブが多く時間がかかってしまいます。

しかし、自動テストの中にはRailsのモデルテストのようにbundle installさえされていれば実行可能なテストも多くあります。そこで、テストを分割し、自身が必要なジョブだけ前段で行うようにすることで早めに終わらせられるテストは終わらせることができるようになりました。

CIのテスト分割というと、コンテナを並列にならべて実行させる方法が王道ですが、それ以外にもワークフロー自体を分割するとさらに削減できる可能性があります。ぜひ見直してみてください。

ムーザルちゃんねる

Discussion