CI実行時間を11分→5分に短縮する

メリークリスマス!🎄

Misoca開発の@kokuyouです。

Misoca Advent Calendar 2017、遂に最終日です。 昨日の記事は@eitoball開発の小ネタ劇場でした。

本日 Ruby 2.5 が出る予定ですが、特に関係ないCIの話をしていきたいと思います。

どうでもいいんですが、CIという略語を見ると Continuous IntegrationなのかCANDY ISLANDなのか迷う今日このごろです。

⏳ ビルド時間の推移

以前の記事で紹介したとおり、MisocaではCIでのRSpec分散実行にrrrspecを使用しています。

これにより20分ほどかかっていたビルドを11分程度まで短縮することができたのですが、それでも11分は結構待たされるな―、という気持ちになります。スピーチとCIのビルド時間は短いほうが良いのです。

そんなわけで、頑張って短縮してみました。

以下は7月~9月ごろのビルド実行時間推移を表したグラフです。

f:id:kokuyouwind:20171225142005p:plain

青線がビルドごとの実行時間、赤線が直前10ビルドの移動平均になっています。

y軸の上限を1000秒(=16分40秒)に取っていますが、初期はこの上限を軽々と突き抜けていることがわかります。 全体的にも600秒(=10分)はまず下回らない感じです。

一方で、対処を始めた8月ごろからグラフが右肩下がりになり、終盤では400秒を下回っています。

直近(10月~12月)では以下の通り、ほとんどのビルドで400秒(6分40秒)を下回り、軽いビルドでは300秒(=5分)程度で完了しています。 なお、たまに飛び跳ねてるのもDependabotで一気にビルドキューが積まれたときのものでした。

f:id:kokuyouwind:20171225142135p:plain

ここまで実行時間が短縮されたことで、待たされていると感じることがだいぶ少なくなりました。

💪 やったこと

💰 金の弾丸

最初は「rrrspecの並列数増やせばスケールするでしょ」と思いmax_workerを5から10に上げてみたのですが、ほとんど短縮せず悲しい気持ちになりました。

仕方がないので、ここからは時間がかかってる処理をひとつずつ調べて潰していく戦略にシフトしました。

🖥 MySQLのローカル同居

当初は レシピに組み込むのがめんどい MySQLの起動オーバーヘッドやメモリ量などを考慮して、rrrspec workerノードとは別にMySQLノードを立てていました。

しかしMySQLノードを固定で立ち上げているとスケールしないことや、接続数・転送量が逼迫していることから、MySQLをrrrspec workerノード内に同居させることにします。

datadirをtmpfsに向けるのに苦戦したり、AppArmorにMySQLプロセスの起動を阻まれたりしたのですが、なんとか完遂し、なぜかこれだけで1分程度実行時間が短くなりました。

帯域とか転送速度の問題でこれまでの戦略がイケてなかったという話なんですが、まぁ結果オーライです。

(この時点でjob全体10min、うちrrrspecが8min)

🔗 rsyncの設定見直し

さらにrrrspecの実行ログを確認したところ、実行対象をrrrspec master -> rrrspec workerへ同期するrsyncが1min以上かかっていました。

以下の設定を見直し、rsyncにかかる時間を5sec程度まで短縮しました。

  • Jenkinsではjobごとにworkspaceを持ちタイムスタンプが異なるため、 --timesではなく--checksum を使う
  • node_modulestmp/cacheなど不要なディレクトリが同期されていたため、--exclude指定に追加する

(この時点でjob全体9min、うちrrrspecが7min)

🔪 specの分割

MySQLがスケールするようになったためmax_workerを再度上げていったのですが、やはりあまり実行時間は短縮されませんでした。

個別に実行時間を見てみると、なんと単独ファイルで7minかかっているfeature specが……

rrrspecはファイル単位で分散実行するため、そりゃ7minは下回れないなぁ…… という気持ちになり、そのspecを幾つかのファイルに分割しました。

その後もボトルネックになったspecを分割したり不要なテストを消したりと地道に潰していった結果、10workerでのrrrspec実行時間を5minまで短縮することができました。

(この時点でjob全体7min、うちrrrspecが5min)

🏗 package install & build

オーバーヘッドの比率が上がってきたため、そちらで時間がかかっている処理に目を向けていきます。

オーバーヘッドのうちbundle installが20sec、yarn installが30sec、yarn buildが1min程度で、依存パッケージ解決と静的ファイルビルドに殆どの時間を使っていました。

ここで依存パッケージは Gemfile.lockyarn.lockが一致していれば同じものを使いまわせますし、yarn buildJavaScriptのソースなどに変更がなければ同じものを使えます。

そこでCircleCI 2.0のキャッシュ機構を参考に、Jenkinsfileでキャッシュを行う関数を用意しました。

def yarn_install() {
  cache(
    ['node_modules'],
    get_cache_key(['package.json', 'yarn.lock']),
    { bash "yarn install --no-progress --pure-lockfile" }
  )
}

def cache(paths, key, callback) {
  if (!fileExists(cachedir(key))) {
    callback()
    for(def path: paths) {
      // node_modulesのrequireの仕様上、ディレクトリ構造の保持が必要なので path/to/file ような場合に path/to までを作成したい
      sh("dirname ${cachepath(path, key)} | xargs mkdir -p")
      sh("mv ${path} ${cachepath(path, key)}")
    }
  }
  for(def path: paths) {
    sh("rm -rf ${fullpath(path)}; ln -s ${cachepath(path, key)} ${path}")
  }
}

cache関数では依存ファイルのパスを元にキーを生成しており、そのキーを持つキャッシュが存在する場合はsymlinkを貼るだけで処理を終わります。

これにより、Gemfileなどに変更がない場合はこれらの処理を合わせて10secちょっとで終わるようになりました。

なおキャッシュを利用するのはfalse positiveが怖いのですが、古いキャッシュはfalse negativeのほうが起こりやすいはずですし、定期的に削除して再生成させているため大きな問題はないだろうと思っています。

✅ 結果

これらの改善処理によって、全体で5min、オーバーヘッドは10sec程度まで短縮することができました🎉

あとは不要なspecを削ったり統合したり、あるいはfixtureを減らしたりcreateからbuildに変えたり、といった地道な改善を続けて少しずつ実行時間を削ることになります。

こういった地道な改善を進める上でも、ひとまずそれ以外の部分で実行時間を大幅に削れたのは効率が良かったと思います。

🎅 Misoca Advent Calendar 2017

最終日がだいぶギリギリになってしまいましたが、無事Misoca Advent Calendar 2017を完走することができました。

個人的には社長の記事が一番バズってるという事実に微妙な気持ちを抱いていますが、全員でいろいろな記事を書くことができて楽しかったです。

来年も気が向いたらアドベントカレンダーを立てたいなーと思います。

📣 宣伝

Misocaでは2018年のアドベントカレンダー記事を一緒に書く仲間を募集しています!!!