きり丸の技術日記

技術検証したり、資格等をここに残していきます。

RailsのRSpecテストを並列化して30分から12分に短縮した(parallel_testsと性能アップ)

CI上のRailsのRSpecテストが30分もかかっていてストレスフルだったので、テストを並列化する等で30分かかっていた時間を12分まで削減しました。

そのときのメモ。

環境

  • AWS CodeBuild
  • Ruby
    • 2.7.2
  • Rails
    • 6.0.3.6
  • Parallel_tests
    • 4.4.0
  • MySQL
    • 8

前提

docker composeでテストは閉じている。そのため、RDS等のアクセスは発生していない。

対応

parallel_testsライブラリを入れる

並列化ライブラリのparallel_testsを導入します。

group :development, :test do
  gem 'parallel_tests'
end

テストのDB名を修正する

並列化したプロセスごとに独立したスキーマを割り当てないとデッドロックが頻発するのでプロセスごとにスキーマを作成します。

parallel_testsライブラリが実行しているプロセスごとに環境変数TEST_ENV_NUMBERを用意してくれるので、suffixに環境変数を指定します。

test:
  database: sample<%= ENV.fetch('TEST_ENV_NUMBER', '') %>
  # sample
  # sample2
  # sample3
  # といった具合に、プロセス数に応じたスキーマ名が振り当てられます。
  # 1は割り振られないので、どうしても1と割り振りたい場合は自作rakeが必要です

テスト実行時のRakeを自作する

私の環境の場合docker composeを使用して閉じた環境でテストしていることもあり、商用環境テーブルは不要でした。そのため、parallel_testsのライブラリをそのまま使用するとマイグレーションチェックが走ってエラーが発生してしまったため、その過程を飛ばしています。

また、自作Rakeがdevelop testgroupに依存しているのもあり、商用でエラーになってしまうので、LoadErrorをスキップするようにしています。

# frozen_string_literal: true

# NOTE: テスト時にしか使用しないものなので、商用ビルドするとインポートエラーが発生してしまう
begin
  require 'parallel_tests/tasks'
rescue LoadError
  puts 'Unable to load parallel_tests/tasks, skipping'
end

namespace :my_parallel do
  # NOTE: 必要な個所のみ抜き出す
  # https://github.com/grosser/parallel_tests/blob/8129c4c6feb6db7f94de144b995ea2a42aae0af5/lib/parallel_tests/tasks.rb#L258C1-L266C8
  task :spec, [:count, :pattern, :options, :pass_through] do |_t, args|
    # 商用環境テーブルのマイグレーションはチェックしない
    # ParallelTests::Tasks.check_for_pending_migrations
    ParallelTests::Tasks.load_lib
    command = ParallelTests::Tasks.build_run_command("spec", args)

    abort unless system(*command) # allow to chain tasks e.g. rake parallel:spec parallel:features
  end
end

CIを修正する(buildspec-ci.yml)

AWS CodeBuildを使用しているので、次のようなymlを用意しました。

phases:
  pre_build:
    commands:
      # MySQL 8 だと_utf8mb4 というsuffixがついてしまうので、それを削除する
      - sed -i 's#_utf8mb4\\\\'\''_\\\\'\''#'\''_'\''#g' db/schema.rb
      # スキーマの削除・作成・db/schema.rbの反映
      - docker-compose run --rm api rails parallel:drop parallel:create parallel:load_schema
  build:
    commands:
      # 自作rakeでのテスト実行
      - docker-compose run --rm api rails my_parallel:spec

プロセス数は指定したイメージのCPU数に依存するので、もし指定したCPU数よりも少ないプロセスで動かしたい場合は環境変数で指定してください。ローカルPCで挙動確認すると、20並列で動いてしまって期待の速度が出なかったので制御していました。

env:
  variables:
    PARALLEL_TEST_PROCESSORS: 4

結果

もともと、30分の内訳としては次のとおりでした。

  • 起動準備
    • 2分
  • PRE_BUILD
    • 5分弱
  • BUILD
    • 23分

テストを並列化した結果、25分になり、内訳は次のとおりでした。

  • 起動準備
    • 2分
  • PRE_BUILD
    • 5分弱
  • BUILD
    • 18分

ここまではコンピューティングが3GBメモリ2vCPUでしたが、7GBメモリ4vCPUに変更した結果。16分になり、内訳は次のとおりでした。

  • 起動準備
    • 2分
  • PRE_BUILD
    • 5分強
  • BUILD
    • 9分

最後に15GBメモリ8vCPUまで性能を上げて、12分になり、内訳は次のとおりでした。

  • 起動準備
    • 1分40秒
  • PRE_BUILD
    • 5分強
  • BUILD
    • 5分弱

AWSが用意するコンピューティングとのバランスを考えたときに、並列化によるこれ以上のメリットはないと考えてここで終了しています。

ソースコード

なし

終わりに

イメージ作成したり、DBを用意したりするPRE_BUILDが測定前に想像していなかったほど重く、ここを改善しないとこれ以上の高速化は見込めませんでした。

ビルドをDocker Build Cloudに任せたり、DBを作ったイメージごとsaveして流用する等々の考えもありましたが…。複雑になる上、高速化に貢献するか微妙なラインだったので、検証はしていません。

EC2だけでなく、Lambdaをうまく組み合わせたら早いとかそういう話があればぜひ聞きたいです。

参考情報

https://www.publickey1.jp/blog/24/docker40docker_build_cloudappleaws_graviton.html