はじめに
これは自分が内定者バイトとして参画していた OPENREC.tv(オープンレック) のタスクとして取り組んだ、CIの高速化に関しての知見を残すために書くブログです。
CIのある環境でコードを書いた経験はありますが、CI自体の構築は3ヶ月前に初めて個人Repositoryで行った程度でした。
そのときに書いた記事Github ActionsでマルチモジュールAndroidプロジェクトのCI環境を整えよう(ビルド/Slack通知/Danger/ktlint)も是非読んでみてください。
Bitirse運用での課題
workflow実行時間が長い
一番の問題がworkflow実行時間が長すぎることです。
OPENREC.tvはサービスを開始してから約5年経過しており、コードの量もかなり増えてきたため1度の実行で30~40分程かかっていました。
PRレビューの修正後も35分待ち、再度レビュー指摘があったりktlintFormat忘れをしようものなら再度35分CIを待たなければなりませんでした。
workflow実行時間が長いせいでなかなかマージができずに作業が進まないことや、hotfixも出すのに時間がかかる問題がありました。
ボトルネック
上図を見ると、 Android Lint
Unit Test
でかなり時間がかかっていることがわかります。
また、これら2つよりは短いものの、 android-build
も他と比べると時間がかかっています。
これらはプロダクトが成長していくにつれてさらに肥大化していくため、今後も実行時間が長くなっていくことが想像できます。
そのため、
- Android Lint(ついでにktlint, detektも)
- Unit Test
- Build
の実行時間を短くするアプローチではなく
これらを並列で実行することでworkflow全体の実行時間を短縮する アプローチで改善していくことにしました。
Bitriseで試みた解決方法(失敗)
そもそもBitriseには、他のサービスと違ってjobを並列で回すことはできません。
しかし、 build-router-startを使うことで、 workflow
の並列化ができます。
ex) Unit Test と lintを並列で回す場合
※画像は
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を用いる
前者に関しては、 公式ドキュメント(環境変数のサイズ制限を増やす)より、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で指摘する場合
解決策は、キャッシュするディレクトリ名を変えたり、できなくはないのですがBitriseの設計思想としておそらく上記のユースケースは想定されていないため、無理やり実装することになりそうです。
結論
結局、上記の理由からBitriseで並列実行するのは諦め、他のCIツールに乗り換えることにしました。
BitriseはGUIで簡単にworkflowが構築できる反面、プロジェクトが大きくなるにつれ辛みもでてくるなーという感じです。
Github ActionsでのCI構築
Github ActionsでマルチモジュールAndroidプロジェクトのCI環境を整えよう(ビルド/Slack通知/Danger/ktlint) にも書いたので、詳しい構文の説明はしません。
jobの構成はこんな感じです。
時間のかかるlint, build, testを並列化し、それらのjobが終わった後にreportを元にdangerでPRにコメントしてSlackに通知する形にしました。
deployのjobはdangerと並列しても良いですし、workflowを分けても良いと思います。
マルチモジュール配下でのjobの並列化
初めての慣れないymlでのCI構築というのと、マルチモジュールでつまづいた点もあったので共有です。
lint結果、test結果のupload
Bitirseでも少し触れましたが、job間は環境がクリアされるためlintやtest結果をjob間でやりとりする必要があります。
Cacheを使うと、上述したような状況になる可能性があります。
そこで使うのが アーティファクトです。
Artifacts allow you to persist data after a job has completed, and share that data with another job in the same workflow
より、同一workflowのjob間で共有することができます。
- name: Upload Hoge
uses: actions/upload-artifact@v2
with:
name: hoge
path: hoge/hogehoge.txt
しかし、multi module環境下では、各moduleでreportは生成され得ます。
upload actionは上記のように、ファイルかディレクトリを指定してuploadします。
multi moduleで、各moduleのディレクトリで良いのですが、moduleが数十もあるととてもじゃないですがメンテナンスができません。
そこで、conference-app-2020(DroidKaigi2020) を参考にし、各moduleのreport結果を1つのディレクトリにまとめてuploadすることですっきり書くことができました。
Dangerfile
でlint reportのディレクトリを指定している場合はDir.glob("**/checkstyle.xml").each do |xml|
のように任意のディレクトリのreportを集めてあげるように修正する必要があるので注意です。
upload, download での注意点
upload-artifactは、ファイル単位でjob間でデータを渡すことができます。
しかし、download-artifactでは、ディレクトリ単位でデータを受け取るため、注意が必要です。
例えば build job
でapkをuploadし、 deploy job
でdownloadするとき、このように書くと意図せぬ結果になります。
- uses: actions/upload-artifact@v2
with:
name: generated development apk
path: ./app/build/outputs/apk/hoge/debug/app-hoge-debug.apk
ここまではok
- 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をするひつようがありました。
そのため、
- uses: actions/upload-artifact@v2
with:
name: generated development apk
path: ./app/build/outputs/apk/hoge
のようにupload時もディレクトリで指定して、downloadするときも同様のディレクトリを指定することで解決しました。
定期実行
schedule構文を使います。
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構文を使います。
tagにバージョンを指定することでリリースビルドが走り、deployするようなことをする際には、
on:
push:
tags:
- ver*
このように書けます。
無駄なworkflowをキャンセルする
Github Actionsは従量課金のため、同一PRで既に走っているworkflowは自動でキャンセルするようにしないとかなりコストがかかってしまいます。
ここに関してはまだデファクトスタンダードなものはないみたいですが、現段階で一番スターの多いCancel Workflow Actionを使ってみました。
- 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
を repo
の読み取りができるアクセストークンを添えてcurlで叩くとworkflowのidが取得できます。
このactionを使うことで、新しくcommitをpushした際に前回分commitのworkflowは自動でキャンセルされるので無駄な課金がされなくなり安心ですね。
おわりに
全てのstepを直列実行していたBitriseからGithub Actionsに移行し、時間のかかるstepを並列で実行したことにより、
1回あたりのworkflow実行時間が30~40分 → 15分に短縮できました👏
これでCIを待つ時間が半分になり快適な開発に貢献できたと思うと、最高ですね。
UI実装や機能開発などの普段のAndroid開発と違い、経験が浅かったため少しのバグや機能追加でも結構詰まりましたが、苦労してやった分かなり力が着いたと思います。
まだ色々Github Actionsで効率化できそうなので、今後も個人でちょくちょく触って記事を書いていくのでよろしくお願いします!