CircleCI 2.0でだいぶテストが速くなったものの、1回のテストが20分くらいかかっているので、もっと速くしたいなぁと思っていました。お金を払えば並列化は簡単にできるのですが、CircleCIの並列化にも今のところ上限があり、1度のテストで16コンテナまでしか使えません(例え20コンテナ契約していたとしても)。しかし、CircleCIの1コンテナには、2CPU 4GBのメモリがあります(デフォルトでは)。
そこで目をつけたのが、gem parallel_testsです。
parallel_testsとは?
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
これはローカルでテストを実行するための設定になります。CircleCIで動かす場合は、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%程度スピードアップするかと思います(絶対ではありません)。
CircleCI 2.0でparallel_testsを動かす
さて、上記を踏まえて、CircleCI 2.0でparallel_testsを動かしてみましょう。実際は動かすのにめちゃくちゃハマったので、みなさんが二の轍を踏まないようにするためにハマったポイントを書いておきます。
ハマりポイント
並列数を省略すると動かない!
今までの説明では、「CPU数だけ並列実行します」と書いていました。放っておいたら、Dockerコンテナに割り当てられているCPU数で並列実行してくれるだろう、と安易に考えていたのですが、いざ実行してみると36並列で実行されました。それもテストのファイルが分割されて36個になっていたからで、もしかしたらファイル数がそれ以上あったら、もっと分割されるかもしれません。これは恐らくホストOSのCPU数を取得してしまっているのだと思います。そして、そんなに並列にした状態でrake parallel:create
を実行すると、postgresqlのDockerコンテナが落ちます。結果、テストは実行できません。
よって、CircleCI 2.0でparallel_testsを動かす場合は、並列数を指定しましょう。
parallel:prepareは使えない
ローカルの説明でparallel:load_structure
を使うように書いていたので、はまらないかもしれません。最初、DBのセットアップに以下の順番で処理を呼んでいました。
- rake parallel:create
- rake db:migrate
- rake parallel:prepare
これは、structure.sqlをgitで管理していないため、一旦db:migrateで生成した後、parallel:prepareでDBを再生成してschemaをロードしようとしたのですが、db:migrate
で使用したDBが使用中と言われてしまい、dropできずに落ちました。なので、load_structureを使うように修正しました。
rake parallel:specは使えない
CircleCI自身の並列化によって、specファイルのリストを引数で取得することができますが、rake parallel:spec
は、テスト対象がパターンでしか認識できません(spec/modelsのようなディレクトリ指定とか)。そのため、引数にspecファイルのリストを渡しても無視して全部のテストを実行します。
これは、直でparallel_rspecというコマンドを呼ぶようにすれば、それはspecファイルのリストを受け取ることができるので、それを呼び出します。
ハマりポイントを回避した.circleci/config.yml(の一部)
以上を踏まえて、書き直したのが以下のようになります。config.ymlを全部載せるのは長いので、要点部分だけ載せます。
肝は、PARALLEL_TESTS_CONCURRENCY
という環境変数を定義することです。
また、CircleCIのテストファイルの分割を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
を使う方法です。CircleCI 2.0で、parallel_testsとknapsack_proを合わせて使ったら、18分かかっていたテストが8分になりました。これについては別の記事で導入方法を書こうと思います。お楽しみに。