Jenkinsでテストケースを並列実行して所要時間を短縮する
プロダクトが成長するにつれてCIの所要時間が長くなる悩みを抱えている方は多いと思います。本稿では、テストケースの並列実行でスローテストを乗り越える方法を検討します。
CIの所要時間を短縮する戦略
CIの所要時間が長くなる主な原因はテストです。だがしかし、テスト以外にコンパイルやスキーママイグレーションなども時間がかかります。まずは、ビルドレポートで何のタスクに時間がかかっているか調べましょう。Gradleの場合は --profile
オプションで各タスクの所要時間を確認できます。
ここではテストの所要時間にフォーカスします。
テストの所要時間を短縮するにはいくつかの方法がありますが、筆者のおすすめは以下を順番に行うことです。
- テストレポートでテストケースの所要時間を確認し、長時間のものから改善していく。
- テストケースを並列実行する。
- 高価なインスタンスタイプに変える。(札束で殴る)
改善に取りかかる前に、異常に時間のかかっているテストケースがないか確認することをおすすめします。テストデータを作成するために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_HOST
と TEST_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の方法を説明します。
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など)
- テストケースを分散させる(サブプロジェクト、パッケージ名など)
Circle CIでCloud Functionをデプロイする
Circle CIからGoogle Cloud Functionsに関数をデプロイする方法を調べたのでメモします。
具体的には、GitHub → Circle CI → Cloud Source Repository → Cloud Functionの流れで継続的インテグレーションと継続的デプロイを行います。
- 開発者がGitHubのリポジトリにソースコードをpushする。
- Circle CIでビルドとテストを行う。
- Circle CIでCloud Source Repositoryに成果物をpushする。
- Cloud Source RepositoryからCloud Functionにデプロイが行われる。
開発者がCloud Source Repositoryに直接pushしてもよいのですが、CIをパスした場合のみデプロイしたい、あるいはビルドした成果物をデプロイしたい場合はCircle CIを経由させると実現できます。
設定方法
まず、Cloud Source Repositoriesで新しいリポジトリを作成します。リポジトリを作成したら、 “Generate and store your Git credentials” でSSHでアクセスするためのクレデンシャルを取得します。下記のような文字列が表示されるのでメモしておきます。
machine source.developers.google.com login **** password ****
Circle CIの管理コンソールで環境変数を設定します。
キー | 設定値 |
---|---|
GCLOUD_PROJECT |
GCPのプロジェクト名 |
GCLOUD_REPO |
Cloud Source Repositoriesのリポジトリ名 |
NETRC |
リポジトリにSSHでアクセスするためのクレデンシャル(前述) |
circle.yml
を作成します。
machine: node: version: 6 deployment: cloud-repository: branch: master commands: - echo "$NETRC" > "$HOME/.netrc" - git remote add google "https://source.developers.google.com/p/${GCLOUD_PROJECT}/r/${GCLOUD_REPO}" - git push google master -f
GitHubのリポジトリにmasterブランチをpushしてみましょう。Circle CIでビルドが実行されて、Cloud Source Repositoriesのリポジトリが新しくなれば成功です。
最後に、Cloud Source RepositoryからデプロイするようにCloud Functionを設定すれば完了です。
APIキー等の秘密情報を追加してデプロイする
Circle CIで秘密情報を管理してデプロイ時に追加することで、公開リポジトリから秘密情報を切り出すことができます。(Cloud Functionsが環境変数に対応するまでのワークアラウンドですが)
deployment: cloud-repository: branch: master commands: - git config user.name circle - git config user.email circle@example.com - echo "$TWITTER_API_CREDENTIALS_JSON" > twitter-api-credentials.json - git add twitter-api-credentials.json - git commit -m 'Add Twitter API Credentials' - echo "$NETRC" > "$HOME/.netrc" - git remote add google "https://source.developers.google.com/p/${GCLOUD_PROJECT}/r/${GCLOUD_REPO}" - git push google master -f
ビルドした成果物をデプロイする
Babel等でビルドした成果物をデプロイしたい場合は、下記のように成果物をコミットしてからpushします。もしくは、新しいローカルリポジトリに成果物を追加してpushします。
deployment: cloud-repository: branch: master commands: - git config user.name circle - git config user.email circle@example.com - git add build - git commit -m 'Add build' - echo "$NETRC" > "$HOME/.netrc" - git remote add google "https://source.developers.google.com/p/${GCLOUD_PROJECT}/r/${GCLOUD_REPO}" - git push google master -f
第10回Jenkins勉強会でエモい話をした
第10回Jenkins勉強会に参加しました。
今回はMultibranch Pipelineの話が多かったですね。GitHub + Jenkinsでチーム開発する時のデファクトですね。
私は本番環境のリリースを自動化したエモい話をしました。
LTだったので技術的な話は省きましたが、本番環境のリリースを自動化するには技術とプロセスの両者が必須です。12 Factor AppやContinuous Deliveryに書いてあることを地道に実践するのが良いと思います。検証環境と同じものを同じ方法で本番環境にリリースすることは大事です。
ビルド職人はJenkinsだけでなくアプリケーションフレームワークやインフラの知識も求められるし、継続的にサービスを改善するための仕組みを作る価値の高い仕事をしていると思います。なので、ビルド職人の需要がもっと増えて待遇もよくなるとよいですね。
勉強会運営のみなさま、ありがとうございました!