こんにちはスタディプラスCTOの島田です。
今回はスタディプラスでCircleCIのPerformance Planを導入し、テストの実行時間を半分以下にした内容を書きます。
Performance Planについて
CircleCIのPerformance Planとは、処理能力を最適化しパォーマンスを最大化するためのプランです。Performance Planを用いるとビルド時間の短縮やビルドのキュー待ち時間を減らす事が出来ます。
料金体系は以下のようになります。
1アクティブユーザー1名あたり$15/月 + $0.06/100クレジット
導入時点でのスタディプラスの過去30日間の利用状況から費用を見積もったところ、
- アクティブユーザー数が19名=$285
- ビルドに要した計算リソースに対する従量課金分 約$250
という内訳で、概算が$535/月となりました。
それまでの料金が、
$349/月(Linux:コンテナ x 3 + Mac OS:STARTUP プラン)
だったので、若干高くなる見積もりとなりました。
ただPerformance Planの場合にはデフォルトで40コンテナを割り当てられ、キューの待ち時間(それまで多い月には69時間/月の待ち時間が発生していました)の減少が想定出来たので、そのコストに換算すれば十分ペイ出来るのではと思い2週間のトライアルから始めてみました。
スタディプラスでのCircleCI利用状況
今回は学習SNSアプリ「Studyplus」のAPIアプリケーション(コードネーム「steak」)を対象に説明をしていきたいと思います。
steakのワークフロー概要
steakはRuby on Railsのアプリケーションで、CIにて以下を実行していました。
- RSpecによるテストの実行
- RuboCopによる静的コード解析 + PullRequestにコメント
- simplecovによるカバレッジ計測結果をSlackへ通知
課題
- 1回のワークフローの時間が長い(8~10分ほどかかる)
- CIを同時に実行するとコンテナが足りなくなり、usageキューがしばしば発生する
Performance Planの適用
ここからPerformance Planの適用ために変更した内容を説明します。
- CircleCIの管理画面からプランを変更
- 以下を実行できるよう、
.circleci/config.yml
を変更- RSpecの並列実行化
- Workflowを分割してRuboCopとsimplecov同時実行できるように修正
circleci/config.yml
実行環境の定義
まずはexecutorsに実行環境(docker
)を定義します。
resource_classはlarge
も試してみましたが、steakのテストの場合にはmedium
でも実行時間に差はありませんでした。
version: 2.1 executors: default: working_directory: ~/steak docker: - image: circleci/ruby:2.6.3 environment: RAILS_ENV: test CONFIG_EAGER_LOAD: true BUNDLE_APP_CONFIG: .bundle - image: circleci/mysql:5.7-ram environment: MYSQL_ALLOW_EMPTY_PASSWORD: 1 MYSQL_DATABASE: stappy_api_test MYSQL_HOST: 127.0.0.1 MYSQL_USER: root command: mysqld --sql-mode=NO_ZERO_IN_DATE - image: redis:3.2.9 resource_class: medium
コマンド
bundle install
等の複数回利用するコマンドを定義する
commands: bundle_install: steps: - run: name: bundle install command: | bundle install -j4 --path vendor/bundle --clean restore_bundle_cache: steps: - restore_cache: keys: - v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }} - v2-bundler-{{ arch }}- restore_repo_cache: steps: - restore_cache: key: v2-1-repo-{{ .Environment.CIRCLE_SHA1 }} setup_repo_bundle: steps: - restore_repo_cache - restore_bundle_cache - bundle_install
ジョブ
build
ジョブについて
並列で実行するようにparallelismを設定します。 ここでは4並列で設定します。
jobs: build: executor: default parallelism: 4 steps: ...
キャッシュ、bundle install
やDatabaseのセットアップ
steps: - checkout - save_cache: key: v2-1-repo-{{ .Environment.CIRCLE_SHA1 }} paths: - ~/steak - restore_bundle_cache - bundle_install - save_cache: key: v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }} paths: - ~/steak/vendor/bundle - run: name: Prepare Database command: | bundle exec rake db:create bin/ridgepole -c config/ridgepole.yml -f db/Schemafile --apply ...
RSpecの並列実行とカバレッジの計測をします。
RSpecの並列実行についてはCircleCI CLIを利用してファイル名で分割して実行しています。
カバレッジの結果についてはCIRCLE_NODE_INDEX
に実行コンテナの番号が渡ってくるので、coverage/.resultset.json
をそれぞれのコンテナで別なファイルになるようにリネームしてコピーします。(ファイル名が同じだと内容が上書きされ、並列実行の最後の結果しか残らないため)
また、後続のWorkflowで共有できるようにpersist_to_workspace
でパスを設定します。
steps: ... - run: name: RSpec command: | circleci tests glob 'spec/**/*_spec.*' \ | circleci tests split --split-by=timings --timings-type=filename \ | tee -a /dev/stderr \ | xargs bundle exec rspec \ --format progress --format RspecJunitFormatter -o tmp/rspec/result.xml - store_test_results: path: tmp/rspec - run: name: Stash Coverage Results command: | mkdir coverage_results cp -R coverage/.resultset.json coverage_results/.resultset-${CIRCLE_NODE_INDEX}.json - persist_to_workspace: root: . paths: - coverage_results ...
rubocop
ジョブについて
RuboCopで静的解析し、結果をSaddlerでPull Requestにコメントします。
jobs: build: ... rubocop: executor: default steps: - setup_repo_bundle - run: name: RuboCop command: | script/run-rubocop.sh when: always
script/run-rubocop.sh
#!/bin/bash -x rubocop_config_changed=$(git diff --name-only origin/master | grep -i rubocop) if [ "${CIRCLE_BRANCH}" == "master" ] || [ -n "$rubocop_config_changed" ] ; then warn=$(bundle exec rubocop) detected=$(echo "$warn" | grep "Offenses:") if [ -n "$detected" ]; then exit 1 fi else warn=$(git diff -z --name-only origin/master --diff-filter=AMRC \ | xargs -0 --no-run-if-empty bundle exec rubocop --force-exclusion) detected=$(echo "$warn" | grep "Offenses:") if [ -n "$detected" ]; then ruby script/check_pull_request.rb \ && echo "$warn" \ | bundle exec rubocop \ --require rubocop/formatter/checkstyle_formatter \ --format RuboCop::Formatter::CheckstyleFormatter \ | bundle exec checkstyle_filter-git diff origin/master \ | bundle exec saddler report \ --require saddler/reporter/github \ --reporter Saddler::Reporter::Github::PullRequestReviewComment exit 1 fi fi exit 0
こんな感じでstudyplus-botがコメントをしてくれます
coverageジョブについて
並列実行したRSpecで出力されたカバレッジの結果(coverage_results/.resultset-${CIRCLE_NODE_INDEX}.json
)をマージして、カバレッジの割合をSlackで通知します。
カバレッジの結果を閲覧できるようにstore_artifacts
で設定しておきます。
jobs: build: ... rubocop: ... coverage: executor: default steps: - attach_workspace: at: . - setup_repo_bundle - run: name: Merge and check coverage command: | bundle exec rake simplecov:report_coverage - store_artifacts: path: ~/steak/coverage destination: coverage - run: name: notify result command: ruby script/post_coverage_to_slack.rb
カバレッジ結果をマージするRakeタスク(ヘルパーを呼び出しているだけ)
lib/tasks/simplecov_parallel.rake
# frozen_string_literal: true if Rails.env.test? require_relative "../../spec/simplecov_helper" namespace :simplecov do desc "merge_results" task report_coverage: :environment do SimpleCovHelper.report_coverage end end end
カバレッジ結果マージヘルパー
spec/simplecov_helper.rb
# frozen_string_literal: true # spec/simplecov_helper.rb require 'active_support/inflector' require "simplecov" class SimpleCovHelper def self.report_coverage(base_dir: "./coverage_results") SimpleCov.start 'rails' do add_filter '/vendor/' merge_timeout(3600) end new(base_dir: base_dir).merge_results end attr_reader :base_dir def initialize(base_dir:) @base_dir = base_dir end def all_results Dir["#{base_dir}/.resultset*.json"] end def merge_results results = all_results.map { |file| SimpleCov::Result.from_hash(JSON.parse(File.read(file))) } SimpleCov::ResultMerger.merge_results(*results).tap do |result| SimpleCov::ResultMerger.store_result(result) end end end
テストカバレッジの結果をSlack通知するスクリプトです。
steakではカバレッジ80%を維持するために閾値を下回った場合にメッセージを変えています。
また、通知のメッセージからカバレッジの結果を確認できるようにartifactsへのパスを入れています。
script/post_coverage_to_slack.rb
require 'net/http' require 'uri' require 'json' CIRCLE_NODE_INDEX = ENV['CIRCLE_NODE_INDEX'] CIRCLE_BUILD_NUM = ENV['CIRCLE_BUILD_NUM'] CIRCLE_ARTIFACT_ID = xxxxxxxx threshold = 80.0 coverage = `cat ~/steak/coverage/.last_run.json | jq -r '.result.covered_percent'`.chomp text = if coverage.to_f > threshold ":white_check_mark: Test Coverage: #{coverage} %. :circleci: coverage report: <https://#{CIRCLE_BUILD_NUM}-#{CIRCLE_ARTIFACT_ID}-gh.circle-artifacts.com/#{CIRCLE_NODE_INDEX}/coverage/index.html|open report :earth_asia: >(build #{CIRCLE_BUILD_NUM})" else ":x: Test Coverage: #{coverage} %. ( :cop: 基準値は #{threshold} % です。テストコードが足りていません) :circleci: coverage report: <https://#{CIRCLE_BUILD_NUM}-#{CIRCLE_ARTIFACT_ID}-gh.circle-artifacts.com/#{CIRCLE_NODE_INDEX}/coverage/index.html|open report :earth_asia: >(build #{CIRCLE_BUILD_NUM})" end uri = URI.parse(ENV['SLACK_TEST_HOOK']) params = { text: text } http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true http.start do request = Net::HTTP::Post.new(uri.path) request.set_form_data(payload: params.to_json) http.request(request) end
通常のSlack通知
閾値を下回った場合のSlack通知
カバレッジの結果画面
CircleCIのcoverageのジョブ画面のArtifactsからも遷移できます
ワークフロー
build
ジョブを実行後に、rubocop
とcoverage
のジョブを実行するように設定します。
... workflows: build_and_test: jobs: - build: context: studyplus-bot - rubocop: context: studyplus-bot requires: - build - coverage: context: studyplus-bot requires: - build
まとめ
トライアル期間中に試行錯誤した結果、ビルド時間が半分以下(3~5分)になり無駄なストレスが軽減されたので、そのまま導入する事を決めました。 以下が導入前後のWorkflowsのキャプチャになります。
Performance Plan導入前(トータル時間 08:25)
rubocopとcoverageのジョブを分割する前(トータル時間 04:30)
ジョブを分割して並列実行(トータル時間 04:13)
カバレッジ結果をまとめるところで苦戦しましたが、並列実行自体することはすぐ試すことができ簡単に動作を確認する事が出来るので、CIの実行時間やビルド待ちで悩んでいる方は是非一度トライアルをされる事をお薦めします。