はじめに
こんにちは、テクノロジー戦略本部、開発1部新卒の尾沼です。
こちらは バイセルテクノロジーズ Advent Calendar 2021 の 5日目の記事です。
前日の記事はなおとさんの 「スクラムを始める時にやっていること」でした。
私は内定者インターン時代から弊社の在庫管理システム「AXIS」にプログラマーとして参画しています。
AXISでは開発初期よりCircleCIを用いてCI/CD環境を構築していましたが、最近AXISのサーバー部分のCI/CDをGitHub Actionsに移行させました。今回は移行にあたっての振り返りをしてみたいと思います。
AXISのCI/CDの課題
AXISのCI/CDの課題は「同時ジョブ実行数」と「1ジョブにかかる時間の長さ」でした。
同時ジョブ実行数
公式に書いてあるように、CircleCIには様々なプランがあります。その中でもAXISではインスタンス数単位での契約(有料インスタンス4台 + 無料インスタンス1台)でCircleCIを利用していました。CircleCIでは基本的には1インスタンスあたり1ジョブの実行となるので、「インスタンス数」単位での契約だと同時に走らせることのできるジョブは「インスタンスの数」に比例します。
AXIS開発グループには現在3人ほどのプログラマーが在籍しているため、5台のCircleCIインスタンスでは力不足感が否めない状況でした。
そこで、ジョブの同時実行に、より柔軟に対応できるGitHub Actionsへの移行が案として出てきました。
1ジョブにかかる時間の長さ
AXISでは兼ねてよりジョブの実行時間の長さが課題になっていました。ここでいうジョブの実行時間というのは、ジョブが発火してから終わるまでの時間です。もちろん、ジョブによってかかる時間に差異はあるものの、最も実行頻度の高い「LintとTest」のジョブはおよそ30分かかっていました。先述した5台のインスタンスを3人ほどでシェアするという状況下に加え、1ジョブ実行時間が長いというのは、順番待ちの時間が長く、開発メンバーからも非効率だという声が挙がっていました。
そこで、ジョブの短時間化への着手が案として出てきました。
なぜGitHub Actionsを選んだか
もちろん、CircleCIのプランを従量課金に変更する等で、同時実行可能ジョブ数を増やすという選択肢もありましたが、
- 弊社で新規開発をする際はGitHub Actionsを採用するケースが増えてきた
- .github/workflows ディレクトリ内にYAML形式のファイルを作成するだけでセッティングができるため、導入が非常に容易である
- GitHubアカウント単位でジョブの並列実行可能数が決まっているため、その範囲内であればYAML形式ファイルの記述を変えるだけで並列実行のジョブ数を容易に増やせる
といった理由があり、GitHub Actionsに移行させることにしました。
改修ポイント
CircleCIの記述をGitHub Actions向けに最適化する際の変更差分は多くて挙げきれないため、今回は特に個人的に工夫した箇所について紹介したいと思います。
同時ジョブ実行数を増やす
これは今回の移行の目的であるので達成しなければなりません。
そもそもGitHub Actionsはデフォルトでジョブを並列実行してくれます。ジョブの実行に順番を付けたい場合は以下のようにneedsオプションをつけてあげればOKです。
needs: [job名]
しかし、同じジョブをn個並列実行したい場合などでは上記とは別のやり方を取る必要があります。
公式 に書いてある通り、GitHub Actionsにはビルドマトリックスという機能があります。これは一言で表すと「複数条件でジョブを並列実行できる機能」です。この機能を用いて以下のように定義してあげることで、柔軟に同じジョブの並列実行数を変化させることができました。
strategy:
matrix:
parallelism: [8]
id: [0,1,2,3,4,5,6,7]
注意点ですが、GitHub Actionsはビルドマトリックスを用いることで簡単に並列数を増やせますが、当然無限ではありません。利用しているGitHubのプランによって上限並列数は変わるため、慎重に並列数を決める必要があります。詳しくは以下をご覧ください。
テストを分割してみる
AXISではテストフレームワークにRSpecを利用しており、大量のSpecファイルを有しています。そしてCircleCI上でテストを実行する際は以下のような工夫をしてテストの待ち時間を短縮していました。
- 対象テストファイルをいくつかのインスタンスに分配して、インスタンス単位で並列実行する。
- gem 'parallel_tests' を用いてマルチコアCPUで並列テスト実行する
テストファイルの分配に関しては、CircleCIでは公式でテストファイルを良い感じに分配するcircleci tests split
コマンドが用意されており、AXISでも以下のようにSpecを分割実行していました。
bundle exec parallel_rspec -n $PARALLEL_TESTS_CONCURRENCY $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=filesize)
問題となったのは、GItHub Actionsにはcircleci tests split
コマンドに相当するコマンドが無いということでした。
RSpecは実行するファイルパスをコマンドライン引数で渡してあげればOKなので、今回はSpecファイルを指定した数分に分割してファイルパスを出力するRubyファイルをspec/spec_file_splitter.rb
として作り、circleci tests split
コマンドを代用することにしました。以下はspec/spec_file_splitter.rb
の例です。
#!/usr/bin/env ruby
# frozen_string_literal: true
# GitHub Actions用にspecのファイルを分割する処理
require 'optparse'
# 初期値を設定しておく
parallelism = 8
id = 0
# コマンドライン引数からparallelismの数と、何個目のGitHub Actionsのインスタンスかを示すidを受け取る
OptionParser.new do |opt|
opt.on('-p', '--parallelism value', Integer) do |passed_parallelism| parallelism = passed_parallelism end
opt.on('-i', '--id value', Integer) do |passed_id| id = passed_id end
opt.parse!(ARGV)
end
SpecFile = Struct.new(:filepath, :line_count)
# 実行に時間がかかる重いspec
heavy_spec_files = [
"実行時間の長いSpecファイルのパス",
"実行時間の長いSpecファイルのパス",
...
]
# 重いspec以外をまとめる
spec_files = Dir.glob('spec/**/*_spec.rb').reject { |filepath| heavy_spec_files.include?(filepath) }.map { |filepath|
# fileを少しずつメモリ展開して行数を取得する
f = File.open(filepath, 'r')
line_count = 0
line_count += 1 while f.gets
SpecFile.new(filepath, line_count)
}
# fileの行数順に並び替える。同じ行数の時はindex順に並び替えることで安定ソートにする
spec_files = spec_files.sort_by.with_index { |file, i| [file.line_count, i] }.reverse
# parallelism - 1数分の箱を用意する。
# 1台は重いspec処理マシンとして使うため。
spec_boxes = []
(parallelism - 1).times do |i| spec_boxes[i] = [] end
spec_files.each do |spec_file|
# spec_boxの中にある箱を、箱の中のfileの行数が少ない順に並べて、一番行数が少ない箱を取得する
min_size_box = spec_boxes.sort_by.with_index { |spec_box, i| [spec_box.sum(&:line_count), i] }[0]
# 一番行数が少ない箱にfileを追加する
min_size_box << spec_file
end
# file pathを返す
if id == parallelism - 1
# 重いspecだけ集めて個別で処理する
puts heavy_spec_files.join(' ')
else
puts spec_boxes[id].map(&:filepath).join(' ')
end
AXISには実行時間が長いSpecがかなりあったため、それらをまとめて実行する専属インスタンスを用意するようなコードにしました。
実際に実行する際は以下のように呼び出します。今回は8インスタンス並列の例です。spec_file_splitterの実行結果をxargsコマンドを用いてspec実行コマンドの引数に渡しています。
strategy:
fail-fast: true
matrix:
parallelism: [8]
id: [0,1,2,3,4,5,6,7]
〜略〜
run |
./spec/spec_file_splitter.rb -p ${{ matrix.parallelism }} -i ${{ matrix.id }} | xargs \ bundle exec parallel_rspec -n 2
CIガチャの退治
以前、以下記事で詳しく説明されていたように、AXISではCIガチャが存在していました。
GitHub Actionsに移行するからには、CIの実行時間の短縮はマストなので、CIガチャでrerunする、すなわちCI待ち時間延長は何としても減らす必要がありました。
そもそも、AXISの開発に携わって半年以上を振り返ると、CIガチャのほとんどは以下のようにPrimary Keyの重複でした。となれば、Primary Keyの重複が起きる原因を突き止めて、それに対して対策をするしかありません。
ActiveRecord::RecordNotUnique:
PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "hoge_pkey"
DETAIL: Key (id)=(1) already exists.
結論
原因はGem "parallel_rspec"
によってマルチコアCPUそれぞれで実行されているspecが共通のDBを参照してしまうことがあるからでした。対処法は色々考えられますが、今回は失敗したspecだけシングルコアでrerunすることで回避することにしました。
parallel_rspecは'-test-options'
のオプションを使うと、rspecのオプションを実行できるので、それを用いて失敗したspecだけrerunするようにしました。
bundle exec parallel_rspec -n ${{ env.PARALLEL_TESTS_CONCURRENCY }} --test-options '--failure-exit-code=0'
bundle exec rspec --only-failures
改修の結果
並列数の調整や重いテストの分離実行などをすることで、実行におよそ30分かかっていた「LintとTest」のジョブは15分程度に短縮することができました。これ以上工夫するとしたら、spec/spec_file_splitter.rb
のファイル分配のアルゴリズムを改善したり、重いテストの中身を軽くしたりすることが必要かなと思います。
まとめ
今回の改修を通じて、CircleCIとGitHub Actionsそれぞれの長所を知ると同時に、それぞれのかゆいところに手が届かない部分も知ることができました。移行作業は調査することと手を加えることが多かったため、学べたものも多かったと思います。
最後に、バイセルテクノロジーズではエンジニアを募集しています!
ご興味を持っていただけた方はぜひご応募ください!
明日の バイセルテクノロジーズ Advent Calendar 2021 は CTO室所属のなおとさんによる 「スクラムに慣れてきたら考えるべきこと」 です。