LINE iOS版のビルドパフォーマンスをBazelで改善

背景 

LINE iOS版のソースコードは、ローンチ以来、数百モジュールもの規模にまで成長しています。2019年末時点で、コード行数も140万行を超えており、現在もその増加ペースはとどまるところを知りません。一方、コードが大規模になると、開発者にとってはビルド時間が悩みの種ともなります。また、プロジェクトの成長に伴い、再現不可能な問題も増えていきました。 たとえば、ローカル環境で動くビルドがCIで動かなかったり、もしくはその逆が起こったり、といったものです。 そこで、一歩退いた目線に立ち、ビルドパフォーマンスと再現性を改善できる方法を考え始めました。   

ライブラリ依存管理 

まず、ライブラリ依存管理についてお話します。LINEでは、2012年の終わりに依存性管理ツールとして CocoaPods を導入しました。 CocoaPodsは非常に便利なOSSで、 Xcodeとの相性も良いです。 自分で書いたコードと同じ感覚で、外部ライブラリのソースコードを確認してデバッグできます。唯一の欠点としては、プロジェクト上でビルドやクリーンをするので、プロジェクトをクリーンすると、すべてビルドし直す必要があるところでしょうか。とはいえ、ライブラリのほとんどがObjective-Cで書かれているものであれば、さほど問題にはならないかと思います。 

チームやプロジェクトがスケールするのに伴い、Swiftを導入しました。  コードではバージョン1.0から使っており、3年ほどはサードパーティSwiftライブラリがないように管理していました。その後、Swift用の依存性管理ツールが必要になったため、Carthage を導入し、CocoaPodsと併用するようになりました。   CocoaPodsと違い、Carthageでは設定がほとんど不要です。依存関係はフレームワークとして事前にビルドされており、手動でプロジェクトに取り込みます。こうして私たちは2つの依存性管理ツールを使うようになりました。 

Carthageでは、外部ライブラリの事前にビルドされたアーティファクトをGitHubからダウンロードするか、ソースコードからローカルでビルドします。セキュリティ上、任意のバイナリをダウンロードするのは避けたかったですし、アプリの性能を考えると、依存関係はすべて静的フレームワークとしてビルドしたいとも考えていました。これらを踏まえた結果、依存性はすべて、開発者が各自のマシンを使ってローカルでビルドすることにしました。 非常に時間のかかる作業で、更新する依存関係の数にもよりますが、通常15〜20分ほどかかりました。Carthageは裏でxcodebuildコマンドを呼び出し、サポートするすべてのアーキテクチャにファットバイナリとして依存関係をビルドします。iOS 10にも対応する場合は、合計で4つのアーキテクチャでビルドが必要なため、依存関係を4回ビルドすることになります。また、Xcodeのアーカイブアクションでは、常にフルのクリーンビルドがかかりますが、これは仕様のようです。

ビルドキャッシュ 

みんなで同じものを何度もビルドしないといけないのは、リソースがもったいないですよね。実はCarthageでは、ビルド成果物をビルド間、マシン間でキャッシュできるのです。こちらもオープンソースなんですが、 Rome というツールがここで役に立ちました。私たちがRomeを導入した頃には、個人のマシンのディレクトリへのローカルキャッシュ、AWS S3からのリモートキャッシュ、またはその両方を指定できました。また、RomeはS3に対応するだけでなく、S3互換のあるオブジェクトストレージサービスのほとんどと相性が良かったので、社内のサーバーインフラで提供しているサービスでも便利に使えました。LINEの場合、各拠点間やデータセンター間のネットワークが非常に速いため、リモートキャッシュにはS3のような外部サービスよりも自前のインフラを使った方が良いということが最終的に分かりました。 

CarthageとRomeの組み合わせはとても上手くいきました。一点だけ大きな懸念となったのは、キャッシュの正確性を検証する方法がないことでした。キャッシュポイズニングはQAテストやリリースビルドでは許容できないので、特定の種類のビルドに関しては毎回すべて一からリビルドすることで対処しました。

