CircleCI での Android プロジェクトのビルド設定と自動化の工夫

この記事は、Mercari Bold Challenge Month の 7 日目の記事です。

こんにちは。メルペイの Android チームでネット決済 (オンラインでの決済手段) の機能開発や開発基盤の改善に取り組んでいる @KeithYokoma です。

メルペイの Android チームでは CI (Continuous Integration) ツールとして Bitrise と CircleCI を使っています。それぞれを使い分けており、日々の開発フローの中でリポジトリに変更をプッシュする場面で CircleCI を、それ以外に開発に必要な成果物の生成 (たとえば API の定義から各言語用のライブラリを吐き出す) 場面で Bitrise を利用しています。

この記事では、Android プロジェクトのビルドにあたって CircleCI をどのように活用しているか、またどんな工夫をしているかを解説します。

なぜ CircleCI なのか

まずはじめに、CircleCI を日々の開発フローで使うことにした理由を説明します。
ちなみにメルペイの Android チームでは本記事の執筆時点で最新の CircleCI 2.1 を使っており、以後の解説でも CircleCI 2.1 を前提にしています。

メルペイの Android チームとして CI に期待していることは次の 3 点です。

  1. 高速にビルドができる
  2. メンテナンスに必要な手数を最小限にできる
  3. 日々の開発フローの中でエンジニアに適切なフィードバックをもたらしてくれる

これらの要求を満たす上で CircleCI が魅力的だったのは、設定の自由度や柔軟性をもっていることです。特に、Docker イメージとしてビルド環境を管理できること、複数ジョブの並列実行の設定と、ジョブそのものの並列実行の設定ができることが選定理由として大きなウェイトをしめています。

メルペイの Android チームでは、CircleCI の高速にビルドができる環境を使いつつ、メンテナンスの必要性を最小限に抑えながらも設定の柔軟性をいかして日々の開発フローの中でエンジニアに適切なフィードバックをもたらしてくれる CI を立ち上げています。

Docker イメージとしてビルド環境を管理できる

CircleCI では Docker コンテナを起動してビルドの各ステップを実行するため、ビルドに必要な環境は Docker イメージとして管理できます。Android 向けのイメージが CircleCI から公式に提供されていて、チームでも利用しています。通常、CI は毎回ビルド環境をセットアップしていますが、Docker イメージとしてビルド環境をあらかじめ用意しておくと、Android SDKを準備したり、更新をインストールしたりする必要がなくなり、ビルド時間の短縮に効果を発揮します。

ジョブの並列実行を設定できる

CircleCI では、コマンドによる処理のまとまりをジョブと呼び、さらにそのジョブのまとまりをワークフローと呼びます。
ワークフローは Git のプッシュや cron のように指定した時間をトリガーとして開始され、そのワークフローで指定したすべてのジョブが終了するか、途中のジョブが失敗すると完了します。
ワークフローでは次のようにジョブごとの依存関係を設定でき、複数のジョブを同時に動かす設定も可能です。

jobs:
jobA:
 # jobA の設定
jobB:
 # jobB の設定
jobC:
 # jobC の設定
workflows:
build:
 # build ワークフローの設定。jobA が完了したら jobB と jobC を実行する。
jobs:
- jobA
- jobB
requires:
- jobA
- jobC
requires:
- jobA

ジョブの処理自体を並列化できる

CircleCI ではジョブごとに Docker コンテナを起動します。デフォルトではひとつのジョブに対しひとつの Docker コンテナが割り当てられますが、ひとつのジョブに対して複数の Docker コンテナを割り当てることも可能です。この設定を使うことで、コンテナごとに処理を分散させてひとつのジョブを高速化できます。

主な利用目的はテストの並列実行で、公式のドキュメントでもテストを並列に動かすことを前提とした解説があります。
あるジョブに対してどれだけの Docker コンテナを割り当てるかは YAML で設定します。

