クックパッド Android アプリ CI を CodeBuild に切り替えた話

こんにちは、モバイル基盤部の加藤です。 モバイル基盤部では開発者の開発環境や CI 環境の改善に取り組んでいます。 今回はその中でクックパッド Android アプリの CI 環境を CodeBuild へ移行した取り組みを紹介します。

クックパッド Android アプリで先行して移行を行った話となっていますが、他プロジェクトに関しては今後順次移行する予定となっています。

これまでの CI 環境

この記事では以前 Android アプリの CI 環境を紹介した Genymotion On Demandを使うようになってAndroidのCIがさらに1分短縮した話 からの差分を中心にご紹介します。 上記の記事をまだ読まれてない方はぜひご一読の上この記事を読まれることをおすすめします。 これまでの CI 環境の概要を説明すると以下のような図の構成となっていました。

f:id:ksfee:20200129181539p:plain

既存環境の問題点

以前の記事でも言及している Genymotion Cloud(旧 Genymotion On Demand) の問題点に加え、現在の CI 環境が抱えている問題についてもそれぞれ解決策を考えました。

  1. 並列実行上限数の壁
    • 3台構成ではピーク時にキューが詰まってしまうことがある
    • ピーク時にシュッと台数を増やせない
    • → CodeBuild の利用による並列実行上限限数の緩和
  2. コストが高い
    • 社内の CI 環境はほぼすべて AWS 上で構築されていますが、その中でも Android 環境は上位に入る高コストとなっていました
      • アプリのビルド時間を短縮するために性能が良い高価なインスタンスを常時稼働状態であることが大きな要因
    • → CodeBuild の従量課金制によるコスト削減
  3. Genymotion Cloud が辛い
    • Instrument Test の実行環境として提供
    • Google Play Service が利用できない
      • アプリ内課金など実行できないテストが発生してしまう
    • エミュレータとの接続が原因不明のエラーで不安定になり、その都度再起動する必要があった
    • 実行環境(API Level や 端末種別)をプロジェクト側で個別に設定できない
      • 実行ごとに AMI を変更することは難しいため
    • → Firebase Test Lab の利用

コスト面に関してはただ高いから移行すべきということではなく、他の2つの問題点とあわせ見直しを図ったものです。 これらの問題を複合的に捉え、今回は EC2 + Genymotion Cloud から CodeBuild + Firebase Test Lab という以下のような構成に変更しました。

f:id:ksfee:20200129181645p:plain

CodeBuild への移行

モバイルアプリの CI サービスとして Bitrise や Circle CI 等のマネージドサービスも有名ですが上述した問題の解決に加え、AWS 上に構築されたモバイル開発に必要なツール群(社内向けアプリ配信基盤、GitHub Enterprise、社内 Maven 等)とのやりとりのしやすさという点を重視して CodeBuild を採用しました。 移行を検討した段階で社内での普及が徐々に進んでおり、環境を整えやすかったという理由もあります。 それぞれの問題点に対しての採用理由は後述します。

CodeBuild について簡単に説明すると AWS が提供する CI/CD 用のビルドサービスであり、Docker 上に構築したビルド環境をいくつかのインスタンスタイプから選択して実行が可能なマネージドサービスとなっています。 並列実行も可能で、最大並列実行数である60までは自動的にスケールし、キャパシティを事前に設定する必要がありません*1

1. 並列実行上限数の壁

元々3台構成で Android CI を運用していましたが、日中開発が活発な時間帯に多くのジョブが同じタイミングで実行されてしまうと、高頻度で CI が詰まってしまうことがありました。 ジョブ単体でみればそのほとんどが実行時間が10分前後のものばかりなので、キューが詰まったとしても何時間も待つことはありませんが、CI 実行を多少なりとも待つ場面を何度観測しました。

