😎

CircleCIを並列化し実行時間を短縮する

2022/09/08に公開

こんにちは。
株式会社ココナラ(以降、弊社と表記)の技術戦略室でエンジニアをしているSSです。

弊社では安全にリリースするためにさまざまな取り組みを行なっており、その1つとしてCIツールを活用しています。

CIツールを使うことで「自動テストを実行」「構文/フォーマットのチェック」が自動化でき、コードの品質を担保しやすくなります。
一方で、規模の増大とともに実行時間が伸びていくため、ある程度の規模になるとCIにかかる時間が無視できなくなります。
マイクロサービス化してCI対象コードを分割するなどの解消方法はあります。しかし、成長を続ける限り組織全体で見ればソースコードの総量は増大します。そのためCIに掛かる時間が長くなっていくことは避けられない問題でしょう。

弊社でも例外ではなく、CIの実行時間が長くなっていることが開発生産性に影響していました。そのため、CIの実行時間短縮が望まれました。
弊社で採用数が多いCIツールはCircleCIでした。
そこで、CircleCIの処理時間の短縮が開発生産性に与える影響が大きいと判断し、実行時間短縮のための改修を行いました。

どのような改修を実施したのかを簡単に紹介します。

今回紹介する内容と紹介しない内容

CircleCIの実行時間を短縮する場合、大きく分けると以下2点の対応があります。

  1. コンテナイメージや依存関係を含むソースコードのキャッシング
  2. ジョブや自動テストの並列化

本記事では「ジョブや自動テストの並列化」に焦点を絞っています。
キャッシングについては本記事では紹介しません。

また、特定のテスティングフレームワークについての設定方法やアーティファクトの使い方、並列化に伴うカバレッジレポートの修正といった具体的なハウツーについては触れません。

本記事では、CircleCIによるワークフローの実行時間を短縮するための並列化の観点で実施した、次の3点の取組を簡単に紹介します。

  • ジョブの並列化
  • 自動テストの並列化
  • ステップの並列化

ジョブを並列実行する

まずはジョブの並列実行です。

CircleCIはワークフローという大きな図の中でジョブという単位を扱うことができ、各ジョブは複数のステップからなります。このジョブはワークフロー内で直列にすることも並列にすることも可能です。

.circleci/config.yml
version: 2.1

jobs:
  lint:
    steps:
      - checkout
      - run:
        name: 構文チェック
        command: # 実際のコマンドがここに入ります
  test:
    steps:
      - checkout
      - run:
        name: データベースのセットアップ
        command: # 実際のコマンドがここに入ります
      - run:
        name: 自動テストの実行
        command: # 実際のコマンドがここに入ります
  report:
    steps:
      - checkout
      - run:
        name: カバレッジレポート
        command: # 実際のコマンドがここに入ります

workflows:
  version: 2
    ci:
      jobs:
        - lint
        - test
        - report:
            requires:
              - test

この例では「lint」と「test」のジョブを並列実行し「report」は「test」の実行終了を待ち直列で実行します。もしワークフロー内で直列となっているジョブが前後で互いの結果に依存しないようであれば、それらを並列化することでCIの実行時間短縮につなげられるでしょう。
https://circleci.com/docs/ja/2.0/jobs-steps

自動テストを並列実行する

次に自動テストの並列実行です。

並列実行では、以下の2つができます。

  • Executorベースの並列実行
  • プロセスベースの並列実行

どちらも自動テストの実行時間を減らすことができます。

Executorベースの並列実行

コア数を増やして実行時間を減らす以外の選択肢として、CircleCIではジョブごとにparallelismというオプションによって、そのジョブを並列に実行するExecutorの数を増やすことができます。
これは自動テストの対象を分割し、実行するExecutorを増やすことで水平方向にスケールさせるアプローチとなります。

.circleci/config.yml
jobs:
  test:
    parallelism: 4
    steps:
      - checkout
      - run:
        name: データベースのセットアップ
        command: # 実際のコマンドがここに入ります
      - run:
        name: 自動テストの実行
        command: # 実際のコマンドがここに入ります

この例ではtestジョブを4つのExecutorが同時に並列で実行します。
このままではすべての自動テストを4つのExecutorが実行するだけになってしまうので、CircleCI CLIを使って自動テストの対象を分割しましょう。
https://circleci.com/docs/ja/2.0/parallelism-faster-jobs#using-the-circleci-cli-to-split-tests

複数Executorで実行するとその分だけコストの対象となる実行時間が加算されますが、4分のテストを4つのExecutorに分割し、各Executorの実行時間が1分であれば、コストは変わらずにCIの実行時間を4分の1にできます。
実際にはセットアップなど自動テスト以外の部分は分割の恩恵に与れないため、ここまで都合良くはいかないですが、費用対効果は高いです。なので、セットアップのコストを払ってでもやる価値はあるでしょう。