jobs:
test:
 # test ジョブに対して 4 つの Docker コンテナを割り当てることで、4 並列で test を実行する
parallelism: 4
steps:
- run:
 # テストの実行コマンド...

ワークフローにおけるジョブの構成

では実際にメルペイの Android チームがどのようにワークフローを定義しているかをご紹介しましょう。

私たちは開発ブランチへのプッシュと、master / release ブランチの更新の 2 つのワークフローを定義しています。これらを分けているのは、CI でチェックしたい内容が開発ブランチと master / release ブランチで異なるためです。また、可能な限り早いタイミングでエンジニアにフィードバックをするための構成を作ることも意識しています。

開発ブランチへのプッシュ

開発ブランチへ変更をプッシュしたときのワークフローでは、アプリケーションのビルドの他に、テスト、テストカバレッジの計測、lint (Android Lint と KtLint) を実行し、それぞれのレポートファイルにある結果を danger を使って Pull Request にコメントしています。

開発ブランチでの CI では、できるだけ早く失敗に気付けるようにするため、 debug と release の BuildVariant それぞれのビルドとテストを同時に実行しています。

また、開発ブランチを master や release にマージした後でもビルドに問題がないかをチェックするため、開発ブランチの CI の段階でマージを試みテストを実行するジョブを独立して動かしています。

開発ブランチにプッシュしたときのワークフロー
開発ブランチにプッシュしたときのワークフロー

master ブランチと release ブランチの更新

master ブランチと release ブランチは特別なブランチで、どちらも必ずコードレビューを経た差分のみがプッシュされる状態にしているため、lint などによる検証はスキップし、取り込んだ Pull Request が正しく結合できたことを assemble と test で確認しています。

master ブランチや release ブランチに更新があったときのワークフロー
master ブランチや release ブランチに更新があったときのワークフロー

test を assemble よりも先に実行しているのは、開発ブランチをマージしたあとにコンパイルが正常にでき、テストが問題なく通ることを検証した上で成果物の生成をするためです。Git のコンフリクトがない場合でも開発ブランチのマージ後に機能のデグレやコンパイルエラーが発生する可能性を考慮して、テストを先に実行することで早めにデグレの検知をできるようにしています。

マルチモジュール構成におけるジョブの設定

メルペイの Android プロジェクトは、機能ごとに細分化したモジュールをもつマルチモジュール構成になっています。例えば、メルペイの決済手段としてネット決済、コード決済、iD決済がありますが、これらはすべて別々のモジュールで開発をしています。また全てのモジュールで共通して使うユーティリティや基盤となるツールキットなども個別のモジュールとして定義しています。

モノリシックなモジュール構成と比較しマルチモジュール構成にしたときに考慮が必要な点として、テストジョブの並列化にあたってのワークアラウンドと、ビルドで生成されるレポートファイルなどの成果物の収集があげられます。

テストジョブの並列化

日々プロダクトが成長していくにつれ、テストケースも増えていきます。単一のコンテナですべてのテストを実行していると、テストケースが増えるほどに実行時間も長くなります。Android プロジェクトで利用している Gradle でもテストの並列実行ができますが、すでに現状 5000 以上のテストケースがあり今後もさらに増え続けることから、ジョブの処理の並列化を使って複数のコンテナでテストを分散して実行することで、すべてのテストが完了するまでの時間を短くすることを考えました。

CircleCI ではテストの並列実行をサポートするツールを提供しており、circleci tests split コマンドでコンテナごとにどのテストを実行するかを決定します。テストコードを記述したファイルの検索を circleci tests glob コマンドで実行しておき、それをそのままパイプで circleci tests split にわたすと、コンテナごとに異なるテストを実行できます。

次のコード例は CircleCI の公式ドキュメントにあるテストの並列実行の例です。10個のコンテナを並列で起動する場合、0 から 9 までのインデックスを割り振られたコンテナが起動し、それぞれのコンテナが circleci コマンドで決定したテストファイルを元にテストを実行します。

