あるプロジェクトでCIに25〜30分かかっていたのを10分切るぐらいに高速化できたので、やったことを細かく分けて書き出します。
そもそも
そのプロジェクトではCircleCIで4コンテナ使って40分ぐらいかかっていましたが、AWS CodeBuildで10並列にして20分ぐらいに高速化することができていました。
ただ、db:migrateやbundle install, assets:precompileなどの環境準備の状態によって時間が伸びるケースがあり、10並列以上に増やしても分単位課金のCodeBuild料金が高くなりやすい傾向でした。
そこで、環境準備部分をCircleCI側に寄せ、CodeBuildはrspec実行のみで並列数を増やしやすくすることができるように改修しました。
やったこと
この節では、そこそこ大きなCircleCIの設定ファイルから、Tipsになりそうなところを抜き出して説明してあります。
利用したサービス構成とすべてが組み合わさった設定ファイル類は、末尾に載せました。
1. checkoutにcacheを効かせる
checkoutでCircleCI上にリポジトリをクローン(単純なgit cloneではなくできるだけ転送量減らそうとはしてる)していますが、長く続いているプロジェクトだとローカル環境でgit cloneすると時間かかるなーって事があると思います。それはCircleCIも同じなので.git
以下のファイルをcacheするとただcheckoutするだけより早くなりました。
du -chs .git
でサイズを確認してみて大きいようなら導入してみるといいと思います。(そもそもリポジトリをきれいにするという方法もある気がします)
commands:
checkout_with_cache:
steps:
- restore_cache:
keys:
- git-cache-v1-{{ .Branch }}-{{ .Revision }}
- git-cache-v1-{{ .Branch }}
- git-cache-v1-master # <- default branch
- checkout
- save_cache:
key: git-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- .git
2. bundle installにcacheを効かせる
これはCircleCIのサンプルにもあります。
ただ、最後にworkflowを作る上で、restore_cacheするだけのときと、そのあとにbundle install, save_cacheをしたい場合で使い分けができるようにparametersで挙動を制御できるようにしました。
commands:
bundler:
parameters:
reload:
type: boolean
default: true
steps:
- restore_cache:
keys:
- gem-cache-v1-{{ .Branch }}-{{ .Revision }}
- gem-cache-v1-{{ .Branch }}
- gem-cache-v1-master # <- default branch
- when:
condition: << parameters.reload >>
steps:
- run:
name: bundle install
command: |
gem update bundler
bundle check --path=vendor/bundle || bundle install --path=vendor/bundle -j4
- save_cache:
key: gem-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- vendor/bundle
3. yarn installにcacheを効かせる
これはbundle installと同様です。
commands:
yarn:
parameters:
reload:
type: boolean
default: true
steps:
- restore_cache:
keys:
- yarn-cache-v1-{{ .Branch }}-{{ .Revision }}
- yarn-cache-v1-{{ .Branch }}
- yarn-cache-v1-master # <- default branch
- when:
condition: << parameters.reload >>
steps:
- run: yarn install --cache-folder ~/.cache/yarn
- save_cache:
key: yarn-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- node_modules
- ~/.cache/yarn
4. assets:precompileにcacheを効かせる
これもbundle installと同様です。
あとは、assetsの設定に動的コンパイルOFFと、単一ファイル化ONを追加しました。
commands:
assets:
parameters:
reload:
type: boolean
default: true
steps:
- restore_cache:
keys:
- assets-cache-v1-{{ .Branch }}-{{ .Revision }}
- assets-cache-v1-{{ .Branch }}
- assets-cache-v1-master # <- defautl branch
- when:
condition: << parameters.reload >>
steps:
- run: RAILS_ENV=test CI=true bundle exec rails assets:clean assets:precompile
- save_cache:
key: assets-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- public/assets
- public/packs-test
- tmp/cache/assets
- tmp/cache/webpacker
Rails.application.configure do
...
if ENV["CI"] == "true"
config.assets.compile = false
config.assets.compress = true
end
end
5. CircleCI上で準備した環境をS3にアップロードする
rspecの実行以外はCircleCIに寄せたいので、準備した環境をtar.gzに圧縮してS3にアップロードする仕組みです。
.tarignore
ファイルは.gitignore
や.dockerignore
と同じような役割の独自ファイルです。
jobs:
code_build:
working_directory: ~/app
docker:
- image: circleci/ruby:2.5.3-node
steps:
- checkout_with_cache # <- checkout
- bundler # <- bundle install
- yarn # <- yarn install
- assets # <- assets:precompile
- run: sudo apt install pigz # <- マルチコアでtarコマンドを使えるようにするライブラリ
- run:
name: Setup CodeBuild sources
command: |
mkdir -p ~/cache
tar -c -I pigz -f ~/cache/src.tar.gz -X .circleci/.tarignore .
bundle exec rake code_build:setup
./.bundle
./.git
./log/*.log
./node_modules
./tmp
namespace :code_build do
require "aws-sdk-s3"
desc "Setup CodeBuild"
task :setup do
object = Aws::S3::Object.new(bucket_name: "**BUCKET_NAME**", key: "cache/#{ENV["CIRCLE_WORKFLOW_ID"]}.tar.gz")
object.upload_file "#{Dir.home}/cache/src.tar.gz"
end
end
6. specファイルを実績時間で分割できるようにtest metadataを読み込む
CircleCIには並列でテスト実行できるように、テストファイル群を並列数で分割するコマンドが組み込まれています。(Running Tests in Parallel)
分割モードには、以前のテスト結果の時間データを元に分割するモードがあるのでそれが使えるように以前のテスト結果を読み込む仕組みを作りました。
このコマンドはかなりトリッキーな仕組みになっていて、CircleCIのジョブがsuccessで完了したあとに取得する必要があるため、workflowを駆使しています。
workflows:
ci_flow:
jobs:
- codebuild
- save_test_metadata:
requires:
- code_build
jobs:
code_build:
... # <- 環境準備
- test_metadata: # <- test_metadataのcache読み込み
restore: true
- run: ... # <- CodeBuildの実行(後述(*1))
... # <- テスト結果の登録など
- run:
name: Cache build number
command: echo $CIRCLE_BUILD_NUM > .build_number # <- test metadata取得用のビルドID
- persist_to_workspace: # <- workflowの後続ジョブにデータ引き継ぎ
root: ~/app
paths:
- .build_number
save_test_metadata:
working_directory: ~/app
docker:
- image: circleci/ruby:2.5.3-node
steps:
- attach_workspace: # <- code_buildジョブ(先発ジョブ)のデータ引き継ぎ
at: ~/app
- test_metadata: # <- test metadataのcache化
save: true
commands:
test_metadata:
parameters:
restore:
type: boolean
default: false
save:
type: boolean
default: false
steps:
- when:
condition: << parameters.restore >>
steps:
- restore_cache:
keys:
- metadata-cache-v1-{{ .Branch }}-{{ .Revision }}
- metadata-cache-v1-{{ .Branch }}
- metadata-cache-v1-master # <- default branch
- metadata-cache-v1
- run:
name: Download test metadata # <- CircleCi導入直後などcacheが存在しないときは強制的に取り込めるようにする
command: |
if [ ! -e circle-test-results ]; then
mkdir circle-test-results
curl "https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/**ビルドID(ハードコーディング)**/tests?circle-token=$CIRCLE_API_TOKEN" > circle-test-results/results.json
fi
- when:
condition: << parameters.save >>
steps:
- run:
name: Get test metadata
command: |
mkdir circle-test-results
curl "https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/$(cat .build_number)/tests?circle-token=$CIRCLE_API_TOKEN" > circle-test-results/results.json
- save_cache:
key: metadata-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- circle-test-results
7. CodeBuildの実行を並列にリクエストする(*1)
6の説明で省略していたCodeBuildの実行指示部分です。
CodeBuildへの実行指示は、ビルドごとに流してほしいspecファイルを環境変数TESTFILESに設定してリクエストを出すようにしました。
並列数が増えるとリクエストするだけでもそこそこ時間がかかるのでrakeタスクの実行自体も並列でリクエストできるようにしてあります。
jobs:
code_build:
... # <- 環境準備、test_metadataのcache読み込み
- run:
name: Start CodeBuild(1~10) with async
environment:
REPO_URL: **ECR_IMAGE_NAME**:2.5.3 # <- CodeBuildで使うdocker image名
CIRCLE_INTERNAL_TASK_DATA: "." # <- test metadataの場所
NODE_START: 1
NODE_END: 10
command: bundle exec rake code_build:start # <- 20並列中の1~10の実行指示
background: true # <- このrunコマンドを非同期に実行する(完了を待たずに次のrunコマンドを実行する)
- run:
name: Start CodeBuild(11~20) with async
environment:
REPO_URL: **ECR_IMAGE_NAME**:2.5.3
CIRCLE_INTERNAL_TASK_DATA: "."
NODE_START: 11
NODE_END: 20
command: bundle exec rake code_build:start # <- 20並列中の11~20の実行指示
background: true
namespace :code_build do
require "aws-sdk-codebuild"
desc "Start CodeBuild"
task :start do
options = {
project_name: "**CODEBUILD_PROJECT_NAME**",
source_version: ENV["CIRCLE_WORKFLOW_ID"],
buildspec_override: File.read(".codebuild/buildspec.yml"),
image_override: "#{ENV["AWS_ECR_ACCOUNT_URL"]}/#{ENV["REPO_URL"]}",
compute_type_override: "BUILD_GENERAL1_SMALL",
timeout_in_minutes_override: 10,
artifacts_override: {
type: "S3",
location: "**BUCKET_NAME**",
path: "result/#{ENV["CIRCLE_WORKFLOW_ID"]}",
namespace_type: "NONE",
name: "results.zip",
packaging: "ZIP",
override_artifact_name: true,
encryption_disabled: true,
},
}
client = Aws::CodeBuild::Client.new
ENV["NODE_START"].upto(ENV["NODE_END"]) do |i|
files = `circleci tests glob "spec/**/*_spec.rb" | circleci tests split --total=#{ENV["CODEBUILD_NODE_TOTAL"]} --index=#{i} --split-by=timings --timings-type=filename`.gsub("\n", " ")
options[:environment_variables_override] = [
{ name: "TESTFILES", value: files, type: "PLAINTEXT" },
{ name: "CI", value: "true", type: "PLAINTEXT" },
{ name: "RAILS_ENV", value: "test", type: "PLAINTEXT" },
{ name: "CIRCLE_API_TOKEN", value: ENV["CIRCLE_API_TOKEN"], type: "PLAINTEXT" },
{ name: "CIRCLE_BUILD_NUM", value: ENV["CIRCLE_BUILD_NUM"], type: "PLAINTEXT" },
{ name: "CIRCLE_WORKFLOW_ID", value: ENV["CIRCLE_WORKFLOW_ID"], type: "PLAINTEXT" },
]
client.start_build(options)
end
end
end
利用したサービス構成
GitHubとCircleCI部分はおなじみの構成です。
CircleCIはまず最初に、CodeBuild上で実行できる環境の準備(git cloneやassets:precompileなど)を実行してできあがったものをS3にアップロードします。
次にCodeBuildに分割したテストファイルリストをリクエストし、CodeBuildがECRからdocker imageのpull、S3からソースコード類(構築済の環境)をダウンロードして、渡されたテストファイルリストにしたがってrspecを実行します。
(ECRに登録してあるdocker imageも別のタイミングでCircleCIから登録したCI用のベースイメージです。この部分の紹介は別途予定しています。)
CodeBuildで実行したrspecの結果はS3に保存され、CircleCIはその保存された結果ファイルが並列数分たまるのを待って、揃ったらダウンロードを始めるようになっています。
最後にテスト結果をCircleCI側で解析してCI完了という流れです。
結果
改修前は10並列なのでrspecの実行時間自体長いのですが、bundle installやassets:precompileなどの事前準備部分でも10分程度時間がかかっていました。
改修後は30並列にして1ビルドあたりのrspec実行時間自体が短くなっただけではなく、事前準備部分の時間も3分未満になってrspec以外の時間を削減できました。
CodeBuildの料金観点でも、CodeBuildの利用時間が減っているためそのまま効いてきています。
provisioningにかかっている時間がECRからdocker imageをpullしている部分で、ここを短くする方法があれば教えてほしいというのが残課題です。
before(10並列) | after(30並列) | |
---|---|---|
ビルド一覧 | ||
ビルド詳細 |
さいごに
CircleCIで事前準備して、CodeBuildの仕事を単純化することで並列数を上げやすくして時間短縮と料金削減することができました。
新しいCI方式で運用を始めて間もないのでrakeタスクなどガッツリ書いている状態なので、徐々にgem化やorb化していくのが次の目標です。
付録
設定ファイル類を載せました。
.circleci/config.yml
はとても長いです m(_ _)m
.circleci/config.yml
version: 2.1
ruby_version: &ruby_version
2.5.3
workflows:
ci_flow:
jobs:
- before_setup
- code_build:
repo: **ECR_IMAGE_NAME**
requires:
- before_setup
- save_test_metadata:
requires:
- code_build
jobs:
before_setup:
executor: ruby
steps:
- restore_workspace
code_build:
executor: ruby
parameters:
version:
type: string
default: *ruby_version
repo:
type: string
steps:
- run: sudo apt install pigz
- restore_workspace:
reload: false
- run:
name: Setup CodeBuild sources
command: |
mkdir -p ~/cache
tar -c -I pigz -f ~/cache/src.tar.gz -X .circleci/.tarignore .
bundle exec rake code_build:setup
- test_metadata:
restore: true
- run:
name: Start CodeBuild(1~10) with async
environment:
REPO_URL: << parameters.repo >>:<< parameters.version >>
CIRCLE_INTERNAL_TASK_DATA: "."
NODE_START: 1
NODE_END: 10
command: bundle exec rake code_build:start
background: true
- run:
name: Start CodeBuild(11~20) with async
environment:
REPO_URL: << parameters.repo >>:<< parameters.version >>
CIRCLE_INTERNAL_TASK_DATA: "."
NODE_START: 11
NODE_END: 20
command: bundle exec rake code_build:start
background: true
- run:
name: Wait CodeBuild jobs
command: bundle exec rake code_build:wait
no_output_timeout: 15m
- run:
name: Check CodeBuild artifacts
command: |
mkdir -p ~/tmp/test-results/rspec ~/log
unzip -q -d ~/tmp/test-results/rspec "tmp/$CIRCLE_WORKFLOW_ID/*.zip"
mv ~/tmp/test-results/rspec/*.log ~/log > /dev/null 2>&1 || true
# logファイルが存在したらrspecでfailureがあったと判断する
if [ -n "$(ls ~/log)" ]; then exit 1; fi
when: always
- store_test_results:
path: ~/tmp/test-results
- store_artifacts:
path: ~/log
destination: log
- run:
name: Cache build number
command: echo $CIRCLE_BUILD_NUM > .build_number
- persist_to_workspace:
root: ~/app
paths:
- .build_number
save_test_metadata:
executor: ruby
steps:
- attach_workspace:
at: ~/app
- test_metadata:
save: true
commands:
restore_workspace:
parameters:
reload:
type: boolean
default: true
bundle_path:
type: boolean
default: true
steps:
- checkout_with_cache
- bundler:
reload: << parameters.reload >>
- when:
condition: << parameters.bundle_path >>
steps:
- run:
name: Set bundle path
command: |
gem update bundler
bundle config --local path vendor/bundle
- yarn:
reload: << parameters.reload >>
- assets:
reload: << parameters.reload >>
checkout_with_cache:
steps:
- restore_cache:
keys:
- git-cache-v1-{{ .Branch }}-{{ .Revision }}
- git-cache-v1-{{ .Branch }}
- git-cache-v1-master
- checkout
- save_cache:
key: git-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- .git
bundler:
parameters:
reload:
type: boolean
default: true
steps:
- restore_cache:
keys:
- gem-cache-v1-{{ .Branch }}-{{ .Revision }}
- gem-cache-v1-{{ .Branch }}
- gem-cache-v1-master
- when:
condition: << parameters.reload >>
steps:
- run: |
gem update bundler
bundle check --path=vendor/bundle || bundle install --path=vendor/bundle -j4
- save_cache:
key: gem-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- vendor/bundle
yarn:
parameters:
reload:
type: boolean
default: true
steps:
- restore_cache:
keys:
- yarn-cache-v1-{{ .Branch }}-{{ .Revision }}
- yarn-cache-v1-{{ .Branch }}
- yarn-cache-v1-master
- when:
condition: << parameters.reload >>
steps:
- run: yarn install --cache-folder ~/.cache/yarn
- save_cache:
key: yarn-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- node_modules
- ~/.cache/yarn
assets:
parameters:
reload:
type: boolean
default: true
steps:
- restore_cache:
keys:
- assets-cache-v1-{{ .Branch }}-{{ .Revision }}
- assets-cache-v1-{{ .Branch }}
- assets-cache-v1-master
- when:
condition: << parameters.reload >>
steps:
- run: RAILS_ENV=test CI=true bundle exec rails assets:clean assets:precompile
- save_cache:
key: assets-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- public/assets
- public/packs-test
- tmp/cache/assets
- tmp/cache/webpacker
test_metadata:
parameters:
restore:
type: boolean
default: false
save:
type: boolean
default: false
steps:
- when:
condition: << parameters.restore >>
steps:
- restore_cache:
keys:
- metadata-cache-v1-{{ .Branch }}-{{ .Revision }}
- metadata-cache-v1-{{ .Branch }}
- metadata-cache-v1-master
- metadata-cache-v1
- run:
name: Download test metadata
command: |
if [ ! -e circle-test-results ]; then
mkdir circle-test-results
curl "https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/**ビルドID(ハードコーディング)**/tests?circle-token=$CIRCLE_API_TOKEN" > circle-test-results/results.json
fi
- when:
condition: << parameters.save >>
steps:
- run:
name: Get test metadata
command: |
mkdir circle-test-results
curl "https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/$(cat .build_number)/tests?circle-token=$CIRCLE_API_TOKEN" > circle-test-results/results.json
- save_cache:
key: metadata-cache-v1-{{ .Branch }}-{{ .Revision }}
paths:
- circle-test-results
executors:
ruby:
working_directory: ~/app
parameters:
version:
type: string
default: *ruby_version
docker:
- image: circleci/ruby:<< parameters.version >>-node
.circleci/.tarignore
./.bundle
./.git
./log/*.log
./node_modules
./tmp
.codebuild/buildspec.yml
version: 0.2
run-as: circleci
phases:
install:
commands:
- mkdir -p $CODEBUILD_SRC_DIR/cache
- |
ruby -r aws-sdk-s3 \
-e 'object = Aws::S3::Object.new(bucket_name:"**BUCKET_NAME**", key:"cache/#{ENV["CIRCLE_WORKFLOW_ID"]}.tar.gz")' \
-e 'object.download_file("#{ENV["CODEBUILD_SRC_DIR"]}/cache/src.tar.gz")'
- tar -x -I pigz -f $CODEBUILD_SRC_DIR/cache/src.tar.gz -C ~/app
finally:
- mkdir -p $CODEBUILD_SRC_DIR/tmp/test-results/rspec
- cd ~/app
pre_build:
commands:
- sudo service mysql start
- bundle config --local path vendor/bundle
- bundle exec rake circleci:watch &
- RAILS_ENV=test bundle exec rails db:migrate
build:
commands:
- |
bundle exec rspec $TESTFILES -f progress -f RspecJunitFormatter \
-o $CODEBUILD_SRC_DIR/tmp/test-results/rspec/${CODEBUILD_BUILD_ID#*:}.xml || \
cp log/test.log $CODEBUILD_SRC_DIR/tmp/test-results/rspec/${CODEBUILD_BUILD_ID#*:}.log
finally:
- touch $CODEBUILD_SRC_DIR/tmp/test-results/rspec/${CODEBUILD_BUILD_ID#*:}.xml
artifacts:
name: ${CODEBUILD_BUILD_ID#*:}.zip
discard-paths: yes
base-directory: tmp/test-results
files:
- "**/*"
codebuild.rake
namespace :code_build do
require "aws-sdk-codebuild"
require "aws-sdk-s3"
require "fileutils"
desc "Setup CodeBuild"
task :setup do
object = Aws::S3::Object.new(bucket_name: "**BUCKET_NAME**", key: "cache/#{ENV["CIRCLE_WORKFLOW_ID"]}.tar.gz")
object.upload_file "#{Dir.home}/cache/src.tar.gz"
end
desc "Start CodeBuild"
task :start do
options = {
project_name: "**CODEBUILD_PROJECT_NAME**",
source_version: ENV["CIRCLE_WORKFLOW_ID"],
buildspec_override: File.read(".codebuild/buildspec.yml"),
image_override: "#{ENV["AWS_ECR_ACCOUNT_URL"]}/#{ENV["REPO_URL"]}",
compute_type_override: "BUILD_GENERAL1_SMALL",
timeout_in_minutes_override: 10,
artifacts_override: {
type: "S3",
location: "**BUCKET_NAME**",
path: "result/#{ENV["CIRCLE_WORKFLOW_ID"]}",
namespace_type: "NONE",
name: "results.zip",
packaging: "ZIP",
override_artifact_name: true,
encryption_disabled: true,
},
}
client = Aws::CodeBuild::Client.new
ENV["NODE_START"].upto(ENV["NODE_END"]) do |i|
files = `circleci tests glob "spec/**/*_spec.rb" | circleci tests split --total=#{ENV["CODEBUILD_NODE_TOTAL"]} --index=#{i} --split-by=timings --timings-type=filename`.gsub("\n", " ")
options[:environment_variables_override] = [
{ name: "TESTFILES", value: files, type: "PLAINTEXT" },
{ name: "CI", value: "true", type: "PLAINTEXT" },
{ name: "RAILS_ENV", value: "test", type: "PLAINTEXT" },
{ name: "CIRCLE_API_TOKEN", value: ENV["CIRCLE_API_TOKEN"], type: "PLAINTEXT" },
{ name: "CIRCLE_BUILD_NUM", value: ENV["CIRCLE_BUILD_NUM"], type: "PLAINTEXT" },
{ name: "CIRCLE_WORKFLOW_ID", value: ENV["CIRCLE_WORKFLOW_ID"], type: "PLAINTEXT" },
]
client.start_build(options)
end
end
desc "Wait CodeBuild"
task :wait do
bucket = Aws::S3::Bucket.new(name: "**BUCKET_NAME**")
until bucket.objects(prefix: "result/#{ENV["CIRCLE_WORKFLOW_ID"]}").count == ENV["CODEBUILD_NODE_TOTAL"].to_i
sleep 10 # seconds
end
FileUtils.mkdir_p "tmp/#{ENV["CIRCLE_WORKFLOW_ID"]}"
bucket.objects(prefix: "result/#{ENV["CIRCLE_WORKFLOW_ID"]}").each do |object|
next if object.size == 0
object.download_file "tmp/#{ENV["CIRCLE_WORKFLOW_ID"]}/#{object.key.split("/").last}"
end
end
end
circleci.rake
namespace :circleci do
desc "Watch CircleCI status"
task :watch do
require "aws-sdk-codebuild"
require "circleci"
CircleCi.configure {|config| config.token = ENV["CIRCLE_API_TOKEN"] }
job = CircleCi::Build.new("**GITHUB_USERNAME**", "**GITHUB_REPOSITORYNAME**", "github", ENV["CIRCLE_BUILD_NUM"])
loop do
case job.get.body["status"]
when "success"
return exit 0
when "canceled", "timedout"
Aws::CodeBuild::Client.new.stop_build(id: ENV["CODEBUILD_BUILD_ID"])
return exit 1
when "failed", nil
return exit 1
end
sleep 10 # seconds
end
end
end