GitHub Actionsとparallel_tests gemを活用してRailsアプリケーションの爆速CIを実現する

こんにちは。

メディアサービス開発部 Webアプリケーション開発課のフサギコ(髙﨑)です。

Ruby on Railsによるバックエンドの実装運用と、AWSによるサービスインフラの設計構築を中心とした、いわゆるバックエンド方面のテックリードとして働いています。

本記事では、GitHub Actions上で実行するRailsアプリケーションの自動テストの所要時間を、parallel_tests gemとGitHub Actionsのmatrix、ruby/setup-rubyアクションによるgemのキャッシュを併用して短縮し、爆速CIを実現する方法についてお話します。

CIで実行する自動テストの所要時間は短ければ短いほど良い

私たちのチームが開発しているプロダクトのコンポーネントのうち、Railsで開発しているバックエンドでは、developブランチやreleaseブランチを用いずmainブランチを全ての中心とする、いわゆるGitHubフローで開発しています。

GitHubフローだけに限った話でもありませんが、GitHubフローでは特に、あらゆるタイミングでCIによる自動テストが実行されます。

プルリクエストを作成するためにブランチをpushしたとき、プルリクエストで受けたレビューをもとに内容を変更して新たなコミットをpushしたとき、approveを受けてmainブランチへマージして、ステージング環境、ひいては本番環境へデプロイする直前…

このように非常に多くの回数実行されるCIでの自動テストは、その所要時間が長いと1日にできる作業量の大きなボトルネックとなってしまいます。

例えばrspecを用いてテストを記述しているRailsアプリケーションの場合、GitHub Actionsのワークフローを素直に書くと下記のようになる*1でしょう。

name: test
env:
  RAILS_ENV: test
on:
  push:
jobs:
  rspec:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: launch containers
        run: docker-compose up -d
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
      - name: bundle install
        run: bundle install
      - name: prepare DB
        run: bundle exec rails db:setup
      - name: execute rspec
        run: bundle exec rspec

このような、並列実行などの工夫をしていない素直なCIを私たちのバックエンドで実際に試したところ、5668個のテストケースの実行にrspecだけで397秒、全体では494秒を要しました。

これでは、テストの実行が終わるのを待つ間にカップラーメンを作れるどころか、食べ終えてしまいかねません。 それをプルリクエストのレビューを受けて修正したりするたびに待っていては日が暮れてしまいます。

このことからもわかる通り、CIでの自動テストはいかにして開始から終了までの所要時間を短くするかが非常に重要になってきます。

parallel_tests gemを使って、1コンテナのなかでテストを並列実行する

1台のマシンあるいは1つのコンテナのなかでテストを並列実行するためのgemとして有名なのが、parallel_tests gemです。

parallel_testsはマシンのCPUコア数を自動検出して同時実行数を決め、その数だけデータベースを作成してテストファイル群を複数のグループに分割、実行します。 GitHub Actionsのデフォルトのランナーは仮想2コアのため、parallel_testsを使って実行すると2並列となります。

parallel_testsを使ったテストのワークフローは下記のようになります。

name: test
env:
  RAILS_ENV: test
on:
  push:
jobs:
  rspec:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: launch containers
        run: docker-compose up -d
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
      - name: bundle install
        run: bundle install
      - name: prepare parallel_tests
        run: bundle exec rails parallel:setup
      - name: execute parallel_rspec
        run: bundle exec parallel_rspec

前節で示したワークフローと比べても、データベースの作成がdb:setupタスクからparallel:setupタスクに、テスト実行がrspecコマンドからparallel_rspecコマンドに変わっているだけです。

このワークフローでテストを実行したところ下記のように、rspecの実行が203秒で48.9%(194秒)の短縮、全体では297秒で39.9%(197秒)の短縮でした。

CIによる自動テストではdockerイメージのpullなどによって数秒程度の誤差なら恒常的に起きるため、これら短縮分は純粋にparallel_testsによって短縮されたものと考えてよいでしょう。

GitHub Actionsのmatrixを使って、複数コンテナでテストを並列実行する

1コンテナでの実行時間短縮は実現できたので、次は複数コンテナでの並列実行によって更に実行時間を短縮します。

GitHub Actionsには複数のパラメータの組み合わせてのテストを簡単に実行できるようにするためにmatrixという機能があります。 今回はこれを利用してコンテナを4つ同時起動して2並列実行x4コンテナ=8並列で実行します。 そのワークフローの記述は下記のようになります。

name: test
env:
  RAILS_ENV: test
on:
  push:
jobs:
  rspec:
    runs-on: ubuntu-latest
    name: rspec(${{matrix.current}}/${{matrix.total}})
    strategy:
      matrix:
        total:
          - 8
        current:
          - 1,2
          - 3,4
          - 5,6
          - 7,8
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: launch containers
        run: docker-compose up -d
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
      - name: bundle install
        run: bundle install
      - name: prepare parallel_tests
        run: bundle exec rails parallel:setup[${{matrix.total}}]
      - name: execute parallel_rspec
        run: bundle exec parallel_rspec -n ${{matrix.total}} --only-group ${{matrix.current}}

1コンテナでのワークフローと比べると、matrixに関する記述が増えているのと、parallel:setupタスクとparallel_rspecコマンドに引数が追加されています。

parallel:setupタスクの第1引数は作成するテスト用データベース数の明示的指定です。今回は8並列実行のため、8を指定しました*2

parallel_rspecコマンドの-nオプションは全体の分割数、--only-groupオプションは分割後のテストグループの何番目を実行するかの指定です。

このワークフローを実行すると、下記のようにコンテナが4個、matrix機能によって並列実行されます。

