JMDC TECH BLOG

JMDCのエンジニアブログです

cache を最適化して RuboCop の CI 実行時間を劇的に改善した話

こんにちは、プロダクト開発部の八杉です。JMDC では主に web フロントエンドの実装や設計を中心に行っているほか、最近は Rails の GraphQL モジュールの設計や CI の最適化にも取り組んでいます。

本記事は JMDC Advent Calendar 2023 11日目の記事です。 qiita.com

この記事では、 RuboCop を CI で実行した際に遭遇した cache にまつわる 3 つの問題とその対処について紹介します。

背景

今回お伝えするのは、私が開発に携わる Pep Up (ペップアップ) という web サービスの話です。

Pep Up は Ruby on Rails 製のアプリケーションで、コードフォーマッターに RuboCop を使用しています。8 年前の開発初期から使用していますが、違反のチェックを厳格に行っていなかったこともあり、ここ数年はフォーマット違反が放置されるケースが目立っていました。コードフォーマットの自動化は開発体験上重要ですから、バックエンドのメンバーを中心にしてこの数ヶ月でフォーマット環境の再整備を進めてきました。具体的には、 cops の整理や pre-commit 時の fix 実行や GitHub Actions でのチェックの実施等の仕組みづくりです。

この取り組みにより、違反コードは CI で検知できるようになりました。コードフォーマットの一貫性が担保され、コードレビュー時に些末な指摘をする必要も無くなってみんなハッピーです。2023 年 8 月のことです。

Cache を有効化して RuboCop 実行を高速化する

ところで、コードベースのすべてのファイルに対して RuboCop を実行するととても時間がかかります。Pep Up の場合、 GitHub Actions のデフォルトの runner を使用して 4-5 分程度かかります。頻繁に実行するものですし、 RuboCop には cache 機構 がありますから、当然これを利用して高速化を図りたいところです。

そこで、 cache action を使用して以下のような GitHub Actions ワークフローを構築しました。

name: RuboCop

on:
  pull_request:
    branches:
      - main

jobs:
  rubocop-all:
    runs-on: ubuntu-latest
    steps:
      # checkout や ruby の setup 等の処理は省略しています
      - name: Cache rubocop
        uses: actions/cache@v3
        with:
          path: ~/.cache/rubocop_cache
          key: rubocop-${{ hashFiles('.rubocop.yml', '.rubocop_todo.yml', 'Gemfile.lock', '.ruby-version') }}
          restore-keys: rubocop-
      - name: Run RuboCop
        run: bundle exec rubocop --format simple

cache key の妥当性については shibayu36 さんのブログ記事がわかりやすいので紹介しておきます。 blog.shibayu36.org

Cache が効かない (★☆☆)

Pep Up では CI の実行時間を Datadog を使って監視しています。次の画像は上記のワークフローを導入してから 3 週間の実行時間の推移です。縦軸が実行時間 (分) で、4 分の位置にある破線はチームで設けている CI 実行時間の許容基準です。

ご覧の通り、平均で 5 分程度かかっていることが見て取れます。稀に 1 分程度で終わっているケースがあり、ここには cache が効いていそうですが、それ以外の多くのケースでは cache が効いていないようです。

原因

ログを追うと、ほとんどの workflow run で actions cache を見つけられていないことがわかりました。

Cache not found for input keys: rubocop-xxxxxxxxx, rubocop-

原因は main ブランチで cache を生成していないことでした。この挙動については公式ドキュメントに記載があります。

The cache action first searches for cache hits for key and the cache version in the branch containing the workflow run. If there is no hit, it searches for restore-keys and the version. If there are still no hits in the current branch, the cache action retries same steps on the default branch. ...

cache の探索時には最初にそのブランチ (今回は pull_request trigger なので正確には merge ref) の cache がチェックされ、その次に main ブランチの cache がチェックされます。PR 作成時の最初の run では当然そのブランチの cache はまだ存在しませんし、 main ブランチで cache を作成していなければ結果として cache が見つからないことになります。

PR の作成後にコミットを追加して再度 run したときは cache が効きますから、前掲のグラフの凹みはそれで説明がつきます。

対策

ということで、 main ブランチへの pull_request 時だけではなく main ブランチへの push 時にも RuboCop を実行して cache を残すようにします。

on:
  pull_request:
    branches:
      - main
  push:
    branches:
      - main # 追加

Cache が効かない (★★☆)

main ブランチで RuboCop を実行するようになった結果、たしかに効果が見られました。以下のグラフは前掲のグラフの状態から数日進めたものです。

