GitHub Actionsのmatrix strategyでCI実行時間を改善する
はじめに
dely株式会社で26卒内定者インターンをしているkaito(@kaito_bq)です。
この記事では、GitHub Actions×AWS CodeBuildで動いているCIの実行時間を手軽に改善した方法について紹介します。
弊社ではRuby on Railsを採用しているため、CIはRSpecで行っていますが、この記事で紹介する方法はRailsに限らず導入できると思います。
概要
クラシルリワードのバックエンドのCIは、GitHub Actionsのセルフホステッドランナーを使用してAWS CodeBuild上で動いています。
元々はGitHub Actions上で動いていたものを、Ubuntu 20.04がEOLを迎えたことなどを踏まえ、セルフホステッドランナーに移行した背景があります。
改善前のCIの実行時間は30分ほどでしたが、GitHub Actionsのmatrix strategyを使用することで約半分に短縮することができました。
Before
After
また、Docker HubのRate Limitを回避する方法、GHAのJob Summary機能についても紹介したいと思います。
matrix strategyの導入
jobs:
execute_rspec:
...
runs-on: codebuild-serverside-ci-${{ github.run_id }}-${{ github.run_attempt }}
strategy:
matrix:
test-group: [1, 2, 3, 4]
fail-fast: false
steps:
...
- name: Start MySQL
...
- name: Run tests
run: |
TEST_FILES_TO_RUN=$(find spec -name "*_spec.rb" -type f | sort | awk "(NR-1) % 4 == $(( ${{ matrix.test-group }} - 1 ))" | tr '\n' ' ')
...
docker run --network ${{ env.Docker_NETWORK }} --env-file .env \
bundle exec rspec ${TESTS_FILES_TO_RUN}
matrix strategyを用いてCodeBuild上にランナーを4つ立ち上げています。
各ランナーが担当するテストファイルは、テストファイルのインデックスを並列数(4)で割った剰余+1 が各ランナーの番号(1~4)と一致するものになります。
これにより、ファイル数を均等に各ランナーに配分できます。
CIを並列で動かす際はデータベースの競合を考慮する必要がありますが、現在の構成では、各ランナーがそれぞれMySQLを立ち上げ、テストを実行しているため、その心配はありません。
parallel_testsを導入する際にはその辺りの考慮が必要になりますが、この記事では取り扱いません。
matrix strategyを導入したことによって、カバレッジレポートが正常に生成されなくなります。
この問題は、RSpec JUnit Formatterや、SimpleCovのResultMergerを使うことで解決できますが、セルフホステッドランナーを利用していることなどから導入が難しく、断念しています。
Docker HubのRateLimitに対応する
段階的な導入のため、matrix strategyを導入したCIを既存CIと並行して動かす期間を設けていました。
観察していたところ、改善後のCIがDocker HubのRateLimitによるエラーが頻発していました。
Docker Hubでは、ログインしている場合にはアカウント単位、ログインしていない場合にはIPアドレス単位でRate Limitが設定されます。
ユーザータイプ | 6時間あたりの制限 |
---|---|
ビジネス(認証済) | 無制限 |
チーム(認証済) | 無制限 |
プロ(認証済) | 無制限 |
個人情報(認証済) | 200 |
認証していない | IPアドレスごとに100 |
matrix strategy導入後のCIでは、以下のイメージをDocker Hubからダウンロードしており、1度CIが動くたびに16回pullが発生することになっていました。
- MySQL
- Redis
- MinIO
- DynamoDB
AWS CodeBuildの東京リージョンに割り当てられたIPアドレスは2つ(2025年6月時点)であるため、CIが回るたびに制限が来ていないIPガチャをしている状態でした。
今回は、Docker HubのRateLimit制限に対応するために、CIに必要なイメージをECRにキャッシュしておくことにしました。
jobs:
execute_rspec:
...
runs-on: codebuild-serverside-ci-${{ github.run_id }}-${{ github.run_attempt }}
strategy:
matrix:
test-group: [1, 2, 3, 4]
fail-fast: false
steps:
...
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Cache service images to ECR
run: |
ECR_REGISTRY=${{ steps.login-ecr.outputs.registry }}
CACHE_REPO=${{ env.CACHE_REPO }}
# ECRからpullできない場合はDocker Hubからpullする
echo "Pulling MySQL image..."
if ! docker pull ${ECR_REGISTRY}/${CACHE_REPO}:mysql-8.0; then
echo "MySQL not found in ECR, pulling from Docker Hub and caching..."
docker pull mysql:8.0
docker tag mysql:8.0 ${ECR_REGISTRY}/${CACHE_REPO}:mysql-8.0
docker push ${ECR_REGISTRY}/${CACHE_REPO}:mysql-8.0
fi
- name: Start MySQL
...
MySQLを実行する前に、ECRにimageがあるかチェックするステップを追加しました。
ECRにイメージが無い場合にのみDocker Hubからpullするようにしています。
ECRにはライフサイクルポリシーがあるため、自動的にimageが破棄される場合があります。
今回は7日で破棄されるように設定しているため、7日おきにDockerHubへのpullが4回発生することになります。
Job Summary機能
matrix strategyを導入することで、CI実行時間は短縮できましたが、各matrixのログは追いづらくなってしまいます。
そこで、GHAのJob Summary機能を利用することにしました。
echo "Hello world!" >> $GITHUB_STEP_SUMMARY
このようにするだけで、各ジョブの結果をSummaryに出力することが可能です。
jobs:
execute_rspec:
...
runs-on: codebuild-serverside-ci-${{ github.run_id }}-${{ github.run_attempt }}
strategy:
matrix:
test-group: [1, 2, 3, 4]
fail-fast: false
steps:
...
- name: Run Tests
run: |
...
set +e # 後続処理のためにRSpecが落ちても続行する
docker run --network ${{ env.DOCKER_NETWORK }} --env-file .env \
-v ${HOST_OUTPUT_DIR}:${CONTAINER_OUTPUT_DIR} \
bundle exec rspec ${TEST_FILES_TO_RUN} > \"${RSPEC_OUTPUT_FILE_IN_CONTAINER}\" 2>&1
RSPEC_EXIT_CODE=$?
set -e
if [ $RSPEC_EXIT_CODE -ne 0 ]; then
echo "RSpec tests failed for group ${{ matrix.test-group }} with exit code $RSPEC_EXIT_CODE"
echo "🔴 **Status: RSpec tests failed** (Exit Code: $RSPEC_EXIT_CODE)" >> $GITHUB_STEP_SUMMARY
exit $RSPEC_EXIT_CODE
else
echo "RSpec tests passed for group ${{ matrix.test-group }}"
echo "✅ **Status: RSpec tests passed**" >> $GITHUB_STEP_SUMMARY
fi
RSpecの実行結果をコンテナ内のファイルに出力し、それをホスト側にマウントしています。
ファイルから必要な部分を抽出し、Summaryに出力することが可能です。
この際、set +e
としておくことで、RSpecが失敗しても後続の処理が続行されるようにしています。
おわりに
この記事ではGitHub Actionsのmatrix strategyを用いてCIを並列実行する方法について書きました。
結果として、実行時間を半減させることができました。
進化しているAIツールやその導入に伴い、自動テストやテスト実行時間の改善はさらに重要になってきていると思います。
delyでも、エンジニアリングをより効率化するために、より生産性の高い環境を追求していきます。
おまけ
各ジョブのテストファイルを収集するために、はじめはls
コマンドを使って集めていました。
業務で使用しているMac上では想定通り動作していたのですが、CI上ではテストファイル数がかなり減っている現象が起こりました。
調べたところ、Bashにはglobstarというオプションがあるらしく、これがオフになっていることが原因でした。
CodeBuild上でCIを動かしているので、GHA上でUbuntuなどを利用した場合の動作までは不明ですが、find
を使うことをお勧めします。(他のCI改善記事などを見る限りfindを使うことが多いようです)
Discussion