PHP
PHPUnit
CI
CircleCI
phpdbg

リネットのPHPアプリケーションのCIを10倍速くするためにやったこと

これはWHITEPLUS Advent Calendar 2018の4日目の記事です。


はじめに

株式会社ホワイトプラスのエンジニアの @ngmy です。

ホワイトプラスでは、自宅にいながら衣類をクリーニングに出せるリネットというサービスを開発しています。

ホワイトプラスの技術スタックに関しては、弊社CTOの @exmeat が書いた1日目の記事を読んでいただくとわかりやすいと思います。

私は普段はクリーニングと長期保管を行うリネット保管というサービスの開発を担当しています。また、要素技術としてPHPとLaravelが得意なので、リネット全体のPHPとLaravelの基盤整備も担当しています。

直近では、リネット全体のPHPとLaravelのアップグレードを行っています。私が入社した2016年10月時点でリネットはPHP 5.6とLaravel 4.2で動いていましたが、今回これをPHP 7.2とLaravel 5.5にアップグレードしようとしてします。

今年の5月からアップグレードの作業に着手していて、8月中旬にいったんLaravel 5.3にアップグレードしてリリースしています。その後、先述のリネット保管の開発のために2週間ほどアップグレード作業を中断・9月から再開して、12月中旬にPHP 7.2とLaravel 5.5にアップグレードしてリリースしようと今まさに絶賛追い込み中です。ガンバりましょう。

また、ただアップグレードするだけでなく、CircleCI 2.0で動かしているCIにPHPStanによる静的解析を導入したり、CIを高速化したりといったことにも取り組んでいます。

今日は、CircleCIで動いている弊社リネットのPHPアプリケーションのCIを高速化するために行ったことについて書きたい思います。

(PHPStanによる静的解析の導入についても、WHITEPLUS Advent Calendar 2018の別日に書く予定です。)


リネットのPHPアプリケーションのCIについて

リネットのPHPアプリケーションのCIは、CircleCI 2.0で動いています。

これ自体は、2017年3月頃に私が構築したものです。

CIでは主に次のことを行なっています。


  • GitHubへのPush毎に動くCI


    • PHPUnitによるユニットテスト



  • 1日1回夜間に動くCI(いわゆる夜間ビルド)


    • PHPUnitによるユニットテスト

    • PHPUnitとXdebugによるコードカバレッジ解析(コードカバレッジのHTMLレポートの生成、コードカバレッジの推移のSlackへの通知等)

    • PHP_CodeSniffer、PHPMDによる静的解析



GitHubへのPush毎に動くCIと夜間ビルドの2つに分けている理由は、PHPUnitとXdebugによるコードカバレッジ解析にとにかく時間がかかり、また当時はプロダクションコードのデプロイまでをGitHubへのPush毎に動くCIの中で行っていたこともあり、CIの実行時間を短くしてリリースを素早く行えるようにすること最優先したためです。

夜間ビルドは構築当時でも2時間以上は余裕でかかっていた記憶があります。

そしてリネットの成長とともに実コードが増え、テストコードも増え(増えたよね?)、今年の8月ついにこれになりました。

スクリーンショット 2018-12-02 15.54.10.png