これらの中で3分18秒と最も時間がかかっていた7,8番目のテストグループの実行時間の内訳は下記のようになっていました。

最初の状態と比べるとrspecだけでは86秒ですから78.3%(311秒)の短縮、全体でも198秒なので59.9%(296秒)短縮されています。

複数のコンテナの並列実行によって時間を短縮しているため、gemファイルの更新によテストに必要なdockerコンテナの起動やbundle installなどの並列化で短縮できない部分によってのべ実行時間は494秒から792秒に増加していますが、のべ実行時間よりも私たち開発者の待ち時間が短いほうが重要です。*3

ruby/setup-rubyアクションでgemをキャッシュする

更に実行時間を短縮するために、依存するgemをキャッシュします。

gemをキャッシュすると、何らかの理由によりgemが公開停止になった場合*4にもテストが実行できてしまうという問題がありますが、そのような場合にはデプロイ前のDockerイメージのbuildの際に失敗するのでそこで気付ける、として割り切ることにしました。

GitHub Actionsにおいてrubyをインストールしているruby/setup-rubyアクションに引数としてbundler-cache: trueを指定すると、ruby/setup-rubyアクションはbundle installも同時に行い、その結果をキャッシュしてくれます。そのワークフローは下記のようになります。

name: test
env:
  RAILS_ENV: test
on:
  push:
jobs:
  rspec:
    runs-on: ubuntu-latest
    name: rspec(${{matrix.current}}/${{matrix.total}})
    strategy:
      matrix:
        total:
          - 8
        current:
          - 1,2
          - 3,4
          - 5,6
          - 7,8
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: launch containers
        run: docker-compose up -d
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: prepare parallel_tests
        run: bundle exec rails parallel:setup[${{matrix.total}}]
      - name: execute parallel_rspec
        run: bundle exec parallel_rspec -n ${{matrix.total}} --only-group ${{matrix.current}}

このワークフローを実行した様子が下記の画像です。

bundle installのステップが削除されてruby/setup-rubyアクションで同時に実行されるようになり、その実行時間もgemのキャッシュが効いた結果わずか5秒で終わるようになりました。

結果として、ワークフロー全体でもわずか108秒で完了しています。最初と比べると実に386秒(78.1%)の短縮です。ついにカップラーメンすら作れなくなってしまいました。

CIによる自動テストの実行時間がここまで削減されれば、プルリクエストの作成からレビュー、マージまでをより短い時間で達成できるため、いわゆる開発速度の向上が期待できます。

更に実行時間を短縮するためには

本記事では採用しませんでしたが、更に実行時間を短縮するには下記のような手段も考えられます。

テストの実際の実行時間に基づくテストグループ分割

parallel_testsのデフォルト動作ではテストファイル群を複数のテストグループに分割する際、実行時間ログが存在すれば実行時間をもとに、存在しない場合はテストファイルのファイルサイズをもとに、ファイル単位で配分している、との記述があります。

すなわち実行時間ログを使って分割するようにすれば各コンテナにテストがより均等に振り分けられ、結果として全体の実行時間が短くなると期待できます。

しかしながら、私たちのプロダクトでは

  • そこまでやらずとも2分未満で完了するようになったこと
  • 複数のコンテナから出力される実行時間ログのマージが面倒そう
  • 起動時に必ず同じ実行時間ログを参照しないとテストの実行漏れが発生する可能性があり、その保証に手間がかかりそう

といったことを理由に行わないことにしました。

YJITを有効にしてみる

YJITはRailsアプリケーションを高速に動作させることを目的に開発されているRuby(MRI)のJITコンパイラです。

RUBY_YJIT_ENABLE環境変数を設定するだけで有効化できるのでサクッと試してみましたが、さすがにプロセスの生存時間が短すぎてJITコンパイルに使ったCPU時間を取り戻すには至らず、実行時間は逆に長くなってしまいました。

まとめ

本記事では、GitHub Actions上で実行するRailsアプリケーションの自動テストの所要時間をparallel_tests gemとGitHub Actionsのmatrix、ruby/setup-rubyアクションによるgemのキャッシュを併用して短縮し、爆速CIを実現する方法についてお話ししました。

本記事でご紹介した各段階での結果をまとめたのが下記の表です。

rspec実行時間[秒] 全体実行時間[秒] 全体実行累計短縮時間[秒] 全体実行短縮率[%]
初期状態 397 494 - -
parallel_tests gemを使う 203 297 197 39.9
matrixで複数コンテナ実行 86 198 296 59.9
gemをキャッシュする 63 108 386 78.1

内容的には目を見張るような工夫があるわけでもない、基本的なことを積み重ねているだけですが、それでもCIによる自動テストの実行時間の短さはプロダクトを機敏に改修していくうえで不可欠です。 この記事が皆さんのRailsアプリケーションのCIを爆速に、ひいては開発を爆速にする一助となれば幸いです。

ブックウォーカーでは物理・電子・Web連載問わず漫画や本が好き、あるいは将来の電子書籍や漫画連載を爆速CI爆速実装で実現していくことに興味があるエンジニアを募集しています。

興味がありましたらぜひ、下記採用情報ページからカジュアル面談のお申込みやご応募、お待ちしています。

*1:rubocopなどによるlintは省略しています

*2:CIシステムでの並列実行に関するparalell_testsのドキュメントを参照するとparallel:createやparallel:seedなどの更に細かいタスクを指定して頑張れと書いてあるんですが、面倒だったのでそこまではしていません

*3:開発の快適性という点でもそうですが、開発者の待ち時間に発生している人件費も塵も積もれば山となります。人間は高いのです。

*4:2021年にmimemagic gemが公開停止になったりしましたね