patorashのブログ

方向性はまだない

Circle CI 2.0でparallel_testsを使ってお金をかけずに高速化する

Circle CI 2.0でだいぶテストが速くなったものの、1回のテストが20分くらいかかっているので、もっと速くしたいなぁと思っていました。お金を払えば並列化は簡単にできるのですが、Circle CIの並列化にも今のところ上限があり、1度のテストで16コンテナまでしか使えません(例え20コンテナ契約していたとしても)。しかし、Circle CIの1コンテナには、2CPU 4GBのメモリがあります(デフォルトでは)。

そこで目をつけたのが、gem parallel_testsです。

parallel_testsとは?

github.com

parallel_testsは、マシンにあるCPUの数だけテストのプロセスを起動して並列実行するgemです。Hyper Threadingが有効な場合は、論理コア数で数えるので、CPU数*2のプロセスが起動することになります。以前はCIを使わずローカル環境でrspecを流していたのでよく使っていたのですが、最近はめっきり使っていませんでした。

ライバルとしては、test-queueというgemがあります。test-queueは私が既存のプロジェクトで動かそうとしたときがうまくいかなかったので、あんまり深追いしていませんのでここでは割愛します。

インストール

Gemfileに追加します。

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

そしてbundle installしときます。

設定

parallel_testsは1台のマシンで並列でテストを実行するため、それぞれが他のテストの影響を受けないようにするために、複数のデータベース、E2Eテストでは複数のブラウザを起動する必要があります。 parallel_tests関連の処理を起動すると、環境変数 TEST_ENV_NUMBERにそれぞれ番号が降られた状態でプロセスが起動するので、それを使って設定していきます。

config/database.yml

これはローカルでテストを実行するための設定になります。Circle CIで動かす場合は、database.circleci.ymlなどを作っている場合があると思うので、そちらにも設定を反映しておいてください。

test:
  database: project_name_test<%= ENV['TEST_ENV_NUMBER'] %>
spec/rails_helper.rb

私のやってるプロジェクトでは、E2EテストでヘッドレスブラウザにPhantomJSを、JavaScriptドライバにpoltergeistを利用しています。poltergeistを起動するときに、各プロセス毎にポートを分けたいので、設定に追記します。

Capybara.javascript_driver = :poltergeist
Capybara.register_driver :poltergeist do |app|
  Capybara::Poltergeist::Driver.new(app,
                                    # 並列数だけportを分ける
                                    port: 51674 + ENV['TEST_ENV_NUMBER'].to_i
  )
end
.rspec_parallel

RSpecを起動するときのオプションは.rspecに書くことができますが、parallel_testsを使う場合は、.rspec_parallelを利用します。テストの結果を取得するために–outの設定をそれぞれのプロセス毎に出力するようにします。

--profile 10
--format progress
--format RspecJunitFormatter
--out tmp/rspec<%= ENV['TEST_ENV_NUMBER'] %>.xml

ローカルで実行

まずはローカルで実行してみましょう。テスト実行時間が長いようだったら、ここは飛ばしてもらっても構いません。

データベースの準備

データベースの作成

以下を実行すると、CPUの数だけデータベースが作られます。

bundle exec rake parallel:create

CPUを使い切ると、ローカルで他の作業が全くできないくらい遅くなるので、敢えて数を減らすこともできます。例えば4CPUあるけれど、2CPUだけ使いたい場合は、以下のようにします。

bundle exec rake parallel:create[2]

以降のrake taskは、引数に並列数を設定可能なので、変えたい場合は上記と同じように記述してください。

schemaのロード

config/application.rbで、config.active_record.schema_format = :rubyならば、以下を。

bundle exe rake parallel:load_schema

config.active_record.schema_format = :sqlならば、以下を。

bundle exec rake parallel:load_structure

もし、schema.rbやstructure.sqlが存在しない場合は、最初にbundle exec rake db:migrateを実行してそれらのファイルを生成しておく必要があります。

やり直したい場合

マイグレーションしてテーブル定義が変わった場合などは、rake parallel:dropして上記を繰り返せばいいのですが、面倒だと思います。それらを一括でやってくれるrake taskがあります。

bundle exec rake parallel:prepare

rspecを実行する

以下を実行すれば、CPUの数だけ並列で動きます。

bundle exec rake parallel:spec

うまくいけば、テスト実行時間が半分〜50%程度スピードアップするかと思います(絶対ではありません)。

Circle CI 2.0でparallel_testsを動かす

さて、上記を踏まえて、Circle CI 2.0でparallel_testsを動かしてみましょう。実際は動かすのにめちゃくちゃハマったので、みなさんが二の轍を踏まないようにするためにハマったポイントを書いておきます。

