はじめに
プロジェクトが成長し、テストコードが増えるにつれてCIの実行時間は肥大化します。私のプロジェクトでは現在約5,500件のテストが存在しますが、様々な最適化を組み合わせることで、CircleCI上で約4分という実行環境を実現しています。
今回は、実際に効果があった最適化手法をまとめました。
DBマイグレーションを5倍速くする「tmpfs」マウント
CIにおいて最大のボトルネックになりやすいのがデータベース(MySQL)のI/Oです。
CircleCIのMachine Executorを利用し、MySQLのデータディレクトリを tmpfs(メモリ上の仮想ファイルシステム) にマウントすることで、ディスクI/Oを排除しました。
- run:
name: Mount tmpfs for MySQL data
command: |
mkdir -p docker/volumes/mysql
sudo mount -t tmpfs -o size=2G tmpfs docker/volumes/mysql
- 効果: スキーマ作成やダンプのインポートといった処理が、通常のファイルシステムと比較して約5倍高速化されました
Xdebugの排除
デバッグやカバレッジ計測に便利なXdebugですが、有効なだけでPHPの実行速度は大幅に低下します。CI環境ではデバッグ機能は不要なため、コンテナ起動時に設定ファイルからExtensionを削除しています。
- 効果: テスト実行時間が約2倍速くなりました
ParaTest × CPU数に応じた並列DB生成
並列テスト実行ライブラリ ParaTest を使用していますが、マシンのスペックをフルに活かすため、CPU数(nproc)を動的に取得し、その数だけ専用のテスト用DBを並列でセットアップします。
CPU_COUNT=$(nproc)
while (( CPU_COUNT > 0)); do
# CPUの数だけ並列でDB作成や権限付与を実行
docker compose exec -T db mysql -u root -p"${DATABASE_PASSWORD}" -e "grant all on \`test_db"${CPU_COUNT}"\`.* to 'user';" &
pids[CPU_COUNT]=$!
CPU_COUNT=$((CPU_COUNT - 1))
done
wait "${pids[@]}"
リソースの競合を避けつつ、ハードウェアの限界まで並列度を高めることが可能です。また、実行時間に応じて柔軟に resource_class を変更できるようになります。
外部通信をiptablesで「即時拒否」する
テスト中に意図せず外部APIへリクエストが飛ぶと、タイムアウト待ちによる数秒〜数十秒のロスが発生します。これを防ぐため、iptables でコンテナからの外部ネットワーク通信を遮断しています。
# DOCKER-USERチェインのデフォルトRETURNを削除し、外部通信をブロックする
sudo iptables -D DOCKER-USER -j RETURN
# コンテナ間通信(プライベートネットワーク)は許可
sudo iptables -A DOCKER-USER -d 172.16.0.0/12 -j RETURN
sudo iptables -A DOCKER-USER -d 10.0.0.0/8 -j RETURN
sudo iptables -A DOCKER-USER -d 192.168.0.0/16 -j RETURN
# TCP接続はRSTで即座に拒否。cURLなどが即エラーを返す
sudo iptables -A DOCKER-USER -p tcp -j REJECT --reject-with tcp-reset
# それ以外のプロトコルはICMPで拒否
sudo iptables -A DOCKER-USER -j REJECT --reject-with icmp-port-unreachable
単にドロップするのではなく tcp-reset を返すことで、クライアントがタイムアウトを待たずに即座にエラーを検知できるため、無駄な待ち時間がゼロになります。
DB接続の再確立を抑える StaticDriver の活用
dama/doctrine-test-bundle の StaticDriver を導入しています。
通常、Symfonyのテストでは bootKernel() ごとにDB接続が再確立されますが、これを静的に保持することで接続コストを削減しています。
効果: テスト実行時間を約40%短縮できました。
初期スキーマの導入に schema:update を使わず「SQLダンプ」をインポートする
Doctrineを使用していると doctrine:schema:update --force でDBスキーマを構築したくなりますが、これはCI環境では非常に低速です。
本プロジェクトでは、あらかじめ書き出しておいた db_dump.sql を直接 MySQL に流し込む手法を採用しています。
# 1つずつマイグレーションを走らせるより、ダンプを流し込む方が圧倒的に速い
docker compose exec -T db mysql -u root -p"${DATABASE_PASSWORD}" app-test"${CPU_COUNT}" < db_dump.sql &
Dockerのボリュームをそのままキャッシュ(バックアップ)して使い回す方法も検討しましたが、環境依存による不安定さやキャッシュの肥大化、リストアにかかる時間といった課題があるため 「クリーンな状態からのSQLインポート」 という、速度と安定性のバランスが最も良い着地点を選びました。
なぜ「DBセットアップ」の高速化にこだわるのか?
PHPUnit(ParaTest)によるテスト実行部分は、CPUコア数を増やせば増やすほど線形に速度が向上します(レバレッジが効く)。
しかし、DBの初期化やマイグレーションといった「前準備」の時間は、並列度を上げても短縮できません。
- 並列度を上げるほど、セットアップ時間がCI全体のボトルネック(支配的)になる
- 前準備を1分縮めることは、テスト実行部分を1分縮めることよりも価値が高い
そのため、前述の tmpfsマウント や SQLインポート によって「逐次実行される前準備」を極限まで削ぎ落とすことが、ハイスペックなCIマシンの性能をフルに引き出す鍵となります。
「AI時代の高速道路」を守る、独自のスローテスト検知
CIを一度高速化しても、日々の開発で「遅いテスト」が紛れ込めば、すぐに元の木阿弥です。特にAIによるコード生成が標準的になった現代では、人間が書くよりも遥かに容易に、 「意図せず大量のエンティティを生成するテスト」 や 「重いクエリを連発するテスト」 が爆速で量産されるリスクがあります。
こうした「AI時代の副作用」からCIの速度を守るため、独自の SlowTestFailListener を導入しています。
なぜ独自Listenerが必要なのか?
PHPUnit自体にも遅いテストを検知して Risky 判定にする仕組みはありますが、ParaTestを導入している環境では、少なくとも今利用しているバージョンでは、Risky判定になってもCIが正常終了(Pass)してしまうという問題があります。
そのため、独自Listenerで制限時間を超えたテストを強制的に Failure (失敗)として扱うようにしています。
// SlowTestFailListener.php のエッセンス
public function endTest(Test $test, float $time): void
{
// CI環境以外(xdebug使用時など)はスキップ
if (!getenv('CI')) return;
// サイズごとの制限時間
$limit = self::TIME_LIMITS[$test->getSize()] ?? self::DEFAULT_TIME_LIMIT;
if ($time > $limit) {
$test->getTestResultObject()->addFailure(
$test,
new AssertionFailedError("テストが {$time}秒かかりました(制限: {$limit}秒)。..."),
$time
);
}
}
「AI時代のための高速道路」の整備
開発者がAIを使いこなし、凄まじいスピードで開発を進めるためには、CIという「高速道路」が常に舗装されている必要があります。
- 早期検知: 実行時間が伸びた瞬間にCIを落とし、開発者にフィードバックする
-
アノテーションによる意図の明示: 本当に重いテストが必要な場合は
@mediumや@largeを付けるよう促す
このように「速く走れる仕組み」だけでなく、「遅くなる原因を自動で弾く仕組み」をセットで用意することが、AI時代のCI/CD戦略には不可欠です。
高速なCIは「最強の開発ツール」
CIを短時間で終わらせることは、単にサーバーコストを抑えることではありません。開発者が「このコードをプッシュしても大丈夫か?」と不安になる時間を最小化し、 試行錯誤のサイクルを高速化させるための、最も投資対効果の高いエンジニアリング です。
PHPUnitの実行時間が課題になっているプロジェクトにとって、これらの手法のどれか一つでも解決のヒントになれば幸いです。