patorashのブログ

方向性はまだない

CircleCI 2.0でparallel_testsとknapsack_proを使って爆速化

前回は、こんな記事を書きました。

patorash.hatenablog.com

チーム内で、「多少は速くなりましたよ」という報告をしていたところ、「でもknapsack_proを使ってテスト時間の均等化したほうがまだ高速だね」という話に。knapsack_proはお試しで使ったことはありましたが、確かに実行時間の均等化ができて、速くなります。

knapsack_proとparallel_testsの合わせ技ができたらいいのになぁ〜と思っていたら、なんとknapsack_proのサイトに連携方法が書いてありました。これは…いけるのではないか…。

knapsack_proとは?

knapsack_proはテストの実行時間の最適化を行える有料サービスです。有料サービスですが、14日間のお試し期間があります。このお試し期間がユニークで、knapsack_proを使った日をカウントして14日間になってます。実験して一旦やめて、数日後に実験しても2日しかカウントされません。素晴らしい配慮です。

Speed up your tests with optimal test suite parallelisation

knapsack_proがどういうふうにしてテストの実行時間を最適化しているかというと、

  1. ファイル毎のテストの実行時間をknapsack_proに保存
  2. テストの実行時間が均等になるようにテストファイルを割り振って配布

ということをしているんだと思います。

テストの実行方式について

テストの実行方式は、regularqueueがあります。regularは、一度にテストファイルのリストを割り振って実行させます。それに対してqueueは、テストが終わったノードがknapsack_proに対して次のテストを取得しにいく方式です。regularは、それぞれに一気に仕事を割り振るイメージ、queueは手が空いてる人に小出しに仕事を割り振るようなイメージです。

regularだと、早くテストが終わってもそのノードのテストはもう終わりなので、ノード毎にテストの実行時間がばらつく可能性があります(それでもランダムに割り振るよりはだいぶマシ)。queueだと、通信は都度発生するものの、テストファイルがなくなるまで割り振り続けるので、暇になるノードが発生する可能性が低くなります。queueのほうがオススメです。

knapsack_proの導入

knapsack_pro側

まずは knapsack_proにアカウント登録してください。 その後、プロジェクトを登録し、APIトークンを取得してください。

CircleCI側

knapsack_proのAPIトークンをCircleCIのプロジェクトの環境変数に登録します。環境変数名は、KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPECです。

Railsプロジェクト側

基本的には、githubに書いてある通りにやっていきます。

github.com

gemのインストール

Gemfileに追記します。

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

そして、bundle installを行います。

spec/spec_helper.rbに設定を追加

CircleCIでknapsack_proを使う場合、テスト結果の集計などを行うために色々やります。形式はrspec_junit_formatterを使うのを想定しています。parallel_testsと組み合わせて使うので、テスト結果のファイルを上書きしてしまわないようにするため、rspec#{ENV['TEST_ENV_NUMBER']}.xmlというふうにしてプロセス毎に分けて保存するようにしてみました。

rspec_helper.rbの先頭に追記します。

CircleCI 2.0だと、環境変数 CIRCLE_TEST_REPORTS は存在しないため、自分で定義する必要があるところに注意してください。

# @see https://github.com/KnapsackPro/rails-app-with-knapsack_pro/blob/master/spec/spec_helper.rb
require 'knapsack_pro'

KnapsackPro::Hooks::Queue.before_queue do |queue_id|
  print '-'*20
  print 'Before Queue Hook - run before test suite'
  print '-'*20
end

TMP_RSPEC_XML_REPORT = "tmp/test-reports/rspec#{ENV['TEST_ENV_NUMBER']}.xml"
# move results to FINAL_RSPEC_XML_REPORT so the results won't accumulate with duplicated xml tags in TMP_RSPEC_XML_REPORT
FINAL_RSPEC_XML_REPORT = "tmp/test-reports/rspec_final_results#{ENV['TEST_ENV_NUMBER']}.xml"

KnapsackPro::Hooks::Queue.after_subset_queue do |queue_id, subset_queue_id|
  if File.exist?(TMP_RSPEC_XML_REPORT)
    FileUtils.mv(TMP_RSPEC_XML_REPORT, FINAL_RSPEC_XML_REPORT)
  end
end

KnapsackPro::Hooks::Queue.after_queue do |queue_id|
  # Metadata collection
  # https://circleci.com/docs/1.0/test-metadata/#metadata-collection-in-custom-test-steps
  if File.exist?(FINAL_RSPEC_XML_REPORT) && ENV['CIRCLE_TEST_REPORTS']
    FileUtils.mkdir_p(ENV['CIRCLE_TEST_REPORTS']) unless FileTest.exist?(ENV['CIRCLE_TEST_REPORTS'])
    FileUtils.cp(FINAL_RSPEC_XML_REPORT, "#{ENV['CIRCLE_TEST_REPORTS']}/rspec#{ENV['TEST_ENV_NUMBER']}.xml")
  end

  print '-'*20
  print 'After Queue Hook - run after test suite'
  print '-'*20
end

KnapsackPro::Adapters::RSpecAdapter.bind

# 略
bin/parallel_tests を作成

これは以下のURLに書いてあるのとほぼ同じです。

https://github.com/KnapsackPro/knapsack_pro-ruby#how-to-run-knapsack_pro-with-parallel_tests-gem

