GitHub Actions上でPHPUnitを並列に動作させて、CI実行時間を1/4にしました

こんにちは、普段PR TIMES STORY(以下STORY)の開発リーダーをしている岩下(@iwashi623)です。

今回は年末のコードフリーズ期間中に、CI実行時間の改善に取り組んだ件について話していこうと思います。

目次

背景

STORYではバックエンドのPull Request作成をトリガーにして、CircleCi上でTestを実行していました。Testの実行時間は11〜13分ほどでした。

常々、このTestの実行時間が長すぎて開発体験の質が落ちているような気がしていました。

Testの実行時間が長すぎると何が問題になるのでしょうか?

以下がSTORYチームがSlack上でしばしば発生していた会話です。

岩下「あ、Aさん!☓☓☓の件について、Pull Requestを作ったのでお手すき時にレビューお願いします!」
A「OKです、手が空いたら見ておきます〜」

〜〜〜1時間後〜〜〜

A「岩下さん、Pull Requestを見ようとしたら、TestがFailしているところがありました、確認してもらえますか?」
岩下「えっ、あっ、はい承知しました!」

この状況の問題点は、私がTestの実行結果を待たずにPull Requestをレビュー依頼したところにありますが、根本の原因は別のところにあります。それは、Testの実行時間が長すぎるところです。

Testの実行時間が長すぎると、Pull Requestを作るたびにTestの実行待ちが発生することになります。10分を超えてくるとなると、必要のない小休憩には長すぎます。そのため別の作業をし始めたりもするのですが、そうすると別作業の方に集中してしまって、Pull Requestのレビュー依頼忘れも発生していました。

またすぐにTestがFailしていることに気づくことができれば、レビュー前に修正してから依頼ができるのでコミュニケーションを1ラリー削減することもできます。

上記のような理由から、実行時間を改善しようと思いました。

やったこと

CircleCiからGitHub Actionsへ移行

Testの実行基盤をGitHub Actionsへ移行しました。移行の理由としては、社内ではGitHub Actionsの活用事例のほうが多く、STORYのやっているTestはCircleCiでしかできないような処理はおこなっていないことが挙げられます。社内ではなるべく同じツールにまとめたほうが知見の共有などができるため、移行したいと考えました。

Cacheの利用

まず初めに、venderディレクトリや、node_modulesディレクトリのCacheをきちんと有効利用していこうと思いました。実装したJobの内容は以下です。

jobs:
  dependencies-install:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Composer Cache
        id: composer_cache
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-composer-

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.0"

      - name: Composer Install
        if: steps.composer_cache.outputs.cache-hit != 'true'
        run: |
          composer install -n --prefer-dist

      - name: Npm Cache
        uses: actions/cache@v3
        id: npm_cache
        with:
          path: node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: npm install
        if: steps.npm_cache.outputs.cache-hit != 'true'
        run: |
          npm ci

Cacheにはactions/cache@v3を使用しています。S3にフォルダごとExportしてそれをCacheとして使い回す方法もあるようなのですが、現状のSTORYの規模では不要かなと思い簡単に実装できるactions/cacheを採用しました。

Test実行Jobを並列で動かす

参考記事

もがき系プログラマの日常
GithubActions で phpunit の並列実行 - もがき系プログラマの日常
GithubActions で phpunit の並列実行 - もがき系プログラマの日常はじめに こんばんは。 今回もテスト系の備忘録です。 以前 CircleCI でテストの並列実行を行った記事を書きました。 kojirooooocks.hatenablog.com 今回はこれのgithub ac...

上記の記事の知見を使って、GitHub ActionsでTestを並列に実行しました。

実装したJobは以下です。

test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        parallel: [5]
        id: [0, 1, 2, 3, 4]

    steps:
      - uses: actions/checkout@v3

      - name: Use Composer Cache
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-composer-
      - name: Use Npm Cache
        uses: actions/cache@v3
        with:
          path: node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.0"
          extensions: bcmath zip exif mongodb imagick sqlite3

      - name: Run Tests
        run: |
          # NR % 5 == ${{ matrix.id }}の5は並行して実行されるインスタンス数
          find tests/ -name '*Test.php' | sort | awk "NR % 5 == ${{ matrix.id }}" | \\
            xargs php artisan dev:xml:generate-phpunit
          ./vendor/bin/phpunit \\
            --verbose \\
            --configuration phpunit-partial.xml