Executorベースの並列化の利点は、後述のプロセスベースの並列化と違い実行環境そのものが並列化される点にあります。
プロセスベースの並列化だと、テンポラリファイルなどグローバルなリソースがあった場合になにも対策が講じられていないと、グローバルなリソースが競合してしまいそれがフレイキーなテストの原因となってしまう恐れがあります。
一方でExecutorベースの並列化であれば実行環境が別となるためそのような問題は起きません。

プロセスベースの並列実行

自動テストは実行時間が生産性に直結するため、テスティングフレームワークにとって高速化は重要な要素の1つです。そうした背景からテスティングフレームワークではマシンリソースを活用し、自動テストを並列実行できるようになっているものがあります。

たとえば、Jestはデフォルトで並列実行をサポートしていますし、RSpecのように並列実行をサポートしていないテスティングフレームワークでもparallel_tests等の並列実行をサポートするためのライブラリを使うことで、自動テストを複数プロセスで並列実行できます。

もしプロセスベースの並列実行をしていないようであれば、実行時間の短縮が期待できるのでぜひ検討してみることをオススメします。Executorを増やすわけではないので、Executorベースの並列化と違い既存の自動テストを既存のマシンスペックのまま並列化するだけであれば、追加のコストはかかりません。

プロセスベースで並列化する上限はCPUのコア数に依存しますがCircleCIでは実行するマシンのリソースを指定できるresource_classという設定があります。

.circleci/config.yml
jobs:
  test:
    machine: true
    resource_class: xlarge

この機能を使うことで垂直方向へのスケール、CPUやメモリといったマシンリソースを増強することが可能です。

Executorごとのリソースクラス、それぞれのスペックと料金は次のページでご確認いただけます。
https://circleci.com/ja/product/features/resource-classes/

並列実行に関する注意点

自動テストを並列で実行すると最終的には分割された単位でもっとも長いテストがボトルネックとなります。
たとえば、分割された最小単位の特定のテストの実行に10分掛かっており、他のテストが平均して1分だったとすると、CIでの自動テスト実行時間は10分となってしまいます。

テストの凝集度の観点から、実行時間が長いだけで考えなしにファイルを分割することは必ずしもオススメできません。
ですが、特定のテストだけが突出して長くなってしまっており、それが課題となっているのであればファイルを分割することも検討する価値はあるでしょう。

ステップを並列化する

最後におまけでステップの並列化です。

CircleCIではステップにbackgroundという真偽値を設定可能なオプションがあります。デフォルトでは値にfalseが設定されているため、ジョブ内の各ステップすべて記載されている順番に直列で実行されます。

このオプションを使い特定のステップの実行が前後のステップに影響を及ぼさない場合は、backgroundの実行にしてしまうことでステップを並列に実行することが可能です。

.circleci/config.yml
version: 2.1

jobs:
  test:
    steps:
      - checkout
      - run:
        name: データベースのセットアップ
        command: # 実際のコマンドがここに入ります
        background: true
      - run:
        name: 構文チェック
        command: # 実際のコマンドがここに入ります

この例では「データベースのセットアップ」をbackgroundで実行しています。これによりステップの終了を待たずに次のステップとなる「構文チェック」が実行されます。一方で「構文チェック」はbackgroundオプションを指定していないため、CircleCIは実行が終了されるまで待ちます。

このアプローチは実行時間が「非同期ステップ < 次の同期ステップ」の関係であれば有効です。
これは「次の同期ステップ」(この例で言えば構文チェック)が完了する時点で「非同期ステップ」(この例で言えばデータベースのセットアップ)も完了している状態となるためです。

非同期ステップに関する注意点

非同期で実行したステップは終了時の結果にかかわらず、CircleCI上では成功扱いとなります。そのためジョブの成否には影響しません。
構文のチェックや自動テストの実行といった「終了時の結果によってジョブを失敗扱いとしたい」性質のステップは、非同期実行しない方が良いでしょう。
https://circleci.com/docs/ja/2.0/configuration-reference#background-commands

まとめ

今回は、CircleCIの並列化によるCI実行時間の削減について紹介しました。

実施した並列化対応は以下となります。

  • ジョブの並列化
  • 自動テストの並列化
    • Executorベース(水平スケール)
    • プロセスベース(垂直スケール)
  • ステップの並列化

自動テストの並列化はセットアップにそれなりの工数がかかる場合もあります。
しかし、実行時間はCIの頻度と比例し、CIの頻度はリポジトリに関わる規模や期間に比例します。

継続的に運用していく前提のソフトウェアであれば、早く取り組むほど価値があります。
一度設定してしまえば、あとは垂直・水平方向のスケールも(予算が許せば)数字を変更するだけの世界となるからです。
"いま"が対応量の少ないタイミングです。
なかなか着手できないと言うのでしたら、本記事を読んだことをきっかけにして、取り組んでみてはいかがでしょうか。

Discussion