こんにちは、普段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を並列で動かす
参考記事

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