目次

目次

GitHub ActionsでCIの実行時間を6倍短縮した話

川又康了
川又康了
最終更新日2024/06/14 投稿日2024/06/14

TL;DR

  • CIをJenkinsからGitHub Actionsへ移行
  • テストを並列実行
  • 実行時間を30分から5分に短縮

はじめに

サーバーサイドエンジニアの川又です。 murketというECソリューションサービスで主にAPIの開発を担当しています。 趣味は筋トレとFPSゲームです。最近植物を育て始めましたが、何も調べずに肥料を与えたところ量が多すぎて植物を枯らしかけてしまいました。

先日、murketで利用しているCIツールをJenkinsからGitHub Actionsに移行して、実行時間を6倍ほど短縮することができたのでその方法を紹介できればと思います。

移行した背景

murketではもともとCIツールとしてJenkinsを利用していましたが以下のような課題を抱えていました。

  • CIの実行に30分~40分ほどかかってしまう
    • 8割くらいがユニットテストの実行時間
  • メンテナンスコストが高い
    • Jenkins自体のバージョンアップやPHPのバージョンアップなど
  • (移行直前あたりには)メモリ不足でCIを最後まで実行できないことがあった

また、以前まではGitHub Enterprise Serverを利用しておりGitHub Actionsの利用に制約があったのですが、GitHub Enterprise Cloudへ移行したことにより制約がなくなったため、この機会に以下の効果を狙って移行をすることにしました。

  • ジョブの並列実行による実行時間の短縮
  • メンテナンス性の向上
  • CI実行結果の見やすさ向上
    • プルリクエストにテスト等のエラーを表示

CIの内容

CIでは主に以下を実行しています。

  • ユニットテスト(PHPUnit)
  • コーディングスタイルチェック(PHPCS)

このうちPHPUnitが実行時間の8割ほどを占めています。 テストケースが増えるにつれPHPUnitの実行時間も比例して増えている状態でした。

移行前後の処理フローの比較

移行前後の簡単なフローを以下に示します。

image-20240527114256769.png

Jenkinsではすべての処理を逐次実行していたため、1回の実行にかなりの時間がかかってしまっていました。 (※JenkinsでもPipelineを使えばジョブの並列化が可能です)

GitHub ActionsではPHPUnitとPHPCSのジョブを定義し、並列に実行するようにしました。 さらにテストを複数のジョブで分割して並列実行することで高速化を図っています。

PHPUnitを並列実行する方法

今回の実行時間短縮の肝はPHPUnitの並列化になります。 とは言ってもやったことはシンプルで、テストケースを複数のジョブで分割して実行し、最後にテスト結果を集約して出力しました。

以下はPHPUnitに関する部分のみ抜粋して簡略化した、GitHub ActionsのYAMLファイルです。 PHPUnitを並列実行するジョブと結果を集約するジョブがあります。

