CIを高速化する技術⚡️

この記事は 10X アドベントカレンダー2023 という企画の1日目(12/1)の記事です。

こんにちは、10Xでソフトウェアエンジニアをしている 岡野(@operandoOS)です。

今回 10Xで3回目となるアドベントカレンダー企画の1日目をありがたく担当させていただきます💪

目次

10X アドベントカレンダー2023ってなに?

株式会社10Xのメンバーが送る、2023年アドベントカレンダー企画です🎄 🎅 🎍

10xall.notion.site

12/1から12/25まで毎日10Xのメンバーが様々なテーマでブログを書いてくれるので、ぜひお楽しみに✨12/25 最終日は10X CEOのyamottyさんがバシッと締めてくれるのも見所です!

さてさて、本題へ

記事のタイトルどおり【CIを高速化する技術】の紹介をしていきます。

今回紹介する高速化のテクニックは多くのCI/CDサービスで汎用的に活用でき、比較的に簡単な実装で高速化が見込めるテクニックを中心に紹介します。紹介するテクニックを身につけることで、多くのCI/CDサービス環境下で高速化が実現できるようになります。

テクニックの実装イメージを掴んでもらうために、GitHub ActionsとCircleCIを参考に取り上げます。今回詳細な実装には触れませんが実装の参考になるドキュメントを記載しています。

CIは絶対に速い方がいい

なぜ速い方がいいのかを説明することが本記事の目的ではないため詳細は割愛しますが、簡単に言うと「速いと待ち時間が短くなりフィードバックサイクルを早く回せる = Developer Experience(開発体験)がいい!!」ので、CIは絶対に速い方がいいんです。

またCIの速度がCD(Continuous Delivery)にも影響するので、CIは絶対に速い方がいいんです

大事なことなのでもう一度言います。CIは絶対に速い方がいいんです

CIを高速化するテクニックの紹介

ここからは多くのCI/CDサービスで汎用的に活用でき、比較的に簡単な実装で高速化が見込めるテクニックをいくつか紹介します。最後に紹介する【テストコードの実行速度を上げる】だけ例外なのですが、こちらはCIを高速化するテクニックとして非常に大事なことなので記載してます。

キャッシュの利用


CI上でビルドやテストを行う前準備としてパッケージ管理ツール(npm、gemなど)でライブラリのダウンロードを行うことはよくあると思います。

CIを実行するたびにそれらを毎回ダウンロードするのは無駄です。一度ダウンロードしたものをキャッシュして以降はダウンロードするものに変化がなければキャッシュを復元して利用することで高速化が見込めます。

このテクニックはどのCI/CDサービスでも簡単に実装できるので、CIを高速化する上で必ず利用すべきテクニックです。

当たり前のように利用してると思いきや改めて見てみると「キャッシュしてないじゃん!」とか「あれ?これもキャッシュできそう!」となることもありますので、今一度キャッシュ利用がどうなってるか見直してみるのもおすすめです。

参考までにGitHub ActionsとCircleCIでのキャッシュ利用に関するドキュメントをのせておきます。

docs.github.com

circleci.com

マシン性能の調整


CIはCI/CDサービスが用意したマシン上で実行されますが、利用するマシンの性能(CPU、メモリなど)次第で大幅な高速化が見込めます。各CI/CDサービスで用意されているマシン性能は異なりますが、マシン性能の変更はどのサービスでも簡単に行なえます。

GitHub Actionsであればruns-on 、CircleCIであれば resource_classで各ジョブを実行する際に利用するマシン性能を指定できます。

例えば各サービスでM1 macOSを利用したい場合は以下のように書くだけです。

# GitHub Actions
runs-on: macos-13-xlarge

# CircleCI
resource_class: macos.m1.large.gen1

参考までにGitHub ActionsとCircleCIが用意しているマシン性能に関するドキュメントをのせておきます。

docs.github.com

docs.github.com

circleci.com

マシン性能を調整するだけで高速化されるわかりやすい例がCI上でiOSアプリのビルドにM1 macOSを利用するケースです。こちらはGitHubから公式アナウンスがあるとおりIntel3コア macOSと比較してビルド時間が最大80%短縮される素晴らしい高速化の事例です。しかもこれがruns-onの指定1行変えるだけで実現できます。

github.blog

マシン性能を上げることでビルド時間やテスト実行時間が短縮される環境では簡単な変更で高速化が見込める一方、マシン性能を上げるに比例して利用時間に対してかかる課金量は増えます。私のこれまでの経験では課金量が増えた分CIの実行時間が正比例して短縮されることは珍しく、性能を調整する際は費用対効果の測定をおすすめします。私が効果測定を行う場合は、数回CIを実行してみて増えた課金量に対してCIの実行時間がどのくらい短縮されるのかざっくり計算してみて判断するだけで難しいことはしてません。ちょっとお金かけてでも実行時間が短くなるならいいよね!という前提がチームや組織内で共有されていると高速化の改善が行いやすいと思います。

