STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

GitHub ActionsでRuby on RailsのCI環境を構築する上でのポイント

STORES 予約 でwebアプリケーションエンジニアをやっております。ykpythemindです。

GitHub Actions、とても便利ですよね。STORES 予約チームでは徐々にCircleCI から GitHub Actionsへの移行を進めていますが、この度歴史あるRailsリポジトリのCIを移行したので知見を公開します。

概要

  • RSpecを実行する
  • CIの実行速度のチューニング(CircleCIと同等の速度にしたい)
    • node_modulesなどのインストール結果をキャッシュする
    • テストを並列実行する

大きな方針として、CircleCI等の他サービスからの乗り換えの場合、同等のCI速度/課金額でないと移行は現実的でないと思いますので、速度面のチューニングも意識しています。

ほぼそのままの設定を貼ります

一部プロジェクト固有のstep等があり注釈コメントをつけています。適宜調整が必要です。

*1

GitHub Action + Rails test example

各ハマりポイントは以下の通りです。

ポイント1. servicesのエントリーポイントを上書きすることができない

servicesとしてMySQLコンテナを立てていますが、ここで任意の引数を渡してコンテナを起動できません。 したがって、step内でsql_modeの調整をしています。

参照: jobs.<job_id>.services.<service_id>.options optionsはdocker createの引数になりそうなので、なんとか渡せそうなのですが、結論としてはできません。

- name: set MySQL sql_mode
  run: |
    mysql --ssl-mode=DISABLE --protocol=tcp --host 127.0.0.1 --user=root --password=${DB_PASSWORD} mysql <<SQL
    SET GLOBAL sql_mode = 'NO_ENGINE_SUBSTITUTION';
    SET GLOBAL character_set_server = 'utf8mb4';
    SET GLOBAL collation_server = 'utf8mb4_general_ci';
    SQL

(2021/05/24追記: volumesを用いて /etc/mysql/conf.d にマウントするという手段もありそうです(未検証) )

ポイント2. needs を使って実行時間を節約する

  backend-test:
    name: ${{ matrix.target }} ${{ matrix.index }}
    needs: prepare

needs を設定することで、 backend-test job は prepare jobの完了を待つようになります。prepare job内で assets precompileやnpm installなどを実行しておくことにより、テストを並列実行するjobの実行時間の節約になります。

*2

なお、billable timeはActionsタブから各run結果のページに行き閲覧できます f:id:ykpythemind:20210520234530p:plain

ポイント3. timeout-minutes を設定する

こちらは良く記事で見る内容です。jobにタイムアウトを実行することで、jobが暴走しても課金額が安心です。

ポイント4. 並列実行で matrix を使用する

    env:
      CI_NUMBER_OF_NODES: 8 # NOTE: 並列に実行する数. ここを増やしたらmatrix.indexも増やすこと.

    strategy:
      fail-fast: false
      matrix:
        target: [backend]
        index: [0, 1, 2, 3, 4, 5, 6, 7] # NOTE: 要素数は CI_NUMBER_OF_NODES の個数分にすること

このようにしておくと、要素の数だけ複数jobが起動し、ステップで CI_NODE_INDEX: ${{ matrix.index }} の形でindexを参照できます。fail-fastをfalseにしておくと、どれかのjobがコケても最後まで実行します。

ポイント5. ファイルのsplitterを実装する

CircleCIでは circleci tests split --split-by=timings *3 を用いることができ、かんたんにテストを各jobに分配できます。また、テストの実行時間をよしなに記録しておいてくれるので、実行時間が特定のコンテナに偏ってしまうことも起きにくいです。

2021/05/20現在GitHub Actionsにはそのような機能は存在しないので、今回はテストファイルを分配するスクリプトを用意して対応しました。

GitHub Action + Rails test example ( splitter )

ファイルの行数が長いファイルはだいたい実行時間が長いと考えて重み付けしてしまっていいだろうという大雑把な仮定のもと、貪欲法で分割しています。 CI_NUMBER_OF_NODESとCI_NODE_INDEXを用いて分配されたファイル群を取り出すことができます。

TEST_FILES="$(ruby spec/spec_splitter.rb --glob='spec/**/*_spec.rb' --node-count=$CI_NUMBER_OF_NODES --node-index=$CI_NODE_INDEX)"
bundle exec parallel_rspec -n $PARALLEL_TESTS_CONCURRENCY -- $TEST_FILES

すごく重いテストがあり調整をしていますが、各job間での実行時間差が1分程度に収まっているので一旦は良しとしています。

ベターな方法としてはテスト結果(実行時間のメタデータ)をartifactとして保存しておく方法 ( GitHub Actions でテストを並列に実行して高速化する (parallelism) ) や、knapsack を使う方法がありますが、前者はmainブランチの結果を取得するために少々ハック感があったので今回は見送っています。後々何らかの導入を考えています。

ポイント6. dependabotが作るPull Requestでsecretsが参照できない

2021年3月から以下のようにsecretsが参照できずにCIが落ちるという現象が起きるようになったようです。 GitHub Actionsは非常にセキュアな印象ですが、(買収したはずの)dependabotが不便になってしまったため、改善されることを期待しています。

simple-minds-think-alike.hatenablog.com

(2021/05/24追記: リンク先の記事にあるように pull_request_target トリガーを使えば解決できますが、セキュリティ上の注意点はありそうです。 )

まとめ

GitHub ActionsはCircleCIとだいたいできることは同じはずなのですが、外部Actionの品質や拡張性、secrets機能などのGitHubとの親和性でかなり楽しく遊べる印象があります。

heyではGitHub Actionsを使い倒すエンジニアを募集中です。よろしくおねがいします。

hello.hey.jp

*1: discourse/tests.yml at main · discourse/discourse · GitHub なお、discourse はすでにGitHub Actionsを使っており参考になります

*2:同僚の指摘で気づいた内容です。テスト自体の実行時間は変わりませんが、課金時間は半分ほどになりました

*3: Running Tests in Parallel - CircleCI