CircleCIからGitHub Actionsへ移行、テスト実行時間をp95で15分から6分に短縮!移行時の課題と解決策 - Timee Product Team Blog

Timee Product Team Blog

タイミー開発者ブログ

CircleCIからGitHub Actionsへ移行、テスト実行時間をp95で15分から6分に短縮!移行時の課題と解決策

エンジニアリング本部 プラットフォームエンジニアリングチームの徳富です。我々のチームでは、CIパイプラインの効率化と開発体験の向上を目指し、CircleCIからGitHub Actionsへの移行を進めてきました。移行によってテスト・静的解析(以降CIと記載する)の実行時間をp95で9分短縮しましたが、この移行にはいくつかの課題もありました。今回は移行の背景や移行時の苦労について紹介します。

本記事のまとめ

  • CircleCIからGitHub Actionsに移行し、CI実行時間をp95で9分短縮
  • 並列実行やテスト結果の連携における課題を解決し、効率化を実現
  • テスト結果のPR通知や分割テストの仕組みを工夫し、開発体験を向上

CircleCIからGitHub Actionsへ移行した背景

以前利用していたCircleCIのPerformanceプランでは、並列実行できるジョブ数が80に制限されていました。1回のCI実行に40の並列ジョブを使用していたため、同時にCIを実行できる開発者は最大2名まで。この状況では、今後開発者が増加するにつれて、CIの待ち時間が長くなるリスクを抱えていました。プランのアップグレードも検討しましたが、以下の理由からGitHub Actionsへの移行を決断しました。

理由1: 学習コストの削減と効率化

弊社では基本的にGitHub Actionsを利用していますが、RSpecテストと静的解析のみにCircleCIを使用していました。これをGitHub Actionsに統合することで、CircleCIとGitHub Actionsの両方を学ぶ必要がなくなり、開発者の学習コストが削減されます。また、以前はCircleCIでのCI完了後に、デプロイをGitHub Actionsで行うワークフローでした。CIの実行基盤をGitHub Actionsに移行することで、テストもデプロイもGitHub Actionsに統一できるようになります。これにより、デプロイフローがよりシンプルになり、メンテナンス性も向上しています。

理由2: セキュリティリスクと管理の効率化

CIプロバイダーをGitHub Actionsに一本化することで、複数のプロバイダーを管理する必要がなくなり、情報流出のリスクが低減し、一貫したセキュリティ対策を実施できます。さらに、管理するツールが減ることで管理業務が簡素化され、運用が効率的になります。

移行する上での課題となっていたポイント

  1. テスト実行時間ベースでテスト分割する仕組みがGitHub Actionsにない
  2. テストの失敗した情報をわかりやすく参照できるUIがない
  3. matrix strategyを使って並列実行をすると後続のjobに結果を送れない
  4. Flakyテストを一覧で参照できない

1. テスト実行時間ベースでテスト分割する仕組みがGitHub Actionsにない

CircleCIには、CircleCI CLI を使用してテストファイルを実行時間ベースで分割する機能がありましたが、GitHub Actionsには同様の機能がありませんでした。

そこで、代替としてmtsmfm/split-testライブラリを導入し、RSpecテストを均等に分割する仕組みを整えました。このライブラリは、過去のテスト実行時間をもとに、実行時間が均一になるようにテストファイルを分割します。そのためデフォルトブランチのテスト結果をS3に保存し、その結果を利用してテストを効率的に分割・実行するフローを構築しました。

2. テストの失敗した情報をわかりやすく参照できるUIがない

CircleCIには、テスト結果をわかりやすく表示する機能が標準で備わっており、開発者はテストの成否や詳細を簡単に確認できました。しかし、GitHub Actionsにはそのような標準機能がなく、開発者がテスト結果を確認するのに手間がかかってしまうという課題がありました。

そこで、SonicGarden / rspec-report-actionにパッチを適用し、テスト結果をPRにコメントする仕組みを導入しました。この改善により、CircleCIにログインせずにPR内で直接テスト結果を確認できるようになり、作業効率が向上しました。

