[iOS] Bitriseのキャッシュをできるだけ生かしてCI / CDの高速化を図る

#はじめに
この投稿は[CyberAgent Developers Advent Calendar 2019](https://adventar.org/calendars/3981) 4日目の記事です！

本日はTapple iOSチームに今年加入した、19新卒の @Kazuma_Nagano が担当します。

今回はTappleのiOSアプリのCIのワークフローをキャッシュをなるべく生かす設定に変えることで、
Bitriseのワークフローを **40%〜50%** ほどの高速化に成功した話を書きたいと思います。

# 高速化前後のワークフロー
現在チームではBitrise上でテストを自動化しています。
ざっくり下記のようなワークフローです。

- リポジトリのクローン
- Homebrewパッケージのインストール（Xcodegen, Linterなど）
- Gemパッケージのインストール（xcov, CocoaPods, danger, fastlaneなど）
- Cocoapodsのインストール
- テストの実行

下記は高速化前のワークフローの実際のサマリー（最後に表示される実行時間）です。

```log:高速化前
+------------------------------------------------------------------------------+
|                               bitrise summary                                |
+---+---------------------------------------------------------------+----------+
|   | title                                                         | time (s) |
+---+---------------------------------------------------------------+----------+
| ✓ | activate-ssh-key                                              | 11.26 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | git-clone                                                     | 1.5 min  |
+---+---------------------------------------------------------------+----------+
| ✓ | Set Env Path                                                  | 5.94 sec |
+---+---------------------------------------------------------------+----------+
| ✓ | cache-pull                                                    | 4.0 min  |
+---+---------------------------------------------------------------+----------+
| ✓ | Bundle Install                                                | 50.90 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | Brew Install                                                  | 1.1 min  |
+---+---------------------------------------------------------------+----------+
| ✓ | XcodeGen                                                      | 15.0  sec|
+---+---------------------------------------------------------------+----------+
| ✓ | CocoaPods Install                                             | 4.1 min  |
+---+---------------------------------------------------------------+----------+
| ✓ | cache-push                                                    | 25.18 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | github-status                                                 | 5.69 sec |
+---+---------------------------------------------------------------+----------+
| ✓ | Fastlane Test                                                 | 19.0 min |
+---+---------------------------------------------------------------+----------+
| ✓ | cache-push                                                    | 15.52 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | github-status                                                 | 5.19 sec |
+---+---------------------------------------------------------------+----------+
| Total runtime: 31.9 min                                                      |
+------------------------------------------------------------------------------+
```

この中で特に、 **Homebrew** , **Gem** , **Cocoapods** (アプリビルド)のそれぞれの設定を見直し、
よりキャッシュを生かすことで、下記のようにワークフローの合計時間を40%〜50%ほど高速化することができました。

```log:高速化後
+------------------------------------------------------------------------------+
|                               bitrise summary                                |
+---+---------------------------------------------------------------+----------+
|   | title                                                         | time (s) |
+---+---------------------------------------------------------------+----------+
| ✓ | activate-ssh-key                                              | 10.15 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | Git Config                                                    | 3.17 sec |
+---+---------------------------------------------------------------+----------+
| ✓ | git-clone                                                     | 29.22 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | Set Env Path                                                  | 4.44 sec |
+---+---------------------------------------------------------------+----------+
| ✓ | cache-pull                                                    | 31.66 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | Brew install                                                  | 8.13 sec |
+---+---------------------------------------------------------------+----------+
| ✓ | XcodeGen                                                      | 11.53 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | rbenv Install                                                 | 4.48 sec |
+---+---------------------------------------------------------------+----------+
| ✓ | Bundle Install                                                | 5.07 sec |
+---+---------------------------------------------------------------+----------+
| ✓ | CocoaPods Install                                             | 38.11 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | cache-push                                                    | 9.71 sec |
+---+---------------------------------------------------------------+----------+
| ✓ | github-status                                                 | 6.70 sec |             
+---+---------------------------------------------------------------+----------+
| ✓ | Fastlane Test                                                 | 13.6 min |
+---+---------------------------------------------------------------+----------+
| ✓ | github-status                                                 | 7.55 sec |
+---+---------------------------------------------------------------+----------+
| Total runtime: 16.4 min                                                      |
+------------------------------------------------------------------------------+
```
※bitrise summaryのTotal runtimeには時間帯、環境によって増減します。
 記事内のものは同時刻帯のものを選んで比較していますが、他の要因も影響もあるかもしれません

<br>
### 補足
上記の高速化前のワークフローでは、
1. `./Pod`（Podfile.lockに変更があった場合）
2. `/Users/vagrant/Library/Caches/Homebrew`
3. `~/Library/Developer/Xcode/DerivedData`
がキャッシュされていました。
高速化後のワークフローでは、2, 3 を効果がみられなかったため、削除しています。

#Bitriseでのキャッシュ機能について
Bitriseの[公式のドキュメント](https://devcenter.bitrise.io/jp/builds/caching/about-caching-index/)にはキャッシュの機能についての記述があります。
いくつか抜粋すると

- キャッシュは全てのキャッシュされたディレクトリや依存性をtarし、Amazon S3内に安全に保存される
- `a/path/to/cache` を指定した場合、 `/path/to/cache/.ignore-me` もキャッシュされる
- キャッシュはブランチ単位で管理される
- PR内ではcache-pushは行われない
- そのブランチ上で新しいビルドが行われなかった場合、７日後に有効期限が切れて削除される


### 利用方法
利用方法は、２つのステップを追加することで利用できます。
>Bitrise.io Cache:Pull step to download the previous cache (if any).
Bitrise.io Cache:Push step to check the state of the cache and upload it if required.

bitriseのワークフローに下記のように記述することで、選択したディレクトリ以下をキャッシュすることができます。

```yml:bitrise.yml
workflows:
    workflow-a:
        steps:
        # 他のワークフロー
        - cache-pull: {} #pullはこれだけ
        # 他のワークフロー
        - cache-push:
        inputs:
        - cache_paths: |-
            $BITRISE_CACHE_DIR
　　　　　　　MyProject/Cache  #キャッシュしたいパス
            ./Pods -> ./Podfile.lock #Podfile.lockに変更があれば./Podsをキャッシュ
```

`Podfike.lock`の例のように変更を監視するファイルを指定することができますが、これはdiffを見ていないわけではなく、

```
Cleaning paths
Done in 1.32941661s
Checking previous cache status
Previous cache info found at: /tmp/cache-info.json
Done in 171.741223ms
Checking for file changes
0 files needs to be removed
0 files has changed
0 files added
No files found in 37.785423ms
Total time: 1.539638885s
|                                                                              |
+---+---------------------------------------------------------------+----------+
| ✓ | cache-push                                                    | 8.99 sec |
+---+---------------------------------------------------------------+----------+
```

ログを確認すると、削除、変更、追加のそれぞれを確認していることがわかります。
なので、変更を監視するファイルを指定することで、このフローを簡略化することができます。

#効果があったキャッシュ設定
以降で効果的だったキャッシュ方法を紹介します。

##Homebrewのキャッシュ
[公式ドキュメント -Caching Homebrew installers](https://devcenter.bitrise.io/builds/caching/caching-homebrew-installers/)では下記のような方法の記載があります。

>The Brew install Step supports caching: if the option is enabled, any downloaded brew installers will be cached from the location of brew --cache. The cache path is ~/Library/Caches/Homebrew/.

>To enable caching of brew installers:

>1. Go to the Workflow in which you want to cache brew installs and select the Brew install Step.
2. Set the Cache option to yes.
3. As always, click Save.

公式では、Homebrewの `--cache` オプションを指定していますが、これでは毎回インストールが走ることは避けられません。

これはバイナリを直接リンクする方法で、インストール済みのfomulaの再インストールを回避することができます。
（`~/Library/Caches/Homebrew` はキャッシュする必要ないため、公式のStepは使わない）

```yaml
- script:
        inputs:
        - content: |-
            # キャッシュするディレクトリを環境変数に追加
            envman add --key BREW_XCODEGEN --value "$(brew --cellar)/xcodegen" #パッケージ名を指定
            envman add --key BREW_OPT_XCODEGEN --value "/usr/local/opt/xcodegen"  #パッケージ名を指定
        title: Set Env Path
- script:
        inputs:
        - content: |-
            #インストールは以下のコマンドを実行
            brew install xcodegen
            brew link xcodegen
        title: Brew install
- cache-push:
        inputs:
        - cache_paths: |-
           $BITRISE_CACHE_DIR

           #以下の二つをcache_pathに追加
           $BREW_XCODEGEN
           $BREW_OPT_XCODEGEN
```

```log:キャッシュが成功している時のログ
+ brew install xcodegen
Warning: xcodegen 2.10.1 is already installed, it's just not linked
You can use `brew link xcodegen` to link this version.
+ brew link xcodegen
Linking /usr/local/Cellar/xcodegen/2.10.1... 2 symlinks created
```

これにより `brew install` ステップを80%ほど短縮することができました。

```log:高速化前
+---+---------------------------------------------------------------+----------+
| ✓ | Brew Install                                                  | 1.1 min  |
+---+---------------------------------------------------------------+----------+
```
```log:高速化後
+---+---------------------------------------------------------------+----------+
| ✓ | Brew install                                                  | 8.13 sec |
+---+---------------------------------------------------------------+----------+
```

また、この方法だと１つ以上インストールが走った場合に、毎回　`brew cleanup` が走り時間を使ってしまいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179503/f7f21efa-8011-8a70-4cfc-5876680fdcd2.png)

これは、HomebrewのGithubリポジトリ内の[cleanup.rb](https://github.com/Homebrew/brew/blob/4873c490e028c111949f686f41b7999c6f153b23/Library/Homebrew/cleanup.rb#L161)を確認すると、 `~/Library/Caches/Homebrew/.cleaned` を使って条件を判別しているため、こちらをキャッシュ対象に追加することで回避できます。

##Ruby Gems

### rbenv
[公式ドキュメント -Caching Ruby Gems](https://devcenter.bitrise.io/builds/caching/caching-ruby-gems/)では下記のような方法の記載があります。
![スクリーンショット 2019-12-05 0.04.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179503/48d5c055-f868-b51c-a433-74849907fcea.png)

手元の環境だとキャッシュパスが `~/.rbenv/versions/2.6.3` を差しています。これはrbenvのキャッシュ指定ですね。
Bitriseから提供されてるスタックだと ruby 2.6.3 はインストールされていないため、この設定は有効でした。

また、リポジトリ内で `.ruby-version` を管理している場合は、push時にはここをチェックすれば良さそうです。

### gem
さらに、gemのインストール先ディレクトリ（tappleでは `./vendor/bundler`）をキャッシュすることで、
`bundle install` 時のインストールも回避することができます。
こちらはGemfile.lockに変更が反映されるので、push時のチェックに指定します。

```
- script:
        title: Bundle Install
        inputs:
        - content: |-
            bundle install --path vendor/bundle
- cache-push:
        inputs:
        - cache_paths: |-
           #以下の二つをcache_pathに追加
           $GEM_CACHE_PATH -> ./.ruby-version #公式ドキュメント↑の環境変数
           ./vendor -> ./Gemfile.lock #gemのインストール先ディレクトリを指定
``` 
```log:キャッシュが成功している時のログ
+ bundle install --path vendor/bundle
Using rake 13.0.1
Using CFPropertyList 3.0.1
...
Using xcprofiler 0.6.3
Bundle complete! 9 Gemfile dependencies, 109 gems now installed.
Bundled gems are installed into `./vendor/bundle`
```

これにより `bundle install` を90%ほど短縮することができました。

```log:高速化前
+---+---------------------------------------------------------------+----------+
| ✓ | Bundle Install                                                | 50.90 sec|
+---+---------------------------------------------------------------+----------+
```
```log:高速化後
+---+---------------------------------------------------------------+----------+
| ✓ | Bundle Install                                                | 5.07 sec |
+---+---------------------------------------------------------------+----------+
```
##CocoaPods
[公式ドキュメント -Caching Ruby Gems](https://devcenter.bitrise.io/builds/caching/caching-ruby-gems/)では下記のような方法の記載があります。

>Before you start, make sure you have the latest version of the Cocoapods Install Step in your Workflow.

>1. Open your app’s Workflow Editor.
2. Insert the Cache:Pull Step after the Git Clone but before the Cocoapods Install Steps.       
IMPORTANT: Make sure that your Step is version 1.0.0 or newer. With the older versions, you have to manually specify paths for caching.
3. Insert the Cache:Push step to the very end of your workflow.

Bitrise公式が用意した `Cocoapods Install` を指定するだけでよしなにやってくれるそうですが、
個人的には公式のステップでは、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179503/fc6d40b3-1152-5616-e171-4f60477ecda6.png)

ログを確認すると、実行時に
- Check selected Ruby is installed
- `$ gem install bundler --force`
- `bundle install`
- `pod install`

と複数の作業をしてくれてるのですが、 前ステップの工程と被っている部分があります。
なので、 `bundle exec pod install` 単体を利用しています。

正しいbundle, rbenvがステップ以前にインストールされていれば、
Podsファイルをキャッシュパスに指定し、Podfile.lockをチェックするだけで短縮できます。

```
- script:
        inputs:
        - content: |-
            bundle exec pod install
        title: CocoaPods Install
- cache-push:
        inputs:
        - cache_paths: |-
        ./Pods -> ./Podfile.lock ##キャッシュパスに追加
```

高速化前のフローでは毎回 `--repo-update` オプションを指定していましたが、シチュエーションを絞ったり、
常に有効なCocoaPodsのキャッシュがある状態にしておくことで、85%ほど短縮することができました。

```log:高速化前
+---+---------------------------------------------------------------+----------+
| ✓ | CocoaPods Install                                             | 4.1 min  |
+---+---------------------------------------------------------------+----------+
```
```log:高速化後
+---+---------------------------------------------------------------+----------+
| ✓ | CocoaPods Install                                             | 38.11 sec|
+---+---------------------------------------------------------------+----------+
```

```log:キャッシュが成功している時のログ
+ bundle exec pod install
Using FBSDKCoreKit
Using FirebaseDynamicLinks
Using Crashlytics
Using FirebaseCoreDiagnosticsInterop
Using FirebaseCore
Using GoogleDataTransport
Using GoogleDataTransportCCTSupport
・・・
```


##さらなる高速化するために
高速化前のサマリーを見たときに一番目につくのが `Fastlane Test ` の部分です。

```log:高速化前
+---+---------------------------------------------------------------+----------+
| ✓ | Fastlane Test                                                 | 19.0 min |
+---+---------------------------------------------------------------+----------+
```

このステップには

- アプリのビルド
- テスト
- Danger
- Slackへの通知

が含まれています。

この部分をキャッシュを使って高速化する手段はないのでしょうか？


### cocoapods-binary
残念ながら、現在BitriseはXcodeのビルドキャッシュには対応していませんが、
`cocoapods-binary` を使った **CocoaPodsの_Prebuild** であればキャッシュすることができます。

参考： https://github.com/leavez/cocoapods-binary

導入方法は省略しますが、 
`./Pods/_Prebuild` （Pods下なので追加のキャッシュパスの指定なし） 
をキャッシュすることで、Podsのビルドを毎回する必要がなくなるため、
大幅にCI上でのビルド時間を短縮することができました。
30%の改善ですが、5.4mと短縮できた時間はここが一番大きかったです。

```log:高速化前
+---+---------------------------------------------------------------+----------+
| ✓ | Fastlane Test                                                 | 19.0 min |
+---+---------------------------------------------------------------+----------+
```
```log:高速化後
+---+---------------------------------------------------------------+----------+
| ✓ | Fastlane Test                                                 | 13.6 min |
+---+---------------------------------------------------------------+----------+
```

# 最後に
今回はBitriseのキャッシュをできるだけ生かして高速化できるポイントを紹介しました。
しかし、忘れてはならないのは、キャッシュを保管し、利用することは、どこかで不整合が起きるリスクを必ず孕んでいるということです。

Bitriseのキャッシュのログをみると、Pullする前にStackが同一であることを確認してくれてます。

```
+------------------------------------------------------------------------------+

| (4) cache-pull                                                               |
+------------------------------------------------------------------------------+
| id: cache-pull                                                               |
| version: 2.1.2                                                               |
| collection: https://github.com/bitrise-io/bitrise-steplib.git                |
| toolkit: go                                                                  |
| time: 2019-12-02T15:03:01Z                                                   |
+------------------------------------------------------------------------------+
|                                                                              |
Config:
- CacheAPIURL: [REDACTED]
- DebugMode: false
- StackID: osx-xcode-11.2.x
Downloading remote cache archive
Checking archive and current stacks
current stack id: osx-xcode-11.2.x
archive stack id: osx-xcode-11.2.x
Extracting cache archive
Done
```

しかし、 **ライブラリのアップデート時にキャッシュを破棄する** 等のTTLを設ける。
いつでもキャッシュを更新、無効にする方法を用意しておく等の対策は必要です。
今回の実装で、キャッシュに頼りつつも、依存しすぎない関係を築くことが大事だなあと感じました。

# 参考
- [Bitrise 公式ドキュメント](https://devcenter.bitrise.io/jp/builds/caching/about-caching-index/)
- [github.com/onmyway133/blog - How to cache CocoaPods #196](https://github.com/onmyway133/blog/issues/196)
- [Qiita - Bitriseで gemとbrewのキャッシュ](/guitar_char/items/cca97bf84e0be71569ee)
- [Sansan Builders Box - Bitrise のキャッシュを利用して、全体のビルド時間を 25% 高速化した話] (https://buildersbox.corp-sansan.com/entry/2019/08/01/105317)
- [github.com/leavez/cocoapods-binary](https://github.com/leavez/cocoapods-binary)