name: ci
...
jobs:
  phpunit:
    name: テスト並列実行ジョブ
    runs-on: ubuntu-latest
    strategy: # ジョブ並列実行設定
      fail-fast: false # あるジョブが失敗になっても他の並列ジョブが止まらないようにする
      matrix:
        job_id: [0,1,2,3,4,5,6,7,8,9]
    steps:
      - uses: actions/checkout@v4
      - name: Create-phpunit-xml
        run: find ./tests/TestCase -name '*Test.php' | sort |  awk "NR % ${{ strategy.job-total }} == ${{ strategy.job-index }}" | xargs php create_phpunit_xml.php
      - name: Run-phpunit
        run: ./vendor/bin/phpunit --coverage-php ./coverage_${{ strategy.job-index }}.cov --log-junit ./junit_${{ strategy.job-index }}.xml --configuration phpunit-partial.xml
      - name: Upload-test-results
        if: always() # テストが失敗してもテスト結果を保存する
        uses: actions/upload-artifact@v4
        with:
          name: test-result-${{ strategy.job-index }}
          path: |
            ./coverage_${{ strategy.job-index }}.cov
            ./junit_${{ strategy.job-index }}.xml

  report-phpunit-result:
    name: テスト結果集約ジョブ
    needs: phpunit # phpunitジョブが終わるまで待つ
    if: always() # phpunitジョブが失敗しても実行する
    steps:
      # phpcovでソースコードを参照するためチェックアウトする
      - uses: actions/checkout@v4
      - name: Download-test-results
        uses: actions/download-artifact@v4
        with:
          path: test-results
          pattern: test-result-*
          merge-multiple: true
      - name: Publish-test-report
        uses: mikepenz/action-junit-report@v4 # テスト結果出力
        with:
          report_paths: './test-results/junit_*.xml'
          check_name: 'テスト結果'
          detailed_summary: true
      - name: Merge-coverage-reports # カバレッジ集約
        run: |
          wget https://phar.phpunit.de/phpcov-8.2.1.phar -O phpcov.phar
          php phpcov.phar merge --clover ./coverage.xml ./test-results/
      - name: Convert-xml-report-to-markdown # Cloverレポートをmarkdownに変換する
        uses: saschanowak/CloverCodeCoverageSummary@1.0.1
        with:
          filename: ./coverage.xml
      - name: Output Code Coverage to Job Summary # Job Summaryへカバレッジ出力
        run: |
          cat code-coverage-summary.md >> $GITHUB_STEP_SUMMARY
          cat code-coverage-details.md >> $GITHUB_STEP_SUMMARY

テストの並列実行

phpunitジョブではmatrixを使って10個のジョブでテストを分割して並列実行しています。 テストを分割して並列実行する方法に関しては以下の記事を参考にさせていただきました。 参考記事:

以下からは主要なステップ(ジョブの中の実行単位)を解説していきます。

Create-phpunit-xmlステップ

このステップでは各ジョブがどのテストを実行するかを決定します。 具体的には対象のテストファイルを記述したPHPUnitのXML設定ファイルを作成します。

$ find ./tests/TestCase/ -name '*Test.php' | \ # (1)
sort | \
awk "NR % ${{ strategy.job-total }} == ${{ strategy.job-index }}" | \ # (2) ${{ strategy.job-total }}や${{ strategy.job-index }}はGitHub Actions上で定義される変数
xargs php create_phpunit_xml.php # (3)

まず(1)でテストファイルの一覧を取得します。 次に(2)ではソート済のファイル一覧をawkコマンドの入力とし、ファイル一覧の行番号( NR)を総ジョブ数(${{ strategy.job-total }})で割ったときの余りが、自身のジョブ番号(${{ strategy.job-index }})と等しい場合のみファイル名をパイプで次のコマンドに渡します。 最後に(3)で各ジョブに割り当てられたファイルをテスト対象とするPHPUnitのXML設定ファイルを生成します。

create_phpunit_xml.phpは引数で渡されたファイルをテスト対象とするXMLファイルを生成するPHPスクリプトです。 スクリプトの詳細は上記の参考記事を参照していただければと思います。

具体例

例として3個のジョブでPHPUnitを分割して並列実行する場合を考えます。 以下のテストファイルがあったとします。

./tests/TestCase
  ├─ 1Test.php
  ├─ 2Test.php
  ├─ 3Test.php
  ├─ 4Test.php
  ├─ 5Test.php
  └─ 6Test.php

find&sortコマンドまでを実行すると以下が得られます。

$ find ./tests/TestCase -name '*Test.php' | sort
./tests/TestCase/1Test.php
./tests/TestCase/2Test.php
./tests/TestCase/3Test.php
./tests/TestCase/4Test.php
./tests/TestCase/5Test.php
./tests/TestCase/6Test.php

次に1番目のジョブがawkコマンドで上記を処理する場合、以下のコマンドを実行することになります。

$ awk "NR % 3 == 1"

