Bitriseで32分かかっていたAndroidのCI/CDをGitHub Actionsに移行して15分にした話

# はじめに

これは自分が内定者バイトとして参画していた [OPENREC.tv(オープンレック)](https://www.openrec.tv/) のタスクとして取り組んだ、**CIの高速化**に関しての知見を残すために書くブログです。

CIのある環境でコードを書いた経験はありますが、CI自体の構築は3ヶ月前に初めて個人Repositoryで行った程度でした。

そのときに書いた記事[Github ActionsでマルチモジュールAndroidプロジェクトのCI環境を整えよう(ビルド/Slack通知/Danger/ktlint)](https://qiita.com/hohohoris/items/f934beb4ea0b432af488)も是非読んでみてください。

# Bitirse運用での課題

## workflow実行時間が長い

一番の問題がworkflow実行時間が長すぎることです。

OPENREC.tvはサービスを開始してから約5年経過しており、コードの量もかなり増えてきたため1度の実行で30~40分程かかっていました。

PRレビューの修正後も35分待ち、再度レビュー指摘があったりktlintFormat忘れをしようものなら再度35分CIを待たなければなりませんでした。　

workflow実行時間が長いせいでなかなかマージができずに作業が進まないことや、hotfixも出すのに時間がかかる問題がありました。

### ボトルネック

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/e3a16d05-cac2-a1f0-29f4-cba148aa4d66.png" width=500/>



上図を見ると、 `Android Lint` `Unit Test` でかなり時間がかかっていることがわかります。

また、これら2つよりは短いものの、 `android-build` も他と比べると時間がかかっています。

これらはプロダクトが成長していくにつれてさらに肥大化していくため、今後も実行時間が長くなっていくことが想像できます。

そのため、

- Android Lint(ついでにktlint, detektも)
- Unit Test
- Build

の実行時間を短くするアプローチではなく

これらを**並列で実行することでworkflow全体の実行時間を短縮する** アプローチで改善していくことにしました。

## Bitriseで試みた解決方法（失敗）

そもそもBitriseには、他のサービスと違ってjobを並列で回すことはできません。

しかし、 [build-router-start](https://www.bitrise.io/integrations/steps/build-router-start)を使うことで、 `workflow` の並列化ができます。

ex) Unit Test と lintを並列で回す場合

※画像は


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/9cc00387-5136-a267-cfbb-b95e08795a22.png" width=300/>

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/96cf2b12-0f81-6124-d5f7-f863409bcaf6.png" width=300/>



`PR` や `push` で左の `build-test` workflowが起動し、 `Bitrise Start Build` で右の `lint` workflowをtriggerさせます。

lintが完了するのを `Bitrise Wait for Build` stepで待つことで、見かけ場は並列実行ができているように見えます。

しかし、

- Bitirseのworkflow並列実行可能数を圧迫してしまうため実質的な高速化にはつながらない
- lint reportをマージするのが困難

この2つの理由から `build-router-start` での並列実行は断念しました。


### Bitirseのworkflow並列実行可能数を圧迫してしまうため実質的な高速化にはつながらない

この方法はあくまでも **workflow** 単位での並列化のため、同時並列実行可能枠が1である無料プランではそもそも使えませんし、有料プランでも並列実行可能枠に応じた課金となるため、この方法ではworkflowを圧迫してしまい、結局高速化には繋がりませんでした。

※4枠あったとして、30分を4枠で同時に実行するのと、15分だけど2枠使うworkflowを4枠で実行するのでは実行時間としてはほぼ同じ（むしろinstall missing Android SDK等で実行時間は伸びる）

### lint reportをマージするのが困難

workflow間でデータの共有をする方法は主に2つあると思っています。

- 環境変数を用いる
- Cacheを用いる

前者に関しては、 [公式ドキュメント(環境変数のサイズ制限を増やす)](https://devcenter.bitrise.io/tips-and-tricks/increasing-the-size-limit-of-env-vars/)より、1つあたり20KB、合計120KBという制限があります。

multi moduleでは各moduleでreportが生成されますし、ktlint以外にもAndroid Lint, detekt, test reportも環境変数で渡そうとするとサイズ制限にひっかかるため難しいので断念。

後者のCacheに関しては、少しややこしいので図解します。

キャッシュは、ブランチ単位で保持されます。

今回やりたいこととしては、

「`lint report`  を `build-test` workflowに共有したい」

のですが、

lint reportがブランチ単位で保持されるため、任意のPRでのみ使用するlint reportをキャッシュで持ってしまうと、タイミングが悪ければ同じブランチのPRでそのキャッシュが適用されてしまう可能性があります。

【具体例】hoge branchで初めてPRを出した時にlint結果をDangerで指摘する場合

<img width="1083" alt="スクリーンショット 2020-07-11 17.03.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/4104b239-c97f-edd7-8ac4-149733501f04.png">

解決策は、キャッシュするディレクトリ名を変えたり、できなくはないのですがBitriseの設計思想としておそらく上記のユースケースは想定されていないため、無理やり実装することになりそうです。


## 結論

結局、上記の理由からBitriseで並列実行するのは諦め、他のCIツールに乗り換えることにしました。

BitriseはGUIで簡単にworkflowが構築できる反面、プロジェクトが大きくなるにつれ辛みもでてくるなーという感じです。


# Github ActionsでのCI構築

[Github ActionsでマルチモジュールAndroidプロジェクトのCI環境を整えよう(ビルド/Slack通知/Danger/ktlint)](https://qiita.com/hohohoris/items/f934beb4ea0b432af488) にも書いたので、詳しい構文の説明はしません。

<img width="895" alt="スクリーンショット 2020-07-11 17.17.21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/8488a52b-0210-84af-7a9b-4120bd2f22eb.png">


jobの構成はこんな感じです。
時間のかかるlint, build, testを並列化し、それらのjobが終わった後にreportを元にdangerでPRにコメントしてSlackに通知する形にしました。
deployのjobはdangerと並列しても良いですし、workflowを分けても良いと思います。

## マルチモジュール配下でのjobの並列化

初めての慣れないymlでのCI構築というのと、マルチモジュールでつまづいた点もあったので共有です。

### lint結果、test結果のupload

Bitirseでも少し触れましたが、job間は環境がクリアされるためlintやtest結果をjob間でやりとりする必要があります。

<img width="803" alt="スクリーンショット 2020-07-11 17.17.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/27aaf385-6c28-c5c3-6ae4-b8a18a0b0387.png">

Cacheを使うと、上述したような状況になる可能性があります。

そこで使うのが アーティファクトです。

[成果物を使用してワークフローデータを永続化する](https://docs.github.com/ja/actions/configuring-and-managing-workflows/persisting-workflow-data-using-artifacts)

> Artifacts allow you to persist data after a job has completed, and share that data with another job in the same workflow

より、同一workflowのjob間で共有することができます。

```yaml
- name: Upload Hoge
  uses: actions/upload-artifact@v2
  with:
    name: hoge
    path: hoge/hogehoge.txt
```

しかし、multi module環境下では、各moduleでreportは生成され得ます。

<img width="539" alt="スクリーンショット 2020-07-11 17.17.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/3c3af1c1-aa0b-db22-3c8d-bd702bd74700.png">


upload actionは上記のように、ファイルかディレクトリを指定してuploadします。

multi moduleで、各moduleのディレクトリで良いのですが、moduleが数十もあるととてもじゃないですがメンテナンスができません。

そこで、[conference-app-2020(DroidKaigi2020)](https://github.com/DroidKaigi/conference-app-2020/blob/master/.ci/scripts/collect-ktlint-artifacts.bash) を参考にし、各moduleのreport結果を1つのディレクトリにまとめてuploadすることですっきり書くことができました。

`Dangerfile` でlint reportのディレクトリを指定している場合は`Dir.glob("**/checkstyle.xml").each do |xml|` のように任意のディレクトリのreportを集めてあげるように修正する必要があるので注意です。


### upload, download での注意点
[upload-artifact](https://github.com/actions/upload-artifact)は、ファイル単位でjob間でデータを渡すことができます。
しかし、[download-artifact](https://github.com/actions/download-artifact)では、ディレクトリ単位でデータを受け取るため、注意が必要です。

例えば `build job` でapkをuploadし、 `deploy job` でdownloadするとき、このように書くと意図せぬ結果になります。

```yaml
- uses: actions/upload-artifact@v2
  with:
    name: generated development apk
    path: ./app/build/outputs/apk/hoge/debug/app-hoge-debug.apk

```

ここまではok


```yaml
- uses: actions/download-artifact@v2
  with:
    name: generated development apk
    path: ./app/build/outputs/apk/hoge/debug/app-hoge-debug.apk

```

このようにdownloadしようとすると、見かけ上はdownloadできているようにlogが残るのですが、
deploy時にバイナリが破損していてdeployできない問題に遭遇しました。

download-artifactのドキュメントを見ると、
> Basic (download to the current working directory):
or
> Download to a specific directory:

と、基本的にはディレクトリのパスを指定してのdownloadをするひつようがありました。
そのため、

```yaml
- uses: actions/upload-artifact@v2 
  with:
    name: generated development apk
    path: ./app/build/outputs/apk/hoge

```

のようにupload時もディレクトリで指定して、downloadするときも同様のディレクトリを指定することで解決しました。

## 定期実行

[schedule構文](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#schedule)を使います。

```yaml
on:
  # default branchの最新commitに対して規定の時刻でworkflowを実行する
  schedule:
    - cron:  '0 14 * * 1-5'
```

土日を抜いた23時にCIを回すよう設定できました。

時間に関しては、デフォルトはUTCです。

他の記事で `envにTZ: 'Asia/Tokyo'` を指定するとみたのですが、TimeZoneの変更がうまくいかず、 UTC時間で記載しています。（わかる方いたら教えてください）

`cron:` のところ、spaceが2つなので気をつけてください。めっちゃハマりました。

## tag push

[tag構文](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onpushpull_requestbranchestags)を使います。

tagにバージョンを指定することでリリースビルドが走り、deployするようなことをする際には、

```yaml
on:
  push:
    tags:
      - ver*
```

このように書けます。

## 無駄なworkflowをキャンセルする

Github Actionsは従量課金のため、同一PRで既に走っているworkflowは自動でキャンセルするようにしないとかなりコストがかかってしまいます。

ここに関してはまだデファクトスタンダードなものはないみたいですが、現段階で一番スターの多い[Cancel Workflow Action](https://github.com/marketplace/actions/cancel-workflow-action)を使ってみました。

```yaml
- name: Cancel Previous Runs
  uses: styfle/cancel-workflow-action@0.4.0
    with:
      workflow_id: 1111111
      access_token: ${{ secrets.GITHUB_TOKEN }}
```

private repositoryの場合は

[`https://api.github.com/repos/:org/:repo/actions/workflows`](https://api.github.com/repos/:org/:repo/actions/workflows) を `repo` の読み取りができるアクセストークンを添えてcurlで叩くとworkflowのidが取得できます。

このactionを使うことで、新しくcommitをpushした際に前回分commitのworkflowは自動でキャンセルされるので無駄な課金がされなくなり安心ですね。

# おわりに

全てのstepを直列実行していたBitriseからGithub Actionsに移行し、時間のかかるstepを並列で実行したことにより、

1回あたりのworkflow実行時間が30~40分 → 15分に短縮できました👏

<img width="884" alt="スクリーンショット 2020-07-11 17.17.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/2640b382-f220-f86a-23a9-f0bfc14106c6.png">

これでCIを待つ時間が半分になり快適な開発に貢献できたと思うと、最高ですね。

UI実装や機能開発などの普段のAndroid開発と違い、経験が浅かったため少しのバグや機能追加でも結構詰まりましたが、苦労してやった分かなり力が着いたと思います。

まだ色々Github Actionsで効率化できそうなので、今後も個人でちょくちょく触って記事を書いていくのでよろしくお願いします!