こんにちは、メルカリの iOS リードアーキテクトを担当する青山 (Ryo Aoyama, @ra1028fe5)です。
今回は、私と同じモバイルアーキテクトチームでビルドシステムを担当する Thi (
Thi Doãn, @thi_dt)と一緒に、メルカリアプリをフルスクラッチする際に採用した Bazel を使ったビルドの改善について紹介します。
背景
メルカリでは、お客様により便利な出品・購入体験を提供するため、長い年月を掛けて想像以上に膨大な機能を提供するようになりました。
2022 年 9 月にアプリをフルスクラッチした後も、週に 100 を超えるプルリクエストがマージされ、ソースコードは絶えずスケールし続けています。
執筆時点で、外部依存性を含むメルカリのソースコードの規模は下記の通りです。フルスクラッチ以前と比べてかなり縮小できたとはいえ、依然として規模は大きいままです。
外部依存を含むメルカリのソースコード量 (clocで計測、一部抜粋)
Language | Files | Comment | Code |
---|---|---|---|
Swift | 17259 | 317042 | 2167377 |
C++ | 717 | 42862 | 258290 |
C/C++ Header | 2253 | 156329 | 239706 |
C | 595 | 41432 | 212548 |
Objective-C | 647 | 18797 | 143736 |
それに伴い、当然ながらプロダクトの成長と共にビルド時間は増大し続けていました。大規模なソースコードのビルドには相応の時間がかかり、開発を徐々に鈍化させる大きな要因となっていました。
また、ビルド結果の信頼性にも課題がありました。皆さんにも、「CI では再現できるがローカルで再現性がない」や「他の開発者のビルド結果と整合性がない」といった経験があるのではないでしょうか?
ビルドはエンジニアリングにおいて最も重要な要素の一つです。開発者は一日に数十回から数百回もビルドを行います。開発者の数が多くなればなるほど、組織におけるビルドのコスト比重は大きくなるので、より高度な機能を持つビルドシステム導入が不可欠でした。Xcode では下記のような懸念点があったからです。
- 発展的なビルド高速化を行う余地が少ない
- ビルドの再現性が低い
- ビルド速度がビルドマシーンのスペックに依存する
- 500〜1000 程度の分割モジュール細分化を進める上で、ビルド構成の再利用性に乏しい
- モジュール細分化による dynamic link のオーバーヘッドが大きい・static link が扱いづらい
そこで、根本的な解決策として、Bazelを採用し、高速で信頼性の高いビルド環境の構築を目指しました。
Bazel とは
Bazel は Google によるオープンソースのビルドツールです。Go や Java のビルドに使われることが多いですが、iOS や Android といったモバイルアプリのビルドにも使うことができます。
Xcode やその他のパッケージ管理ツールと比べた場合の主な利点は、下記のようなものです。
- 多言語サポート: 複数の言語を含む Monorepo を想定してデザインされており、特定の言語に特化せずさまざまな言語をサポートしている。
- 拡張性: オフィシャルにサポートされていない言語や手続きも、自分でビルドルールを記述して拡張することができる。
- 再現性: 出力に環境依存の情報を含まないように設計されているので、CI では再現できるがローカルでは再現性がない問題が起こらない。
- 記述言語: Bazel のビルド構成ファイルはStarlark という Python に似たスクリプト言語で記述でき、DSL ではないので変数やマクロを使って高度な共通化を行う事ができる。
- 高度なキャッシュ: ビルド定義から依存グラフを構築し、一度ビルドしたものは必要になるまで再ビルドしない。また、リモートキャッシュや分散ビルドによって複数マシーンで出力を共有することができる。
- タスクの自動化: 言語のコンパイルだけではなく、コード生成のようなタスクの実行もビルドプロセスに組み込む事ができる。
ビルドキャッシュ
ビルドやテスト結果のキャッシュは生産性を向上させる最も効果的な方法です。
外部依存ライブラリの場合は、更新頻度が高くないという性質から、Carthage のようなツールで事前ビルドし、二回目以降のビルドを高速化するのが一般的です。
また、その事前ビルドバイナリをキャッシュストレージを通して開発者間で共有するというような応用的な方法を行う場合もあります。
とはいえ、実際にはビルド時間の大部分を占めるのは、自分たちのソースコードのコンパイルです。自分たちのソースコードは絶えず変更されるため、事前ビルドすることは難しく、上記のような方法では達成できないでしょう。
達成するには、ビルドターゲット間の依存グラフを分析し再ビルドの必要があるかどうか決定、それらのプロセスを並列処理できるかどうかの保証、ビルドに必要なクリティカルパスの算出などといった高度な機能を必要とします。Bazel はこういった増分ビルドに必要な機能を高度にサポートしています。
ところで、Bazel はよくビルドが速いと表現されますが、少なくとも iOS アプリ開発において、Bazel のビルドが速いというのは、少し誤解のある表現かもしれません。
iOS アプリをビルドするための rules_apple と rules_swift は、内部的に Xcode とほとんど同じ方法でコンパイルを行います。
Xcode も賢いインクリメンタルビルドを行うので、ローカルビルドの場合はほとんど同じか、Xcode の方がむしろ多少高速なケースもあるでしょう。後述するように Static リンクによるオーバーヘッドの影響もあります。
しかし、Xcode ではビルド速度改善の方法が限られているのに対して、Bazel ではより柔軟な方法で対応する事ができます。例えば、モジュール細分化を前提として設計されているため、適切にモジュール分割されていればその分キャッシュ効率を最適化する余地が大きいのです。
また、Bazel の最も大きなアドバンテージはリモートキャッシュが使える点です。リモートキャッシュは、ビルドやテストの出力を任意のストレージサーバーにホストして、開発者・CI 間で再利用する機能です。例えば、チーム全員と CI 環境がリモートキャッシュを共有していれば、あらかじめ CI上でビルド済みであれば、他の開発者の環境でも再ビルドが行われることはありません。開発者がローカルで変更を加えても、Bazel はその変更点の影響範囲を分析して最小限のビルドを行うので、基本的にローカルではクリーンビルドを行う機会がないということです。
これはテストについても同様で、Bazel はモジュールのテスト結果をキャッシュし、次に必要になるまで再実行せず結果のみを出力します。Xcode では、ユニットテストの量が多いと CI の実行が 1 時間を超えてしまうケースも珍しくないので、リードタイムを大きく改善してくれました。
下図は、メルカリの CI ビルドログの実例です。見ての通り、217 のテスト中 2 つのテストのみが実行されています。他のテスト結果は、プルリクエストによる影響がなかったのでリモートキャッシュから取得されています。(cached)
と記されているテストは、実際には実行されていません。既に結果がわかっているテストの実行を待つ必要がなく、著しくテスト実行時間を短縮してくれます。
なおリモートキャッシュは、Bazel が無償提供するものではなく、リモートキャッシュバックエンドを自分たちで管理する必要があります。任意のサーバーを選びメンテナンスするか、Bazel のリモートキャッシュに互換性がある Google Cloud Storage (GCS) を使うことができます。メルカリはバックエンドシステムに Google Cloud Platform を多用しているので、GCS を選択するのが自然な流れでした。しかしながら、GCP は高速ですが、Bazel の Remote Builds without the Bytes
機能に対応したガベージコレクションをサポートしていません。
その後、後述する Remote Build Execution の導入を検討した際に、Remote Builds without the Bytes
に対応する BuildBuddy のリモートキャッシュバックエンドに移行しました。
どちらを利用した場合でも、メンテナンスにおける人的コストはゼロに等しいです。
分散ビルド・テスト
リモートキャッシュと類似した機能として、Remote Build Execution (RBE) があります。RBE はプロジェクトのビルドやテストを、任意数の別マシーン上で分散ビルド・テストする機能です。リモートキャッシュと同様に、Bazel の出力には再現性があるので、別のマシーン上でビルドした結果をマシーン間で共有して最終的な成果物にすることができます。
これを活用できれば、分散ビルドによりビルドやテストにかかる時間を著しく改善できますし、例えば CI のマシーンスペックが高速なビルドを実現するために心許ない場合、任意のリモート実行環境上で実行することができるなどの利点があります。
メルカリでは、リモート実行環境としても、BuildBuddy を利用し、RBE によって Apple silicon M1 ビルドファーム上の数百コアで分散ビルド・テストを行っています。開発者がプルリクエストを送信すると、CI が下記のコマンドによって、すべてのビルドとテストをビルドファーム上で実行します。
bazel test --config=RBE //…
フルスクラッチ後のメルカリアプリはすでに著しくモジュール化されているので、テストは並列実行され、並列数はビルドファームの総コア数によってのみ制限されることになります。
RBE を導入する際に直面した一つの大きな問題は、CI が純粋に Intel ベースの Mac で構成されているのに対し、ビルドファームは全て Apple silicon の Mac で構成されていることです。さらに、同時期にメルカリの IT 部門から M1 MacBook Pro の提供が始まっていたため、開発者の環境は Intel と M1 の Mac が混在しています。そのため、キャッシュヒット率が低下しているマシーンがないことを確認しながらサポートしなければなりませんでした。この問題を解決した方法の詳細については、次のブログ記事を御覧ください。
また、BuildBuddy はビルドイベントを可視化するダッシュボードも揃えているので、ビルド効率改善のための分析にも役立ちます。私達はローカルビルドイベントを BuildBuddy のダッシュボードにストリーミングしているので、開発者間でログを共有することができます。メルカリではオフィスで作業することが必須ではないので、この機能がビルドに関する共同作業を容易にしてくれました。
メルカリではキャッシュの安定性や CI のコストとキャッシュヒット率のバランスを考慮して、新しいコードが master(main) ブランチにマージされた時にだけキャッシュを BuildBuddy にアップロードしています。各開発者がビルドする必要があるのは、ローカルで加えた変更の影響を受けるコードだけです。
また、今の所 RBE は CI 上でのみ有効化していて、ローカルではビルドイベントを共有・可視化する用途で利用していますが、ローカルでも RBE による分散ビルドを有効にするかどうかは、コストの観点から検証中です。
下図は、現在のiOS アプリのビルドが行われるフローを略記したものです。
参考値として、私のマシーン上で簡単な計測を行いました。条件は下記のとおりです。
- Macbook Pro, Apple M1 Pro, 32GB RAM
- デバッグビルド
- 試行回数 3 回
- 各実行前にローカルのキャッシュを削除
- フルビルド: キャッシュ無しのフルビルドをした場合
- リモートキャッシュ: リモートキャッシュを有効にした場合
- RBE: リモートキャッシュと RBE を両方有効にした場合
メルカリアプリのデバッグビルド速度計測
ビルド方法 | 1回目 | 2回目 | 3回目 |
---|---|---|---|
フルビルド | 256.092 s | 241.716 s | 247.491 s |
リモートキャッシュ | 75.130 s | 74.969 s | 76.271 s |
RBE | 36.814 s | 36.955 s | 46.060 s |
ここまでBazel の利点をいくつか紹介しましたが、採用には懸念点もありました。次のセクションからは、どのような懸念があったか、それをどのように解決したのかを紹介します。
Xcode 統合
Bazel と Xcode の統合は Bazel の採用においてもっとも大きな懸念でした。
Xcode はビルドシステムと密結合したやや特殊な IDE なので、外部ビルドシステムとの統合が難しいのです。特に indexing や LLDB デバッグを正しく動作させるのは困難でした。
統合とはつまり、Bazel によるビルドのアウトプットを利用して Xcode がサポートする動作を再現することを意味していて、主に下記のような要件を満たす必要があります。
- Bazel のビルド構成ファイル群を解析して Xcode プロジェクトを生成する
- Xcode ビルドの実行を抑制し、代わりに Bazel ビルドを実行する
- ビルド出力を特定の場所に配置し、indexing などの用途で Xcode が利用できるようにする
上記を満たして初めて、機能開発に従事するエンジニアが Bazel を使った場合でも今まで通り快適に開発を行う事ができます。Xcode 統合の主なツールとして下記のようなものがよく知られています。
- Tulsi
- XCHammer
- rules_xcodeproj
- XcodeGen + カスタム Shell スクリプト
- index-import
Xcode と Bazel のビルド方法の違いの一つは、Xcode がソースファイルを常に絶対パスで扱うのに対して、Bazel は可能な限り相対パスで扱うことが挙げられます。主な理由は Bazel はビルドの再現性を可能な限り高めるように設計されているからです。別のマシーンやディレクトリでビルドが行われたとしても、ビルドの出力は常に同じであるべきです。
しかし執筆時点では、LLDB が全ての情報を絶対パスで持っている場合にのみバイナリのデバッグが可能です。これを実現するには、バイナリがデバッガにアタッチする際に、カスタムlldbinit
ファイルを使って相対パスを絶対パスにリマップする必要があります。これは indexing についても同様です。
私達はプロジェクトの開発開始当初、上記のツールやそれぞれを自前のスクリプトと組み合わせた方法をいくつか試行錯誤し、Tulsi と index-import を使ったハイブリットな方法を採用しました。Tulsi はバイナリの絶対パスリマッピングを既に処理していますが、index-import を使って indexing ファイルのリマッピングを行う post-build ステップを追加する必要がありました。
Tulsi は Google の内部プロジェクトでは機能しているようですが、外部プロジェクト、特に多言語が混在するプロジェクトではそのままでは完全には動作しません。幸運なことに、私達はプロジェクトをフルスクラッチする機会があり、ファーストパーティのソースコードは全て Swift 化されています。そのため、Tulsi を機能させるのにそれほど大きな労力はかかりませんでした。ただし、私達にとっての使い勝手を良くするため、Tulsi をフォークして少し改良を加えています。
Xcode には、IDEIndexShowLog
という indexing のログを調査するための設定があり、下記のコマンドで有効化できます。
defaults write com.apple.dt.Xcode IDEIndexShowLog -bool YES
Tulsi や rules_apple, rules_swift がアップデートする度に indexing のログを調査し、エラーがなければ、indexing がファーストパーティのコードに対して機能することになります。
この方法は効果的でしたが、メンテナンスに時間がかかり、エラーが発生しやすいものでした。メルカリアプリのフルスクラッチを終えてから、私達は統合方法の改善を模索してきました。
今年に入ってからは、BuildBuddy のリードと、メルカリを含むいくつかの大企業からの貢献により、新しい Xcode 統合ツールの rules_xcodeproj が活発に開発されています。Xcode 統合に必要なすべてが組み込まれ、カスタムソリューションやハックは必要なく、Bazel を利用するあらゆる iOS プロジェクトですぐに使うことができます。
rules_xcodeproj が実用段階になってからは試験運用を経て Tulsi から移行し、今では非常に快適な開発環境になったと感じます。
他のプロジェクトでよく見られる問題点は、Xcode から Bazel への移行の際に、両方のビルドシステムをサポートしなければならないことです。移行後でも Xcode ビルドをサポートしているプロジェクトもあります。
私達にはフルスクラッチをする機会があったため、初期から Bazel をビルドシステムとして採用できたのは幸運でした。Bazel をメインビルドシステムとして利用しながら、Xcode ビルドもサポートし続けるのには、Xcode を選んでビルドした開発者にとってビルドがかなり遅くなってしまうことや、ビルドシステムチームに両方のシステムを維持し続けるという負担がある、などと言った欠点があります。これは生産性を損なう要因になってしまうので、私達は Bazel だけをサポートすることにしました。
ランタイムパフォーマンス
モジュールが増加することのデメリットの一つとしてアプリの起動時間が遅くなってしまうことが知られています。
これはモジュール分割自体に問題があるわけではなく、Dynamic framework (.dylib
+ .bundle
) が多く含まれる場合に起こります。アプリが Dynamic framework を含む場合、iOS は起動時にそれらを動的リンクするので起動時間を増加させてしまうのです。
反面、Static library (.a
) はビルド時にモジュールを静的リンクするので、ビルド時間に加えてリンクの時間がかかりますが、アプリの起動時間には影響しません。
最近では dyld の高速化や iOS のキャッシュによって、起動時間を気にする必要はほとんどなくなっていると言っても過言ではありません。しかし、現在のメルカリのように 1000 近いモジュールがある場合は UX を損なう可能性があるので、Static library を利用することが不可欠です。
Bazel の場合、全てのモジュールを Static library としてビルドするのがスタンダードになっているため、基本的にバイナリサイズの問題はありません。
Dynamic framework が混在する場合、ビルドの仕方によってはシンボル重複とアプリサイズが増加する問題が知られていますが、この場合も Bazel が正しくリンクを行ってくれます。
ちなみに、アプリと App Extension は、それぞれ独立したバイナリとしてバンドルされるので、Bazel でもシンボル重複する可能性があります。その場合は、Dynamic frameworkを仲介させる こともできます。
フルスクラッチ後のアプリでは、モジュール分割を大幅に促進させたものの、Firebase Performance Monitoring で計測した起動時間を比較したところ、旧アプリとほとんど同じ起動時間を維持できているようです。
改善とまでは至らなかったのが残念ですが、フルスクラッチ以前も起動時間の改善に取り組んではいたので、プロダクトの規模を加味すると満足できる結果になりました。
- フルスクラッチ前: 4.106.0 (136002)
- フルスクラッチ後: 5.19.0 (207952)
依存パッケージ管理
Bazel を使う上で最も懸念されることの一つは、(バージョン管理の機能を持つ)依存性マネージャがないことです。公平を期すために言えば、Bazel はビルドシステムであり、依存性マネージャではありません。Bazel は依存関係の管理についての哲学を持ち、バージョン管理機能を持たないのは意図的なようです。
iOS のエコシステムの中で、CocoaPods は(Carthage と Swift Package Manager と比較して)これまでで最も信頼できる依存性マネージャです。当初、私たちは CocoaPods を依存性マネージャとして使用することにしました。更に利便性のため、CocoaPods をフォークして、pod install
コマンドで Bazel の BUILD ファイルを生成させ、サードパーティライブラリを取得するようにしました。これは長い間うまくいっていたのですが、プロジェクトが依存するサードパーティライブラリが少なくなって来ると、労力に見合わないと感じ始めました。既にRenovateを使って依存関係の自動更新を行っていたので、依存性マネージャはもう必要がないと判断し、プロジェクトから CocoaPods を削除しました。
Bazel では、外部ライブラリやツールも便利かつキャッシュ可能な方法で扱う事ができます。また、それに依存したターゲットがビルドされた時に、本当に必要な外部依存性のみを取得してくれるので効率的です。
外部パッケージが Bazel ビルドに対応している場合はプロジェクトルートの WORKSPACE ファイルに下記のような依存性定義をすれば簡単に統合できます。例えばSwiftLintは Bazel に対応しています。
- WORKSPACE
http_archive(
name = "SwiftLint",
sha256 = "7c454ff4abeeecdd9513f6293238a6d9f803b587eb93de147f9aa1be0d8337c4",
url = "https://github.com/realm/SwiftLint/releases/download/0.49.1/bazel.tar.gz",
)
load("@SwiftLint//bazel:repos.bzl", "swiftlint_repos")
swiftlint_repos()
load("@SwiftLint//bazel:deps.bzl", "swiftlint_deps")
swiftlint_deps()
Bazel に対応していない場合でも、ローカルで定義した BUILD ファイルを使ってビルドすることができるので、利用したいライブラリがパッケージマネージャをサポートしていないといった問題に悩まされることもありません。
- WORKSPACE
http_archive(
name = "lottie-ios",
build_file = "//Externals/ThirdParty:lottie-ios.BUILD",
sha256 = "e168b05792d8af1830a73daee2f3b4f3a24b1ec512a949adf60fac6f0b6c99f5",
strip_prefix = "lottie-ios-3.3.0",
url = "https://github.com/airbnb/lottie-ios/archive/3.3.0.zip",
)
- lottie-ios.BUILD
swift_library(
name = "Lottie",
srcs = glob(
["Sources/**/*.swift"],
exclude = ["Sources/Public/MacOS/**"],
),
visibility = ["//visibility:public"],
)
ほとんどの Swift、Objective-C パッケージは Bazel に対応していないので、依存性のビルド構成はパッケージごとに書く必要があります。
PodToBUILD という CocoaPods の podspec から Bazel のビルド構成にコンバートするツールを使うこともできますが、私達の場合は都度手書きでビルド構成を書くようにしています。
これは、外部パッケージの追加が頻繁でないことと、管理や学習のコストと引き換えにそれらをどのようにビルドするかを明示的に設定できる利点が大きいからです。
学習コスト
Bazel を採用するもう一つの懸念は学習コストです。アーキテクトチームは、モジュールのソースコードやテストファイルの場所など、プロジェクトをどのように構成するか決める事ができます。下記はモジュールに含まれるファイルがどのように構成されるかの一例です。
Projects/Libraries/Logger/
├── BUILD
├── Sources
│ ├──Logger.swift
└── Tests
└──LoggerTests.swift
この規約を遵守してさえいれば、例えば、ライブラリターゲットを宣言する library
マクロ、ユニットテストターゲットを宣言する unit_test
マクロなど、ほとんどの設定をデフォルトとした Bazel マクロが記述できるのです。これは Swift Package Manager のPackage.swift
と同様の仕様で、Bazel について学習する必要性はゼロに等しくなります。ほとんどの場合、開発者が新しいターゲットを作成する際には、ターゲット名とその依存性について考えるだけでよいのです。
load(
"//BazelExtensions:rules.bzl",
"library",
"unit_test",
)
library(
name = "Logger",
deps = [
"//Projects/Libraries/FoundationPlus",
],
)
unit_test(target = ":Logger")
コミュニティへの貢献
Bazel は iOS ビルドのファーストパーティビルドシステムではありません。当然、Xcode が新機能を導入するたびに、Bazel が同じ機能をサポートし始めるまでに時間がかかります。
Bazel 自体は簡単にフォークできますし、Bazel はフォークせずにビルドルールをパッチする簡単な方法を提供していますが、私たちは常にフォークやハックをできるだけ避けるように心がけています。 必要な場合は、その解決策を一般化し、Bazel コミュニティに提供するように努めています。
例えば下記のようなものです。
- IT 部門が開発者に M1 Mac を配布し始める前に、Intel ベースの Mac と Apple silicon の間でビルド互換性をもたせるため、
--@build_bazel_rules_swift//swift:universal_tools
フラグを追加した。Pull-Request apple_dynamic_xcframework_import
とapple_static_xcframework_import
というビルドルールを追加し、XCFrameworks を公式にサポートした。 Pull-Request- 画像回帰テスト実行時の言語設定を固定するため、
test_arg
を通してCommandLineArguments
に値を渡せるようにした。Pull-Request
これから
Bazel を活用することでビルドの高速化や信頼性を大きく改善することができました。これからもビルドのボトルネックの解消やモジュール細分化を進めることで生産性の向上に取り組んで行きたいと思っています。
メルカリでは、Bazel を使ったビルド環境改善に協力してくれるエンジニア、またはメルカリの成長に貢献する機能開発に協力してくれるエンジニアを募集しています!