🧘‍♂️

Rails CI チューニング 2024 年末

2024/12/21に公開

この記事は MICIN Advent Calendar 2024 の 21日目の記事です。

https://adventar.org/calendars/10022

前回はkbaseさんの、「セキュリティチェックシートを楽にしたい」 でした。

こんにちは、MICIN のプラットフォームチーム所属の酒井です。

先日、 ISUCON14@abekoh@rikson と一緒にチーム tokiwa として参加してきましたが、大したスコア貢献もできなかった上、失格になるという失態を犯してしまったので、 Rails の CI のパフォーマンスチューニングをしています(?)。

現状

弊社には、創業以来運用している築8年の Rails モノリスがあり、オンライン診療クロンのほか、クロンお薬サポートクロンスマートパスといったプロダクトのバックエンドを担っています。

このアプリケーションの CI として、 GitHub Actions 上で、 RSpec の実行のほか、いくつかの静的解析をしています。

これらの CI が極端に遅くストレスになっているわけではないのですが、いくつか改善できる余地はありそうだと以前から思っていたので、その改善をしてみました。

改善前の GitHub Actions のスクショを見ると以下のようなワークフローが実行されています。

改善前のワークフローの実行時間

詳細は割愛して、簡単にそれぞれ以下のような処理が実行されています。

  • download-test-report からの並列テスト
    • RSpec のテストを並列で実行
  • rubocop
    • Rubocop による Lint
  • constants_check
    • 未定義のトップレベル定数(クラスやモジュール)への参照が存在しないかのチェック
    • こちらを参考に実装したスクリプト
  • verify-graphql-schema
    • GraphQL Ruby で実装したスキーマから、スキーマファイルをエラーなく出力できるか・コミットされているスキーマファイルと差分がないかをチェック
  • packwerk
    • Packwerk によるパッケージ依存関係のチェック
  • typecheck
    • Sorbet による型チェック

ワークフロー全体としては5分強というところですが、コミット量も多いリポジトリのため月間の GitHub Actions の Usage を見てもそこそこ使用しているようでした。
実行時間を削減できれば多少のコスト減になりそうです。

改善

結果として、いくつかの小さな改善を積み重ねた形なので、以下、ラフにそれらを列挙します。

DBヘルスチェックのタイミングを遅らせる

RSpec の実行など DB に依存したジョブの実行では、 Actions 内で PostgreSQL のサービスコンテナを立てています。
元々は、以下のように、 options でヘルスチェックコマンドを実行していました。

      postgres:
        image: postgis/postgis:17-3.5
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

ただ、これだとヘルスチェックが通ってから、以降のジョブステップの実行が開始される挙動となるようです。一方で、厳密に PostgreSQL の起動を待たずとも、実際に DB にアクセスが発生する前には Ruby 等の依存関係をインストールするステップがあるため、大体その間に PostgreSQL の起動は完了します。

そのため、ヘルスチェックの options は削除して、実際に DB アクセスが発生する前にシェルからヘルスチェックをするように変更しました。

    - name: Wait for DB to be ready
      run: |
        until PGPASSWORD=postgres psql -h localhost -U postgres -c '\q'; do
          echo "Waiting for PostgreSQL to become available..."
          sleep 1
        done
        echo "PostgreSQL is up and running."

    - name: DB Setup
      run: bin/rails db:setup

地味ですが、大体 10s 前後の短縮です。

テストの並列実行を parallel_tests + Large Runner に変更

これは少し大きめの変更です。

元々は、こちらの記事を参考に、標準の GitHub Hosted Runner (2コア)を4並列にして RSpec を実行していました。

ただ、こちらの記事を見て、1つのジョブにまとめて、コア数を増やした方が速い可能性もありそうだということで試してみました。

https://r7kamura.com/articles/2023-10-31-rails-ci

テストの並列化は parallel_tests を使用し、8コアの Large Runner で実行しました。