ハマりポイント

並列数を省略すると動かない!

今までの説明では、「CPU数だけ並列実行します」と書いていました。放っておいたら、Dockerコンテナに割り当てられているCPU数で並列実行してくれるだろう、と安易に考えていたのですが、いざ実行してみると36並列で実行されました。それもテストのファイルが分割されて36個になっていたからで、もしかしたらファイル数がそれ以上あったら、もっと分割されるかもしれません。これは恐らくホストOSのCPU数を取得してしまっているのだと思います。そして、そんなに並列にした状態でrake parallel:createを実行すると、postgresqlのDockerコンテナが落ちます。結果、テストは実行できません。

よって、Circle CI 2.0でparallel_testsを動かす場合は、並列数を指定しましょう。

parallel:prepareは使えない

ローカルの説明でparallel:load_structureを使うように書いていたので、はまらないかもしれません。最初、DBのセットアップに以下の順番で処理を呼んでいました。

  1. rake parallel:create
  2. rake db:migrate
  3. rake parallel:prepare

これは、structure.sqlをgitで管理していないため、一旦db:migrateで生成した後、parallel:prepareでDBを再生成してschemaをロードしようとしたのですが、db:migrateで使用したDBが使用中と言われてしまい、dropできずに落ちました。なので、load_structureを使うように修正しました。

rake parallel:specは使えない

Circle CI自身の並列化によって、specファイルのリストを引数で取得することができますが、rake parallel:specは、テスト対象がパターンでしか認識できません(spec/modelsのようなディレクトリ指定とか)。そのため、引数にspecファイルのリストを渡しても無視して全部のテストを実行します。

これは、直でparallel_rspecというコマンドを呼ぶようにすれば、それはspecファイルのリストを受け取ることができるので、それを呼び出します。

ハマりポイントを回避した.circleci/config.yml(の一部)

以上を踏まえて、書き直したのが以下のようになります。config.ymlを全部載せるのは長いので、要点部分だけ載せます。

肝は、PARALLEL_TESTS_CONCURRENCYという環境変数を定義することです。 また、Circle CIのテストファイルの分割をtimingからfilesizeに変更しました。これは、timingでテスト実行結果を保存する処理が、parallel_testsで並列化されたことで結果がお互いに上書きしているのか、何度実行してもテスト実行時間の均等化がほとんど行われなかったからです。filesizeのほうがマシな結果になるんじゃないか?と思ったら、そうなったので、こうしています。

version: 2
jobs:
  build:
    working_directory: ~/project_name
    docker:
      - image: circleci/ruby:2.4.2-node
        environment:
          RAILS_ENV: test
          TZ: "/usr/share/zoneinfo/Asia/Tokyo"
          CIRCLE_TEST_REPORTS: /tmp/test-results
          PARALLEL_TESTS_CONCURRENCY: 2
      # Postgresql 9.5 + PostGIS2.3
      - image: circleci/postgres:9.5-alpine-postgis-ram
        environment:
          TZ: "/usr/share/zoneinfo/Asia/Tokyo"

    parallelism: 5
    steps:
      - checkout
      - run: cp config/{database_circleci,database}.yml

      # gemやnode_modulesをキャッシュする。
      # 今回は省略

      # Database setup
      - run:
          name: Database setup
          command: |
            bundle exec rake parallel:create[$PARALLEL_TESTS_CONCURRENCY]
            bundle exec rake db:migrate
            bundle exec rake parallel:load_structure[$PARALLEL_TESTS_CONCURRENCY]

      - run:
          name: Run rspec in parallel
          command: |
            bin/parallel_rspec -n $PARALLEL_TESTS_CONCURRENCY $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=filesize)
       # 以降は省略

実行結果

18〜23分くらいかかっていたテストが、14分くらいになりました。お金をかけずに20%以上の高速化ができました。ただ、テストファイルの配布状況次第では、処理の重いファイルが1つのコンテナに集中すると、遅くなる可能性もありますのでご注意ください。

気づき

parallel_tests、まだまだ使えるなぁと思えました。とはいえ、結構ハマったので、それは大変でしたが、テストが速くなると、チーム全体の時間の節約ができるので、やってみる価値はあると思います。

ちなみに、お金をかけてさらにテストを速くする方法があります。knapsack_proを使う方法です。Circle CI 2.0で、parallel_testsとknapsack_proを合わせて使ったら、18分かかっていたテストが8分になりました。これについては別の記事で導入方法を書こうと思います。お楽しみに。