ちなみに本記事では詳細には紹介しませんが、CI/CDサービスが提供しているマシン性能では満足しない方はセルフホストランナーという自前で用意したマシン上でCIを動かす仕組みを利用して高速化を実現するケースもあります。

docs.github.com

circleci.com

ジョブの並列実行とテスト分割


CI上でテスト実行を高速化したい場合にジョブの並列実行とテスト分割を利用することで大幅な高速化が見込めます。

言語やテスティングフレームワーク次第ですが多くの場合テストコードそのものをいじらなくても、CI設定ファイルの簡単な変更のみでテスト実行を高速化できます。

ジョブの並列実行とテスト分割による高速化は図にすると以下のような感じです。

テストを各コンテナに分配し並列実行することでジョブの実行時間が短縮され、次のジョブに進むまでの時間が早くなります。

多少注意が必要なのは、並列実行にしたことでジョブ全体でかかる実行時間は並列実行しない時よりも長くなることが多いです。下記図のように並列実行時のステップをまとめて横に並べるとわかるとおりテスト実行準備を2回行うことになるからです。結果的にジョブの実行時間は短縮されますが、ジョブ全体の実行時間は増えるため課金量が増えます。並列実行にしたら絶対増えるわけではなく、並列実行にしたことでCPUやメモリへの負荷が減り、各テスト実行が速くなり全体の実行時間が短くなることもあります。その場合は課金量が減るので良いこと尽くしです。

CircleCIではジョブの並列実行とテスト分割を行うための仕組みが用意されています。並列実行の指定をparallelismで行い、テスト分割をcircleci testsコマンドを利用して行います。

詳細は以下のドキュメントを参照してください。

circleci.com

GitHub ActionsではCircleCIほどわかりやすい機能が提供されていないのですが、matrixとシェルコマンドを利用することで並列実行とテスト分割が行なえます。

以下の記事が参考になります。

kojirooooocks.hatenablog.com

最適なテスト分割


先ほどジョブの並列実行とテスト分割を説明するための図を見て勘のいい方は気づいたかもしれませんが、あの図のあまりよくないテスト分割になっています。

以下の図のようになるのが最適なテスト分割です。

このようにテストの実行時間に基づいて最適なテスト分割を行うことでさらなる高速化が見込めます。

全テストの実行時間がわかるものがあればどんなCI/CDサービスでも最適なテスト分割は実現可能です。一般的にテスティングフレームワークにはJUnit XML形式で全テストの実行時間を書き出す機能があります。その機能を利用してテスト実行時間をファイルに書き出し、書き出したファイルを何かしらのツールに通すことで最適なテスト分割を行うのが一般的です。

CircleCIではcircleci tests splitコマンドを利用することで最適なテスト分割が簡単に行えます。

詳細は以下のドキュメントを参照してください。

circleci.com

GitHub Actionsでは以下のようなカスタムアクションを利用することで最適なテスト分割が行えます。

github.com

ジョブの実行順序・依存関係の最適化


CIのワークフローはいくつかのジョブで構成され、それぞれのジョブは実行順序に依存関係があることが一般的です。実行順序の依存関係のつながりが長ければ長いほどCIの実行時間も長くなります。

ジョブの実行順序・依存関係の最適化とは、ジョブ同士の依存関係を見直し、並列実行できるジョブを増やすことでCIの実行時間を短縮するテクニックです。

わかりやすい例として下記図のようにすべてのジョブを逐次実行しているワークフローあったとします。ジョブ同士の依存関係を見直したところ、ジョブ1の実行結果に基づきジョブ2,3,4は並列実行でき、ジョブ2,3,4の実行結果に基づきジョブ5を実行するワークフローに見直せたとします。その結果、並列実行できるジョブが増えたことでCIの実行時間は短縮されます。

既にジョブ同士の依存関係が整理されているワークフローでも改めて依存関係を見直すことでCIの実行時間を短縮できる可能性がありますので、定期的に見直してみることをおすすめします。

ジョブ同士の依存関係はCircleCIであればrequires、GitHub Actionsであればneedsで定義することができます。それぞれ参考になりそうなドキュメントをのせておきます。

circleci.com

docs.github.com

不要なジョブ・ステップを削除する


CIのワークフローをよくよく眺めてみると不要なジョブやステップを見つけることがあります。それらを削除することでCIの実行時間が短縮されるかもしれません。