この問題に対して CodeBuild では最大並列実行数が増えたため、キューに積まれた状態のまま実行を待つという状況が発生しなくなりました。 現在最大並列実行数に届いてしまう状況は観測していませんが、今後多くのプロジェクトで CodeBuild が利用された結果、キューが詰まるようになってしまう状況も考えられるため、今後改善が必要になるかもしれません。

またピーク時のみ実行数が一時的に増加するという状況に対して、事前にキャパシティを設定する必要がない CodeBuild の従量課金制は非常に相性が良かったです。

2. コストが高い

以前の環境では EC2 インスタンスを c4.4xlarge + m4.large(Genymotion Cloud)という構成で3セット利用していました。 これらのインスタンスを仮にオンデマンドインスタンスで起動し続けた場合、月に約$2400のコストがかかります(2019年12月時点の東京リージョン)。 これは社内で運用している CI 環境の中でもかなりの高コストとなっており、課題感がありました。

上述した料金は Android CI 環境全体のコストであり、今回はクックパッドアプリの CI 環境のみを移行した段階における内容なので、まだ全体のコストの比較が可能な段階ではないので具体的な値を記述することができません。 しかし大雑把にコストを見積もった限りでは、コストを約1/6に抑えることができるようになる予定です(あくまで予定です)。

また Genymotion Cloud に変わる Firebase Test Lab については、実行数制限がない Blaze プランで実機デバイスでも $5/hour という価格帯なので、Genymotion Cloud と比較すると雀の涙程度の料金となっています。 (参考としてクックパッド Android アプリの Instrument Test は1回10分未満程度で実行可能です)

3. Genymotion Cloud が辛い

CodeBuild では EC2 と同様に KVM 等を利用して Android エミュレータのハードウェアアクセラレーションは利用できないため、CodeBuild とは別で Android 実行環境を用意する必要がありました。スケールの面を主軸に考え、マネージドサービスを中心に検討を行いました。

Android 実行環境を検討するにあたり考慮した点は以下の3点です。

  • Google Play Service が利用可能
  • 柔軟に実行数をスケール可能
    • CodeBuild の実行数に合わせてスケール可能な状態に
  • 各プロジェクトで実行環境の設定が可能

Google Play Service を CI 環境でも利用したい(主にテストが書きたい等)という要望があがり始めていたので、今回の CI 環境移行のタイミングで改善することとなりました。 また CodeBuild の実行状況に合わせて柔軟にスケールが可能であり、かつ CodeBuild からできるだけ容易に接続できるかという点を考慮しました。 さらに特定のスペックの端末でテストを実行したいというような要望も合わせて検討しました。

現在 Android 実行環境を提供しているマネージドサービスはたくさん存在しています。 AWS にも DeviceFarm という機能がありますが、プロビジョニング時間や端末のバラエティ、またエミュレータが利用できない等のいくつかの理由から利用を諦めました。 Bitrise や Circle CIなどの環境も考慮しましたが、AWS 上に構築された社内リソースをうまく活用できるという点からも CodeBuild を採用したので、これにうまく組み合わせることができる Firebase Test Lab を採用しました。

Firebase Test Lab では実機とエミュレータの2種類から実行環境を選択でき、エミュレータを選択することでデバイスの空き状況を気にせずに並列実行を行うことができ、また Google Play Service が利用可能なデバイスを利用であり、かつ Google が提供するサービスというのが大きな決め手となりました。 以前から Test Lab の RoboTest を利用しており、その延長線で Instrument Test の実行も Test Lab に移したという面もあります。

実行方法にも触れておくと、CodeBuild から以下のような独自の Gradle タスクを利用して実行しています。