結果としては、割と実行時間にばらつきは出るのですが、おおよそ 10s 〜 30s の短縮がみられ、ワークフロー自体もシンプルになったので、この変更は採用しました。

Rubocop のキャッシュ

Rubocop は、バージョンや設定に変更がなく、対象ファイルにも変更がなければ、キャッシュを使えるようです。下記の記事を丸っと参考にさせてもらい、キャッシュを行うようにしました。

https://blog.shibayu36.org/entry/2022/03/31/173000

以前は Rubocop 実行のステップに2分強かかっていましたが、キャッシュが効くケースでは数秒で終わるようになりました。

おまけ

ついでに、フォーマッタの出力として、 GitHub Actions Formatter を追加しました。

bundle exec rubocop --format progress --format github --parallel

これで、 違反時に PR やコミットの該当箇所にアノテーションが付き、確認がしやすくなります。

apt-get install のキャッシュ

Rails アプリケーションの依存として、 GitHub Hosted Runner にはプリインストールされていないシステムパッケージをインストールしていました。(具体的には libvips42fonts-ipafont です。)

これらは、 apt-get install でインストールしていたのですが、地味に時間がかかっており、ばらつきはあるのですが 20s 〜 40s の時間がかかっていました。

全てのジョブで共通のステップなので、キャッシュを効かせたいなあと思いつつ、標準的な Action は無さそうだったのですが、 awalsh128/cache-apt-pkgs-action が期待通り動いてくれるようだったので、これを利用するようにしました。

    - uses: awalsh128/cache-apt-pkgs-action@f2fc6d1af4d6abf8a4dcd37fd74a9a15c2273b9f
      with:
        packages: libvips42 fonts-ipafont
        version: 1

PostgreSQL のデータディレクトリに tmpfs を使う

DB を利用しているジョブでは、 tmpfs を使用するようにしました。

      postgres:
        image: postgis/postgis:17-3.5
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --mount type=tmpfs,destination=/var/lib/postgresql/data

これによって、 10s 前後短縮が見られました。

結果

以上を盛り込んだ結果、仕上がりとしては以下のようになっています(ただしキャッシュがうまく効くケースです)。

改善後のワークフローの実行時間

  • ワークフローの実行時間は5分強から3分半くらいに
  • 総実行時間(課金対象の時間) が32分前後から8分前後に。 Large Runner のコストを考慮すると標準の Runner で18分相当。

まずまずの改善といったところでしょうか…?

ボツ案

試したがダメだったもの達です。

ARM Runner を使う

本番環境は AWS ECS 上で x86_64 の Fargate で動いているので、 RSpec については同じアーキテクチャで実行した方が良いと考えますが、一方、 Rubocop 等の静的解析シリーズは ARM コンテナで実行することにそれほど懸念はなさそうな気もするので、その乗せ替えがうまくいくなら実行時間短縮・コスト減になるかもと考えていました。

試してみたのですが、 setup-ruby Action の ARM 対応がなされていないようだったので、今回は諦めています。
https://github.com/ruby/setup-ruby/issues/577

Bootsnap キャッシュを利用する

こちらの記事を参考に、 Bootsnap のキャッシュを利用するようにしてみました。

ただ、元々の起動時間がそれほど大きくなく効果が薄いのか、目に見えた変化はなかったので、変更は取りやめました。

更なる改善?

今回は、CI 実行ステップの改善を中心に行い、この周辺は一旦アイディアは尽きています。

ここからは、テスト自体の改善を地道に行う必要があるのかなと思っています。

特に弊社のテストは、 FactoryBot でデータをDBに書き込んでテストするテストケースが多いのですが、この書き込みがテスト実行時間の大半を占めているのではないかと睨んでいます(ちゃんと計測してない)。
TestProf で、 Factory のプロファイルをとってみると、大量のレコードを書き込んでいる Factory もいくつかあったので、これらを改善すると実行時間はもう少し短くなるのではないかと思っています。

また、折を見てチャレンジします。


MICINではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!
https://recruit.micin.jp/

株式会社MICIN

Discussion