Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

CircleCIのPerformance Planでテスト時間を半減させた

こんにちはスタディプラス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の適用ために変更した内容を説明します。

  1. CircleCIの管理画面からプランを変更
  2. 以下を実行できるよう、.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がコメントをしてくれます

f:id:yo-shimada:20191010164247p:plain

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通知 f:id:yo-shimada:20191010171900p:plain

閾値を下回った場合のSlack通知 f:id:yo-shimada:20191010172006p:plain

カバレッジの結果画面 f:id:yo-shimada:20191010172434p:plain

CircleCIのcoverageのジョブ画面のArtifactsからも遷移できます f:id:yo-shimada:20191010172516p:plain

ワークフロー

buildジョブを実行後に、rubocopcoverageのジョブを実行するように設定します。

...
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) f:id:yo-shimada:20191010172710p:plain

rubocopとcoverageのジョブを分割する前(トータル時間 04:30) f:id:yo-shimada:20191010173603p:plain

ジョブを分割して並列実行(トータル時間 04:13) f:id:yo-shimada:20191010172943p:plain

カバレッジ結果をまとめるところで苦戦しましたが、並列実行自体することはすぐ試すことができ簡単に動作を確認する事が出来るので、CIの実行時間やビルド待ちで悩んでいる方は是非一度トライアルをされる事をお薦めします。