x3 Speed Up Android CI at Cookpad

海外事業部の松尾(@Kazu_cocoa)です。こちらは、私たちの x3 Speed Up Android CI at Cookpad に公開した記事の日本語訳です。英語でご覧になりたい方は原文を一読ください。

以下に登場するAndroidアプリは海外版のクックパッドアプリとそれにまつわる環境を指します。国内のものと構成も異なるところがありますので、そのあたりを頭に入れつつ読んでいただければと思います。


この記事では、現在のAndroidアプリ開発向けCI環境の紹介とその環境構築の流れを紹介します。現在の環境では、GitHub上に作成されたプルリクエストへのプッシュ毎にビルド・テストの実行含めて処理が 7分程度 で完了します。これらの処理にはAPKの作成、各種テストの実行が含まれます。

以前は、私たちのCIはプッシュ毎に 合計で25分程度 かかっていました。そのころは、2つの役割の異なるJenkins Jobを並列実行させていました。合計25分はそれらを含めた合計値です。

以下では、どのようにしてこのような環境を構築したのかを知ることができます。テストの実行環境として、エミュレータの並列起動とそれを用いたテスト実行の話を載せています。この記事ではCIにおけるビルド/テスト環境に焦点を当てるため、他の話は行いません。

マルチモジュール、1000以上のテストケース

まずは対象となるAndroidプロジェクトの概要の共有です。

Androidアプリ

  • 20個のモジュールを保持
  • unit/instrumented/Espressoを含む、合計1000以上のテストケースを保持
  • ビルド毎、社内配布用のapkを配布

現在のCI環境

現在のCI環境は以下で構成されています。

  • AWS上に構築されたJenkins環境
    • Master/Slave構成(Android向けのJenkins Slaveには i3.metal instanceを利用)
    • Jenkins環境はAndroid以外のプロジェクトも利用
  • GitHubへのプッシュ毎に実行されるタスク
    • unit/instrumented/Espresso testsの実行
      • 取得可能なものはカバレッジの取得も含む
    • 必要なapkのビルド
  • テスト端末
    • 合計14個のエミュレータを作成・起動し、並列にAndroid APIの必要な instrumented/Espresso テストを全て実行
  • ガイド

擬似的な Gradle タスクを用いて上記を模倣すると、以下のような処理がプッシュ毎に実行されています。

./gradlew clean
./gradlew checkLicenses
./gradlew testAllUnitAndInstrumentedTests
./gradlew assembleOurInternalApk
./gradlew uploadApkForInternal

GitHubへのプッシュを契機に以下のような流れで処理が Jenkins Slave 上で実行されます。

GitHub <----> Jenkins (master) <-----> Jenkins (slave)
                                        Build/All Tests

旧CI環境

変更する前の環境は以下です。

  • AWS上に構築されたJenkins環境
    • Master/Slave構成(Android向けのJenkins Slaveには i3.metal instance 以外 を利用)
    • このJenkins環境はAndroid以外のプロジェクトも利用
  • GitHubへのプッシュ毎に実行されるタスク
    • unit/instrumented/Espresso testsの実行
      • 取得可能なものはカバレッジの取得も含む
    • 必要なapkのビルド
  • テスト端末
  • ガイド

旧CI環境では、以下のように2つの Jenkins Slave を用意し、実施するテストを分ていました。

GitHub <----> Jenkins (master) <-----> Jenkins (slave) 1
                        |              Build/non-UIテスト
                        |------------> Jenkins (slave) 2
                                        Build/UIテスト

上の図に出ている non-UIテスト とは、unit/instrumentation-based JUnitを指す、UI描画を含まないテストをさします。これらにはGenymotion Cloudを使い、いくつかのビルド含む18~20分かけてビルドしていました。 UIテスト とは、Espressoを使ったテストです。OpenSTFを使いテストを実行していました。いくつかのビルド含む、合計で15分程度かかるジョブです。

