RSpec を 6 倍速くしてカオスな CI を正常化した話

サービスエンジニアリング本部の山本です。

この記事は Enigmo Advent Calendar 2019 の 18 日目の記事です。

普段はフロントエンド中心の開発をしていますが、たまに DX(Developer Experience) 的なことにも手を出しています。 今回はそんな DX のお話です。

やばい CI

f:id:hi_yamamoto:20191217182336p:plain

エニグモが運営している BUYMARuby on Rails アプリケーションとして動いており、自動テストフレームワークとして RSpec を採用しています。
CIツールとしては Jenkins を採用していましたが、1 年以上の期間、常に Fail しているというエニグモのようなイケてるウェブ企業としてはあるまじき状態が続いていました。
Jenkins は素晴らしいソフトウェアですが、当時動いていた Jenkins のバージョンは 1 系かつオンプレミスサーバーで動いていたこともあり色々と問題を抱えていました。
また、エニグモでは Git ホスティングサービスとして Gitlab を採用していて、ちょうど社内では Gitlab CI を使っていこうという流れもあったため RSpec も Gitlab CI に移行することにしました。

Jenkins 時代の課題

  • ローカルと CI で実行結果が違う
    • ローカルだと通ってたのに CI だと通らない
  • 実行するだけで 2 時間くらいかかっていた
    • 並列実行したいがサーバーのリソースの問題で難しい
  • ブランチごとに実行出来ないため、マージしてみないと CI 上の結果がわからない
    • ブランチごとに実行しようとするとサーバーのリソース不足になる
    • ローカルですべてのスペックを実行するのは非現実的なため関係ありそうな箇所だけローカルでテストしたらマージしてしまう

# jenkins という slack チャンネルがありましたが、失敗しているのが当たり前になってしまっていたため誰も気にしておらず、失敗通知が虚しく響き渡っていました。(割れ窓理論)

どうやって解決していったか

ローカルと CI で実行結果を同じにしたい!

まず、ローカルと CI での実行結果が違う問題について。
これは単純に Docker を使うことにしました。 BUYMA の開発環境はもともと Vagrant を使っていて Docker 化はされていませんでした。 今回はローカルの開発環境全部を Docker 化するのではなく、一旦テスト実行環境だけを Docker 化して CI 上でも同じイメージを利用する方針としました。

Gitlab CI にはジョブを実行する Gitlab Runner というツールを利用します。 Gitlab Runner の実行方式である Executor は Shell 、 Docker などの複数のなかから選択することができますが、今回は後述の実行時間短縮を実現するために並列で実行することを考えて Docker を選択しました。
Docker 上で Docker を動かす Docker in Docker という方式があり、それ専用のイメージも用意されているのでそちらを採用しました。 詳しくはこちら

各ブランチのMR ( Merge Request )ごとに実行したい!

Jenkins 時代は開発用メインブランチである develop ブランチにマージされた時だけテストが走るようになっていたため、 MR をマージして初めてテスト結果がわかるという状態でした。
この場合マージした後に失敗したことに気づき修正する、という流れになりますが、テスト時間が長いこともあり失敗していることに気づかず放置されてしまいます。
マージする前にテストを実行する、という当たり前のことが出来ていない状態だったのです。

これを解決するための Gitlab CI の設定はかんたんで、 .gitlab-ci.yml にこのように記述するだけです。

only: 
 - merge_requests

問題はリソースです。
Gitlab CI への移行当初、 Gitlab Runner サーバー はオンプレミスのサーバーだったため、複数の MR が同時に作成され RSpec のジョブがトリガーされた場合リソースが足りなくなってしまう懸念がありました。 そこで Gitlab Runner サーバー を AWS へ移行して Gitlab Runner の AutoScaling 機能を利用しました。
AutoScaling については こちらの記事が参考になります。

f:id:hi_yamamoto:20191217153040p:plain
移行前の構成


f:id:hi_yamamoto:20191217153141p:plain
移行後の構成

この構成の設計や移行作業は全て弊社インフラエンジニアである夏目さんによるものです。ありがとうございました!!

実行速度を速くしたい!

さて、ブランチごとに実行する環境は整いました。
しかし全てのテストが完了するまで2時間ほどかかってしまうという最大の問題が解決されていません。
MR を作成してマージするぞ!っと思ってからテストが終わるまで 2 時間は流石に待ってられません。
RSpec を速くする方法としては、テストコード内で無駄にレコードを作らないなど色々プラクティスがあると思いますが、数百ファイルのテストファイルを全て見て修正するというのは現実的ではありませんでした。

