プロジェクト管理ツールにGradleを使ったSpring Bootプロジェクトのユニットテストを、GitLab CIのパイプラインで実行しているが、完了するのに45分程度かかっていた。
その後のデプロイなど含めると、トータル1時間程度かかってしまう。
短縮できないか調べてみたのでメモ。
問題
Gradleを使ったSpring Bootプロジェクトのユニットテストが遅く、GitLab CIパイプラインでの実行に平均45分程度かかっている。
ユニットテストにはSpock 2 + JUnit 5を使用し、DBは application.properties
にてH2DBをインメモリで実行するよう設定している。
調査
当初はGradleのメモリを増やせばよいかと思い、 ./gradlew test
前に export GRADLE_OPTS="-Dorg.gradle.daemon=false -XX:MaxRAMPercentage=75"
を設定してみたが、まったく改善されなかった。
Gradleとは別に、ユニットテスト用のJVMが起動しているのかと思い、Test DSLを見てみると、以下の記載を見つけた。
Test are always run in (one or more) separate JVMs
テストは1つ以上のJVMで実行されるとある。
Gradleのtestタスクのプロパティ
テストの並列処理に関連しそうなプロパティは以下。
プロパティ名 | 型 | 説明 |
---|---|---|
maxParallelForks |
int |
並列実行するテストプロセス数。 指定した数または max-workers の小さい値まで、テストプロセスを作成して並列実行する。デフォルト 1 |
forkEvery |
long |
1つのテストプロセスで実行する、テストクラスの最大数。1 以上の値を指定すると、指定した数のテストクラスを実行した後、テストプロセスの再起動が行われる。0 の場合はテストプロセスの再起動は行われない。デフォルト 0L |
maxHeapSize |
String |
プロセスの最大ヒープサイズ。 512m や 1g など、単位付きで指定。-Xmx に展開される模様。デフォルト null |
minHeapSize |
String |
プロセスの最小ヒープサイズ。指定方法は maxHeapSize と同様。-Xms に展開される模様。デフォルト null |
jvmArgs
もプロパティに存在するが、そちらに最大/最小ヒープサイズは含めず、 maxHeapSize
および minHeapSize
を利用する模様。
テストプロセスの生成について
「テストは1つ以上のJVMで実行される」とあるが、このJVMが maxParallelForks
で作成されるテストプロセスとイコールなのかが読み取れなかった。
試しにPC上で maxParallelForks
をCPU論理プロセッサ数、 maxHeapSize
を物理メモリ容量の半分に設定したところ、テスト開始時にスレッド生成に失敗したことから、テストプロセスはそれぞれ個別のJVMとして起動される模様。
また、インメモリで起動しているH2DBは、 application.properties
の spring.datasource.url
が同じ値でも、各テストプロセス内で固有のデータを保持することが確認できた。JVMごとにメモリ領域が分かれていると思われる。
max-workers
について
maxParallelForks
の上限になっている max-workers
は、Gradleの最大ワーカー数。
ビルド環境によると、デフォルトではCPUプロセッサ数。おそらく java.lang.Runtime#availableProcessors()
が使われている。
プロパティ org.gradle.workers.max=...
を指定したり、Gradleコマンド実行時に --max-workers=...
で設定可能だが、明示的に値を設定するより、未設定にしてデフォルト値を使うのがよさそう。
forkEvery
と maxHeapSize
について
マルチコアプロセッサの場合、 maxParallelForks
に大きな値を設定し、 forkEvery
を 0L
、 maxHeapSize
を未設定とした場合、実行時にフォークされた各JVMがメモリを食いつぶし、OutOfMemoryError
が発生した。
forkEvery
を適当な値に設定し、定期的にテストプロセス(JVM)を再起動させるか、 maxHeapSize
を指定してヒープサイズに上限を設ける必要がある。
現状確認
現時点の値を確認すると、以下のようになっていた。
maxParallelForks
: 4forkEvery
: 1maxHeapSize
,minHeapSize
: 未設定
GitLab CI RunnerではAWS EC2でvCPU 16のインスタンスを使用しているため、 maxParallelForks
が少なく設定されている。また、 forkEvery
が1になっており、1テストクラスごとにテストプロセスの再起動が行われる状態だった。
対応
公式のテスト実行に関する記述をもとに、パラメータを調整する。
1点、公式では forkEvery
の使用が記載されているが、適切な値設定ができなかったため、 maxHeapSize
を指定している。
// CIパイプラインからの実行時に環境変数を設定し、ローカル実行かパイプライン実行かを判定する // CI/CDにはGitLab CIを利用しており、その場合CIがtrueに設定されるので、それを判定に利用 // https://docs.gitlab.com/ee/ci/variables/predefined_variables.html final boolean onPipeline = System.env.CI != null test { // 中略 if (onPipeline) { maxParallelForks = Runtime.runtime.availableProcessors() forkEvery = 0L maxHeapSize = "1g" } }
maxHeapSize
には、当初 free
した際の空きメモリから、Gradleが利用するメモリ容量を引いた値を availableProcessors()
で割った値を渡していたが、それだとメモリ不足でスレッド生成に失敗した。
H2DBが別途メモリを使用しているのか、スレッド生成にOS側でもメモリを使うのかなど推測したが、詳細は不明。
計算値から512MB~768MB程度引いた値で設定すると実行できたため、取り急ぎ切りのいい1GBで設定した。
結果
テストの失敗が多発したが、原因のほとんどは、データ件数不整合、主キー重複、外部キー制約違反。これまでは1テストクラスごとにJVMが再起動し、それに合わせてH2DBのデータもクリアされていたが、再起動しないよう変更し、データ不整合が生じていた。
テストデータ作成は抽象クラスにまとめていたため、そのクラスの cleanup
メソッドに、全テーブルのデータを削除するよう処理を追加することで解消。
データ削除処理のコスト増があるものの、テスト実行時間は45分から10分と大幅に減少した。
振り返り
並列実行時にインメモリのH2DBが各テストで共有されると面倒だなと思っていたので、各テストプロセスで独立して動いてくれたのはありがたかった。
また、現状Gradle Wrapperのダウンロードや、依存Jarのダウンロードが都度実行されており、約4分程度かかっているため、このあたりをGitLab CIのキャッシュ機能で改善できそう。