TESTFILES=$(circleci tests glob "spec/**/*.rb" | circleci tests split --split-by=timings)
bundle exec rspec -- ${TESTFILES}

Android プロジェクトの場合、単一モジュールのプロジェクトであれば、テストファイルを指定したテストの実行が可能です。一方でマルチモジュール構成のプロジェクトの場合、テストファイルを指定したテストの実行はうまく動作しません。これは、すべてのモジュールに対してファイル名を指定してテストを動かそうとするためで、そのファイル名が存在しないモジュールではテストが見つからず失敗するためです。

メルペイの Android プロジェクトもマルチモジュール構成であるため、ファイル単位での分散は諦め、モジュール単位で分散させるようにしてこの問題に対処しました。次のようなシェルスクリプトを記述し、コンテナに割り振られたインデックスをみてどのモジュールをテストするか決めてテストを動かすようにしています。コンテナに割り振られたインデックスは CIRCLE_NODE_INDEX という環境変数に設定されています。

#!/bin/bash
set -e
# プロジェクトにあるモジュールのリストを命名規則に従って検索 (common. または feature. で始まるモジュール名)
MODULES="$(ls | grep "^(common.|feature.)")"
# 実行中のコンテナ内でテストを動かす対象のモジュールのリストを定義
EXECUTE_MODULES=()
# 並列度を config.yml の定義と合わせて 10
PARALLELISM=10
# $CIRCLE_NODE_INDEX 番のコンテナでテストを動かす対象のモジュールを決定
IDX=0
for MODULE in $MODULES; do
if [[ $(($IDX % $PARALLELISM)) -eq $CIRCLE_NODE_INDEX ]]; then
EXECUTE_MODULES+=($MODULE)
fi
((IDX=IDX+1))
done
echo "Execute tests in ${EXECUTE_MODULES[@]}"
# テスト実行
COMMAND="./gradlew --parallel --max-workers=8 "
for MODULE in ${EXECUTE_MODULES[@]}; do
COMMAND=${COMMAND}":$MODULE:test :$MODULE:jacocoTestReport --stacktrace --no-daemon "
done
eval ${COMMAND}

うまく動作すると、CircleCI の Web コンソールでは次のように表示されます。

テストを並列実行したときの Web コンソールの表示
テストを並列実行したときの Web コンソールの表示

レポートファイルの収集

マルチモジュール構成のプロジェクトでは、テストや lint などのレポートファイルは各モジュール配下のディレクトリに生成されます。これを danger 等のツールに渡して Pull Request のコメントに書き出すフローを作っていますが、モジュールごとにファイルを指定してツールに渡すような設定を記述してしまうと、モジュールの増減があったときに都度メンテナンスをしなくてはなりません。実際、メルペイの最初のリリース移行も新機能を実装するためにモジュールが増え続けているので、モジュールを増やしたとしても設定を書き換える必要のない仕組みが欲しくなりました。

レポートファイルは多くの場合 XML で書き出されます。そこで、各モジュールごとに生成されたレポートファイルを読み出して root ノードの下にぶら下がっている要素を取り出し、一つの XML ファイルにまとめて書き出すスクリプトを作ることを考えました。
このスクリプトは Gradle Custom Task で実装しています。次のコード例では、Android Lint のレポートファイルを収集してまとめる Gradle Custom Task を実装しています。