2つの異なるJenkins job環境

UIテストはOpenSTFを用いて実行していた、と上記で書いています。

AWS上では、ARMベースのAndroidエミュレータしか実行できませんでした。そのため、UIテストは特に実行/描画速度が遅く、UIの描画を含むテストを安定して実行することが厳しかったです。私たちの環境では、Genymotionも多少なりもとも制限を有していました。そのため、UI描画が不要なテストと、UI描画が必要なテストは分けて実行していました。

各々のJenkins jobは並列して実行されるようにしていました。それぞれ、15分程度は少なくとも実行環境まで時間がかかります。

このくらいの待ち時間になると少しコーヒーで一息つくことができる感じですね。

f:id:kazucocoa:20180705183344j:plain

よりJenkins Jobを細かく分割などすれば時間は短縮することはできますが、Jobの管理などが複雑になってきます。

なぜAWSを使っているか

現在、私たちは可能な限り自分たちで物理リソースを保守したくありません。私たちはAWSを主に利用しています。

強力な物理マシンを用意しそれをCIとして利用できれば今回と同様のことを実現できるでしょう。その場合は環境を拡大するさい、そのつど端末購入が必要になってきます。現在のiOSビルド環境に似たようなものですね。それは保守コストや将来的にもチーム拡大の足枷になるとふんでいました。

そのため、私たちはAWS上で利用可能なそのようなマシンを待っていました。

AWSのベアメタルインスタンス

昨年の終わり頃、Bare Metal Instances for EC2が発表されました。その環境下では、Androidのx86エミュレータによる実行が可能になると期待できていました。Androdエミュレータ向けのVMアクセラレーションの各種機能を利用できることも期待できました。

少し、x86エミュレータについて説明を残しておきます。

Googleは公式のAndroidエミュレータを公開しています。そこから、私たちはいくつかのCPUアーキテクチャ上で動作するエミュレータを利用することが可能です。その中で、 x86 とはInetel Processor上で動作可能なものを指します。

AWS上では、元々はARMエミュレータだけが利用可能で、x86エミュレータは利用不可能でした。一方、ベアメタルインスタンスではx86エミュレータも利用可能です。

新しい環境

Amazon EC2 Bare Metal Instances の一般向け公開をお知らせします によってベアメタルインスタンスが利用可能になったのち、私たちはJenkins Slaveとしてその環境を検証し、使いはじめました。その途中でいくつか問題にぶつかったので、その経験を共有したいと思います。

i3.metalでAndroid環境を構築する

i3.metalの設定

現在、ベアメタルインスタンスは入手可能な地域が限られています。まずは利用したい地域でベアメタルインスタンスを入手可能か確認してください。 ベアメタルインスタンスはEC2向けの通常のインスタンス起動ウィザードから作ることが可能です。ウィザード起動後、AMIを選択してウィザードを次に進めてください。表示されるインスタンス一覧の最下部に i3.metal の表記を見つけることができるはずです。その i3.metal がこのベアメタルインスタンスです。i3.metal 選択後、各々の環境にあった設定を選んでいき、インスタンスを作成してください。

なお、以下ではUbuntuをベースとしたAMIで動作を確認しています。そのほかの環境では手順が異なるところがあるかもしれませんのでお気をつけください。 i3.metal は起動までに少し時間がかかります。その間、エスプレッソでも飲みながら待ちましょう。

f:id:kazucocoa:20180705183349j:plain

Android SDKの入手

まずは Android SDK コマンドラインツールを取得する必要があります。ダウンロードページからLinux向けのものを入手してください。 i3.metal インスタンス上でそれらを展開した後、 ANDROID_HOMEANDROID_TOOLS の設定を忘れずに行なってください。

各種アクセラレーションを有効にする

i3.metal を使った大きな理由は、先でも少し述べたようにAndroidエミュレータの各種アクセラレーション機能を使うことです。この機能はARMエミュレータでは利用できません。そのため、上で述べたように私たちはOpenSFTやGenymotionをAndroid CIに使っていました。