導入日の 9/4 前後を境に実行時間が改善しています。しかし、よく見るとその後もときどき 4 分のラインを超えるケースが見られます。この間 actions cache の key として列挙している 4 つのファイル (.rubocop.yml, .rubocop_todo.yml, Gemfile.lock, .ruby-version) に変更はありません。

workflow run のログを調べると、これらのケースでは actions cache が exact match しているにも拘わらず RuboCop が実行時にそれを利用していないことがわかりました。

つまり、 RuboCop はこれら 4 つのファイルの他にも何らかの条件に基づいて cache を invalid と見なすことがあるようです。cache key の設定が誤っていると cache の効率に影響しますから、 actions cache の key には必要十分な値を指定したいところです。

原因

前掲の shibayu36 さんのブログ記事によれば、 RuboCop の cache は次のような仕組みになっています。

RuboCopは以下のようなキャッシュ構造になっている。 {cache_root}/{rubocop_checksum}/{context_checksum}/{file_checksum} たとえば ~/.cache/rubocop_cache/48495939d6d5ca59d7f0a191fd9c11432a988b9d/eb1638cf9f9405f1dfcb03f4d43f86ec4b3f6af5/bab558f367464a4c3a65c3c112fae2e3168613cd のようなファイルに、どんな違反があったかの情報が書かれている。現在RuboCopチェックをかけようとしているファイルのキャッシュがあれば、違反内容は変わらないためキャッシュファイル内に記載された違反内容をそのまま返せば良い。

Rubyのバージョン変更、RuboCopの設定変更、RuboCopのオプション変更はrubocop_checksumやcontext_checksumへ影響を及ぼすので、全キャッシュが無効になるようだ。

まったく何も変更がない場合、checksumの計算 + RuboCopチェックをかける分のキャッシュ読み込みのみ行うため、ほぼ一瞬でRuboCopの結果が返ってくるようだ。

そこでログを追うと、 cache が効いていない run では context_checksum の部分に差異があることがわかりました。

ということで、ここで RuboCop のソースコードを見に行ってみます。context_checksum は次のようなコードです。

https://github.com/rubocop/rubocop/blob/a31a37cef46b707b8d4b2e221b5a40fd50915afe/lib/rubocop/result_cache.rb#L227-L238

def team_checksum(team)
  @checksum_by_team ||= {}.compare_by_identity
  @checksum_by_team[team] ||= team.external_dependency_checksum
end

def context_checksum(team, options)
  Digest::SHA1.hexdigest([team_checksum(team), relevant_options_digest(options)].join)
end

ここの team.external_dependency_checksum というコードを見てピンと来ました。Pep Up ではいくつかの RuboCop extension を使用していますから、そこが怪しそうです。

果たして、 extension のコードを一つずつ見ていくと RuboCop Rails のコードにそれらしいものが見つかりました。

https://github.com/rubocop/rubocop-rails/blob/a27f6df798b112f3f479519f2746055596238987/lib/rubocop/cop/mixin/active_record_helper.rb#L30C39-L39

def external_dependency_checksum
  return @external_dependency_checksum if defined?(@external_dependency_checksum)

  schema_path = RuboCop::Rails::SchemaLoader.db_schema_path
  return nil if schema_path.nil?

  schema_code = File.read(schema_path)

  @external_dependency_checksum ||= Digest::SHA1.hexdigest(schema_code)
end

RuboCop Rails が schema.rb の内容を cache key に加えていることがわかりました。

RuboCop Rails は Rails のベストプラクティスにフォーカスした extension です。この extension はどうやら schema.rb の内容を参照して Rails/UniqueValidationWithoutIndex のような制約のチェックに利用しているようです。そのために schema.rb に変更があったときは cache を invalidate する必要があるわけですね。納得です。

対策

ということで、 actions cache の key に schema.rb を含めるようにします。

また、 RuboCop の cache invalidation に影響を与える gem はごく一部ですから、 Gemfile.lock が更新されるたびに actions cache を破棄するのは効率が悪そうです。そこで、次のように Gemfile.lock のセクションを分離して cache 効率の最適化も図ります。

- name: Cache rubocop
  uses: actions/cache@v3
  with:
    path: ~/.cache/rubocop_cache
    key: rubocop-${{ hashFiles('.rubocop.yml', '.rubocop_todo.yml', '.ruby-version', 'db/schema.rb') }}-${{ hashFiles('Gemfile.lock') }}
    restore-keys: |
      rubocop-${{ hashFiles('.rubocop.yml', '.rubocop_todo.yml', '.ruby-version', 'db/schema.rb') }}-
