しばやん雑記

Azure Serverless とメイドさんが大好きなフリーランスのプログラマーのブログ

GitHub Actions / Azure Pipelines 上での .NET Core アプリケーションのビルド時間を短縮する

最近は Azure DevOps で開発していたアプリケーションを GitHub に移行しつつ、ビルドとデプロイ周りを Azure Pipelines から GitHub Actions に切り替えていたのですが、アプリケーションのビルドに時間がかかっていたので短縮するために色々と作業をしました。

Azure Pipelines と GitHub Actions は Runner が同一のものが使われているので直接比較できます。先にどのくらい改善したかを簡単に出しておきます。まずは元々のビルドパイプラインです。

f:id:shiba-yan:20201023193019p:plain

まだデプロイを全て移し切れていないので Pull Request 時の処理で比較しています。実際にコードを書いていて PR にコミットを積むたびに 5-6 分待たされるのは正直かなりストレスフルでした。

GitHub Actions に切り替えた後は以下のように 2 分ちょいで完了するようになりました。

f:id:shiba-yan:20201023193026p:plain

この時に 10 近くのプロジェクトをビルドしていますが、このくらいなら十分許容範囲です。

これまでにもビルド時間の短縮のために色々と試してきたので、その時の経験をまとめて共有しておきます。当たり前ですが全てのケースに当てはまるとは限らないので、必ずそれぞれ個別に効果を確認してから導入してください。*1

無駄なパッケージの復元・ビルドを避ける

Pull Request でテストを実行する場合は大体その前にビルドを行って、そもそもビルドが通るかどうかのチェックを行うことが多いと思います。

コマンドとしては dotnet builddotnet test を使うだけなのでシンプルではありますが、これらのコマンドは暗黙的にパッケージの復元とビルドを行います。明らかに不要な場合には --no-restore / --no-build を指定して処理をスキップさせると実行時間の短縮になります。

最近は .NET Core 向けの場合には deterministic オプションがデフォルトで有効になっているはずなので、一応はビルドした後にはキャッシュが効くようになっていますが、不要な場合は明示的にオプションを指定した方が関連する処理が行われないので高速です。

今回は dotnet test を例に上げましたが、他にも dotnet publishdotnet pack などのコマンドでも同じことがいえるので、多くのプロジェクトを対象にする場合には注意しましょう。

可能な場合はソリューション単位で処理を行う

GitHub hosted な Runner は Azure 上の Standard_DS2_v2 インスタンスで実行されるので、2 コアまで CPU を利用できます。ちなみに Hyper-Threading ではない物理コアが割り当てられるインスタンスです。

つまり 2 並列までは効率よく実行できるということです。実際に .NET Core のビルドに使われている MSBuild では、デフォルトでマシンの CPU の数だけ並列で処理を行おうとします。

ビルドに関してはプロジェクト単位での並列実行になるので、プロジェクトが多い場合にはソリューションを指定することでビルド時間を短縮できます。ただしソリューションに .NET Core に対応していないプロジェクト*2が含まれていると失敗するので、事前の準備が必要になります。

Azure Pipelines だとワイルドカードでビルド対象のプロジェクトを指定出来るので、意図しないまま並列実行が無効化されているケースも多そうです。

キャッシュの利用は慎重に

パッケージの復元に時間がかかる場合には、ダウンロードしたパッケージをキャッシュして高速化したいと考えることが多いです。特に npm に関してはキャッシュは良く行われている印象で、ドキュメントでも例として npm が挙がっています。

確かに効果的な場合もあるのですが逆に遅くなることも良くあります。キャッシュの保存と復元のコストと、パッケージを都度ダウンロードするコストを比較してから導入しましょう。

経験上、NuGet に関してはキャッシュをするより都度ダウンロードした方が早いことが多いです。

ただしキャッシュのメリットとしては NuGet などが落ちていてもビルドが継続できる可能性があるという点があります。最近は静的ファイルと CDN が使われているので、大規模な障害はあまりお目にかからないですが、頭の片隅にでも入れておいて損はありません。

並列実行が常に早いとは限らない

これもキャッシュに近い話になるのですが、GitHub Actions や Azure Pipelines での並列実行は複数の VM を使って行われるため、スピンアップの時間や Artifacts を使っている場合はダウンロードと展開などのオーバーヘッドを意識する必要があります。

それぞれの OS やランタイムバージョン毎に実行させたい場合には間違いなく並列実行が有利ですが、複数のプロジェクトのビルドに感がかかるからと言って並列にしてもオーバーヘッドの方が大きくなるでしょう。

大した処理ではない割に時間がかかるアプリケーションのデプロイに関しては、アプリケーションの数次第ではありますが並列実行にした方が全体的な処理時間を短縮できます。実際に App Service / Azure Functions のデプロイではウォームアップも行うため、待ち時間が長くなりがちなので効果が出やすいです。

当然ながら Artifacts のアップロードとダウンロードがオーバーヘッドとして乗ってくるので、ちゃんと試してから組み込みましょう。パフォーマンス改善では常に計測することが重要になります。

可能な場合は Linux の Runner を利用する

正直なところ、何じゃそれと言われそうですが Windows の Runner を使うよりも Linux の Runner の方が早いです。インスタンスのスペックは変わらないですが、主に I/O 周りで差が付いているように思います。

.NET Core で Framework Dependent なアプリケーションをビルドする場合には Windows と Linux のどちらでも問題がないため、最近は Windows の App Service / Azure Functions で動かすアプリケーションであっても ubuntu-latest を使っています。時間単価も安いのでお得です。

f:id:shiba-yan:20201019205519p:plain

Azure Pipelines では時間ではなく並列実行数での制限なのであまり意識しませんでしたが、GitHub Actions は使用した時間での制限となり、時間単価が Windows は Linux の 2 倍に設定されています。

ASP.NET 4.8 や Visual Studio が必要な Desktop Bridge 向けのビルドは流石に行えませんが、それ以外は Linux の Runner を使った方がビルド時間の短縮も行えつつ、節約も行えるのでメリットが大きいです。

ただし ReadyToRun を使っている場合には注意が必要です。現在クロスターゲットがサポートされていないため、Linux でビルドした ReadyToRun 済みアセンブリを Windows で動かすことは出来ません。Windows の Runner のままにするか ReadyToRun をオフにするかの判断が必要です。

*1:パフォーマンス改善では考えられること全て一度に入れてしまい、何が効いたか分からなくなるケースが多い

*2:SQL Project や Desktop Bridge Project など