VMアクセラレーション

Configure VM accelerationでは、VMアクセラレーション環境の構築を知ることができます。

私たちはUbuntuベースのi3.metal インスタンスを構築しています。そのため、Ubuntu KVM Installationに沿って環境構築を進めました。環境構築の後、以下のような入力に対して出力が得られたのであればでは、VMアクセラレーション環境環境の構築は完了です!

$ sudo /home/ubuntu/android/tools/emulator -accel-check
  # accel:ad
  # 0
  # KVM (version 12) is installed and usable.
  # accel

SwiftRenderによる描画のアクセラレーション

描画に対するアクセラレーション環境はいくつかの種類提供されています。 VMアクセラレーションだけでinstrumented testsに対する高速化は十分です。ただ、UIの描画のからむEspressoのテストも含んでくるとそれでは足りません。この環境構築が必要となってきます。

この中で、私は1つ問題に出くわしました。

OpenGL 関係の描画問題

まず、私は host モードを使ってみました。OpenGLが利用可能であれば i3.metal でも macOS など同様の描画が可能だと期待したためです。しかし、Could not initialize OpenglES emulationというエラーに出くわし、エミュレータを起動することはできませんでした。

Elastic GPUを用いることができるかと期待しましたが、この時は利用することができませんでした。

次に、私はエミュレータの -no-window モードを利用しました。エミュレータの起動には成功したのですが、Espressoのテストを実行すると以下の例外が発生してテストが中断されました。

E AndroidRuntime: DeadSystemException: The system died; earlier logs will point to the root cause
W System.err: java.lang.Throwable: tname=main - android.os.DeadSystemException
W System.err:    at adgh.a(PG:17)
W System.err:    at adgh.uncaughtException(PG:20)
W System.err:    at java.lang.Thread.dispatchUncaughtException(Thread.java:1955)
W System.err: Caused by: java.lang.RuntimeException: android.os.DeadSystemException
W System.err:    at android.app.ContextImpl.registerReceiverInternal(ContextImpl.java:1442)
W System.err:    at android.app.ContextImpl.registerReceiver(ContextImpl.java:1394)
W System.err:    at android.app.ContextImpl.registerReceiver(ContextImpl.java:1382)
W System.err:    at android.content.ContextWrapper.registerReceiver(ContextWrapper.java:609)
W System.err:    at ajmt.a(Unknown Source:10)
W System.err:    at akmp.a(Unknown Source:117)
W System.err:    at akma.a(Unknown Source:12)
W System.err:    at akmu.a(Unknown Source:7)
W System.err:    at aklm.a(Unknown Source:8)
W System.err:    at ajqf.a(Unknown Source:2)
W System.err:    at ajpo.handleMessage(Unknown Source:11)
W System.err:    at android.os.Handler.dispatchMessage(Handler.java:106)
W System.err:    at android.os.Looper.loop(Looper.java:164)
W System.err:    at android.app.ActivityThread.main(ActivityThread.java:6494)
W System.err:    at java.lang.reflect.Method.invoke(Native Method)
W System.err:    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438)
W System.err:    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:807)
W System.err: Caused by: android.os.DeadSystemException
W System.err:    ... 17 more
I Process : Sending signal. PID: 3501 SIG: 9

これはQtの問題だと予測したので、apt-get install xorg openboxQT_QPA_PLATFORM='offscreen' を試してみました。しかしそれでも改善はしませんでした。

解決策を試行錯誤している中で、最終的にSwiftRenderが助けてくれました。no-windowオプションと合わせてエミュレータを起動した結果、正しくエミュレータが起動し、Espressoのテスト実行も完了することができました。 Configuring graphics acceleration on the command line にも書かれている通り、これは swiftshader_indirect モードによるエミュレータ起動で利用することができるものでした。このライブラリはOpenGL ESをCPU処理を使い実現するものです。