3. matrix strategyを使って並列実行をすると後続のjobに結果を送れない

GitHub Actionsでは、matrix strategyを使うことで、並列実行ができます。しかし、この並列実行には問題があり、後続のジョブにテスト結果を渡せないという課題がありました。

最初は、各マトリックスで生成されたテスト結果をアーティファクトとしてアップロードし、後続のジョブでダウンロード・集計する方針を検討しました。しかし、25並列で実行していたため、アーティファクトのアップロード頻度が増加し、すぐにGitHub Actionsのレート制限に達してしまいました。

この問題に対処するため、テスト結果をGitHub Actionsのアーティファクトではなく、S3にアップロードする方法に切り替えました。これにより、レート制限の問題を回避でき、後続のジョブでテストの結果をPRに投稿する仕組みが無事に機能するようになりました。

4. Flakyテストを一覧で参照できない

CircleCIではテストインサイトを使ってFlakyテストを確認できましたが、GitHub Actionsにはそのような機能がありませんでした。当初はCodecovのTest Analyticsを使ってFlakyテストを確認していましたが、絞り込み機能が不十分で、ページの表示に時間がかかるという問題がありました。

そこで、現在はDatadog Test Visibilityを利用してFlakyテストを確認しています。Datadogを使用することで、テストのTrace情報などの詳細なデータを収集できるだけでなく、不安定なテストが検出された際にアラートを設定することも可能になりました。

完成したワークフローの一例

ここで、最終的に完成したワークフローの一部をご紹介します。GitHub Actionsへの移行を検討している方にとって、少しでも参考になれば幸いです。

name: ci