長期的アプローチ 

重要なのは外部依存ライブラリだけではありません。外部ライブラリのリビルドが不要になっても、自分のコードをビルドする必要があります。依存関係に限らず、ほとんどのコードはXcodeでビルドしています。大した変更がなかった場合、Xcodeが全部リビルドしたケースもありました。また、最新の変更をプルした後にコードのビルドが上手く行かない時は、クリーンを実行することが多いですが、このやり方では、ほとんど変更されていないターゲットからのビルド成果物もすべて消えてしまいます。 

やはり長期的なアプローチとしては、コードのリビルドを完全になくすのが一番です。そこで私たちは、まずコードベースをモジュールに分割し、それからビルド、キャッシュを行うことにしました。しかし、以下の理由からCarthageを使うことはできません。 

  • ローカルターゲットを別のリポジトリに分割し、すべての変更のバージョニングをしてから、メインのリポジトリで参照するバージョンをアップデートする必要がある 
  • ローカルターゲットは事前にビルド済みのバイナリであるため、メインプロジェクト上でデバッグできなくなる 

この条件を受け入れたとしても、開発の生産性に大きく打撃を与えてしまいます。確かに外部依存関係の問題はCarthageとRomeである程度解決できました。では自分たちで書いたコードはどうでしょうか。LINEのコードは大半が自社で開発したものです。コードの一部をプレビルドしてその部分のデバッグは諦めるといった妥協をしない限り、こうしてキャッシュを使ったアプローチはうまくスケールしません。 

Bazel 

ここで注目したのがBazelです。高度なキャッシュ機能を持つオープンソースツールですが、当初、Appleのプラットフォーム、中でもiOSのビルドで使われるイメージはありませんでした。もともと、大規模なmonorepo向けツールとして開発されたこともあり、多くの企業が抱えるニーズを満たしていない部分があります。特にヘッダーマップやClang Modules、混合言語のターゲットに対するサポートなど、Appleプラットフォームにとって重要な機能がサポートされていません。 

しかし、Bazelには高い拡張性という強みがあります。Bazel自体はC言語ファミリーとJavaでしかビルドできませんが、自分でビルドルールを書けば、ほとんど何でもビルドできるように拡張できるのです。 Starlarkを使ってBazel公式のビルドルールを拡張することで、上記の制限の一部を解消できました。中でも苦労したのは、Bazelで混合言語のターゲットをサポートすることでした。そこで、まずMyModuleというモジュールを使って次のアプローチをとりました。 

  • 基になるObjective-CモジュールをMyModuleObjcという名前でコンパイルする 
  • 同様に、Swiftのモジュールも MyModuleという名前でコンパイルする。このモジュールでは、Swift内部の @_exported というAttributeを使ってObjective-Cモジュールをエクスポートする 

ExportObjcModule.swift 

  @_exported import MyModuleObjc  

モジュールのObjective-CとSwiftの部分がお互いの宣言をインポートする必要がない場合では、ほとんどのユーティリティモジュールでこの方法が使えましたが、それ以外の場合ではうまくいきません。 

そのため、混合言語のフレームワークターゲットのビルドには、しばらくXcodeを使っていました。Xcodeで混合言語のフレームワークターゲットをビルドするには、まず基になるObjective-Cモジュールに対してモジュールマップを作成し、それをターゲットのMODULEMAP_FILEビルド設定にアサインする必要があります。 

FoundationLineUtils.modulemap

 framework module FoundationLineUtils { 
    requires objc 
    umbrella "Headers" 
    exclude header "FoundationLineUtils-Swift.h" 
}  

すると、Xcodeは、渡されたObjective-CモジュールをインポートしてSwiftモジュールをコンパイルします。次に、コンパイルしたSwiftモジュールを使い、渡されたモジュールを拡張します。そして、Objective-Cモジュールをコンパイルします。 

module.modulemap 

framework module FoundationLineUtils { 
    requires objc 
    umbrella "Headers" 
    exclude header "FoundationLineUtils-Swift.h" 
} 