package com.merpay.gradle.tasks
import groovy.util.Node
import groovy.util.XmlNodePrinter
import groovy.util.XmlParser
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
open class AndroidLintReportAggregationTask : DefaultTask() {
@Input
lateinit var aggregatedReportFileName: String
@TaskAction
fun aggregate() {
// 全モジュールのレポートファイルが生成されるパスから Android Lint のレポートファイルを探す
val androidLintReportDirs = project.subprojects.map {
File("${it.buildDir.absolutePath}/reports")
}
val allReports = findAndroidLintReports(androidLintReportDirs, "lint-results.xml")
// 最終的にすべてのレポートをまとめるファイルを作成する
val aggregatedFile = File("${project.rootDir}/$aggregatedReportFileName")
if (aggregatedFile.exists().not()) {
aggregatedFile.createNewFile()
}
// XML を書き出す準備
val printer = XmlNodePrinter(PrintWriter(FileWriter(aggregatedFile).apply {
write("<?xml version='1.0' encoding='UTF-8' ?>n")
}))
printer.isExpandEmptyElements = true
// Android Lint のフォーマットに従って root ノードを作る
val rootNode = Node(null, "issues", mapOf("format" to "5", "by" to "lint 3.4.0"))
// 各ファイルの子ノードを書き出し先の root ノードにうつす
allReports.forEach { report ->
val children = XmlParser().parse(report).children()
if (children.isEmpty())
return@forEach
children.map {
if (it !is Node)
return@map
rootNode.append(it)
}
}
// 書き出し
printer.print(rootNode)
println("Aggregated to the file ${aggregatedFile.absolutePath}")
}
private fun findAndroidLintReports(dirs: List<File>, fileName: String) = dirs.map { dir ->
dir.walk().find { file ->
file.name == fileName
}
}.filterNotNull()
}

定常タスクの自動化

メルペイでよく発生する定常的なタスクとして、Protocol Buffers のライブラリ更新と release ブランチのマージが挙げられます。これらのタスクは CircleCI で定期実行するよう設定し、細かい差分で確認できるようにしています。

release ブランチのマージ

メルペイでは普段の機能開発は master ブランチに対して Pull Request を投げるフローになっていますが、リリース前の QA フェーズでは release ブランチを master から派生させ、バグ修正を release ブランチに取り込んでいくことになっています。

そうすると、master ブランチと release ブランチでそれぞれにコミット履歴が進んでいくため、日に日に master ブランチと release ブランチの差分が大きくなります。最終的に release ブランチは master ブランチにマージされますが、差分が大きければ大きいほどコンフリクトしたときの作業が大変になります。

現在は毎日 12:00 と 19:00 に release ブランチをマージするタスクを実行し、Pull Request を作るところまでを自動化しています。マージの際にコンフリクトがある場合は、Slack に通知を飛ばすようにしています。

マージに失敗したときの Slack の通知
マージに失敗したときの Slack の通知

proto のアップデート

メルペイでは Protocol Buffers を用いてサーバと通信をしています。Protocol Buffers では proto ファイルをもとに Java や Swift など言語ごとに API でどんなデータをやり取りするかを定義したライブラリが生成されます。proto ファイルの更新があると、自動でこのライブラリの生成も行われますが、実際にアプリケーションに組み込むには、ビルドスクリプトに記述したライブラリのバージョンを更新しなければなりません。ライブラリの更新も、差分が大きいほど破壊的変更や挙動の変更に対応するパワーが必要になります。

release ブランチのマージと同じく、proto のアップデートも毎日 12:00 と 19:00 に自動で実行されるよう設定しています。

エンジニアへのフィードバックの仕組み

Pull Request のステータスチェック

Pull Request にはステータスチェックがあり、作った変更にたいしてテストや lint の結果が正常かどうかが確認できます。一方で、このステータスチェックでは作った変更をマージしたあともテストや lint が正常かどうかは確認できません。コンフリクトの有無は GitHub が確認してくれますが、コンフリクトがないことが確認できてもマージ後の動作が正常かどうかは別で確認が必要です。

この Pull Request のマージ後のチェック機構は Bitrise の機能として提供されています。これを CircleCI でも実現する場合は、次のように Pull Request に関するデータを自分で取得してマージを試みるスクリプトを書くことになります。

