@int128

int128.hatenablog.com

Jenkinsでテストケースを並列実行して所要時間を短縮する

プロダクトが成長するにつれてCIの所要時間が長くなる悩みを抱えている方は多いと思います。本稿では、テストケースの並列実行でスローテストを乗り越える方法を検討します。

CIの所要時間を短縮する戦略

CIの所要時間が長くなる主な原因はテストです。だがしかし、テスト以外にコンパイルスキーママイグレーションなども時間がかかります。まずは、ビルドレポートで何のタスクに時間がかかっているか調べましょう。Gradleの場合は --profile オプションで各タスクの所要時間を確認できます。

ここではテストの所要時間にフォーカスします。

テストの所要時間を短縮するにはいくつかの方法がありますが、筆者のおすすめは以下を順番に行うことです。

  1. テストレポートでテストケースの所要時間を確認し、長時間のものから改善していく。
  2. テストケースを並列実行する。
  3. 高価なインスタンスタイプに変える。(札束で殴る)

改善に取りかかる前に、異常に時間のかかっているテストケースがないか確認することをおすすめします。テストデータを作成するためにauto commitで数千件のINSERTを回していたとか、大量の一時ファイルに書き込んでいたけど実は不要だったとか、改善できるものが見つかるかもしれません。

それでも打ち手がなくなった場合はテストケースの並列実行を検討しましょう。すでにJenkins Agentを複数ノードで運用しているのであれば、設定を変えるだけなので簡単です。これからJenkins Agentノードを増やす場合はインスタンスタイプも見直してみてもよいでしょう。金で解決できるなら早いもんです。

Jenkins Agentを複数ノードで運用する

テストを並列実行するには、まずJenkins Masterに複数のJenkins Agentを登録する必要があります。この時、テストの実行時にどのJenkins Agentか識別できるように、環境変数 JENKINS_AGENT_ID を設定しておきます。

実行環境 環境変数 JENKINS_AGENT_ID の設定値
Jenkins Agent #1 1
Jenkins Agent #2 2
Jenkins Agent #3 3

Jenkins Agentサーバは運用管理が面倒なので、プロビジョニングを自動化してImmutableにしておくとよいでしょう。不要になったら削除、必要になったら起動ができると理想です。

リソースの競合を避ける

データベースに依存するテストケースを同時に実行すると競合してしまうため、Jenkins Agentごとに独立したデータベースを割り当てる必要があります。CIの所要時間を考えると、1つのデータベースサーバにあらかじめ複数のデータベースを作成しておくとよいでしょう。例えば、下記のように割り当てます。

実行環境 データベース接続先 データベース名
ローカルでテスト実行 localhost example0
Jenkins Agent #1 でテスト実行 RDS example1
Jenkins Agent #2 でテスト実行 RDS example2
Jenkins Agent #3 でテスト実行 RDS example3

MySQLなら下記で新しいデータベースを作成できます。

CREATE DATABASE example0;
GRANT ALL ON example0.* to 'foo'@'%' identified by 'bar';

Spring Bootであれば application-test.yml環境変数を使ってデータソースを設定できます。

spring:
  datasource:
    url: "jdbc:mysql://${test.db.host:localhost}/${test.db.schema:example0}?useSSL=false"
    username: "foo"
    password: "bar"
    driver-class-name: com.mysql.jdbc.Driver

上記のように書くと、ローカルでは mysql://localhost/example0 、Jenkins Agentでは環境変数 TEST_DB_HOSTTEST_DB_SCHEMA の値を使うことができます。

最後に、Jenkinsfileでテストの実行時に環境変数を渡すように設定します。

def rdsEnv() {
  assert env.JENKINS_AGENT_ID
  [
    "TEST_DB_HOST=xxxxxxxx.xxxxxxxx.ap-northeast-1.rds.amazonaws.com",
    "TEST_DB_SCHEMA=example${env.JENKINS_AGENT_ID}",
  ]
}

node {
  checkout scm
  stage('test') {
    withEnv(rdsEnv()) {
      sh './gradlew check'
    }
  }
}

データベースだけでなく、Redisなどのキャッシュや外部APIも同じです。

Jenkins Agentサーバにデータベースを立てる方法もありますが、運用管理が面倒になることや、同じサーバのCPU時間やI/O帯域を消費してしまうことから、マネージドサービスを利用する方がよいでしょう。

テストケースを分散させる

テスト設計やモジュール構成に依存しますが、一般には以下の方法があると思います。

  1. サブプロジェクトごとにテストケースを並列実行する。
  2. テストケースをパッケージ名やアノテーションでグループ化して、並列実行する。

ここでは1の方法を説明します。

Jenkinsfileでは parallel ブロックで並列実行を定義できます。例えば、下記のように書くとGradleの api:check タスクと batch:check タスクを並列実行できます。

parallel(
  api: {
    node {
      checkout scm
      stage('test') {
        withEnv(rdsEnv()) {
          sh './gradlew api:check'
        }
      }
    }
  },
  batch: {
    node {
      checkout scm
      stage('test') {
        withEnv(rdsEnv()) {
          sh './gradlew batch:check'
        }
      }
    }
  },
}

ごちゃごちゃするので省いていますが、実際にはテスト実行後にテストレポートを回収するタスクが必要です。下記を finally で囲んで入れておくとよいでしょう。

junit allowEmptyResults: true, testResults: 'api/build/test-results/test/*.xml'
publishHTML([allowMissing: true, alwaysLinkToLastBuild: true, keepAll: false, reportDir: 'api/build/reports/jacoco/test/html', reportFiles: 'index.html', reportName: 'Coverage (api)'])
publishHTML([allowMissing: true, alwaysLinkToLastBuild: true, keepAll: false, reportDir: 'api/build/reports/tests/test', reportFiles: 'index.html', reportName: 'Test (api)'])

まとめ

本稿では、Jenkinsでテストケースを並列実行し、CIの所要時間を短縮する方法を説明しました。

  • リソースの競合を避ける(データソース、キャッシュ、APIなど)
  • テストケースを分散させる(サブプロジェクト、パッケージ名など)