Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

GitHub Actions でテストを並列化して CI 時間を短縮する

広告技術部の yamayu です。 ホグワーツレガシーが気になっているのですがまだ手を出せていません。 映画はファンタビ以外は全部見ており、原作は 7 巻の上巻まで読んでいるため楽しめそうとは思っています。

さて、弊社ではこれまで CI/CD ツールとして CircleCI をメインに利用していたのですが、最近は GitHub Actions でも同等の機能が提供されるようになりつつあり、また GitHub の他の機能との連携が容易である等の理由から徐々に切り替えていくような動きがあります。

広告技術部で管理しているリポジトリも少しずつ GitHub Actions への移行を進めており、その中で CI/CD のプロセスの見直しを行いました。 結果として、CI の実行時間を大幅に短縮することができたので、今回はそのことについて書いていきます。

長い重い多いテスト

Gunosy Ads の管理画面は、社内でそれなりに長い歴史のある Rails 製のシステムです。 機能の数が多く、膨大な数のテストケースを抱えており、テストの実行に時間がかかっていました。 そのため、新機能のリリースや緊急時のリバートを俊敏には行えないという問題がありました。

緊急時のリバートについては、GitOps を導入し、CI と CD を分離することで解消したのですが、リリースに時間がかかる点は未解決でした。

GitOps 導入については ↓ の記事にまとめてありますのでそちらを参照ください。

tech.gunosy.io

このプロダクトではテストに rspec を使っているため、ここからは rspec を前提にしますが、他の言語やフレームワークにも一般化できる部分はあると思います。

テストの並列化

実行時間を改善する方針の一つとして、テストの並列化が挙げられます。 並列化の方法として、複数のノードのそれぞれで DB コンテナなどのテスト環境を用意して並列化するアプローチ(マルチノード化)と、 1 ノードの中でテスト環境は共有しつつ CPU リソースを活用し並列化するアプローチ(マルチプロセス化)が考えられます*1。 今回はマルチノード化とマルチプロセス化の両方の面から並列化を行いました。

マルチノード化

CircleCI では複数のノードを立ち上げてテストを並列化するように設定できます。

Test splitting and parallelism - CircleCI

GitHub Actions ではこれと同等のことを matrix で実現可能です。

Using a matrix for your jobs - GitHub Docs

ドキュメントでは OS と バージョンの組み合わせに対してジョブを実行していますが、 下記のようにすることで、テストのファイルを複数のノードに分割して割り当てるような使い方ができます。

# .github/workflows/test.ymljobs:
  test:
    strategy:
      matrix:
        id: [0, 1, 2, 3]  # 4 並列で実行
    steps:- name: run test
        run: |
          # テストファイルのインデックス NR-1 を並列数 n で割った剰余が
          # ジョブのインデックス i に一致すれば割り当てる
          TESTS=$( \
            find spec -type f -name *_spec.rb | \
              awk -v n=${{ strategy.job-total }}  \
                -v i=${{ strategy.job-index }} \
                '{ if((NR-1)%n == i){ print $0 } }'
          )
          # 割り当てたテストファイルのみを実行する
          bundle exec rspec $TESTS

ただ上記のままだとテストの分割は単純にファイル数ベースで決めているのでノードごとの実行時間にばらつきがでる可能性があります。 並列化したタスク全体としての実行時間はすべてのノードの実行時間の max になるため、ばらつきがあると並列数のわりに速くなりません。

実際には、過去の実行時間のレポートを元にノードごとの実行時間の見積もりが均等になるように分割しています。 chaosaffe/split-tests を使えば、JUnit 形式のテストレポートから実行時間が均等になるようにテストを分割してくれます。

レポートの管理には artifacts を用いており、 テスト実行前に過去に実行した際のレポートをダウンロード、実行後に最新版のレポートをアップロードするようにしています。 ここで、執筆時点では標準の actions/download-artifact には同一 workflow 内の artifacts しかダウンロードできないという縛りがあり、過去に実行した workflow から artifacts を取得できません。 そのため dawidd6/action-download-artifact でこれを実現しています。

# .github/workflows/test.ymlon:
  pull_request:
  push:
    branches: [main]
concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true
jobs:
  test:
    timeout-minutes: 30
    strategy:
      fail-fast: false
      matrix:
        id: [0, 1, 2, 3]
    steps:- name: download test report from previous workflow runs
        uses: dawidd6/action-download-artifact@v2
        with:
          name: test-report
          path: ./tmp/report-in
          workflow_conclusion: success
          search_artifacts: true
          if_no_artifact_found: ignore
      - uses: chaosaffe/split-tests@v1-alpha.1
        id: split-tests
        with:
          glob: spec/**/*_spec.rb
          split-total: ${{ strategy.job-total }}
          split-index: ${{ strategy.job-index }}
          junit-path: tmp/report-in/*.xml
      - name: run rspec
        run: |
          bundle exec rspec ${{ steps.sort-tests.outputs.test-suite }} \
            --format RspecJunitFormatter \
            --out ./tmp/report-out/report-${{ strategy.job-total }}-${{ strategy.job-index }}.xml
      - name: upload report
        uses: actions/upload-artifact@v3
        with:
          name: test-report
          path: ./tmp/report-out

並列化する上での tips ですが、timeout-minutes は設定したほうが良いです*2。 実行時間 x 並列数で課金されてしまうので暴走した際の上限を設けておくと安全です。 設定しない場合は最大で 6 時間は停止されません

concurrency を設定しておくことで連続 push 時に、実行途中の workflow を自動的にキャンセルしてくれます。 これにより Organization の同時実行数を食いつぶしてしまうというような事態も防げます。 groupgithub.head_ref から github.run_id にフォールバックするようにしているのは、 main ブランチでは実行中の workflow をキャンセルしないようにするためです*3github.head_refpull_request イベントのみで定義されるので push イベントで main ブランチで実行される際には github.run_id の方を参照します。 github.run_id は workflow の実行ごとにユニークな値であるため、group もユニークな値になります。

fail-fast で並列で実行しているジョブが失敗した際に残りのジョブを中断するかどうかを設定できます。 テストにおいては中断しない方が良いので false にしています。

今回は 4 並列なので問題になっていませんが、並列数を大きくする際には注意が必要です。 GitHub Actions の billing time は job ごとに分未満を切り上げて計算されるため、例えば、1 秒で終わる job でも 100 並列で実行した場合には billing time は 100 分になってしまいます。

また、matrix を使用した場合には billing time の反映にラグがあるようなので、その点も意識しておきたいです。

マルチプロセス化

GitHub Actions ではデフォルトで 2 core の CPU のマシンが割り当てられます*4。 これを十分に使いきるためにノード内でもテストを並列で実行するようにしました。

ここで問題になるのが、DB などのデータストアです。 テストの書き方次第では並列化によってレースコンディションが発生する可能性があります。

今回扱ったプロダクトでは、MySQL の他に S3、Redis などいくつかデータストアを利用していますが、幸いにも並列化によって問題が発生しうるのは MySQL のみでした。 grosser/parallel_tests を使えば、プロセスごとに独立した database を作成できるため、それも問題になりませんでした。

プロセス単位でも並列化したバージョンが下記になります。 --runtime-log オプションで実行時間のログを渡してあげることでプロセスごとでもテストの分配を均等化してくれます。 .rspec_parallel は parallel_spec 内で実行する rspec のオプションを指定するための erb の設定ファイルです。

# .github/workflows/test.ymlon:
  pull_request:
  push:
    branches: [main]
concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true
jobs:
  test:
    timeout-minutes: 30
    strategy:
      fail-fast: false
      matrix:
        id: [0, 1, 2, 3]
    steps:- name: download test report from previous workflow runs
        uses: dawidd6/action-download-artifact@v2
        with:
          name: test-report
          path: ./tmp/report-in
          workflow_conclusion: success
          search_artifacts: true
          if_no_artifact_found: ignore
      - uses: chaosaffe/split-tests@v1-alpha.1
        id: split-tests
        with:
          glob: spec/**/*_spec.rb
          split-total: ${{ strategy.job-total }}
          split-index: ${{ strategy.job-index }}
          junit-path: tmp/report-in/*.xml
      - name: run rspec
        run: |
          bundle exec parallel_rspec \
            --runtime-log <(cat ./tmp/report-in/parallel-*.log) \
           ${{ steps.sort-tests.outputs.test-suite }}
        env:
          NODE_INDEX: ${{ strategy.job-index }}
          PARALLEL_TEST_FIRST_IS_1: true
      - name: upload report
        uses: actions/upload-artifact@v3
        with:
          name: test-report
          path: ./tmp/report-out
# .rspec_parallel
--format RspecJunitFormatter
--out ./tmp/report-out/report-<%= ENV['NODE_INDEX'] %>-<%= ENV['TEST_ENV_NUMBER'] %>.xml
--format ParallelTests::RSpec::RuntimeLogger
--out ./tmp/report-out/parallel-<%= ENV['NODE_INDEX'] %>.log

改善前後でどうなったか

結論から述べると、課金時間はそのままに待ち時間を元の 1/3 程度に短縮することができました*5。 もともと 2 並列で実行していたものを 8 並列 (= 4 node x 2 core) にしたので、理想的には 4 倍くらい速くなって欲しかったのですが、並列化できない部分もあるためこの程度の速度になっています。 しかし、問題となっていたリリースの遅さは大幅に改善することができました。

まとめ

この記事では GitHub Actions において、マルチノード、マルチプロセスでテスト並列実行する方法について書きました。 結果として、テストの実行時間を大幅に短縮することができました。 CircleCI から GitHub Actions に移行してみて、便利な機能がある一方微妙に痒いところに手が届かないと感じる側面もありますが、その点はこれからの改善に期待したいところですね。

*1:マルチスレッド化するアプローチもあります

*2:並列化しなくてもですが

*3:main ブランチではデプロイも走るため途中でキャンセルされたくないという気持ちがあります

*4:さらに大きなリソースが必要な場合には larger runners もあります

*5:CircleCI から GitHub Actions に移行したから速くなったわけではなく、単純に並列化の恩恵ということは補足しておきます