afterEvaluate {
    def testDirs = android.sourceSets.androidTest.java.srcDirs
    // Instrument Test が存在しないモジュールはスキップ
    if (!project.files(testDirs).getAsFileTree().isEmpty()) {
        android.testVariants.all { variant ->
            File targetApk
            def testApk = outputs.first().outputFile
            def script = "script file" // 引数を使って gcloud コマンドを実行するスクリプト
            def runner = android.defaultConfig.testInstrumentationRunner

            if (android.hasProperty('applicationVariants')) {
                task connectedDebugTestLabAndroidTest(type: Exec, dependsOn: ['assembleDebug', 'assembleDebugAndroidTest']) {
                    targetApk = android.applicationVariants.first().outputs.first().outputFile
                    executable script
                    args targetApk, testApk, runner
                }
            } else if (android.hasProperty('libraryVariants')) {
                task connectedDebugTestLabAndroidTest(type: Exec, dependsOn: ['assembleDebugAndroidTest']) {
                    // ライブラリモジュール用のダミー APK
                    targetApk = File.newInstance("dummy.apk")
                    executable script
                    args targetApk, testApk, runner
                }
            }
        }
    }
}

通常の Instrument Test の実行と同様に、テストが実装されていない Gradle モジュールは実行をスキップする仕様になっています。 また Test Lab のインタフェース上、ライブラリモジュールであってもテストターゲットとなる APK/AAB のアップロードが必要なのでダミーの APK を送信するようにしています。

f:id:ksfee:20200129181745p:plain

実行時間の短縮

ここまで CodeBuild と Test Lab の構成について説明しましたが、移行を検証する中でこれまでのジョブの実行を移しただけではビルド時間が約3倍になってしまうことがわかりました。 以前の CI 環境では処理能力の高いインスタンスを利用していたためビルド時間が早く、またインスタンスは常駐しており、かつ3台に集約していたため、インスタンス内の Gradle キャッシュをうまく利用できていました。 しかし CodeBuild を利用し実行環境が分散された結果、その恩恵を受けることができなくなってしまいました。 移行した結果ビルド時間が伸びてしまうことではいくら並列実行が可能になったとしても、結果的に開発者の待ち時間が伸びてしまい本末転倒です。 そこでビルド時間の最適化を図るためいくつかの取り組みを行いました。

キャッシュ

CodeBuild でジョブの実行に利用していた Docker イメージには Android SDK と必要最低限のツール類だけが含まれていました。 このためジョブを実行する度にプロジェクトごとに必要な外部ライブラリ等をダウンロードする必要があり、さらに CodeBuild から外部ネットワークへの通信が非常に遅く、ダウンロードのプロセスがオーバーヘッドとなることがわかりました。 そこで外部ネットワークへ接続せず、ジョブ実行時にすでにキャッシュデータがダウンロードされている状態を作り出すことを考えました。

S3 キャッシュ

はじめに CodeBuild のキャッシュ機能を試しました。 CodeBuild にはS3キャッシュとローカルキャッシュという2種類のキャッシュ機能があります。 S3キャッシュはその名の通りS3をストレージとしたキャッシュですが、データ量が数GBほどになると保存と復元がオーバーヘッドになってしまいます*2。 ローカルキャッシュは速度の心配はありませんが揮発するタイミングが早く、1日に何度か揮発してしまうため、その都度ビルド時間が増加してしまうことは許容できないと判断しS3キャッシュを利用することにしました。 ただ前述したとおり、キャッシュとして扱うデータ量が多くなってしまうとS3との通信がオーバーヘッドとなってしまうことがわかったので、可能な限り扱うデータ量を少なくする必要がありました。

そこで外部ライブラリ等の比較的静的なもの($HOME/.gradle/caches/jars-3 等)はS3キャッシュで、比較的動的に置き換わるビルドキャッシュを Gradle ビルドキャッシュサーバで扱うことにしました。 Gradle ビルドキャッシュサーバについてはドキュメント通りの利用方法なので、ここでは割愛します。

f:id:ksfee:20200129181819p:plain

キャッシュの利用によってビルド時間は以前の約1.5倍程度まで短縮しました。 S3キャッシュの復元・更新がオーバーヘッドとなり、これ以上の短縮は難しい状況でした。