module FoundationLineUtils.Swift { 
    requires objc 
    header "FoundationLineUtils-Swift.h" 
}  

Xcodeでは、モジュール定義が重複しないための工夫が施されています。それがSwiftをコンパイルする際の VFSオーバーレイ です。これを使って、最終のモジュール間インターフェースを、基になるObjective-Cモジュールで隠します。コンパイル終了時にオーバーレイも使わなくなるので、モジュールは一つしか残りません。 

unextended-module-overlay.yaml

{ 
  'version': 0, 
  'case-sensitive': 'false', 
  'roots': [{ 
    'type': 'directory', 
    'name': "<REDACTED>/Products/Debug-iphonesimulator/FoundationLineUtils.framework/Modules" 
    'contents': [{ 
      'type': 'file', 
      'name': "module.modulemap", 
      'external-contents': "<REDACTED>/FoundationLineUtils.build/unextended-module.modulemap", 
    }] 
  }] 
}   

このようなコンパイルモデルをBazelで再現するのは一筋縄ではいきませんでした。まず、Bazelではフレームワークではなく静的ライブラリにターゲットをビルドします。そのため、フレームワークの検索パスでモジュールをインポートする代わりに、各モジュールのモジュールマップが定義されている場所を -fmodule-map-fileフラグを使って示す必要があります。また、VFSオーバーレイはマップの絶対パスを知る必要があるため、VFSオーバーレイを使って基になるObjective-Cモジュールを隠すこともできません。ソースコードのクローン間や異なるマシン間でパスは異なるため、リモートキャッシュには向いていないのです。そこで、Bazelでは次のステップを使ってコンパイルしました。 

  • 基になるObjective-Cモジュールのモジュールマップを生成する 
  •  生成したモジュールマップでSwiftコードをビルドするために、swift_library ターゲットをインスタンス化する。生成したモジュールマップがターゲットの swiftc_inputsに追加される。(ただし、依存関係リストには追加されない) 
  • 最後に、Objective-Cコードをビルドするために、 objc_library ターゲットをインスタンス化する。ここでは swift_library ターゲットに依存性を持つ必要がある。混合言語ターゲットに依存するターゲットは、最終の objc_library ターゲットのみ参照すれば良い 

ここで注意が必要なのは、Objective-Cモジュールマップを、モジュール依存性としてではなく、Swiftコンパイルの入力として宣言する点です。そうすることで、基になるモジュールは、このモジュール自体がコンパイルされている間のみ利用可能となり、依存関係グラフには反映されません。混合したソースプロジェクトを抱えていて、Bazelをプロジェクトに導入したい場合は、LINE’s Apple rules for Bazelの apple_library と mixed_static_framework ルール を参照してください。 

Bazelでは、SDK開発者向けに、サードパーティに提供可能な 静的フレームワーク にiOSモジュールをビルドできるようになっており、Carthageの代替案としてBazelを検討していた時にも役に立ちました。その時は、カスタムルールを使って、Bazelで外部依存関係を静的フレームワークにプレビルドし、手動でXcodeのプロジェクトに統合しました。ワークフローはCarthageと似ていますが、Bazelは次の点に優れています。 

  • 必要なものだけビルドできる柔軟性の高さ。当時使っていたCarthageは事前に定義された各依存関係のすべてのビルドスキームをビルドするため、最初のビルドで非常に時間がかかった 
  • ビルトイン型のリモートキャッシュ機能。リモートキャッシュサーバー設定で、ビルド間やマシン間でビルドキャッシュを簡単に共有できた 

Bazelを使ったビルドは、一部の内部モジュールから始めました。モジュールに変更をかけることも折々で発生したため、修正したモジュールのリビルドでBazelを呼び出す必要があるか判断するスクリプトを事前にビルドし、Xcodeのプロジェクトで保持しておきました。Xcodeのフレームワーク検索のために前もって定義してあるディレクトリがあるので、スクリプトがそこにモジュールをコピーします。これでも一応動きましたが、Xcodeで不要なリビルドが発生する恐れがあるため、更新するファイルとしないファイルの見極めは非常に慎重に行う必要があります。ひどい場合、変更に対して依存性があるとリビルドしないこともあります。 

