SRE team の suzuki-shunsuke です。 今年の 8 月(約3か月前)から freee の SRE team に join しました。
本記事では Terraform Monorepo の CI の実行時間を CIAnalyzer で可視化し 2 分 (70 percentile で約 160 %) 以上高速化した話を紹介します。
背景
freee には AWS などを管理する Terraform の Monorepo があり、 SRE を中心に多くの開発者がそのリポジトリで開発を行っています。 CI には GitHub Actions の Self-hosted Runner を使っています。 フローとしては GitHub Flow を採用しており、 Pull Request を作成すると CI で terraform plan が実行されます。 terraform apply は GitHub の Merge Queue を使って実行され、 terraform apply が失敗するとマージされないようになっています。
自分が入社する前からこの CI に時間がかかり開発者体験を損なっているという課題がありました。 自分が入社して少し触っただけでも確かに CI が遅いと感じました。
上述の通り SRE を中心に多くの開発者がそのリポジトリで開発を行っているため、このリポジトリの CI を高速化することで多くの開発者の生産性を向上につながると考え、改善に取り組むことにしました。
やったこと
やったことは大きく分けて次の 5 つです。
- OKR の設定
- 実行時間の計測・可視化
- SLI / SLO の設定
- ボトルネックの分析
- 改善
OKR の設定
まず最初に OKR を設定し、やることを簡潔に明確にしました (実際のものに若干手を加えています)。
- Objective: CI を高速化し、全開発者が素早く Terraform でインフラを構築したり変更を加えられるようにし、開発者の生産性に貢献する
- KR1: CI にどのくらいかかっているのか可視化され、いつでも確認することができる
- KR2: SLI/SLO が定まっており、 CI の速度について客観的に評価・議論できるようになっている
- KR3: SLO を達成する
実行時間の計測・可視化
改善の前にまずは CI の実行時間を計測・可視化し、改善を評価できるようにする必要がありました。 そうでないと改善したとしても、実際本当に速くなったのか・どのくらい速くなったのか感覚でしか分からず、客観的な議論ができません。 可視化のためのツールとして次の 2 つが候補に上がりました。
CIAnalyzer については作者様のブログも参照してください: CI/CDのデータを収集するCIAnalyzerの紹介
GitHub Actions Usage Metrics は GitHub 公式の機能で、 Plan にもよりますが特別なセットアップなしに使えます。 一方 CIAnalyzer は OSS で CIAnalyzer を実行する CI や BigQuery や Looker Studio などのセットアップが必要で GitHub Actions Usage Metrics より手間がかかります。
そこでまずは GitHub Actions Usage Metrics を眺めてみることにしました。 眺めてみた結果、詳細なボトルネックの分析などは GitHub Actions Usage Metrics では難しいことが分かりました。
そこで CIAnalyzer を構築しました。 手間がかかるとは書きましたが、公式のドキュメントに従えばさほど難しくはありません。 GitHub Actions で定期実行してデータを BigQuery に収集するようにしました。 CIAnalyzer では公式に Looker Studio のサンプルが提供されていますが、我々のニーズにはあまり合わなかったため自作しました。 結果としてどの Workflow, Job, step にどのくらい時間がかかっているかが分かるようになりました。
補足: Actions Performance Metrics について
この記事の執筆中に Actions Performance Metrics が Public preview になりました。 Actions Usage Metrics と比べると各 Job の平均実行時間や成功率なども見れて便利です。 Actions Usage Metrics 同様特別なセットアップ無しで使えるのも良いところです。 ただし、 step 単位の情報は取れませんし CIAnalyzer のように可視化は出来ないので、やはり CIAnalyzer が必要でした。
補足: actions-timeline について
CIAnalyzer の作者様が作っている actions-timeline という別のツールについて補足します。
GitHub Actionsのワークフローを可視化するactions-timelineを作った
actions-timeline は同じ作者が CIAnalyzer のあとに作ったものであり GitHub Star 数も上回っていることからこれらのツールをよく知らない人からすると actions-timeline を使ったほうが良いのではないかと思うかもしれません。 しかし、これらのツールは機能的に全く異なるものであり actions-timeline は決して CIAnalyzer の上位互換ではありません。 actions-timeline は BigQuery などを必要とせず非常に導入が容易で workflow の実行結果を Mermaid を使って分かりやすく描画してくれるツールですが、あくまで個々の workflow run の結果を可視化するツールであり、過去の workflow の実行結果を蓄積して統計的に分析できるようなツールではありません。 今回のように CI の実行結果に関するダッシュボードを構築したり、改善活動を行った N ヶ月でどのくらい CI が改善したのか客観的に示したりするのには使えません。 今回は今のところ actions-timeline の必要性を感じなかったので導入しませんでした。
SLI / SLO の設定
CI の速度について客観的に評価・議論できるようにするための SLI/SLO を設定しました。 Google の SRE が公開している The Art of SLOs によると SLI は workflow の実行時間のようなものではなく 0 ~ 1 に収まるような値が良いとされています。 そこで以下のようなものを SLI として設定しました。
成功した workflow のうち目標時間以内に完了した workflow の割合
数式で表すと以下のようになります。
SLI = (タイムフレーム A の間に B 分以内に成功した workflow の数) / (A時間のタイムフレームの間に成功した workflow の数) * 100%
ここで 2 つのパラメータ A, B があります。
- A: どのくらいの時間でデータを集計するか (1時間, 1 日, etc)
- B: workflow の実行時間の期待値 (3 分, 5 分, 10 分, etc)
あとは目標とする SLI の値 (SLO) もあります。
これらのパラメータをどう設定するかは正解のない難しい問題ですが、暫定で設定し必要に応じて見直すことにしました。 workflow の実行時間は queuedDurationSec + workflowDurationSec としました。
ボトルネックの分析
時間のかかっている Job や Step を特定し、ボトルネックになっているところから優先的に改善しました。 まず checkout にやたら時間がかかっていることが分かり、そこを改善するだけでも結構速くなることが分かりました。
改善
分析に基づき実際に workflow を修正し高速化しました。
- Job の並列実行
- Workflow や Job のマージ
- shallow clone
- sparse checkout
- 無駄な処理の削除
- リポジトリにコミットされている zip ファイルの削除
- ファイルやディレクトリの存在チェックのためだけに態々 checkout していたのを GitHub Content API に置き換え
- terrform plan が no change のとき infracost を skip
改善の PR を作成する際は、予め改善対象の処理にどの程度時間がかかっているのかわかっているため、その PR によってどの程度の改善が見込めるかが客観的に分かり、 PR に説得力をもたせることが出来ました。
Job の並列実行
job の依存関係を見直し、直列で実行されている job を並列で実行するようにしました。
Workflow や Job のマージ
独立した job で別々に同じ処理を行っているケースがありました。例えば terraform plan を実行するメインの workflow とは別に terraform plan の結果に対して infracost を実行する workflow があり、両方の workflow で独立して terraform plan を実行していました。 そこで infracost を実行する workflow をメインの workflow とマージすることで無駄な workflow の実行をなくしました。 これは高速化というよりは Self hosted Runner のリソース効率の改善とも言えます。
workflow や job を分割して並列実行して高速化する場合もありますが、 Job を実行すれば Job を実行する Node のスケジューリングやセットアップがあり、なにもしなくてもオーバーヘッドがあります。 また job 毎に checkout を実行していれば今回のように checkout に時間がかかるケースでは無駄が大きくなります。
shallow clone
先述の通り checkout に時間がかかっているので改善しました。 まず shallow clone せずに全履歴を checkout している job があったので、なぜ shallow clone していないのか調べ、特に理由がなければ shallow clone をするようにし、必要があればロジックを改修して shallow clone でも動くようにしました。 actions/checkout はデフォルトで shallow clone なので理由もなく shallow clone をしないのは変な話ではありますが、 shallow clone で良いところでも shallow clone しないコードがコピペされて来てしまったとか、歴史的経緯で shallow clone できるようになったのに shallow clone しないままになってたということが考えられます。 PR で変更されたコードを検出するのに git コマンドを使っていて、そこで git の履歴が必要なので shallow clone だと動かないケースもありましたが、そこは GitHub API で PR で変更されたファイルの一覧を取得することで解決しました。
REST API endpoints for pull requests - GitHub Docs
なお、 API だと 3,000 ファイルまでしか取得できないという制約はありますが、ほぼ問題にならないと思います。
リポジトリにコミットされている zip ファイルの削除
shallow clone でも思ったほど速くならなかったのが不思議だったのですが、結構な数の大きめの zip ファイルがコミットされていることが判明しました。 それらは全て Lambda Function のコードでした。
まず大きな zip ファイルのようなバイナリファイルを Git で管理するのは適切ではありません。 Git で管理するとしたら Git LFS のようなものを使うべきでしょうが、自分の理解が正しければ Git LFS を使っても shallow clone は速くなりません。
About Git Large File Storage - GitHub Docs
加えて今回の zip ファイルはコードから生成された Lambda Function のコードですが、これを手元で生成してコミットするのは CI の高速化とは別に大きな問題があります。
- コードと生成された zip に差分がある可能性がある(コードを更新したあとにうっかり zip を更新し忘れるとか)
- 悪意のあるコードが仕込まれ、 Lambda で実行されうる
- zip の中身を PR レビューするのが困難
そこで改善案が主に 2 つあります。
- zip を S3 に移す
- lambroll などを使ってデプロイする
今回問題になっている Lambda Function は SRE team が管理するものではなかったので、具体的にどう対応するかはお任せしつつリポジトリから zip ファイルを削除するように依頼して回りました。
sparse checkout
shallow clone でも思ったほど速くならなかったので、 sparse checkout をすることにしました。 sparse checkout は actions/checkout の sparse-checkout input で対象のパスを指定するだけです。 大きな Monporepo ではリポジトリ全体を checkout せずとも CI に必要なスクリプトや設定ファイルと対象のディレクトリだけ checkout すれば動く場合もよくあります。 また、例えば actionlint や ghalint で GitHub Actions Workflow の lint をする場合 .github 配下だけ checkout すれば十分だったりします。
ただし、依存する local Module なども checkout してないと動かなくなってしまうケースがありました。 そこで単に terraform を実行するディレクトリだけでなくそのディレクトリが依存するディレクトリも checkout しないといけないのですが、その依存関係をコードを checkout する前に解決するのは困難でした。 今回の場合、ディレクトリ階層の最初のディレクトリがサービス名になっており、他のサービスディレクトリ配下にある local Module を参照しているようなケースはなかったため、以下のディレクトリをチェックアウトすることにしました。
- リポジトリ全体で共通の local module が置かれているディレクトリ
- 変更されたディレクトリが属する最初のディレクトリ
- CI に必要なスクリプトなどがあるディレクトリ
sparse checkout により checkout が数秒で終わるようになり、大きな高速化に繋がりました。
ファイルやディレクトリの存在チェックのためだけに態々 checkout していたのを GitHub Content API に置き換え
REST API endpoints for repository contents - GitHub Docs
GitHub CLI を使えば簡単にファイル・ディレクトリの存在チェックを行えます。
gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ /repos/OWNER/REPO/contents/PATH?ref=main
無駄な処理の削除
無駄に actions/setup-node で Node.js をセットアップしていたりしたので削除しました。それだけで 10 数秒の短縮になりました。 それ以外にも色々無駄な処理をなくしたりしました。
terrform plan が no change のとき infracost を skip
terraform plan の結果を infracost に渡して結果を PR にコメントしていますが、これに結構時間がかかっています。 Infracost はリソースの操作によってどの程度金銭的コストが発生ないし削減されるかを出力してくれるツールです。 これが terraform plan の結果にかかわらず実行されていましたが、 terraform plan の結果が No Change であれば当然コストの変化もないはずで、時間をかけて infracost を実行する意味はありません。 そこで terraform plan の結果が No change のときは infracost を skip して高速化しました。
成果
様々な改善を行った結果、 CI を 2 分以上高速化することが出来ました。 以下の図は terraform plan を実行する workflow の実行時間を日ごとに 70 percentile でプロットしたものです。
縦軸が実行時間で横軸が日付です。 休日は workflow が実行されていないのでデータが欠落していますが、全体として右下がり、つまり実行時間が短縮されているのがわかるかと思います。 なお、 terraform plan の実行時間は当然対象ディレクトリで管理されているリソースに依存するため、多くのリソースを管理しているディレクトリが修正されればその分実行時間は伸びます。そのため、グラフがある程度乱高下するのは仕方がありません。
同僚からは「CI が爆速になって助かっている」とポジティブなコメントを多くいただました。 加えて CIAnalyzer によってダッシュボードを構築し、継続的に CI の実行時間を可視化することが出来ました。 今回は Terraform Monorepo に CIAnalyzer を導入しましたが、非常に便利だったため他のリポジトリへの展開も検討しています。
さいごに
今回 CI の高速化に関連して一定の成果を上げることが出来ましたが、 CI の改善のためにやることはまだまだあります。 現在進めている施策もあるのでいずれまたブログなどで紹介できればと思います。