1行目 ./tests/TestCase/1Test.php1 % 3 == 1がtrueとなるためはテスト対象となりますが、2行目./tests/TestCase/2Test.php2 % 3 == 1がfalseとなりテスト対象になりません。 すべての行を処理した結果以下のようになります。

$ find ./tests/TestCase -name '*Test.php' | sort | awk "NR % 3 == 1"
./tests/TestCase/1Test.php
./tests/TestCase/4Test.php

最後にPHPスクリプトに上記の結果が渡されて、 ./tests/TestCase/1Test.php./tests/TestCase/4Test.phpをテスト対象とするXML設定ファイルが作られます。 これを各ジョブごとに実行します。

Run-phpunitステップ

先述の方法で生成したXMLファイルを使ってテストを実行します。

$ ./vendor/bin/phpunit --coverage-php ./coverage_${{ strategy.job-index }}.cov --log-junit ./junit_${{ strategy.job-index }}.xml --configuration phpunit-partial.xml

後述するphpcovでカバレッジを集約するために、 --coverage-phpオプションでcoverage<em>{ジョブ番号}.covという名前のカバレッジレポートを出力します。この中身はカバレッジデータをシリアライズしたものです。 また、テスト結果はJUnit形式で junit</em>{ジョブ番号}.xmlとして出力します。

Upload-test-resultsステップ

テスト結果を後続のテスト結果集約ジョブに渡すために保存します。

テスト結果の集約

report-phpunit-resultジョブでテスト結果を集約してGitHub Actionsのサマリとして出力します。 すべてのテスト実行ジョブが終わるまで待つ必要があるため、 needsでphpunitジョブを依存ジョブとして指定しています。

  report-phpunit-result:
    name: テスト結果集約ジョブ
    needs: phpunit # phpunitジョブが終わるまで待つ
    if: always() # phpunitジョブが失敗しても実行する
    steps:
    ...

Download-test-resultsステップ

各ジョブのテスト結果をtest-resultsディレクトリにダウンロードします。

Publish-test-reportステップ

テスト結果をJUnit Report Actionというアクションを使ってサマリに出力します。 複数あるテスト結果ファイル junit_*.xmlを渡すとまとめて出力してくれるので便利です。

Merge-coverage-reportsステップ

カバレッジデータの集約にはphpcovというライブラリを使用します。 phpcovのmergeコマンドを使うと指定したディレクトリ ./test-results/内にあるカバレッジデータ*.covをマージして、指定した形式--clover ./coverage.xmlで出力してくれます。

$ php phpcov.phar merge --clover ./coverage.xml ./test-results/

Convert-xml-report-to-markdownステップ

カバレッジを見やすくするためにClover Code Coverage Summaryアクションを使ってClover形式のカバレッジをmarkdownに変換します。 あとはこれをサマリに出力すれば集約されたカバレッジを見ることができます。

結果

結果として、CI実行時間を30~40分から約5分に短縮することができました。 もともとはテストがボトルネックだったのですが、逆に今はPHPCSの方がボトルネックになるほどテスト時間を短縮できました。

ちなみに単純に考えればテストの実行時間は1/{ジョブ数}になりそうですが、各ジョブごとにオーバーヘッドが発生するためジョブ数とテスト時間は単純には反比例しません。 並列実行するジョブ数を増やせばそれだけオーバーヘッドによる実行時間も増え、GitHub Actionsの実行時間枠を消費してしまうのでそこは注意が必要です。

テスト結果は以下のように出力されます。 JenkinsではHTMLで表示できていたので、結果の表示で言えば以前の方がリッチでしたが、いまの状態でも十分な内容かと思います。

image-20240529055652651

また、テストで失敗した場合やPHPCSのエラーはプルリクエスト上に表示してくれるので見やすくなりました。

image-20240529055700293

結構長くなってしまいましたが以上となります。 もしCIの実行時間が長くて悩んでいる場合は処理の並列化を検討してみてはいかがでしょうか。

最後までご覧いただきありがとうございました!

川又康了

目次