メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

お財布に優しいCI改善小ネタ集

こんにちは。サーバーサイドエンジニアの三村(@t_mimura)です。

主に保険薬局と患者さまを繋ぐ「かかりつけ薬局」化支援アプリ kakariのサーバーサイド開発(Ruby on Rails)を担当しています。

今回はRailsシステムのCI時間をコスト追加なしで半減した話をします。

目次

前提

対象プロジェクト

まず、今回対象となるRailsシステムの規模感について簡単にご紹介します。

kakariは2019年にリリースされ、今現在も活発に開発が進んでいるプロジェクトです。 大まかに以下のような構成となっています。

  • バックエンド Ruby on Rails
    • フロントエンドとモバイルアプリ向けのAPIを提供
    • 一部システムではwebpackを利用しVue.jsが組み込まれているRails Viewも提供
  • フロントエンド Vue.js
    • Rails Viewに組み込まれている
  • モバイルアプリ Kotlin, Swift
    • ※ 今回のスコープ外

今回はこれらのうち「Ruby on Railsとそれに組み込まれているVue.js」を管理しているGit リポジトリのCIを改善した話となります。

$ rails stats
+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers          |  25114 |  18962 |     700 |    2768 |   3 |     4 |
| Helpers              |    336 |    240 |       1 |      42 |  42 |     3 |
| Jobs                 |      6 |      3 |       1 |       0 |   0 |     0 |
| Models               |  68048 |  46214 |    1537 |    5532 |   3 |     6 |
| Mailers              |   1128 |    871 |      60 |     117 |   1 |     5 |
| Views                |    182 |    172 |       0 |       0 |   0 |     0 |
| Libraries            |   1473 |   1140 |      16 |      77 |   4 |    12 |
| Controller specs     |    459 |    389 |       0 |       2 |   0 |   192 |
| Decorator specs      |    135 |    111 |       0 |       0 |   0 |     0 |
| Haml_lint specs      |     39 |     26 |       0 |       0 |   0 |     0 |
| Helper specs         |    320 |    240 |       0 |       1 |   0 |   238 |
| Lib specs            |   1126 |    936 |       0 |       2 |   0 |   466 |
| Mailer specs         |   6797 |   6046 |       0 |       3 |   0 |  2013 |
| Model specs          |  77120 |  61488 |       3 |     140 |  46 |   437 |
| Push_notifier specs  |   2547 |   2173 |       0 |       6 |   0 |   360 |
| Request specs        |  54132 |  45976 |       0 |     407 |   0 |   110 |
| Serializer specs     |   1820 |   1509 |       0 |       0 |   0 |     0 |
| System specs         |  28542 |  22830 |       0 |      20 |   0 |  1139 |
| Task specs           |    203 |    169 |       0 |       0 |   0 |     0 |
| Validator specs      |   1355 |    957 |      20 |       6 |   0 |   157 |
| Worker specs         |   7776 |   6388 |       0 |      25 |   0 |   253 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total                | 278658 | 216840 |    2338 |    9148 |   3 |    21 |
+----------------------+--------+--------+---------+---------+-----+-------+
  Code LOC: 67602     Test LOC: 149238     Code to Test Ratio: 1:2.2

CIの状況