GitHub Actionsでは、strategy.matrix.parallelを記述するとそのJobで複数台のインスタンスを実行できます。今回は5台のインスタンスを利用しています。各インスタンスには0〜4のIDを振っています。

Run Testsでは各インスタンスに振り分けられたIDを用いて、インスタンスごとに実行するTestを分けているのですが、そちらについては上記した参考記事をご参照ください。

また、STORYでは各インスタンスごとに実行するTestを識別するための設定ファイルであるphpunit-partial.xmlを作成する独自コマンド、php artisan dev:xml:generate-phpunitが実装しされています。

public function handle()
    {
        $files = $this->argument('files');

        $basePath = base_path();

        $originalXmlFilePath = $basePath . '/phpunit.xml';

        $xml = new \\DOMDocument();

        $xml->load($originalXmlFilePath);

        $testsuite = $xml->createElement('testsuite');
        $testsuite->setAttribute('name', 'partial');

        foreach ($files as $file) {
            $testsuite->appendChild($xml->createElement('file', $file));
        }

        $newTestSuites = $xml->createElement('testsuites');
        $newTestSuites->appendChild($testsuite);

        $phpunit = $xml->getElementsByTagName('phpunit')->item(0);

        $currentTestSuites = $phpunit->getElementsByTagName('testsuites')->item(0);

        $phpunit->replaceChild($newTestSuites, $currentTestSuites);

        $newXmlFilePath = $basePath . '/' . self::XML_FILENAME;

        $xml->save($newXmlFilePath);
    }

やっていることは、テンプレとしてリポジトリに保存しているphpunit.xmlを書き換えて、並列実行のための新たなphpunit-partial.xmlを作成しています。

Jobの依存関係を整理する

CI上ではTestだけでなく他にも様々なJobが実行されています。例えば、コードの品質を均一にするためのLinterなどがあります。これらはTestのJobとは依存関係にありません。そのため、このようなJob達もTestのJobと並列で実行したいです。

以下の画像のようなイメージです。

  • フロントエンドのファイルbuild
  • Linter
  • 依存Packageの脆弱性チェック
  • Test

これらのJobがお互いの結果を待たずに並列で実行されます。

と言っても、もともとGitHub ActionsのJobはそれぞれ独立して実行されるため「build」「linter」「security-check」「tests」のJobは別々のJobに分けて記述すれば勝手に並列になります。ここでのポイントはいずれのJobも「dependencies-install」Jobが完了後にスタートしたいということです。

そのためには、

test:
    runs-on: ubuntu-latest
    needs: dependencies-install <- 追記
    strategy:
      matrix:
        parallel: [5]
        id: [0, 1, 2, 3, 4]

のように各Jobのneedsに実行を待ちたいJobのidを記述します。こうすることで、Job間の依存関係を決めてWorkflowを実行することができます。

完成したもの

完成したWorkFlowは以下のようになっています。

「dependencies-install」のJobが完了後に、それぞれのJobが並列に走っていることがわかるかと思います。

実行時間はおよそ2−4分に短縮されて、見事実行時間を1/4にすることができました🎉

体験としても、目に見えて早くなったため、Pull Requestを作成後にTestの実行結果を待ってレビューの依頼をすることが苦でなくなりました!

さいごに

キャッシュやJobの並列化を用いると、余計な時間がかからずにCIを実行できるため、ご自身のサービスで導入されていない方はぜひ参考にしていただけると幸いです。

PR TIMESでは、PHPを用いたアプリケーション開発や開発環境の改善を引き続き行っていきます。会社の雰囲気や技術スタックにご興味がある方は、ぜひ一度カジュアル面談に応募してくださると幸いです。

株式会社PR TIMES
カジュアル面談申し込みフォーム - 株式会社PR TIMES
カジュアル面談申し込みフォーム - 株式会社PR TIMES株式会社PR TIMESでは現在00-0. カジュアル面談(全職種OK)を募集しています。

この記事を書いた人

PR TIMES STORYの開発をしています。
AWSのSAA/DVA/SOA/SAPを持ってます。

目次
閉じる