こんにちは、エンジニアのオオバです。

お悩みさん
お悩みさん
  • JenkinsのUnityビルドが毎回遅い
  • Libraryを残したいけどgit cleanは外せない
  • CIのビルド時間をどこから削ればいいか分からない
  • オオバ
    オオバ
    本記事ではこれらの悩みを解決します。

    JenkinsでUnityのAddressablesビルドを回していたら、毎回25〜41分かかっていました。

    アセットをちょっと直しただけでも40分待ち。

    「さすがに遅すぎない?」

    CIの待ち時間が長いと、確認したいことがどんどん後ろにずれていきますよね。

    調べてみると、原因は毎回のビルドで Library/ が消えていたことでした。

    ということで本記事では、Libraryのキャッシュを残すまでの失敗と解決、そのあとアプリビルド(Android / iOS)を約45分から約7分20秒まで縮めた工夫をお届けします。

    Libraryの実体をワークスペースの外に置き、シンボリックリンクでつなぐ

    結論から言うと、git clean は残したまま、Libraryの実体だけをワークスペースの外へ逃がしました。

    ワークスペースの Library は、外にある実体へのシンボリックリンクにします。

    今回のポイント
    • Clean before checkout(git clean -fdx)が毎回Libraryを消していた
    • Cleanを外すとサブモジュールの.metaが残ってビルドが全滅した
    • Libraryの実体をワークスペース外に置いてシンボリックリンクにすれば両立できる
    • さらにAndroidとiOSの並列化・Unity起動回数の削減などでDEV全体が約7分20秒になった

    起きたこと:毎回フルインポートになっていた

    Jenkinsのジョブには「Clean before checkout」が設定されていました。

    チェックアウトの前に git clean -fdx を実行する設定です。

    -x が付いているので、.gitignore に入っているファイルも消えます。

    Unityの Library/ は普通 .gitignore に入れているので、毎回まるごと消えていたわけです。

    Libraryには、インポート済みのアセットやコンパイル結果のキャッシュが入っています。

    Libraryが無い状態でUnityを起動すると、全アセットのインポートからやり直し。

    Addressablesも毎回フルビルドになっていました。

    つまり、毎回まっさらなPCでプロジェクトを開いているのと同じ ということです。

    失敗した対策:Cleanをやめたらビルドが全滅した

    「じゃあCleanを外せばいいじゃん」って思いますよね。

    僕も最初はそう考えて、Clean設定を外しました。

    ビルドは確かに速くなりました。

    ところが、別の問題が出ます。

    サブモジュールの.metaがワークスペースに残る

    このプロジェクトはサブモジュールを使っていました。

    Unityはビルド中に、サブモジュール側の .meta を書き換えます。

    その数、約1,270件。

    Cleanをしないので、書き換わった .meta がワークスペースにそのまま残りました。

    次のsubmodule updateが失敗する

    次のビルドでは、submodule update が次のエラーで失敗しました。

    「Your local changes ... would be overwritten by checkout」

    ローカルの変更が上書きされるから、チェックアウトできないというエラーです。

    ここから約1時間、アセットとアプリ(Android / iOS)のビルドが全部落ちました。

    正直、かなり焦りました。

    Clean設定はすぐに元へ戻しています。

    Cleanには役目があった

    振り返ると、CIのCleanは「サブモジュールのゴミ掃除」の役目も持っていました。

    消しすぎで遅い。

    消さないと壊れる。

    Cleanとキャッシュは、どちらかを選ぶしかないトレードオフに見えます。

    解決:Libraryだけワークスペースの外へ逃がす

    そこで、Cleanは残したまま、Libraryだけを守る方法にしました。

    実体はワークスペースの外に置く

    Libraryの実体は ~/unity-library-cache/<JOB_NAME> に置きます。

    ジョブ名ごとにフォルダを分けているので、ジョブ同士でLibraryを取り合うことはありません。

    ワークスペースの Library は、そこへのシンボリックリンクにしました。

    git cleanが消すのはリンクだけ

    git clean -fdx が消すのは、ワークスペースにあるシンボリックリンクだけです。

    リンク先の実体はワークスペースの外にあるので、残ります。

    ロッカーの鍵を捨てられても、ロッカーの中身は無事、というイメージです。

    鍵(リンク)は毎回作り直せばいいので、Cleanでサブモジュールのゴミを掃除しつつ、Libraryのキャッシュも残せるようになりました。

    リンクはUnity起動前に張り直す

    リンクは、Unityを起動する前に共通スクリプトで張り直しています。

    Unityを起動したあとに張っても遅いので、順番が大事です。

    Libraryを消したいとき用のパラメータ

    キャッシュが壊れたときや、まっさらな状態でビルドしたいときもあります。

    そのために、Libraryを実体ごと消すJenkinsパラメータを用意しました。

    普段はキャッシュを使い、必要なときだけ消す運用です。

    続き:アプリビルドを約45分から約7分20秒に

    Libraryのキャッシュを入れたあと、アプリビルドもさらに詰めました。

    対象は、DEV環境でAndroidとiOSの両方を作るパイプラインです。

    ざっくり言うと、並列化、Unityの起動回数、キャッシュ、dSYMの4つに手を入れました。

    1. AndroidとiOSを並列にする(約14分→10分7秒)

    パイプラインには、次のコメントが書かれていました。

    「Unity は同一プロジェクトの並列実行不可のため直列で実行」

    そのため、Android → iOS の順に build job していました。

    ただ、実際にはAndroidとiOSは別ジョブです。

    別ジョブなので別ワークスペース、つまり別のUnityプロジェクトフォルダになります。

    throttle-concurrents のカテゴリも android / ios で別でした。

    なので同時に動かせます。

    parallel('Android': {...}, 'iOS': {...}) に変えただけで、全体の時間はiOSの時間で決まるようになりました。

    ただし、同じマシンで同時に動くので、1本あたりは遅くなります。

    それでも、合計は縮みました。

    コメントに書かれた制約も、今の構成で本当にそうなのかを一度確かめておくと得をする場面があります。

    2. Unityの起動回数を減らす(1台あたり約80秒)

    工程ごとにUnityを起動していました。

    1. Switch Platform(環境切り替え)
    2. ローカルAddressablesビルド
    3. アプリビルド

    合計3回です。

    Unityのログを見ると、起動ごとに「Domain Reload 約6秒+初回 Refresh 約9秒」の固定時間がかかっていました。

    変更が無くても、毎回かかる時間です。

    そこで、ローカルAddressablesビルドとアプリビルドを同じ起動にまとめました。

    ビルド用メソッドは、もともと両方できる作りでした。

    CIが -skipLocalAddressableBuild で飛ばしていただけです。

    Switch Platformは残した

    Switch Platformの起動は残しています。

    なぜなら、環境切り替えの処理がスクリプトのシンボル(define)を書き換えるから。

    同じ起動のままビルドすると、切り替え前のシンボルでコンパイルされたエディタのまま、アセットをビルドしてしまう恐れがあります。

    「Switch Platformを外せば速くなるのでは?」という意見もありました。

    計測してみると、66秒のうち62秒は、gitで変わったファイルの取り込みとスクリプトのコンパイル(初回 Refresh)でした。

    外しても、その時間は次の起動に移るだけです。

    削れるのは起動の固定時間(15〜20秒)だけなので、安全側に倒しました。

    3. ローカルAddressablesのキャッシュを毎回消さない

    ビルドスクリプトが、毎回 AddressableAssetSettings.CleanPlayerContent() を呼んでいました。

    Libraryを残しても、Addressablesのビルドキャッシュを自分で消していたわけです。

    バッチモード(CI)では呼ばないようにして、差分ビルドにしました。

    キャッシュを捨てたいときは、さきほどのLibraryごと消すパラメータを使います。

    4. DEVではdSYMを作らない(フラグで切り替え)

    xcodebuild に DEBUG_INFORMATION_FORMAT=dwarf を渡すと、dsymutil の工程が消えます。

    ただ、dSYMが無いと、クラッシュレポートで関数名を見たいときに困ります。

    そこでJenkinsパラメータ GENERATE_DSYM を追加しました。

    事前に、Xcodeプロジェクトに dSYM をアップロードする Run Script(sentry-cli 等)が無いことも確認しています。

    アップロード処理があると、dSYMが無いことで失敗するかも知れないためです。

    5. 小ネタ:毎回のbrew installを飛ばす

    毎回 brew install git-lfs が走っていました。

    Homebrewの自動アップデートが動くので、数秒〜十数秒かかります。

    command -v git-lfs で入っているかを確かめて、入っていれば飛ばすようにしました。

    結果

    ここまでの効果をまとめます。

    ビルド時間の変化
    最初Library キャッシュ後並列化後今回
    DEV 全体約45分約14分10分7秒約7分20秒
    Android23分約5.5分8分27秒(並列で遅く)4分05秒
    iOS21分約8分9分52秒7分06秒

    いちばん効いたのは、やはりLibraryのキャッシュでした。

    約45分が約14分になっています。

    残りは、iOSのXcodeビルド(約3分)と、Switch Platform(約76秒、ほぼファイル取り込み)です。

    計測のコツ:ログファイルの時刻で工程を測る

    どこを削るかを決めるには、工程ごとの時間が必要です。

    ところが、Jenkinsのコンソールログには時刻が出ていませんでした。

    そこで、各工程が書き出すUnityのログファイルに注目しました。

    ログファイルの作成時刻(birth time)と更新時刻を stat -f "%SB %Sm" で並べると、工程の開始と終了が分かります。

    作成時刻が開始、最後の更新時刻が終了という見方です。

    さらに、Unityのログの次の行を見ると、起動ごとの固定コストが分かりました。

    「Switch Platformを外しても62秒は移るだけ」と判断できたのも、この数字のおかげです。

    注意点:Unityを起動する新しいスクリプトでもリンクを張る

    この方式には、1つ注意点があります。

    Unityを起動する新しいCIスクリプトを作るときは、起動前にリンクを張る処理を必ず呼ぶこと。

    呼び忘れると、git clean のあとのワークスペースには Library がありません。

    Unityは何事も無かったように新しいLibraryを作り、フルインポートが始まります。

    エラーにならないので、気付きにくいんですよね。

    「なんか今日のビルド遅いな」と思ったら、まずリンクが張られているかを確認してみてください。

    まとめ

    本記事では、JenkinsのUnityビルドが毎回40分かかっていた原因と、git clean とLibraryキャッシュをシンボリックリンクで両立した方法、そのあとのアプリビルドの高速化をお届けしました。

    簡単に全体をまとめます。

    今回のまとめ
    • Clean before checkout(git clean -fdx)が毎回Libraryを消していた
    • Cleanを外すとサブモジュールの.metaが残りsubmodule updateが失敗した
    • Libraryの実体をジョブごとにワークスペース外へ置きシンボリックリンクでつないだ
    • AndroidとiOSは別ワークスペースなので並列にできた
    • Unityの起動をまとめて固定時間を削った
    • DEVではdSYMを作らないパラメータにした
    • 工程の時間はUnityのログファイルの時刻で測った

    Cleanとキャッシュは、どちらかを捨てるしかないように見えます。

    それでも、キャッシュをワークスペースの外に置けば両方を取れました。

    CIのビルドが遅くて困っている方は、まずLibraryが毎回消えていないかを確認してみてください。

    一緒にビルド時間を削っていきましょう。

    オススメ記事
    検証環境