やっていることは、

  1. KNAPSACK_PRO_CI_NODE_TOTALをparallel_testsの並列数を掛け算して上書き
  2. PARALLEL_TESTS_CONCURRENCY_INDEXにparallel_testsの並列化の何番目なのかを設定
  3. KNAPSACK_PRO_CI_NODE_INDEXに、knapsack_proの並列化の何番目なのかを上書き
  4. ちゃんと並列設定できているかをechoで表示
  5. knapsack_pro経由でrspecを実行

となります。

#!/bin/bash
# This file should be in bin/parallel_tests

# updates CI node total based on parallel_tests concurrency
KNAPSACK_PRO_CI_NODE_TOTAL=$(( $PARALLEL_TESTS_CONCURRENCY * $KNAPSACK_PRO_CI_NODE_TOTAL ))

if [ "$TEST_ENV_NUMBER" == "" ]; then
  PARALLEL_TESTS_CONCURRENCY_INDEX=0
else
  PARALLEL_TESTS_CONCURRENCY_INDEX=$(( $TEST_ENV_NUMBER - 1 ))
fi

KNAPSACK_PRO_CI_NODE_INDEX=$(( $PARALLEL_TESTS_CONCURRENCY_INDEX + ($PARALLEL_TESTS_CONCURRENCY * $KNAPSACK_PRO_CI_NODE_INDEX) ))

# logs info about ENVs to ensure everything works
echo KNAPSACK_PRO_CI_NODE_TOTAL=$KNAPSACK_PRO_CI_NODE_TOTAL KNAPSACK_PRO_CI_NODE_INDEX=$KNAPSACK_PRO_CI_NODE_INDEX PARALLEL_TESTS_CONCURRENCY=$PARALLEL_TESTS_CONCURRENCY

# you can customize your knapsack_pro command here to use regular or queue mode
#bundle exec rake "knapsack_pro:queue:rspec[--format progress --format RspecJunitFormatter --out tmp/test-reports/rspec${TEST_ENV_NUMBER}.xml]"
bundle exec rake "knapsack_pro:rspec[--format progress --format RspecJunitFormatter --out tmp/test-reports/rspec${TEST_ENV_NUMBER}.xml]"

違うところは、最後の2行と、そこで渡しているオプションです。

queueモードをコメントアウトして、regularモードでテストを行なっています。これは、初回はregularモードでテストを行い、テスト実行時間をknapsack_pro側に教える必要があるからです。ガイドに書いてありました(RSpecとCircleCIを選択すると最後の方に書いてあります)

docs.knapsackpro.com

まずはregularモードでテストが通るようになったのを確認した後、queueモードに移行しましょう。

.circleci/config.ymlの修正

前回の.circleci/config.ymlとの差分だけ載せます。

- run:
    name: Run rspec in parallel
    command: |
        KNAPSACK_PRO_CI_NODE_TOTAL=$CIRCLE_NODE_TOTAL \
        KNAPSACK_PRO_CI_NODE_INDEX=$CIRCLE_NODE_INDEX \
        KNAPSACK_PRO_LOG_LEVEL=info \
        bundle exec parallel_test -n $PARALLEL_TESTS_CONCURRENCY -e './bin/parallel_tests'

./bin/parallel_testsで使う環境変数を設定しながら、parallel_testでプロセス毎に./bin/parallel_testsを実行させています。環境変数は、CircleCIの全ノード数であるCIRCLE_NODE_TOTALと、何番目のノードなのかを示すCIRCLE_NODE_TOTALをそのまま渡しています。

CircleCIで実行

私の環境では、CircleCIのコンテナが5つ、parallel_testsの並列化がCPU2つなので2つに設定しているので、10並列になれば成功です。

では結果を見てみましょう!

f:id:patorash:20170922130705p:plain f:id:patorash:20170922130718p:plain f:id:patorash:20170922130730p:plain f:id:patorash:20170922130744p:plain f:id:patorash:20170922130750p:plain

KNAPSACK_PRO_CI_NODE_TOTALが10に、KNAPSACK_PRO_CI_NODE_INDEXが0,1,2,...,9まで設定されています。成功です!

regularモードでテストが通ったら、bin/parallel_testsを修正してqueueモードにして再びテストを実行しましょう。

# you can customize your knapsack_pro command here to use regular or queue mode
bundle exec rake "knapsack_pro:queue:rspec[--format progress --format RspecJunitFormatter --out tmp/test-reports/rspec${TEST_ENV_NUMBER}.xml]"
# bundle exec rake "knapsack_pro:rspec[--format progress --format RspecJunitFormatter --out tmp/test-reports/rspec${TEST_ENV_NUMBER}.xml]"

Before

以前は18分かかると書いていましたが、docker imageの最適化など行なったのと、CircleCI側でのテストの均等化がまぁまぁうまくいくようになっていたので、parallel_testsとknapsack_proを導入する前でもテストの結果は14分台になっていました。これはかなり調子がいいときの結果です。

f:id:patorash:20170922131731p:plain

では、parallel_testsとknapsack_proを使って、queueモードで実行したらどうなるか…。

After

f:id:patorash:20170922132055p:plain

な、なんと7分台で終わってしまいました!!しかもテスト時間の最適化具合がすごい!!ほぼ横一線です。parallel_testsすごい!knapsack_proすごい!ほぼ倍の速さでテストが終わりました!

気づき

CircleCIのコンテナ数を増やすにしても上限がありますし、無闇にコンテナ数を増やすよりは、knapsack_proを使ってテスト時間の最適化を行なったほうがコストパフォーマンス的にいいかもしれません。思った以上に効果が出てよかったです!knapsack_proはまだお試し期間で利用中ですが、契約したほうがいいなと実感できました。

追記(2017-10-02)

正式に弊社で採用することが決定しました。よかった!