まとめ

ここの章は少し長かったので軽くまとめます。

  • VMアクセラレーションを有効にするためにKVMをインストールする
  • 描画のアクセラレーションを有効にするためにSwiftRenderを有効にする
    • このライブラリは $ANDROID_HOME/emulator/lib64/gles_swiftshader に見つけることもできます
  • エミュレータは "-no-boot-anim -no-snapshot-save -netfast -noaudio -accel on -no-window -gpu swiftshader_indirect" のオプションで起動する

ここで、 i3.metal インスタンスのAndorid向けビルド環境構築の説明は終わりです。こちらをJenkins Slaveとして利用可能にすると、Android向け Jenkinsインスタンスとして利用可能になります。

最後に1つ、 i3.metal インスタンスの良い点を以下にあげておきます。実は、個人的には以下の環境を手に入れることがベアメタルインスタンスの、もう一つの大きな目的でした。これにより、短時間のAndroidテスト環境を構築することができます。

CI上でテストを並列実行する

i3.metal インスタンスは非常に高性能です。72コアや、大量のメモリを搭載しています。そのため、苦無く複数エミュレータを同時に起動可能です。

エミュレータの複数同時起動により、Androidテストを実行するときに必要となる adb install の1つのエミュレータに対する実行頻度を下げることができます。shardingの機能も利用することができます。エミュレータの起動数を上げることで、テストの並列実行数をあげることもできます。

テスト対象となるapkのインストール時間はAndroidテストの実行の中で時間を必要とする処理です。私たちのプロジェクトにはモジュールが20個あります。その中で、14個のモジュールにAndroidテストが存在します。そのため、14モジュール分のandroidTest実行における adb install が処理される必要があります。それが直列で行われる場合、それなりに時間がかかってしまいます。それを解決するために、各々のモジュール1つに対して1つ専用のエミュレータを作成し、並列してそれらを実行できるようにしました。

以下のGIFアニメーションが並列実行の例です。現在は、このような状況が合計で14個、Jenkins Slave上で動作しています。並列実行を達成するためにcomposerswarmerを利用しました。(感謝も含めて、何か問題や追加したい機能があればPRを送るなども行いたいですね。)

f:id:kazucocoa:20180705183355g:plain

以下のスクリプトは複数のサブプロセスを操作するbashスクリプトの例です。これにより、シェルのサブプロセスでバックグラウンド実行するAndoridテストの終了を待ち、結果を集計してJenkinsの成功/失敗をまとめることができるようになっています。

function wait_and_get_exit_codes() {
    children=("$@")
    EXIT_CODE=0
    for job in "${children[@]}"; do
       echo "PID => ${job}"
       CODE=0;
       wait ${job} || CODE=$?
       if [[ "${CODE}" != "0" ]]; then
           EXIT_CODE=1;
       fi
    done
}

EXIT_CODE=0
children_pids=()

# composerスクリプトを `&` 指定で実行
children_pids+=("$!")

wait_and_get_exit_codes "${children_pids[@]}"

exit "${EXIT_CODE}" # いずれかのサブプロセスの終了コードが1の場合だと、ここが1になる

Conclusion

読んでいただいてありがとうございます。

上記により、 i3.metal インスタンスを使ったAndroid CIの環境構築方法を知ることができました。また、複数エミュレータ起動によるテストの並列実行の様子も見ることができたかと思います。

これらの対策により、AndroidのCI環境は今までよりも高速に、安定するようになりました。全てのプッシュに対するCI実行は7分程度で完了します。まだチューニングの余地はあるので、必要であればより最適化する余地もあります。ベアメタルインスタンスは価格がほかのインスタンスよりも高価なので、より効率的な利用ができるようにしていくことも将来的な挑戦でもあります。

最後に、検証・本番環境構築にKohei Suzukiさん、 Takayuki Watanabeさんの協力もいただきました。ありがとうございます。