そこで RSpec の並列化という方法で解決することを考えました。
元々 CI 上でのテストは他のテストファイルの影響を無くすため、通常の rspec コマンドではなくそれぞれのテストファイルを独立して実行するスクリプトを利用していました。

#!/bin/bash

# 全てのスペックファイルを単独で実行するためのスクリプト。

fail_count=0

spec_array=()
while IFS=  read -r -d $'\0' spec; do
    spec_array+=("$spec")
done < <(find spec/ -name '*_spec.rb' -print0)

spec_count=${#spec_array[@]}
echo "Running $spec_count spec files"

export SKIP_CODE_COVERAGE=1

for (( i=0; i<$spec_count; i++ )); do
  echo -e "\n#########################################################################"
  echo -e "\nRunning spec $((i+1)) of $spec_count"
  if ! bin/rspec "${spec_array[$i]}"; then
    ((fail_count++))
  fi
  export SKIP_DATABASE_SEEDING=1
done

echo -e "\n$spec_count test files, failures in $fail_count files\n"

[[ $fail_count -eq 0 ]]

このスクリプトと Gitlab CI の parallel オプションを利用して並列化を実現しました。

まず、 .gitlab-ci.yml のジョブに parallel フィールドを追加します。 今回は 10 個のジョブを並列で実行することにしました。

parallel: 10

この parallel フィールドを追加すると、 ジョブの環境変数として CI_NODE_TOTALCI_NODE_INDEX という環境変数がセットされます。 CI_NODE_TOTAL には parallel で指定した値、 CI_NODE_INDEX にはそのジョブのインデックスがセットされるので、この 2 つの環境変数から実行するファイルを算出します。 並列化後のスクリプトはこちらです。

#!/bin/bash

# 全てのスペックファイルを単独で実行するためのスクリプト。

fail_count=0

spec_array=()
while IFS=  read -r -d $'\0' spec; do
    spec_array+=("$spec")
done < <(find spec/ -name '*_spec.rb' -print0)

spec_count=${#spec_array[@]}
echo "CI_NODE_TOTAL is $CI_NODE_TOTAL"
echo "CI_NODE_INDEX is $CI_NODE_INDEX"
count=$((spec_count / CI_NODE_TOTAL))
start_index=$((count * (CI_NODE_INDEX - 1)))

if [[ $CI_NODE_INDEX -eq $CI_NODE_TOTAL ]]; then
  count=$((spec_count - start_index))
fi

echo "Running $count spec files"
echo "Start at $start_index"

export SKIP_CODE_COVERAGE=1

for (( i=$start_index; i<$start_index+$count; i++ )); do
  echo -e "\n#########################################################################"
  echo -e "\nRunning spec $((i+1 - start_index))(total: $(($i+1))) of $count(total: $spec_count)"
  if ! bin/rspec  "${spec_array[$i]}"; then
    ((fail_count++))
  fi

 
  export SKIP_DATABASE_SEEDING=1
done

echo -e "\n$count test files, failures in $fail_count files\n"

[[ $fail_count -eq 0 ]]

テストを 10 分割することにより 2 時間かかっていたテストが約 20 分で完了するようになりました!

残念ながらジョブ実行用のスポットインスタンスの起動にある程度時間がかかるため、 10 分割で 10 倍速くなるというわけにはいきませんでした。
また、これ以上並列数を増やしても速くなることもありませんでした。

結果

  • ローカル環境と CI 環境での差異がなくなったため、ローカルで成功したのに CI では落ちることがなくなった
  • MR ごとに実行できるようになったためテストが成功した状態でマージでき、メインブランチは常にクリアな状態が保てるようになった (たまに落ちてることもある)
  • テストが 20 分で終わるようになった

所感

長らく不満に思っていた、常に Fail している CI から脱却することができました。
人によるとは思いますが、個人的には CI が落ちて放置されているというような状態に精神が乱されるタイプなので心の平穏を取り戻すことができました。

上記の作業をする前に落ちまくって放置されているテストを全て直すという作業があったのですが正直そこが一番きつかったです。


エニグモではカオスな開発環境を一緒に正常化していきたいエンジニアを募集中です!

hrmos.co