kakariでは主にCircleCIを利用しており、以下のようなJobを実行しています。

  • Ruby on Railsのテスト(RSpec
    • 各種モデル群の単体テスト
    • APIのRequest Spec
    • Rails Viewを提供しているエンドポイントに対してのSystem Spec
  • JavaScriptのテスト(Jest
    • JavaScriptで完結する各種Vue.js Componentの単体テストなど
  • 静的解析

他にも細かなJobが幾つかありますが、いずれも1分前後の小さなJobのため今回は割愛します。 また、Playwrightを活用したE2Eテストもありますが、これに関しても実行環境・タイミングなどが特殊なため今回の対象外とします。

改善結果

細かな改善内容はさておき、具体的な改善結果をお見せします。

改善前のCI Workflow 14分18秒

改善後のCI Workflow 6分35秒

「14分」から「6分半」への大幅な改善を達成することができました 🎉

また、 「お金💰の力で並列数を増加」のような対応はとらずに一定改善できたため副次的効果ですがコストの削減にも繋がりました。

改善内容

前提知識: CIのキャッシュ機能

具体的な改善内容紹介の前に、ここで「CIのキャッシュ機能」について前提知識として理解しておきましょう。

CircleCI公式ドキュメントが丁寧のためそちらをご参照ください。 circleci.com

例示されている通り、yarn packageやruby gemsなどのライブラリのインストール処理にてキャッシュを活用し、各Job内でのインストール時間を短縮することが典型的な活用事例かと思います。

また、今回はCircleCIを利用しましたがGitHub ActionGit Lab CI/CDなど大抵のCIサービスには同等の仕組みがあるため適宜読み替えてください。

それでは、改善内容を紹介します。

webpack buildのキャッシュを活用

出落ちになりますが、本改善がかなり大きなものとなります。

前述した通り、本Railsシステムには一部Vue.jsが組み込まれているエンドポイント(画面)が存在します。 そのエンドポイントに対してのSystem Specを動作させるにはwebpackのbuildが必要な構成となっています。

このwebpack buildの実行時間が毎回5分弱ほどでした。

この時間を短縮するためにwebpackのpersitent cachingという仕組みを活用しました。 これは「キャッシュ」という名から推測できる通りビルド結果をキャッシュし、ビルド時間を短縮してくれる仕組みです。

改善効果

「5分弱」から「1分弱」(4~5分)の時間短縮

※ キャッシュが適用される場合

対応方法

以下のようにwebpackの設定を記述してキャッシュ情報をファイル出力し、そのファイルをCircleCIのキャッシュ対象に含めるだけです。

  // webpack.config.js
  cache: {
    type: 'filesystem',
    cacheLocation: path.resolve(__dirname, 'tmp/cache/webpack'),
  },
  snapshot: {
    buildDependencies: { hash: true },
    module: { hash: true },
    resolve: { hash: true },
    resolvebuildDependencies: { hash: true },

ポイントはキャッシュデータ有効性の判定方法を「hash 方式」としている点です。 キャッシュの有効性判定の方法は以下の二種類が選べます。

  • hash(ファイルコンテンツハッシュ) 方式
    • ファイル内容のハッシュ値が同一かどうかで判定
    • 公式原文 Compare content hashes to determine invalidation (more expensive than timestamp, but changes less often).
  • timestamp(ファイルタイムスタンプ)方式 デフォルト
    • ファイルのタイムスタンプ(更新日時)が同一かどうかで判定
    • 公式原文 Compare timestamps to determine invalidation.

CIでは毎回git cloneする都合上ファイルタイムスタンプが更新されます。 そのため、timestamp方式を利用するとキャッシュされているスナップショットが不正と判定されてしまいキャッシュを活用することができなくなってしまいます。

参考資料

RuboCopのキャッシュを活用

RuboCopにも同様のキャッシュの仕組みがあります。

docs.rubocop.org

キャッシュの有効性判定は以下の通りで、「Ruby・RuboCopのバージョン、Cop設定が変わらない限り同一ファイル内容に対してのCop結果は不変」ということから、Cop結果をキャッシュとして記録し次回実行時にキャッシュされているCop結果を流用する仕組みです。

Cache Validity
Later runs will be able to retrieve this information and present the stored information instead of inspecting the file again. This will be done if the cache for the file is still valid, which it is if there are no changes in:

・the contents of the inspected file
・RuboCop configuration for the file
・the options given to rubocop, with some exceptions that have no bearing on which offenses are reported
・the Ruby version used to invoke rubocop
・version of the rubocop program (or to be precise, anything in the source code of the invoked rubocop program)

改善効果

「2分」から「10秒弱」(2分)の時間短縮

※ キャッシュが適用される場合

対応方法

実はRuboCopはデフォルトでキャッシュファイルを生成します。 生成されるキャッシュファイルをCircleCIのキャッシュ対象に含めるだけです。

以下のように --cache-root オプションでキャッシュファイルの生成先を指定することができます。

bundle exec rubocop --cache-root tmp/cache/rubocop

参考資料

ESLintのキャッシュを活用

そろそろキャッシュにも慣れてきた頃でしょうか。
冗長な説明となるため簡単にご紹介します。

eslint.org

改善効果

「30秒前後」から「3秒前後」(30秒)の時間短縮

※ キャッシュが適用される場合

対応方法

eslint app/bundles/javascripts spec/javascripts --ext js,vue,ts \
  --cache \ # キャッシュを有効化
  --cache-location tmp/cache/eslint/ \ # キャッシュファイル生成先を指定
  --cache-strategy content # キャッシュの有効性判定を「ファイル内容方式」とする(理由は上記webpack buildと同様)

参考資料

Jestのキャッシュを活用

キャッシュはもう飽きてきましたね。
これが最後のキャッシュ対応です。

jestjs.io

改善効果

「3分前後」から「2分前後」(1分)の時間短縮

※ キャッシュが適用される場合

対応方法

cacheDirectory を指定し、生成されたキャッシュファイルをCircleCIのキャッシュ対象に含めるだけです。

# jest.config.js
module.exports = {
  cacheDirectory: 'tmp/cache/jest',
};

参考資料

RSpec Jobをテスト特性ごとに分割

RSpec実行Jobを「webpack buildが必要なSystem Spec rspec_with_assets」と「それ以外(Model Specなど) rspec_without_assets」の二つに分割し、「時間がかかるWebPack Buildを待たずに rspec_without_assets を開始」することで全体の総時間を短縮する試みです。
こちらは同チームのサーバーサイドエンジニアの谷(@yuyat137)が対応しました。

RSpecを分割実行しJob依存関係を最適化(before)

RSpecを分割実行しJob依存関係を最適化(after)

改善効果

これまでの改善とは異なり特定のJobが改善されるようなものではないため改善効果の計測が難しいのですが、最大(webpack buildのキャッシュが適用されない場合)は4,5分の時間短縮が期待できます。
また、後述するリソースクラスと並列数の最適化にも繋がります。

対応方法

特別な工夫はなく、単にRSpec実行Jobを分割するだけです。 Jobを分割する分メンテナンスコストは増えるため、期待できる改善効果とのトレードオフで採用・不採用を検討すると良いかと思います。

CircleCIのリソースクラスと並列数の最適化

まず前提知識として、CircleCIには特定のJobを並列稼働させる仕組みがあります。 例えば以下はRSpec実行Jobを8並列で稼働している様子です。

CircleCIにてRSpecの実行Jobを8並列で稼働している様子

このように並列数を上げることでCIの総時間を短縮することができます。 ただし、CircleCIは「サーバー稼働時間の合計 ≒ コスト」となるため、並列数を上げるとその分コストが増えます。(※ Job実行のための事前処理が並列数分実行されるため)

circleci.com

もう一つの前提知識として、CircleCIでは各種Jobのリソースクラス(サーバーのスペックのようなものと解釈してください)を調整することが可能です。 例えばCPUやメモリをあまり消費しないようなJobの場合は小さめの small なんかを指定するとコスト削減に繋がります。

今回の改善は「並列数」と「リソースクラス」の両方を調整し、最適化しました。

端的に言うと「RSpec実行Jobのリソースクラスを下げて並列数を上げた」となります。 実際の最適化内容は以下です。

※ 消費クレジット= ここではCircleCI上のコストの単位と解釈してください。 (参照: https://circleci.com/docs/ja/credits/

最適化前

  • 「Medium(CPU=2, Mem=4, 消費クレジット=10/min)」を8並列

最適化後

前述の「RSpec Jobをテスト特性ごとに分割」が完了していたため、それらのJobの特性に応じてリソースクラスと並列数を最適化。

  • 重ためのSystem Spec系実行Job
    • 「Medium(CPU=2, Mem=4, 消費クレジット=10/min)」を4並列
  • テストケースが多いSystem Spec以外実行Job
    • 「Small(CPU=1, Mem=2, 消費クレジット=5/min)」を8並列

最適化前後で消費クレジットの変化なし(共に80/min)

改善効果

「10分30秒」から「7分弱」(4分弱)の時間短縮

細かなテストが多いRSpec群に対しては「スペック抑えめで並列数を増やす」というアプローチが良さそうでした。

採用しなかった・見送った改善候補

HAML-Lint, Fasterer, Brakemanのキャッシュを活用

RuboCopやESLint同様に静的解析のJobは極力キャッシュを活用したかったのですが、ツール側が機能提供していなかったので断念しました。
OSSコントリビュートチャンスですね。

Stylelintのキャッシュを活用

Stylelintにもキャッシュの仕組みがあるためこれを活用することができたのですが、元々のJob実行時間が10秒未満と短かったため今回は見送りました。

bootsnapを活用

Bootsnapは各種ファイルパス情報などをキャッシュすることで、Rubyアプリケーションの起動を高速化するGemです。
詳細は公式ドキュメントなどを参照ください。

github.com

Bootsnapが生成するファイルをCircleCIのキャッシュ対象に含めるだけでJob内のコマンド実行が高速化する可能性があります。 Bootsnapは巨大なアプリケーションほど効果を発揮しますが、kakariはせいぜい小〜中規模程度なため「Bootsnapで受けられる恩恵 < Jobの複雑さ」と判断し今回は見送りました。

Jestの並列実行化

kakariではJestをシリアル実行していたので、並列で稼働する(デフォルト)の設定に変更してみましたが、逆にCI時間が延びたため今回は見送りました。 JestのCI時間はそこまで支配的でなかったため、深く調べてはいませんが恐らくCPU/メモリリソースが十分に割り当てられていなかったことが原因かと思われます。 jestjs.io

まとめ

以上が最近kakariプロジェクトで取り組んだCI改善の小ネタ集でした。

繰り返しになりますが、追加コストなし でCIの実行時間を「14分」から「6分半」に短縮することができました 🎉

いずれも裏技のような改善ではなく、公式ドキュメントに記載されているような正攻法ばかりでした。
「重たいテストを一つ一つ修正する」ことも大事ですが、まずは落ち着いて公式ドキュメントを読み込むことも大事ですね。

本記事がきっかけで世界で動いているCI時間が少しでも短縮することを願っています。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら

medpeer.co.jp

■エンジニア紹介ページはこちら

engineer.medpeer.co.jp