- name: Run Rubocop
  run: bundle exec rubocop --format simple

Cache が効かない (★★★)

これで解決したかと思いましたが、問題はまだ続きます。次のグラフは前項の対策を導入後 1 週間ほど経過したときの状態です。

9/8 に対策を導入したものの、それ以降も状況がほとんど改善していません。これはどういうことでしょうか。

原因

前回の調査時には気が付きませんでしたが、 ログを調べると rubocop_checksum の方も意図しないタイミングで変化していることがわかりました。

RuboCop の cache 構造を再掲しておきます。

{cache_root}/{rubocop_checksum}/{context_checksum}/{file_checksum}

ということで、さらに調査していると次の issue にたどり着きました。

github.com

問題の概要から原因と解決策にいたるまでこの方が詳細にまとめてくれていますが、この問題は次のように説明されます。

  1. RuboCop は自身が require する feature の集合 (rubocop_required_features) を rubocop_checksum の計算に利用する
    • 自身が require する feature とは、 rubocop.rb を実行後の $LOADED_FETURES から実行前時点の $LOADED_FETURES の値を除いたものである
  2. cache directory (~/.cache/rubocop_cache) が存在しないときだけ rubocop.rb の実行前に fileutils を使った directory 作成処理が呼ばれる
  3. rubocop.rb の実行時に fileutils が require される
  4. ゆえに、実行時の cache directory の有無に応じて rubocop_required_features の値が変化し、 rubocop_checksum は異なる値を返すことになる

今回のケースに当てはめると、 RuboCop の cache がほとんど効かなかったのは次の機序によります。

  1. cache directory (.cache/rubocop_cache) が存在しない状態で RuboCop を実行し、その結果が actions cache に保存される
  2. 後続の run では 1. で作成された actions cache が exact match して復元される
    1. 今回は cache directory が存在する。rubocop_checksum の値が 1. とは異なるため RuboCop は cache を利用できない
    2. exact match なので actions cache は更新されない
  3. 2. に戻る

対策

ということで、 issue で提示されているワークアラウンドの通り、 RUBYOPT 環境変数を指定して fileutils を事前に require しておくようにします。これにより rubocop_required_features から fileutils が常に除外されるため rubocop_checksum の生成が安定するというわけです。

- name: Run RuboCop
  run: RUBYOPT='-rfileutils' bundle exec rubocop --format simple

ただし、 fileutils の問題に関しては今後 RuboCop 側で修正される可能性もありますから、あくまで 2023-12 現在のワークアラウンドであるということは書き添えておきます。

ともあれ、ようやく無事に cache が効くようになりました。導入日の 9/19 以降に実行時間が劇的に改善していることがわかります。これで一件落着です。

最終的な workflow yaml は以下のようになりました。

name: RuboCop

on:
  pull_request:
    branches:
      - main
  push:
    branches:
      - main

jobs:
  rubocop-all:
    runs-on: ubuntu-latest
    steps:
      # checkout や ruby の setup 等の処理は省略しています
      - name: Cache rubocop
        uses: actions/cache@v3
        with:
          path: ~/.cache/rubocop_cache
          key: rubocop-${{ hashFiles('.rubocop.yml', '.rubocop_todo.yml', '.ruby-version', 'db/schema.rb') }}-${{ hashFiles('Gemfile.lock') }}
          restore-keys: |
            rubocop-${{ hashFiles('.rubocop.yml', '.rubocop_todo.yml', '.ruby-version', 'db/schema.rb') }}-
      - name: Run RuboCop
        run: RUBYOPT='-rfileutils' bundle exec rubocop --format simple

おわりに

この記事では、 RuboCop を CI で実行した際に遭遇した cache にまつわる 3 つの問題とその対処について紹介しました。cache を最適化することで、初めは 5 分以上かかっていたテストを最終的には 1 分以内に収めることができました。CI の最適化や開発体験の向上については他にも取り組んでいることがありますので、また機会があれば記事にしたいと思います。

明日12日目は山本さんです。お楽しみにー


JMDCでは、ヘルスケア領域の課題解決に一緒に取り組んでいただける方を積極採用中です! フロントエンド /バックエンド/ データベースエンジニア等、様々なポジションで募集をしています。 詳細は下記の募集一覧からご確認ください。

hrmos.co

まずはカジュアルにJMDCメンバーと話してみたい/経験が活かせそうなポジションの話を聞いてみたい等ございましたら、下記よりエントリーいただけますと幸いです。

hrmos.co

★最新記事のお知らせはぜひ X(Twitter)をご覧ください!

https://twitter.com/jmdc_tech