on:
  push:
    branches:
      - '**'
    tags-ignore:
      - '*'

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  generate-matrix:
    runs-on: ubuntu-latest
    timeout-minutes: 1
    outputs:
      parallelism: ${{ steps.set-matrix.outputs.parallelism }}
      ids: ${{ steps.set-matrix.outputs.ids }}
    steps:
      - id: set-matrix
        run: |
          parallelism=25 # テストの並列数
          ids=$(seq 0 $((parallelism - 1)) | jq -s | jq -c)
          echo "parallelism=[$parallelism]"
          echo "ids=$ids"
          echo "parallelism=[$parallelism]" >> "$GITHUB_OUTPUT"
          echo "ids=$ids" >> "$GITHUB_OUTPUT"

  rspec:
    needs: [generate-matrix]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    env:
      DD_CIVISIBILITY_AGENTLESS_ENABLED: true # Datadog Test Visibility用
      DD_API_KEY: ${{ secrets.DATADOG_API_KEY }} # Datadog Test Visibility用
      DD_ENV: ci # Datadog Test Visibility用
    strategy:
      fail-fast: false
      matrix:
        parallelism: ${{ fromJson(needs.generate-matrix.outputs.parallelism) }}
        id: ${{ fromJson(needs.generate-matrix.outputs.ids) }}

    services:
      # rspec実行に必要なコンテナを起動
   
    steps:
      - name: Generate token
        id: generate_token
        uses: actions/create-github-app-token@v1
        with:
          app-id: app idを指定
          private-key: キーを指定

      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ steps.generate_token.outputs.token }}

      - name: Setup Ruby with caching
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.3
          bundler-cache: true

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: # S3にアクセスできる権限をもつロールを指定
          aws-region: # リージョン名

      # NOTE: デフォルトブランチで記録された各RSpecテストの実行時間データをダウンロード
      - name: Download rspec junit
        run: aws s3 cp s3://デフォルトブランチでのテスト結果(JUnit XML)が保存されているS3を指定 tmp/rspec_junit --recursive

      # NOTE: テストファイルを分割するためのツールをダウンロードし、後続ステップでテストファイルをダウンロードして分割する。
      #       これにより、マトリックスを使用して並列で実行されるRSpecテストが均等に分割され、
      #       すべてのテストジョブがほぼ同時に完了するようにする。
      - name: Split test file
        run: |
          curl -L --output split-test https://github.com/mtsmfm/split-test/releases/download/v1.0.0/split-test-x86_64-unknown-linux-gnu
          chmod +x split-test

      - name: RSpec
        run: |
          # テストファイルを分割しテストを実行する
          bundle exec rails db:create db:schema:load
          ./split-test --junit-xml-report-dir tmp/rspec_junit \
            --node-index ${{ matrix.id }} \
            --node-total ${{ matrix.parallelism }} \
            --tests-glob "spec/**/*_spec.rb" \
            --tests-glob "packs/*/spec/**/*_spec.rb" | xargs bundle exec rspec \
            --format progress \
            --format RspecJunitFormatter \
            --out report/rspec_junit/${{ matrix.id }}.xml \
            -f j -o report/results/${{ matrix.id }}.json \
            -f p

      # テストが完了したら、テスト結果をS3にアップロードし、後続のジョブで利用できるようにする
      - name: Upload test report
        if: always()
        run: |
          aws s3 cp report s3://テスト結果が保存されているS3を指定 --recursive

  rspec-status:
    runs-on: ubuntu-latest
    timeout-minutes: 1
    needs: [generate-matrix, rspec]
    if: ${{ !cancelled() && github.event_name == 'push' }}
    steps:
      - name: Check previous job status
        run: |
          if [ "${{ needs.rspec.result }}" == "success" ]; then
            echo "テスト成功"
          else
            echo "テスト失敗"
            exit 1
          fi

  upload-rspec-junit:
    runs-on: ubuntu-latest
    timeout-minutes: 2
    needs: [generate-matrix, rspec]
    if: github.ref == 'refs/heads/main' # デフォルトブランチを指定
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: # S3にアクセスできる権限をもつロールを指定
          aws-region: # リージョン名

      - name: Download rspec junit
        run: aws s3 cp s3://各マトリックスでのテスト結果が保存されているS3を指定 rspec_junit --recursive

      - name: Upload rspec junit
        run: aws s3 cp rspec_junit s3://各テストのJUnit XMLをアップロードするS3を指定 --recursive

  pr-comment:
    needs: [generate-matrix, rspec]
    if: ${{ !cancelled() }}
    runs-on: ubuntu-latest
    timeout-minutes: 2
    steps:
      - name: Generate token
        id: generate_token
        uses: actions/create-github-app-token@v1
        with:
          app-id: app idを指定
          private-key: キーを指定

      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ steps.generate_token.outputs.token }}

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: # S3にアクセスできる権限をもつロールを指定
          aws-region: # リージョン名
     
      - name: Download RSpec reports
        run: aws s3 cp s3://各マトリックスでのテスト結果が保存されているS3を指定 report --recursive

      - name: RSpec Report
        if: steps.find_pr.outcome == 'success'
        uses: SonicGarden/rspec-report-actionアクションに一部パッチを適用したアクションを指定
        with:
          token: ${{ steps.generate_token.outputs.token }}
          json-path: report/results/*.json
          comment: "${{ github.event_name == 'push' }}" # PRにコメントするかどうか
          pull-request-id: ${{ fromJson(steps.find_pr.outputs.pr_json).number }}
          hideFooterLink: true

得られた成果

テスト時間の短縮による開発効率の向上

GitHub Actionsへの移行により、p95でテスト実行時間が9分短縮されました。並列実行の制限がなくなったことで、CI/CDのパフォーマンスが大幅に向上しました。この改善により、開発者の作業効率が向上し、より早くフィードバックを得られる環境が整いました。

  • CircleCIの時はp95で15分

  • GitHub Actionsに移行後はp95でテストが6分程度で終わるようになっているになっている

移行のまとめと今後の課題

CircleCIからGitHub Actionsへの移行により、テスト実行時間の大幅な短縮という大きな成果を得られました。しかし、移行に伴う課題も少なくなく、とくにテスト結果の連携テストの分割には工夫が必要でした。

今後は、GitHub Actionsの新機能やさらなる最適化手法を活用し、テストやデプロイのより一層の効率化を目指していきます。本記事が、同様の課題に直面している方々の参考になれば幸いです。