Docker イメージによるキャッシュ

オーバーヘッドとなる S3 キャッシュを利用せず、かつ外部ライブラリのキャッシュをジョブごとに取得しない方法として Docker イメージにキャッシュを載せる方法を試しました。 この方法の懸念点として Docker イメージのサイズ肥大化によって CodeBuild のプロビジョニング時間が伸びる可能性がありましたが、Docker イメージのサイズが約1GBから約3.5GBとなってもプロビジョニング時間にほぼ差はなかったため、復元時間をほぼ0にすることができました。 また Docker イメージの更新はジョブ実行とは別で定期的に実行するようにしたため、更新時間も0にすることができました。

Docker イメージに外部ライブラリをダウンロードは analyze*Dependencies タスクを実行することで実現しています。

ジョブの分割

CI ジョブはそれぞれ実行時間が重要なものとそうでないものがありますが、特に重視したのは Pull Request に合わせて実行されるジョブでした(以後 PR ジョブ)。 開発者が普段開発する中で最も実行機会の多いPRジョブですが実行に時間がかかり過ぎてしまうと、レビューが通ったとしても無駄に待つ必要がある、CIでテストを実行したいのにいつまで経っても終わらない等の問題が発生してしまいます。

PR ジョブではユニットテスト、Lint、ライセンスチェック、社内向けアプリ配信サービスへのアプリアップロードなど、非常に多くのタスクを実行していました。 これらのタスクは互いに依存しておらず、また CodeBuild を利用することで最大並列実行数が緩和されたことからジョブの分割実行を試すことにしました。 ジョブを複数に分けることで、後続のタスクでキャッシュを活かすことができなくなる等の理由で個々のタスク実行時間は伸びてしまいましたが、結果的に実行時間の短縮につなげることができました。

ここまでの取り組みで PR ジョブにおいては以前の CI 環境と比べ、同等またはそれ以下の実行時間を実現することができました。

f:id:ksfee:20200129181904p:plain

今後の課題

冒頭に挙げた問題点はほぼ解消できたように見えますが、まだまだ課題があります。 前述したように、現在S3キャッシュがオーバーヘッドとなっていますが、Gradle キャッシュに保存される外部ライブラリをまるごと保存しています。 ライブラリのバージョンが更新され新しい JAR や AAR を使うようになっても、古いバージョンがそのままキャッシュとして保存されてしまいデータ量が増え続けてしまうので、プロジェクトが依存するライブラリのバージョンに合わせて必要最低限のキャッシュを維持する仕組みを構築する必要があります。

また冒頭に記述したとおり、まだ全ての Android アプリの環境が移行されたわけではないので、今後も他のアプリの移行を推進して並列実行数の緩和やコスト削減などにつなげていく予定です。

今回CI用に Gradle ビルドキャッシュサーバを導入しましたが、キャッシュの読み取りは各開発者の環境においても活用できるので、今後開発者の手元からも利用できるようするなどの展望があります。

まとめ

今回はクックパッド Android アプリの CI 環境をいくつかの問題点を考慮し、CodeBuild + Firebase Test Lab という環境を構築し、またその中で行った取り組みについて紹介しました。 並列数上限の緩和、また各プロジェクトごとで構築可能なビルド環境、スケールする Android 実行環境によって開発者ドリブンな CI 環境を構築することができました。 また移行により増加してしまったビルド時間については、キャッシュやジョブの分割による工夫により以前と同程度の実行時間を実現することができました。

CI は開発者が開発を効率化や仕組み化するために用いるものであるため、今後も日々の開発の効率化を念頭に環境整備を行っていく予定です。

モバイル基盤部ではこのように開発環境の改善及び仕組み化を行っているので、ご興味がある方はぜひ一度クックパッドオフィスまでお気軽に遊びに来てください。

https://info.cookpad.com/careers/

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/