😊

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の導入

.github/workflows/ci.yml
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にキャッシュしておくことにしました。

.github/workflows/ci.yml
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機能を利用することにしました。
https://docs.github.com/ja/actions/reference/workflow-commands-for-github-actions#adding-a-job-summary

echo "Hello world!" >> $GITHUB_STEP_SUMMARY

このようにするだけで、各ジョブの結果をSummaryに出力することが可能です。

.github/workflows/ci.yml
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を使うことが多いようです)

dely Tech Blog

Discussion