ファインディでのGitHub Actions高速化の事例

ファインディ株式会社でフロントエンドのリードをしております 新福(@puku0x)です。

弊社では、数年前に社内のCI環境をすべてGitHub Actionsに移行しました。

この記事では、弊社のGitHub Actions活用事例の内、CI高速化についてご紹介します。

なぜCI高速化に力を入れるのか

当ブログをはじめ弊社では、たびたびCI高速化の大切さについて言及しています。

これはなぜでしょうか?

開発が進むにつれて、コードベースが肥大化し、CIの待ち時間が増えていくのは皆さんにも経験があると思います。

CIの待ち時間が長いとついレビューを放置してしまいがちです。 レビューが遅いとブランチの生存期間が伸び、コンフリクトの発生確率が上がります。 コンフリクトを解決しても、CIが遅い状態ではまた同じことの繰り返しとなるでしょう。

GitHubの調査では、開発者は多くの時間をCI待ちに費やしていると報告されています。

github.blog

見方を変えると、CI高速化はコーディングの効率化と同程度のインパクトがあると言えます。

チームの開発生産性を支える基盤として、弊社はCI高速化に力を入れているのです。

CI高速化

キャッシュの活用

弊社では、actions/cache を使って、依存関係のインストールを省く工夫を取り入れています。

ここでは例として、フロントエンド系のリポジトリのワークフローを紹介します。

- uses: actions/setup-node@v4
  id: setup_node
  with:
    node-version: 20

- uses: actions/cache@v4
  id: cache
  with:
    path: node_modules
    key: ${{ runner.arch }}-${{ runner.os }}-node-${{ steps.setup_node.outputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }}

- if: steps.cache.outputs.cache-hit != 'true'
  run: npm ci

フロントエンド周りのCIを組んだことのある方はピンと来たかと思います。

このワークフローでは、node_modules ディレクトリをキャッシュしています。

npm公式では非推奨とされていますよね?

なぜこのような書き方でも大丈夫なのでしょうか?その秘密はキャッシュのキーにあります。

key: ${{ runner.arch }}-${{ runner.os }}-node-${{ steps.setup_node.outputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }}

キャッシュキーとして、OSやNode.jsのバージョン、パッケージマネージャーの種別などが細かく設定されています。

node_modules ディレクトリのキャッシュが非推奨とされる理由は、異なる環境で実行されることによるファイルの不整合を防ぐためです。これに気を付けていればキャッシュしても良いのです。
※最近のGitHub ActionsではArmランナーが利用できるため、キャッシュキーにCPUアーキテクチャを追加するとより堅牢になるでしょう。

node_modules がキャッシュヒットしなかった場合を考慮して、.npm ディレクトリのキャッシュも含めると次のようになります。

- uses: actions/setup-node@v4
  id: setup_node
  with:
    node-version: 20

- uses: actions/cache@v4
  id: cache
  with:
    path: node_modules
    key: ${{ runner.arch }}-${{ runner.os }}-node-${{ steps.setup_node.outputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }}

- uses: actions/cache@v4
  if: steps.cache.outputs.cache-hit != 'true'
  with:
    path: |
      ~/.npm
    key: ${{ runner.arch }}-${{ runner.os }}-node-${{ steps.setup_node.outputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: ${{ runner.arch }}-${{ runner.os }}-node-${{ steps.setup_node.outputs.node-version }}-npm-

- if: steps.cache.outputs.cache-hit != 'true'
  run: npm ci

弊社の場合では、これでおよそ20秒〜30秒ほど高速化できました。

ジョブの並列化

バックエンドのテストを例に挙げます。このワークフローでは、matrix 機能を用いてテストを10並列で動作させるようにしました。

strategy:
  matrix:
    ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
steps:
  - name: Test
    env:
      CI_NODE_TOTAL: ${{ strategy.job-total }}
      CI_NODE_INDEX: ${{ matrix.ci_node_index }}
    run: |
      # get spec files order by filesize
      TEMP_FILE_PATH=$(mktemp)
      git ls-tree -r -t -l --full-name HEAD | grep '_spec.rb' | sort -n -k 4 | awk '{ print $5 }' | ./scripts/ci/rspec_split_files.sh > $TEMP_FILE_PATH

      # echo outputs
      echo "====SPEC FILES COUNT===="
      cat $TEMP_FILE_PATH | tr ' ' '\n' | wc -l
      echo "====SPEC FILES===="
      cat $TEMP_FILE_PATH | tr ' ' '\n'

      # run rspec
      bundle exec parallel_rspec -- --format progress -- $(cat $TEMP_FILE_PATH)
#!/bin/bash

i=0
ret=()

while read -r line
do
  if [ $[i % $[CI_NODE_TOTAL]] = $[CI_NODE_INDEX] ] ; then
    ret+=($line)
  fi
  let i++
done

echo ${ret[@]}

単純にテストを均等配分するのではなく、ファイルサイズでソートするという工夫が施されています。これは、テスト実行時間がファイルサイズに比例するという仮定に基づいています。

これらの工夫により、実行時間が従来の約半分になるまで高速化できました 🚀

詳細は↓こちらの記事をご覧ください。 tech.findy.co.jp

Larger Runners

基本的には並列化でCI高速化を目指しますが、難しい場合はLarger Runnersを使うのも手です。
(並列化の可否の判定は、テスト対象ファイルの分割を外部から制御可能かどうかで決めています)

Larger Runnersは、契約しているプランが「GitHub Teamプラン」または「GitHub Enterprise Cloudプラン」の場合に利用可能です。 最大で64コアまでスペックアップできるらしいです。いつか使ってみたいですね!

docs.github.com

Armランナーについては先日GAとなったこともあり、積極的に移行を検討するようになりました。

github.blog

3割程度のコスト削減ができることから、既に社内のいくつかのプロジェクトではLinux Armランナーに完全移行しています。

2024年9月現在では、ruby/setup-ruby などサードパーティの対応が進んでいないものもあります。移行の際は動作検証を十分にしておくと良いでしょう。

参考までに、弊社のフロントエンドでの高速化の例を示します。

Build Test
2コア 15m8s 12m7s
4コア 8m38s 6m56s

スペックアップするとコストは増えますが、その分CIの待ち時間の削減が期待できます。実質的な負担に大きな変化が無い場合は強気でスペックアップしていきましょう 💪

まとめ

いかがでしたでしょうか?

この記事では、弊社のGitHub Actions活用事例の内、CI高速化についてご紹介しました。

CIの待ち時間については、「継続的デリバリー」の書籍を参考に 10分以内 を目指すと良いでしょう。

www.kadokawa.co.jp

実際に弊社では、これらの取り組みを行う前は1PRあたり15分〜20分ほどかかっていたCIが、10分以内の完了を目指して取り組んできた結果、平均5分程度まで高速化できた例もあります。

弊社にはCIの整備に関心の高いメンバーが多く在籍しております。勉強会等でお会いする機会がありましたらぜひお声がけください。

こちらの発表については、connpassのイベントページにアーカイブ動画へのリンクを載せております。 findy.connpass.com

次回へ続きます!👋

ファインディでは一緒に会社を盛り上げてくれるメンバーを募集中です。興味を持っていただいた方はこちらのページからご応募お願いします。

herp.careers