この時点で、ビルドシステムを自分たちで実装するのとあまり変わらないことに気づきました。また、ビルドグラフの中でも上位レベルにあるターゲットをBazelに移行するにつれて同期の難易度も上がっていったため、この方法で移行するのはやめました。代わりに、Bazelによるアプリビルド全体の開発に注力することにしました。

両者の良いとこ取り 

難易度が高い部分の移行は既に終わっていたため、この時点でプロジェクト全体をBazelに切り替えるために残っていた主な作業は、残りのターゲットのBUILDファイルの記述でした。数日ほどかけて作業するのに合わせて、次の項目を習慣化するよう、プロジェクトの再編成を行いました。 

  • 1つのターゲットにつき1つのXcodeプロジェクト:各オリジナルのプロジェクトはXcodeGenで生成されており、ターゲットごとにproject.yml ファイルが1つずつあるので、各project.yml をBUILDファイルに変換すれば良い 
  • ターゲットは3種類のみ:Objective-Cファミリーのターゲット(一部C、C++、Objective-C++を含む)、Swiftターゲット、およびObjective-CとSwiftとの混合ターゲット 
  • 1つのディレクトリにつき1つのターゲットとし、ディレクトリ名はモジュール名とする:こうすることで、たとえば #import <Module/Module-Swift.h> が混合言語のターゲットでヘッダサーチパスやヘッダマップの追加無しで利用できるなど、後々便利なことが増えた 

開発者が新しいターゲットを追加する際は、project.ymlの作成に加え、BUILDファイルを作成して新しいターゲットを宣言する必要があります。 現在このプロセスは自動化されていませんが、 前もって定義したXcodeGenのテンプレートとBazelのルールのおかげで、project.ymlとBUILDファイルはとてもシンプルになっていることが多いです。おおかた、モジュール名だけ気をつけていれば問題ないでしょう。 

また、リモートキャッシュをCI上のプルリクエストで活用するために、CI上のビルドをBazelに変更しました。この時点では、誰かがBUILDファイルに変更を加えた後、しかるべき変更を同じターゲットのproject.ymlに更新し忘れてしまうと、Xcodeのビルドは失敗します。この問題は、CI上のXcodeとBazelの両方でビルド検証を行うことで解決できますが、PRのマージまでにかかる時間は変わりません。また、ビルドワーカーの数が限られているにもかかわらず、各ビルドに必要なリソースが2倍になってしまいます。そこで、Bazelの依存関係グラフが常に正であり、BUILDファイルを信頼できる情報源として使えることから、各BUILDファイルで宣言された依存性をそれぞれ対応するproject.yml ファイルに同期するスクリプトを作成しました。Gitのpre-commitフックによるスクリプト実行を設定することで、誰かがBUILDファイルに変更を加えた時は自動でproject.ymlを更新できるようにしました。 

結果

BETAビルド時間 XCODE BAZEL 
最短 28.40 4.40 
最長 35.42 26.53 
平均 30.96 14.53 

Bazelに移行後、ビルド時間の大幅な改善を達成しました。また、QA期間のターンアラウンドタイムも大きく改善することができました。新しいビルドをテスターに配布するたびに、ビルドとテストで1時間待たされるということがなくなったのです。 

これから 

非常に満足できる結果を出せたとはいえ、まだ解決しなければいけない問題は残っています。リモートキャッシュは便利ではありますが、ビルドグラフに大きなビルドターゲットがある場合はボトルネックになるケースも多いです。リモートキャッシュをさらに活用していくためのモジュール化についてはまだまだ道半ばです。 

最後に、現在新メンバーを募集中です。LINEでしかできない開発経験を積みたい、iOSのエコシステムに貢献したいというiOSエンジニアの方のご応募をお待ちしています! 

 

 

Related Post