🏎️

Github ActionsでRailsのrspecを並列処理して高速化する

2024/01/15に公開

最近CIの見直しをして大きく時間を短縮できたのですが、rspecだけ目立って残ってしまいましたので、こちらも高速化するためにテストを分割して並列で処理させてみました。Github Actionsのmatrixは通常の用途としては複数の環境(言語バージョン、プラットフォーム)でもビルド(テスト)することかと思いますが、マルチプロセス的に処理している特性を利用してみました。

もともとこれが

こうなります。

parallel_tests導入とローカル環境で実行

ドキュメント通り以下を追加してインストール

gem 'parallel_tests', group: [:development, :test]

そしてテスト用のDB設定を書き換えました

test:
  <<: *default
  # <%= ENV['TEST_ENV_NUMBER'] %>を追記
  database: <%= ENV.fetch("DATABASE_NAME", "test") %><%= ENV['TEST_ENV_NUMBER'] %>

そしてプロセスの数だけDBをセットアップ(database作成&スキーマの適用)

bundle exec rake parallel:setup

実行してみる

bundle exec rake parallel:spec

あれれ、半数近くのテストが通らなくなった。なにやら状態が期待値と異なるものが多い。もしかしてDBは作られてるけど、同じDBを参照してしまっているとか?しかし調べてみたらそんなことはなかった。docker,docker-compose,M1 Macという環境要因がないかissueをざっと見るものの見つからず。また同じ問題に直面したという記事も無い、、

一方で、全てのテストが通るコマンドもいくつか発見しました。テストの数や割り振りの問題でなく、どうやらマルチプロセスで同時に動かしてしまうと、少なくとも私の環境では通るものでも落ちる模様

bash -c 'bundle exec rake parallel:spec[1]' # CPUが1つのみ
bundle exec parallel_test spec/ -n 4 --only-group 1 --type rspec # 一部グループのみ
bundle exec parallel_test spec/ -n 4 --only-group 2 --type rspec # 一部グループのみ

今回のCIの高速化という目的においてはGithub ActionsのMatrixで分割して実行さえできればよく、これはマルチコアでなく完全に独立したインスタンス群が並列で立っているはずと予想し、それならばローカルのマルチコアがコケる問題は再現しないと仮説をたてました。なのでこの件は一旦スキップして、CI組み込みに挑戦してみました。(※上記database.ymlは元に戻しました)

CIのrspecを並列化

以下の通りCIに組み込みました。今まではcheckという1つのjobで実行してましたが、requiredとrspecというジョブを加え3つのジョブ構成にしました。まず並列実行したいrspec部分を別のjobとして独立させました。その後に元々のジョブcheckをパスすることがプルリクのマージ要件でしたが、requiredという第3のジョブをマージ要件にしました。(rspecもマージ要件に加える必要がありましたが、モノレポなのでrspecが実行されないときもあり、直接rspecを指定できまなかったため。)

jobs:
  check:
    # 元々のCIステップ。中身省略
  required:
    # rspecのjobはスキップされることがあり、CIの自動マージの要件にできないので、「スキップor成功」を「成功」とみなすjobを用意して自動マージの要件にする
    # https://github.com/actions/runner/issues/491#issuecomment-850884422
    runs-on: ubuntu-latest
    needs: [rspec, check]
    if: always()
    steps:
      - run: echo "${{ toJSON(needs) }}"
      - if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
        run: exit 1
  rspec:
    needs: check
    if: ${{ needs.check.outputs.run-rspec == 'true' }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: true
      matrix:
        ci_node_total: [3] # とりあえず3分割。DB立ち上げなどオーバーヘッドで稼働時間が増えるので程々に
        ci_node_index: [0, 1, 2]
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: DB、gem、環境変数など色々セットアップ
        # run: 〜省略〜
      - name: Load schema
        working-directory: ./rails
        run: bundle exec rake db:schema:load
      - name: RSpec
        working-directory: ./rails
        env:
          CI_NODE_TOTAL: ${{ matrix.ci_node_total }}
          CI_NODE_INDEX: ${{ matrix.ci_node_index }}
        run: bundle exec parallel_test spec/ -n $CI_NODE_TOTAL --only-group $CI_NODE_INDEX --type rspec

requiredジョブの挙動ですが、想定どおり以下のようになります。

  1. checkがpass、rspecがskippedでrequiredがpass
  2. checkがpass、rspecがfailでrequiredがfail
  3. checkがcanceled、rspecがskippedでrequiredがfail

振り返り

結果としては当然実行時間の短縮になったものの、しかし期待したほど劇的に大きな変化はありませんでした。なぜなら、マルチプロセスに処理したい部分を単一のjobとして抜き出すのですが、rspecの実行に必要な必要なDBのセットアップ、gemのインストール、環境変数の展開などもジョブcheckにあったものをrspecでも改めて実行することになっており、オーバーヘッドが発生します。(checkとrspecのジョブを並列で走らせると大幅に効率化しますが、無駄なCIになることもあるので不採用にしました)また、rspecでも実行される重複した前処理は並列処理の数だけ実行されますので、CIへの総課金額は増加してしまいます。

rspecの並列実行はオーバーヘッドが気にならないほど、rspecが伸びてきてから、が良さそうです。

以上です。

ゲームエイトテックブログ

Discussion