Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

CircleCIを並列化してCIの実行時間を半分以下に改善した話

はじめに

こんにちは。サーバーサイドエンジニアの佐野きよ(@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.yamlparallelismに値を指定すると、指定した数分のExecutorを独立して並列実行することができます。

version: 2.1
jobs:
  test:
    parallelism: 2
    resource_class: large
# 省略

以下のようなイメージで、parallelismに指定した数分水平方向にスケールさせることができます。

Executorの並列実行のイメージ画像
CicrleCIのExecutorの並列実行のイメージ(https://circleci.com/docs/ja/parallelism-faster-jobs/#specify-a-jobs-parallelism-level より抜粋)

Executorごと分割させるためcomposer installやDBなどのセットアップは各Executorごとに行われるようになります。そのため、この分のオーバーヘッドが発生してしまいますが、S3バケットやDBなどのリソースは実行環境ごとに用意されるため、上述したテストの実行プロセスを並列実行する場合で発生するようなリソースの競合などは気にしなくて良くなります。そのため、比較的低コストで実装することができます。

しかし、CircleCIでは各Executorの実行時間を合計したものがクレジットとして請求されるため、セットアップのオーバーヘッドがある分トータルコストは並列化する前よりも増えます。(料金の詳細はCircleCI のリソース クラス - CircleCI を参照)

また、parallelismを無闇に増やせば良いというわけでもないので、ここを調整して一番コスパの良い箇所を探ると良いでしょう。

今回の場合2を指定したら、合計実行時間は2割増しぐらいで40%ほど早くなりましたが、これより数を増やしても合計実行時間だけ増えてしまい実行時間はそこまで変わらないという結果になったため、この数字に落ち着きました。

テスト分割

上記のparallelismを利用しただ並列化しただけでは各Executorで自動テストがそのまま実行されるだけなので、いたずらに実行時間が増えてしまいます。

実行時間を減らすには、CircleCI CLIを利用しテストケースを各Executorに分割する必要があります。

circleci.com

CircleCI CLIでのテスト分割

CircleCI CLIを利用したテスト分割を行うことで効率良くテスト分割を行うことができます。今回はCircleCIで用意されているタイミングベースのテスト分割機能を利用しました。

circleci.com

CIrcleCIではデフォルトでは以下のようにファイル/クラスネームによるテスト分割が行われます。

ファイル/クラスネームによるテスト分割の画像
https://circleci.com/docs/ja/parallelism-faster-jobs/#how-test-splitting-works より抜粋

上記を見ると、テスト分割はされるので一定効率化はできますが、例えば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を並列で動かすことが可能です。

以下のように書くことでlinttestジョブが並列に動きます。

workflows:
  version: 2
  push_workflow:
    jobs:
      - lint
      - test
      - report_coverage:
          requires:
            - test

まとめ

上記の並列化を行うことで、10分以上かかっていたものが4~5分ほどで終わるようになり、実行時間を半分以下にすることができました。

テスティングフレームワークを用いたプロセスベースの並列化を行うのに比べ、CircleCIのparallelismを利用した並列化は工数少なく導入することができるうえに、実行時間はうまくいけば半分以下まで短縮できるということがわかりました。

とはいえテストケースが多くなってくると実行順序へ依存しているテストの修正がつらくなってきますので、継続して運用していくことがわかっているサービスであれば、導入するタイミングが早いほど価値が出てくるかと思います。この機会に是非導入を検討してみてはいかがでしょうか。

参考

さいごに

ヤプリでは今年も引き続きサーバーサイドエンジニアを募集しています。興味を持った方、是非一度カジュアル面談を受けてみませんか??

open.talentio.com

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