はじめに
こんにちは。サーバーサイドエンジニアの佐野きよ(@Kiyo_Karl2)です。
ヤプリではコード品質維持のため、CI(継続的インテグレーション)を取り入れており、GitHubへのプッシュをトリガーに自動テストや構文/フォーマットチェックなどが実行されます。
しかし、サービスの成長に伴いソースコードの総量はどんどん増えていきます。すると、CIの実行時間がどんどん長くなっていきます。これはどんなサービスでも避けられない問題かと思います。
CIによるチェックをすべてパスしないとPRをマージできないような運用としている場合、CIの実行時間が長くなることは開発生産性にダイレクトに影響してきます。
ヤプリではCIツールとしてCircleCIをメインで利用しており、並列化することで実行時間を半分以下に改善することができました。
今回はこの取り組みについて紹介したいと思います。
実装
いきなりですが、まずはコードを記載します。実際にはDBのセットアップやcomposer install
をしたり色々なことをしていますが、わかりにくくなるため関連箇所を抜粋しております。
version: 2.1 jobs: lint: resource_class: medium+ docker: # 実際には利用するDockerイメージが記述されます working_directory: ~/project steps: - setup_laravel - run: make lint - run: make analyse test: parallelism: 2 resource_class: large environment: # 実際にはこの間でDB接続情報などの各種環境変数が入ります XDEBUG_MODE: coverage docker: # 実際はこの間でPHPやDBコンテナなどの各種必要なイメージを記述します working_directory: ~/project steps: # 実際にはこの間でcomposer installなどのテスト環境のセットアップを行います - run: name: run parallel test command: | mkdir -p {coverage,test-results} circleci tests glob "./tests/**/*Test.php" | circleci tests split --split-by=timings | xargs php .circleci/generate_phpunit_ci_xml.php phpdbg -d memory_limit=-1 -qrr vendor/bin/phpunit \ --coverage-clover coverage/coverage-${CIRCLE_NODE_INDEX}.xml \ --log-junit test-results/junit.xml \ --configuration phpunit_ci.xml - run: name: format test result for make timing data command: | sed -i -e "s|/home/circleci/crm/||g" test-results/junit.xml - store_test_results: path: test-results - persist_to_workspace: root: coverage paths: - coverage-*.xml report_coverage: docker: # 実際には利用するDockerイメージが記述されます working_directory: ~/project steps: - checkout - attach_workspace: at: coverage - run: name: install php-coveralls command: | wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.7.0/php-coveralls.phar chmod +x php-coveralls.phar - run: name: send coverage report to coveralls command: | ./php-coveralls.phar -v \ --json_path=coverage/coveralls-upload.json \ --coverage_clover=coverage/coverage-*.xml workflows: version: 2 push_workflow: jobs: - lint - test - report_coverage: requires: - test
.circleci/generate_phpunit_ci_xml.php
<?php declare(strict_types=1); $base_path = '/home/circleci/project'; $files = array_slice($argv, 1); $xml_file_string_data = []; foreach ($files as $file) { $xml_file_string_data[] = "<file>{$base_path}/{$file}</file>"; } $test_file_string = implode("\n", $xml_file_string_data); // CI用のphpunit.xmlを生成 $template = <<<XML <?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="${base_path}/vendor/phpunit/phpunit/phpunit.xsd" bootstrap="{$base_path}/tests/bootstrap.php" colors="false" > <testsuites> <testsuite name="Test Suite"> {$test_file_string} </testsuite> </testsuites> <coverage processUncoveredFiles="true"> <include> <directory suffix=".php">${base_path}/app</directory> </include> </coverage> <php> <server name="APP_ENV" value="testing"/> <server name="DB_DATABASE" value="testing"/> <server name="BCRYPT_ROUNDS" value="4"/> <server name="MAIL_MAILER" value="array"/> <server name="QUEUE_CONNECTION" value="sync"/> <server name="SESSION_DRIVER" value="array"/> <server name="TELESCOPE_ENABLED" value="false"/> </php> <listeners> <listener class="JohnKary\PHPUnit\Listener\SpeedTrapListener" /> </listeners> </phpunit> XML; file_put_contents("${base_path}/phpunit_ci.xml", $template);
コードを見てなんとなくイメージがつかめたところで、以下詳細を解説していきます。
自動テストの並列実行
CIで一番ボトルネックになるのは自動テストになることが多く、今回はここを効率化することが主となります。
効率化のアプローチとしては、大きく分けて
- Executorを並列実行
- テストの実行プロセスを並列実行
の2つがあるので、それぞれについて見ていきます。
テストの実行プロセスを並列実行
テスティングフレームワークで用意されているオプションなどを利用して、一度のテストの実行でプロセスを垂直方向にスケールさせるアプローチです。 この方法のメリットは、CircleCIで用意する実行環境はひとつで済むうえに実行時間は短くなるため、コストダウンができることです。
例えば、Laravelを利用している場合以下のようにオプションを付与することで複数のプロセスで同時にテストを実行することができます。
php artisan test --parallel
しかし、このアプローチだと、複数のプロセスでテストケースが同時実行されるため、ファイルの読み書き、DBアクセス、S3バケットなどのグローバルリソースで競合が発生し、Flakyなテストとなる可能性が極めて高いです。
Laravelの場合DBについては、--recreate-databases
オプションを付与すれば、テストを実行する並列プロセスごとにテストデータベースの作成とマイグレーションを自動的に処理させることができるので、テスト時にDBを1つだけ参照しているようなケースでは比較的楽に並列化できるかと思います。(詳細は8.x テスト: テストの準備 Laravelを参照)
しかし、ヤプリではテストでCSVやTSVファイル、S3バケット、MySQLやPostgreSQLなどのリソースを利用しており、これらすべてをプロセス数分複製させるロジックを書くのは少々大変なので、後述するExecutorを並列実行する方法を採用しました。
以下、Executorを並列実行する方法について詳しく解説していきます。
Executorを並列実行
CircleCIでは、以下のようにconfig.yaml
のparallelism
に値を指定すると、指定した数分のExecutorを独立して並列実行することができます。
version: 2.1 jobs: test: parallelism: 2 resource_class: large # 省略
以下のようなイメージで、parallelism
に指定した数分水平方向にスケールさせることができます。
Executorごと分割させるためcomposer install
やDBなどのセットアップは各Executorごとに行われるようになります。そのため、この分のオーバーヘッドが発生してしまいますが、S3バケットやDBなどのリソースは実行環境ごとに用意されるため、上述したテストの実行プロセスを並列実行する場合で発生するようなリソースの競合などは気にしなくて良くなります。そのため、比較的低コストで実装することができます。
しかし、CircleCIでは各Executorの実行時間を合計したものがクレジットとして請求されるため、セットアップのオーバーヘッドがある分トータルコストは並列化する前よりも増えます。(料金の詳細はCircleCI のリソース クラス - CircleCI を参照)
また、parallelism
を無闇に増やせば良いというわけでもないので、ここを調整して一番コスパの良い箇所を探ると良いでしょう。
今回の場合2
を指定したら、合計実行時間は2割増しぐらいで40%ほど早くなりましたが、これより数を増やしても合計実行時間だけ増えてしまい実行時間はそこまで変わらないという結果になったため、この数字に落ち着きました。
テスト分割
上記のparallelism
を利用しただ並列化しただけでは各Executorで自動テストがそのまま実行されるだけなので、いたずらに実行時間が増えてしまいます。
実行時間を減らすには、CircleCI CLI
を利用しテストケースを各Executorに分割する必要があります。
CircleCI CLIでのテスト分割
CircleCI CLI
を利用したテスト分割を行うことで効率良くテスト分割を行うことができます。今回はCircleCIで用意されているタイミングベースのテスト分割機能を利用しました。
CIrcleCIではデフォルトでは以下のようにファイル/クラスネームによるテスト分割が行われます。
上記を見ると、テスト分割はされるので一定効率化はできますが、例えばExecutor1
だけ実行時間が3分、他のExecutorが1分で終わったとすると結局実行完了までは3分かかってしまいます。
それであれば、Executor1
のテストを他のExecutorに分配してすべてのExecutorで2分ぐらいで収まるようにした方が実行時間を短縮できます。しかし、ファイル/クラスネームによるテスト分割は一定の順番にほぼ固定されてしまうため、これ以上の最適化が難しいです。
そこで、タイミングベースでのテスト分割を利用します。
CircleC CLIではsplit --split-by=timings
とすることでテストの実行時間ベースで最適化されるので、より効率的にテスト分割が適用されるようになります。
circleci tests glob "./tests/**/*Test.php" | circleci tests split --split-by=timings
注意点として、タイミングベースでのテスト分割を行うにはstore_test_results
を利用する必要があります。詳細はCircleCI CLI を使用したテスト分割 - CircleCIを参照下さい。
加えて、タイミングベースでのテスト分割を行うことにより実質テストの実行順序が代わるため、実行順序に依存してしまっているテストがある場合は修正する必要があります。なので、すでにテストコードが大きい場合は導入コストは高くなるかもしれません。
各Executorへのphpunit_xmlの出力
テスティングフレームワークにはPHPUnitを利用しているため、並列でテスト分割するためには、各Executorにタイミングベースで分割されたテストファイルへのパスを動的に出力したphpunit_xml
を生成してあげる必要があります。
circleci tests glob "./tests/**/*Test.php" | circleci tests split --split-by=timings | xargs php .circleci/generate_phpunit_ci_xml.php
上記箇所で、パイプでCircleCI CLIによって分割されたファイルのパスをgenerate_phpunit_ci_xml.php
で受け取るようにして
<file>/home/circleci/project/tests/unit/UserServiceTest.php</file> <file>/home/circleci/project/tests/unit/SlackServiceTest.php</file> // ...(以下略)
みたいな文字列になるように処理を行い、<testsuites>
タグの中に埋め込むといった処理をやっています。
カバレッジレポート
カバレッジレポートの出力は少々工夫が必要です。Executorを分割したことで、単純にひとつのファイルで集計したものをCoverallsに送るのではなく、各Excecutorで集計したcoverage-*.xml
をマージしてCoverallsに送るようにする必要があります。そうしないと正確なカバレッジ計測をすることができません。
そのためにはtest
ジョブとreport_coverage
ジョブ間でcoverage-*.xml
を共有する必要があります。
そこで、persist_to_workspaceを利用してcoverageディレクトリへすべてのcoverage-*.xml
を保存し、report_coverage
ジョブでattach_workspace
を利用して保存したカバレッジファイルを取得し、--coverage_clover=coverage/coverage-*.xml
とすることでマージしたカバレッジレポートをCoverallsに送るといった処理をするようにしました。。
タイミングデータがうまく生成されないとき
最初、何度CIを実行しても以下のようなメッセージが出てしまい、タイミングベースによるテスト分割がうまくできずデフォルトのファイル/クラスネームによるテスト分割にフォールバックしてしまっていました。
Error autodetecting timing type, falling back to weighting by name. Autodetect no matching filename or classname. If file names are used, double check paths for absolute vs relative. Example input file: "tests/Feature/User/xxxTest.php" Example file from timings: "/home/circleci/tests/Unit/xxxxTest.php"
メッセージをよく見ると、タイミングデータに利用するファイルは絶対パスではなく相対パスを指定して欲しいようです。PHPUnitを実行後出力されるJunitのXMLレポートには絶対パスが出力されるので、以下で相対パスになるように置換するようにしたらうまく行きました。
- run: name: format test result for make timing data command: | sed -i -e "s|/home/circleci/crm/||g" test-results/junit.xml
ジョブの並列実行
最後にジョブの並列実行による効率化です。CircleCIではワークフローという大きい単位の中でジョブというものが動いています。これは並列で動かすことができます。
基本PHPStanなどのLintチェックはDBを必要としないはずですので、LIntチェックとPHPUnitを並列で動かすことが可能です。
以下のように書くことでlint
とtest
ジョブが並列に動きます。
workflows: version: 2 push_workflow: jobs: - lint - test - report_coverage: requires: - test
まとめ
上記の並列化を行うことで、10分以上かかっていたものが4~5分ほどで終わるようになり、実行時間を半分以下にすることができました。
テスティングフレームワークを用いたプロセスベースの並列化を行うのに比べ、CircleCIのparallelism
を利用した並列化は工数少なく導入することができるうえに、実行時間はうまくいけば半分以下まで短縮できるということがわかりました。
とはいえテストケースが多くなってくると実行順序へ依存しているテストの修正がつらくなってきますので、継続して運用していくことがわかっているサービスであれば、導入するタイミングが早いほど価値が出てくるかと思います。この機会に是非導入を検討してみてはいかがでしょうか。
参考
さいごに
ヤプリでは今年も引き続きサーバーサイドエンジニアを募集しています。興味を持った方、是非一度カジュアル面談を受けてみませんか??
最後まで読んでいただきありがとうございました!