長期間色んな人がメンテナンスしてきたワークフローは削除チャンスに出会うことが多いです。不要なものを消すことでCIの実行時間短縮だけでなく、ワークフローのメンテンス性も向上するため定期的に見直してみることをおすすめします。

テストコードの実行速度を上げる


これまで紹介してきたテクニックは主にCI/CDサービスの仕組みを活用してCI設定ファイルを変更することで高速化を実現するものでした。しかしこれらのテクニックを駆使しても思ったよりCIが早くならない場合もあります。

例えばテストコードの実行がなんかすごく遅いなどがそれにあたると思います。テストコードそのものの改善は利用している言語やテスティングフレームワークによって改善方法は様々です。

様々な中でもテスティングフレームワークによくある機能やテスト周辺にある技術、CI/CDサービスに用意された機能などを使いテスト実行速度を上げることで、CIの高速化が見込めます。

テストコードそのものを改善するヒントを得るために、CI/CDサービスのテストインサイトを活用する方法があります。

CircleCIにはテストインサイトというテストのパフォーマンスを表示してくれる素晴らしい機能があります。これを見るだけでテストがどんな状態なのかざっくり理解でき、改善すべきポイントを絞り込むことができます。

circleci.com

利用しているCI/CDサービスのテストインサイト的な機能がない場合は、利用しているテスティングフレームワークの機能を使いテストの状態を可視化できないか調べてみましょう。

例えばrspecには–profile オプションがあり、これを利用することで遅いテストを抽出してくれます。

https://rspec.info/features/3-12/rspec-core/configuration/profile/

他にもテスティングフレームワークには何らかの形式で全テストの実行時間を書き出す機能がついてることが多く、その結果を利用することで遅いテストを特定することができます。

またテスティングフレームワークではなくテストのプロファイリングに特化したライブラリなども言語によっては存在します。

github.com

こういったものを利用し、遅いテストやテスト実行を遅くしている原因を解消することで、さらなるCI高速化を実現できます。

テストコードそのものを変更せずテスト実行時間を短縮したい場合には、CPUコア数分テストを並列実行するような仕組みを検討してみるものいいでしょう。

私達がStailerの開発で利用しているDart testにはconcurrencyオプションがあり、これを使うとより多くのテストを同時に実行できるようになります。

pub.dev

もしお使いのテスティングフレームワークにそういった機能がない場合は、Rubyで代表的なparallel_testのようにテスティングフレームワークとは別のライブラリを使い、並列実行ができないかを調べてみるといいでしょう。

github.com

紹介したテクニックを活用した10XでのCI高速化事例

ここまで紹介してきたテクニックを活用して私が10XでCIの高速化を行った事例を2つ紹介します。

アプリのビルド時間の大幅短縮に成功!!


私達が開発しているStailerではお客様や店舗スタッフさんが利用するアプリをFlutterで開発し、iOS / Androidで提供しています。これらの開発ではCIにGitHub Actionsを利用しています。

Stailerのアプリはパートナー企業様ごとに作成されています。そのため全パートナー企業様のアプリをビルドするためには、2023/12現在OSごとに26ビルド並列実行する必要があります(開発版アプリのビルドも含めると2倍になるので52ビルド…🙄)。

アプリが増え続ける中で特に困っていたのがiOSのビルド時間が長いことでした。GitHub Actionsでは長い間 Intelコア版のmacOSランナーしか提供されておらず、ビルド時間がなかなか縮まりませんでした。セルフホストランナーを利用する選択肢もありましたが、メンテンスコストが懸念であり採用を見送りました。

そんな中今年の10月 GitHub ActionsでM1 macOSランナーの提供が始まりました。

github.blog

Intelランナーで平均ビルド時間が16分だったのが、M1ランナーに変更したことで平均ビルド時間が8分になりビルド時間が半分になりました!やったね!

またAndroidアプリのビルドでも同じようにCPU2コアランナーをCPU4コアランナーに変えたことでビルド時間が半分になりました。

github.blog

このために行った変更はたったの1行のみです。これは高速化テクニックで紹介した【マシン性能の調整】がうまく行った事例です。

その他にも Flutter / Flutter package / Gradleのキャッシュ、不要なステップの削除などを行い、CIの実行時間をかなり短縮できました。

紹介したテクニック【マシン性能の調整】、【キャッシュの利用】、【不要なジョブ・ステップを削除する】を実践した事例紹介でした。

APIのテスト実行時間の大幅短縮に成功!!


私達が開発しているStailerのバックエンド(API)はDartで開発しています。これらの開発ではCIにCircleCIを利用しています(一部GitHub Actionsも利用)。

機能がどんどん増え続ける中で特に困っていたのがテスト実行の時間が長いことでした。またFlakyなテストもあり「困ったな〜😣」って感じでした。