$\style{font-family:"Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif;font-size:small}{\text{えるしっているか CircleCIは 5時間でタイムアウトする}}$

夜間ビルドの実行時間が5時間を超えるようになり、CircleCIがタイムアウトするようになりました。

さすがにこのままではまずいということで、PHPとLaravelのアップグレードの作業の中で、CIの高速化にも取り組むことにしました。


やったこと1:Xdebugを捨てphpdbgを使う

CIの実行時間の大半を占めるのはPHPUnitとXdebugによるコードカバレッジ解析です。

Xdebugが遅いことはとても有名な話ですが、当時はまだPHP 5.6だったのでどうすることもできず放置していました。

しかし、PHP 7からはphpdbgという新しいPHP用デバッガーを使ってコードカバレッジ解析を行うことでき、これが高速だという話を色々なところで耳にしていたので、今回これを導入してみました。

まず、CircleCIで使用しているPHPコンテナのphp.iniでXdebugを読み込まないようにします。


docker-php-ext-xdebug.ini

;zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20170718/xdebug.so


その上で、PHPUnitとXdebugを使ってテスト実行とコードカバレッジ解析を行なっていたところを、PHPUnitとphpdbgを使って行うように変更します。

変更前:


unit_test.sh

# テスト実行とコードカバレッジ解析

docker exec -it lenet-web php ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --exclude-group hokan --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.no-hokan.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.no-hokan.cov || PHPUNIT_RESULT_NO_HOKAN=$?
docker exec -it lenet-web php ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --group hokan --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.hokan.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.hokan.cov || PHPUNIT_RESULT_HOKAN=$?

# テスト結果のマージ
docker exec -it lenet-web php ${PROJECT_ROOT}/merge-phpunit-xml.php ${PROJECT_ROOT}/build/phpunit.result.xml/ ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.xml

# コードカバレッジのマージ
docker exec -it lenet-web php ${PROJECT_ROOT}/vendor/bin/phpcov merge --html ${PROJECT_ROOT}/build/coverage.html --xml ${PROJECT_ROOT}/build/coverage.xml ${PROJECT_ROOT}/build/coverage.cov/

# 全体のテスト結果の判定
exit $(expr \
${PHPUNIT_RESULT_NO_HOKAN:-0} \
+ ${PHPUNIT_RESULT_HOKAN:-0} \
)


変更後:


unit_test.sh

# テスト実行とコードカバレッジ解析

docker exec -it lenet-web phpdbg -qrr ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --exclude-group hokan --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.no-hokan.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.no-hokan.cov || PHPUNIT_RESULT_NO_HOKAN=$?
docker exec -it lenet-web phpdbg -qrr ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --group hokan --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.hokan.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.hokan.cov || PHPUNIT_RESULT_HOKAN=$?
docker exec -it lenet-web php ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --group isolated --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.isolated.xml || PHPUNIT_RESULT_ISOLATED=$?

# テスト結果のマージ
docker exec -it lenet-web php ${PROJECT_ROOT}/merge-phpunit-xml.php ${PROJECT_ROOT}/build/phpunit.result.xml/ ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.xml

# コードカバレッジのマージ
docker exec -it lenet-web php -dzend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20170718/xdebug.so ${PROJECT_ROOT}/vendor/bin/phpcov merge --html ${PROJECT_ROOT}/build/coverage.html --xml ${PROJECT_ROOT}/build/coverage.xml ${PROJECT_ROOT}/build/coverage.cov/

# 全体のテスト結果の判定
exit $(expr \
${PHPUNIT_RESULT_NO_HOKAN:-0} \
+ ${PHPUNIT_RESULT_HOKAN:-0} \
+ ${PHPUNIT_RESULT_ISOLATED:-0} \
)


Dockerコマンドが出てきているのは、リネットは開発環境も本番環境もすべてDockerで動かしており、CircleCIでもDockerを起動させてコンテナの中でPHPを実行しているためです。

変更前の時点でテストをリネット保管以外とリネット保管という2つのグループに分けて実行しているのは、全部まとめて実行するとメモリ使用量がCircleCIの上限値である2GBを超えてしまうためです。

分割実行したPHPUnitのテスト結果とコードカバレッジのマージは以前書いた記事の方法で行っています。

ハマりポイントとしては次の点がありました。


  • phpcovを使ったコードカバレッジのマージにはXdebugが必要なので、phpcovの実行時だけXdebugを有効にする必要があります

  • phpdbgを使うとphp_sapi_name()で返ってくるSAPI名がphpdbgになります。PHPUnitの場合はcliです。そのため、実コードやテストコードの中でphp_sapi_name()の戻り値がcliであることを期待した処理が書かれていると動かなくなるので、その場合はphpdbgでも動くように修正する必要があります

  • phpdbgではisolatedテスト(@runInSeparateProcessを使ったテストのことをここではそう呼びます。この記事で書いたように、Mockeryでハードな依存をモックをするときに使うことが多いです)を動かすことができませんでした。そこで、isolatedなテストには@group isolatedを付けて、これらのテストだけは変更前と同じようにPHPUnitで実行することにして、コードカバレッジ解析の対象から除外することにしました

この時点でCIの実行時間が45分になりました。速い!(Xdebugが遅いとも言える)

phpdbgはPHP 7以上であれば最初から使える上に、テスト結果やコードカバレッジのフォーマットはXdebugのときと一切変わらないので、導入がとても簡単で、その割に効果が絶大です。オススメ。

しかし、もっと速くしたい。

スクリーンショット 2018-12-02 16.50.11.png

$\style{font-family:"Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif;font-size:small}{\text{phpdbgの効果は絶大}}$


やったこと2:テストを並列実行する

もっと速くしたいということでGNU Parallelを使ったテストの並列実行に手を出しました。

GNU ParallelはCircleCI 2.0ではAPTを使ってインストールすることができます。


config.yml

- run:

name: Install required libraries
command: |
sudo apt-get update
sudo apt-get install -y parallel

GNU Parallelを使うと先ほどのテスト実行とコードカバレッジ解析は次のように書けます。

テストの並列実行後:


unit_test.sh

# テスト実行とコードカバレッジ解析

parallel --gnu ::: \
"docker exec -i lenet-web phpdbg -qrr ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --exclude-group isolated,hokan --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.no-hokan.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.no-hokan.cov > out.no-hokan.txt 2>&1" \
"docker exec -i lenet-web phpdbg -qrr ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --group hokan --exclude-group isolated --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.hokan.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.hokan.cov > out.hokan.txt 2>&1" \
|| PHPUNIT_RESULT_NO_HOKAN_HOKAN=$?
cat out.no-hokan.txt
cat out.hokan.txt
docker exec -it lenet-web php ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --group isolated --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.isolated.xml || PHPUNIT_RESULT_ISOLATED=$?

# テスト結果のマージ
docker exec -it lenet-web php ${PROJECT_ROOT}/merge-phpunit-xml.php ${PROJECT_ROOT}/build/phpunit.result.xml/ ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.xml

# コードカバレッジのマージ
docker exec -it lenet-web php -dzend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20170718/xdebug.so ${PROJECT_ROOT}/vendor/bin/phpcov merge --html ${PROJECT_ROOT}/build/coverage.html --xml ${PROJECT_ROOT}/build/coverage.xml ${PROJECT_ROOT}/build/coverage.cov/

# 全体のテスト結果の判定
exit $(expr \
${PHPUNIT_RESULT_NO_HOKAN_HOKAN:-0} \
+ ${PHPUNIT_RESULT_ISOLATED:-0} \
)


並列実行する各プロセスの標準出力と標準エラー出力をCircleCIの画面に表示したかったので、ファイルにリダイレクトしてそれを出力しています。

しかし、これだけだとデータベースを使ったテストがうまく動きませんでした。

並列に複数のテストが同じデータベースに対して読み書きを行うため、テストが前提とするデータベースの条件が狂ってしまい、エラーになるテストが頻出しました。

そこで、データベースを使ったテストが並列に動かないように、テストのグループをさらに細かく分けて実行することにしました。

もともとリネットでは、PHPUnitのsetUp()tearDown()でのデータベース接続を減らしてテストを高速化する目的で、データベースを使うテストには@gorup dbを付けるようにしていました。また、DBを含む外部のAPIやファイルにアクセスするテストには@group integrationを付けるようにしていました。

そのおかげでスムーズに実現することができました。

データベースを使ったテストが並列に動かないようにしたものは、次のようになります。

テストの並列実行後(ただしデータベースを使うテストは並列実行しない):


unit_test.sh

# テスト実行とコードカバレッジ解析

parallel --gnu ::: \
"docker exec -i lenet-web phpdbg -qrr ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --exclude-group isolated,db,integration,hokan --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.no-hokan.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.no-hokan.cov > out.no-hokan.txt 2>&1" \
"docker exec -i lenet-web phpdbg -qrr ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --group db --exclude-group isolated,integration --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.db.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.db.cov > out.db.txt 2>&1" \
|| PHPUNIT_RESULT_NO_HOKAN_DB=$?
cat out.no-hokan.txt
cat out.db.txt

parallel --gnu ::: \
"docker exec -i lenet-web phpdbg -qrr ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --group hokan --exclude-group isolated,db,integration --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.hokan.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.hokan.cov > out.hokan.txt 2>&1" \
"docker exec -i lenet-web phpdbg -qrr ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --group integration --exclude-group isolated --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.integration.xml --coverage-php ${PROJECT_ROOT}/build/coverage.cov/coverage.integration.cov > out.integration.txt 2>&1" \
|| PHPUNIT_RESULT_HOKAN_INTEGRATION=$?
cat out.hokan.txt
cat out.integration.txt
docker exec -it lenet-web php ${PROJECT_ROOT}/vendor/bin/phpunit -c ${PROJECT_ROOT}/phpunit.xml --group isolated --log-junit ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.isolated.xml || PHPUNIT_RESULT_ISOLATED=$?

# テスト結果のマージ
docker exec -it lenet-web php ${PROJECT_ROOT}/merge-phpunit-xml.php ${PROJECT_ROOT}/build/phpunit.result.xml/ ${PROJECT_ROOT}/build/phpunit.result.xml/phpunit.result.xml

# コードカバレッジのマージ
docker exec -it lenet-web php -dzend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20170718/xdebug.so ${PROJECT_ROOT}/vendor/bin/phpcov merge --html ${PROJECT_ROOT}/build/coverage.html --xml ${PROJECT_ROOT}/build/coverage.xml ${PROJECT_ROOT}/build/coverage.cov/

# 全体のテスト結果の判定
exit $(expr \
${PHPUNIT_RESULT_NO_HOKAN_DB:-0} \
+ ${PHPUNIT_RESULT_HOKAN_INTEGRATION:-0} \
+ ${PHPUNIT_RESULT_ISOLATED:-0} \
)


これでCIの実行時間が30分になりました。

でももうちょっと速くしたい。

スクリーンショット 2018-12-02 16.47.30.png

$\style{font-family:"Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif;font-size:small}{\text{並列実行は難しいのでやりすぎない方がいいと思うけど効果は高い}}$


やったこと3:コードカバレッジのHTMLレポートをgzip圧縮する

これが地味だけど意外と効果がありました。

リネットでは、コードカバレッジのHTMLレポートをCircleCIのArtifactsにアップロードして、コードのどの部分がカバーできているかを各開発者が確認できるようにしています。

このコードカバレッジのHTMLレポートが大量のHTMLファイルを含んでおり、一つひとつアップロードするのにかなりの時間がかかっており、30分のうちの3〜4分程度を占めていました。

「CircleCIのArtifactsから直接コードカバレッジのHTMLレポートを見れなくても、gzip圧縮したものをアップロードしておいて、そのダウンロードリンクを開発者に通知すれば十分じゃないか?」と冷静に思い直し、gzip圧縮するようにしてみました。


unit_test.sh

docker cp lenet-web:${PROJECT_ROOT}/build/coverage.html - > /tmp/coverage.html.tar && gzip /tmp/coverage.html.tar


すると3〜4分程度かかっていたアップロードが一瞬で終わるようになりました。圧縮は基本だけど効きますね。

これでCIの実行時間が26分になりました。

もう無理か?

スクリーンショット 2018-12-02 16.56.01.png

$\style{font-family:"Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif;font-size:small}{\text{圧縮は基本的だが効果的}}$

スクリーンショット 2018-12-02 16.57.13.png

$\style{font-family:"Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif;font-size:small}{\text{CI完了後にSlackにコードカバレッジの推移とgzipのダウンロードリンクを通知する}}$

Slackへの通知は @u-minor さんの記事で紹介されていたスクリプトを改造して使わせてもらっています。ありがとうございます。

gzipのダウンロードリンク用のURLは、CircleCIのArtifacts APIを使って取得しています。


やったこと4:CircleCIの各種キャッシュを効かせる

ここまでくるともう大幅に改善できそうなところはなく、あとは本質的には巨大なリポジトリを分割することを検討しなくてはならないと思うんですが、最後にひと足掻きします。

CircleCIのソースキャッシュを使い.gitをキャッシュすることで、リポジトリのチェックアウトを高速化します。

これはCircleCIの公式ドキュメントで紹介されている方法そのままです。


config.yaml

- restore_cache:

keys:
- source-v1-{{ .Branch }}-{{ .Revision }}
- source-v1-{{ .Branch }}-
- source-v1-
- checkout
- save_cache:
key: source-v1-{{ .Branch }}-{{ .Revision }}
paths:
- ".git"

さらにDocker Layer Cachingも有効にします。


config.yaml

- setup_remote_docker:

- docker_layer_caching: true

これらもわずかながら効果があり、最終的にCIの実行時間は25分になりました

なお今回は実施していませんが、「CircleCI 2.1からOrbを使ったShallow Cloneが使えるよ!」とCircleCIの中の人に教えてもらいました。

リネットではCircleCI 2.0を使っているので今回は試せませんでしたが、2.1に移行した際にはぜひ試してみようと思います。

スクリーンショット 2018-12-02 17.07.36.png

$\style{font-family:"Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif;font-size:small}{\text{リネットの場合はCircleCIのキャッシュはそこまで効果がなかった。しかし、やらないよりはやった方がいい}}$


まとめ

CIの実行時間を5時間から25分に短縮し、10倍以上の高速化を実現しました。

これによりリネットでは、夜間ビルドを廃止し、ユニットテストとコードカバレッジ解析とPHPStan・PHP_CodeSniffer・PHPMDによる静的解析を全部乗せしたCIを、GitHubのPush毎に実行できるようになりました。

なお、25分でもまだ長いんですが、これはもうリポジトリサイズが大きいので仕方ないという割り切りです。中途半端にCIを高速化しようとして、実行するテストを減らしたり、静的解析を減らしたり、コードカバレッジ解析をしないようにすると、あっという間にコードベースが腐っていくので、デメリットの方が大きいと判断しました。

また、今年の11月にリネットのインフラ基盤をKubernetesを使うためにAWSからGCPに移行しており、その際にプロダクションコードのデプロイをCircleCIのCIとは別で行うようにしました。そのおかげで、CircleCIのCIに時間がかかっても、リリースの足を止めることはないようになっています。

この辺りのAWSからGCPに移行した話については、弊社インフラ基盤担当エンジニアの @akaimo がきっと何か書いてくれるでしょう。


明日の担当

明日は弊社のエンジニアマネージャーでありアプリケーション設計リードでもある @yamakii の担当です。よろしくお願いします。


追記


2018年12月15日

その後、CircleCI 2.1に移行してShallow Cloneするようにしたら、さらに高速化することができました。めでたしめでたし。

https://qiita.com/ngmy/items/9c27b09ecc58cfe8ea18

なお、上記記事の検証の過程でよくよく確認したら、ソースキャッシュはキャッシュしない場合に比べて逆に遅くなっていたことが判明しました(汗)。

リポジトリのサイズによってはかえって逆効果になる場合もあるってことですかね。

こちらの追記をもって訂正に代えさせていただきます。