CircleCI の環境変数では、ワークフローに紐付いている Pull Request の URL が CIRCLE_PULL_REQUEST に設定されます。ここから Pull Request 番号を取得して GitHub API を使い、Pull Request のマージ先ブランチについて調べます。その後は git のコマンドを利用してマージを実行し、テストを実行します。
テストが失敗したり、コンパイルが失敗した場合は、マージ先のブランチに rebase することを推奨するメッセージを表示します。

jobs:
mergeability_check:
steps:
- checkout
- prepare_git_push
- run:
name: Try merging the branch into the target branch
command: |
export PR_NUM=${CIRCLE_PULL_REQUEST##*/}
export TARGET=$(curl -X GET -H "Accept: application/vnd.github.v3+json" -H "Authorization: token $GITHUB_API_TOKEN" https://api.github.com/repos/org/repo/pulls/$PR_NUM | jq -r '.base.ref')
git checkout $TARGET
git pull --rebase origin $TARGET
git merge --no-commit --no-ff $CIRCLE_BRANCH
echo "Changes can be merged without conflict"
./gradlew test
test "$?" == "0" && echo "Can be merged safely without degrade" || echo "Rebasing is recommended to keep up with $TARGET"

テスト結果の通知

CircleCI では、テストのレポートファイルを登録すると CircleCI の Web コンソールで成功数や失敗数、どのテストが失敗したかなどのデータが表示されます。メルペイの Android プロジェクトでは、danger を使って失敗したテストの一覧を Pull Request のコメントとしても残すようにして、どのテストが失敗したかを GitHub でチェックできるようにしています。

danger はもともと、静的解析ツールが生成するレポートファイルをもとに Pull Request のインラインコメントをつける目的で導入しており、lint や apk analyzer の結果をコメントしていました。これを JUnit テストのレポートファイルを読めるよう次のような Dangerfile を記述しています。テスト結果のレポートファイルを読むときにも、マルチモジュール構成ではそれぞれのモジュール配下にファイルが生成されることに気をつけます。

# test-results 配下にあるレポートファイルを見て、失敗したテストの一覧をコメントに書き出す
Dir.glob("#{ENV['HOME']}/test-results/junit/*.xml") { | file |
junit.parse file
junit.show_skipped_tests = true
if junit.failures.nil? or (junit.failures.empty? and junit.errors.empty?)
else
fail('Tests have failed, see below for more information.', sticky: false)
report(junit, file)
end
}
def report(junit, file)
message = "### Tests: nn"
tests = (junit.failures + junit.errors)
common_attributes = tests.map { |test| test.attributes.keys }.inject(&:&)
keys = junit.headers || common_attributes
attributes = ["File"]
attributes << keys.map(&:to_s).map(&:capitalize)
message << attributes.join(' | ') + "|n"
lines = ['---']
lines << keys.map { '---' }
message << lines.join(' | ') + "|n"
tests.each do |test|
row_values = [file.split('.')[-2]]
row_values << keys.map { |key| test.attributes[key] }.map { |v| auto_link(v) }
message << row_values.join(' | ') + "|n"
end
markdown message
end
# テストファイルへのリンクを貼る
def auto_link(value)
if File.exist?(value) && defined?(@dangerfile.github)
github.html_link(value, full_path: false)
else
value
end
end

テストが失敗すると、次のようなコメントが付きます。

失敗したテストを一覧表示したコメント
失敗したテストを一覧表示したコメント

おわりに

CircleCI は YAML で非常に柔軟な設定ができる CI サービスのひとつです。日々成長を続けるプロダクトと同じように CI も成長を続けますし、プロダクトの成長に合わせて CI の設定もメンテナンスし続けていく必要があります。CircleCI の設定の柔軟性をいかして、高速にビルドしつつ日々エンジニアに素早くフィードバックを届け続ける CI を構築していきましょう。

次の記事は、@wakanapo による「メルカリ写真検索における近傍探索サーバーのC++化」です。 お楽しみに!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加