改善を進めるにあたりまずはテストの状態を知るためにCircleCIのテストインサイトを活用し、遅いテストの特定を行ってから遅い原因を調査しました。

調査した結果、原因はテストデータの大量作成による処理のつまりでした。つまりテストコードの実装起因で実行が遅くなっていました。

テストコードを修正しこの問題を解消することで、ハチャメチャに遅いテストが速くなりテスト実行時間が半分になりました!やったね!(この時調査を手伝ってくれた沢田さんありがとう🥹)

その他にも最適なテスト分割、並列実行数を上げる、ジョブの依存関係最適化、Lintの実行時間短縮などを行い、CIの実行時間をかなり短縮できました。

またマシン性能の調整においてはCPUとメモリの使用率がそこまで高くなかったことからresource_class: largemedium+にダウングレードすることで課金量の節約も行いました。

紹介したテクニック【テストコードの実行速度を上げる】、【ジョブの並列実行とテスト分割】、【最適なテスト分割】、【ジョブの実行順序・依存関係の最適化】、【マシン性能の調整】を実践した事例紹介でした。

CIを高速化するために日々取り組んでいること

CIを高速化するために私が日々取り組んでいることをいくつか紹介します。

CI/CDサービスの集計情報を定期的に確認する


CIは生き物なのでいつどんな変化が起きるかわかりませんので、日々体調チェックしてあげることが大事です。

CircleCIであればInsightsダッシュボードがとても役に立ちます。時系列でCIの実行結果を知ることができるため、何か異常が起きた際にいつから起きたのかなども簡単に調べることができます。その他にも実行時間が遅い順にテストを表示してくれるので便利です。

circleci.com

GitHub ActionsにはまだCircleCI相当のInsightsダッシュボードが存在しないため、気になるワークフローは定期的に実行時間を見て異常が起きてないかチェックしています。チェックして「あれ?なんか遅いかも?」と思ったら、実行結果やワークフローを確認して問題がないか確認しています。弊社ではDatadogを利用しているので、今後GitHub Actionsの実行結果可視化はDatadog CI Visibilityを活用する予定があるみたいです。早く使いたい☺️

www.datadoghq.com

CI/CDサービスの最新情報を追う


最新情報が流れてくるブログのRSSを購読したり、Xのポストを追ったりしています。

GitHub Actionsの情報は以下あたりを見てます。

github.blog

https://twitter.com/ghchangelog

CircleCIの情報は以下あたりを見てます。Developer Advocateの方が隔週でWhat's Newまとめ記事をQiitaに書いてくださっていてありがたいです😊

circleci.com

https://twitter.com/circleci

qiita.com

その他にも今度どういった機能が追加される予定なのかを知るために各サービスのロードマップもたまに見てます。

github.com

circleci.com

CI高速化事例のブログなどをいっぱい読む


私と同じように日々CIの高速化と向き合っている方が世界中にたくさんいます。その方々の成果から高速化の知見を得る機会は多いです。自身が知っている高速化テクニックだったとしても細部まで見てみるとそれぞれの工夫がされており、学んでいて飽きない領域だなーと思います。

とにかく色々試す


高速化できそうなアイデアが思いついたり、CI/CDサービスで新機能がリリースされたらとにかく試してみることを心がけています。

やってみてうまくいかなければ適用しなければいいだけですし、試す過程でワークフローの見直しや高速化できそうな勘所が身につくのでガンガン試していきましょう!

ほどほどに高速化する


高速化のためにメンテンス性を犠牲にした結果、自分しかメンテンスできないものができてしまってはいい高速化とは言えません。

「目先の1分を短縮できるけどこの実装は分かりづらいし、このCIが実行される頻度を考えると…やめたほうがいいな!」みたいに自問自答することが大切です。

できる限りシンプルな実装を保つことで、CI/CDサービスが提供する新機能を試しやすくなるメリットもあります。

「CI遅い…待てない…」と日々思い続ける


高速化しても時間が経つにつれてまた遅くなったり、遅くならなくても感覚的な慣れによって「CI遅い…待てない…」となることが多々あります。この気持はとても大事です。

「遅い!」と思った時が見直し時であり、「遅い!」という思いから改善は始まります。

おわりに

「なぜそんなにCIの高速化が好きなんですか?」と聞かれたら、「CIが早くなるとみんな嬉しいじゃないですか」と答えます。

単純にCI/CDが好きというのもありますが、自分が好きな技術を駆使してみんなが喜んでくれるのは幸せなことです。

今回紹介したテクニックなどを参考にした読者の方々のCI/CDが高速化され、共に働くメンバーに喜んでもらえることを祈っています。

ちょっと早いですが、メリークリスマス🎅🎄🎁