GitHub Actions のコスト戦略
TLDR
- 開発体験が良くなると CI のコストも減る
- 不必要なジョブ実行を減らし、割れ窓を直すことから始めると良い
- Self-hosted runners ではクラウドコスト最適化の一般的なプラクティスも併用する
GitHub Actions のコスト構造
- GitHub-hosted runners
- Self-hosted runners
CI ジョブの特徴
- リソース使用量のバラツキが大きい
- 所要時間のバラツキが大きい
- CI では多様な処理が実行されるため、ジョブの所要時間は均一にならない。例えば、数秒で終わるジョブと数十分かかるジョブが同一ノードに配置される場合もある
- 待ち行列の増減が激しい
- Pull Request の作成やマージなどのアクションを契機に多数のジョブが実行される
- 一方で、夜間や休日などほとんどジョブが実行されない時間帯もある
- 成功するまで再実行される
- 一般的なブランチ戦略では CI が品質ゲートとなっているので、CI が成功するまで Pull Request をマージできない
- Flaky な CI は開発体験を阻害し、コストを浪費する
Self-hosted runners のコスト戦略
ここでは Kubernetes (AWS EKS / EC2) で actions-runner-controller を運用している前提を考える。他のパブリッククラウドでも同様の考え方が使えると思う。
AWS のコスト構造は以下のようになる。
- 固定費
- Kubernetes クラスタやその運用に必要なコンポーネントの実行コスト
- 変動費
- EC2 インスタンスの実行コスト
- ネットワークコスト
ネットワークコストは EC2 インスタンスを Public subnet に配置することで大幅に削減できる。そのため、支配的な EC2 インスタンスの実行コストについて考えていく。
コストの計測
同一インスタンスに多数なジョブが配置されて頻繁に入れ替わるため、ジョブ単位の厳密なコスト計算は難しい。
- インスタンスの開始から終了までの実行コストを、ジョブの所要時間やリソース割り当てで按分する
- 全インスタンスの実行コストを、各ジョブの所要時間で按分する
- (対象ジョブの所要時間 / 全ジョブの所要時間) × 全インスタンスの実行コスト
- 全インスタンスの実行コストは AWS Budget で簡単に計測できる
- ジョブの所要時間は https://github.com/int128/datadog-actions-metrics で計測できる
コストの最適化
EC2 インスタンスの実行コストは以下の契機に依存する。
コストを削減するには以下のアプローチがある。
- ジョブの不必要な実行を減らす
- 最も簡単にできる
- ワークフローの paths や branches を見直す
- Dependabot や Renovate などの Bot は大量のジョブが実行される原因になりうる
- ワークフローはコピペで増えていくので、割れ窓を直して conftest で違反を防ぐと、長期的に効いてくる
- Flaky なジョブの再実行を減らす
- 一般的なブランチ戦略では CI が失敗すると Pull Request をマージできない。成功するまでジョブを再実行する必要があるので、Flaky な CI はコストを浪費する
- テストケースの失敗率をモニタリングし、継続的にテストを改善する
- OOM もジョブが失敗する原因になるので、OOM をモニタリングし、継続的にリソース割り当てを改善する
- ジョブの所要時間を短くする
- テストケースの所要時間をモニタリングし、継続的にテストを改善する
- キャッシュが有効に活用されるように見直す
- ジョブに割り当てるリソースを減らす
- リソースを削減しすぎると所要時間が伸びる、OOM による再実行が増える、といった副作用がある
- 前述のようにモニタリングツールでリソース使用量のスパイクを捕捉することは難しい
- OOM 失敗率をモニタリングしながらメモリサイズを減らしていく
- インスタンスサイズや集積率の最適化
Renovate で organization private repository を参照する
Renovate の GitHub Releases Datasource で Organization 内の Private repository 間の参照を試したところ、普通にちゃんと動きました。ある Private repository で新しいバージョンをリリースした契機で、別の Private repository にあるマニフェストを自動的に更新するといった使い方ができそうです。
試したこと
以下の Private repository があるとします。
example-org/repo-aexample-org/repo-b
どちらにも Renovate App をインストールしておきます。
既存リリースの作成
example-org/repo-b で新しいリリース v1.0.0 を作成しておきます。内容はなんでも構いません。
バージョン参照と Renovate config の作成
example-org/repo-a の README に適当な文字列を埋め込んでおきます。
repo-b-version: v1.0.0
example-org/repo-a で以下の Renovate config を作成します。
{ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "regexManagers": [ { "description": "Try another private repository in an organization", "fileMatch": ["^README.md$"], "matchStrings": ["repo-b-version: (?<currentValue>.+?)\\n"], "depNameTemplate": "example-org/repo-b", "datasourceTemplate": "github-releases" } ] }
example-org/repo-a の Dependency Dashboard で v1.0.0 が認識されていることを確認します。
新規リリースの作成
example-org/repo-b で新しいリリース v1.1.0 を作成します。内容はなんでも構いません。
example-org/repo-a の Dependency Dashboard でバージョンを v1.1.0 に上げる Pull Request が現れることを確認します。
2022年のお仕事まとめ
今年やっていた仕事をまとめてみた。
デプロイパイプラインの再構築
現職では、Pull Request を作るとマイクロサービス一式のプレビュー環境が Kubernetes にデプロイされるようになっている。メインブランチにマージせずに動作確認やレビューができるため、漸進的に開発していく上で重要な仕組みになっている。
歴史的経緯から以下の課題を抱えていた。
この仕組みを以下の技術スタックで再構築した。
- GitHub Actions (TypeScript)
- Kaniko
- Argo CD
現職では大規模な monorepo を運用しているため、GitHub Actions の制約で苦労している。例えば、GitHub Actions では paths filter がサポートされているが、Pull Request の差分が300ファイルを超える場合は最初の300ファイルしか評価されない問題がある。
開発体験にはまだまだ課題がたくさんあるため、2023年も引き続き改善に取り組んでいきたい。
データパイプラインの再構築
現職では、開発、テスト、カスタマーサポート、データ分析に使うデータベースが本番環境から日次同期されるようになっている。これは本番に近いデータを使って開発する上で重要な仕組みで、みんなが同じものを見るという文化の形成にも役立っている。日々の生活では空気のような存在になっているが、以下の課題を抱えていた。
- 日々の開発やデータ分析を支える基盤であるにも関わらず動作が不安定
- いつどのスナップショットが開発環境に入るべきといった仕様の一貫性がなく、明文化もされていなかった
- 乱開発されたスクリプト
長い歴史を抱える仕組みではよくある話であろう。
この仕組みを以下の技術スタックで再構築した。
- AWS Step Functions, EventBridge, ECS Fargate Task
- Aurora S3 export
- Go (aws-sdk-go-v2)
- Sentry, Datadog
EventBridge は大きな可能性を秘めた仕組みであることを実感した。複数の AWS アカウントをまたいでイベントを連携したり、イベントを Slack などの HTTPS API に送ったり、といった多様な使い方を学べて面白かった。
2022年で概ね移行が完了したが、データ基盤まわりはまだやり残したことがあるので、2023年も引き続き取り組んでいく。
Aurora major version upgrade の改善
現職では主に Amazon Aurora (PostgreSQL) データベースを採用しており、マイクロサービスのオーナーが各データベースを管理している。定期的に新しいメジャーバージョンに対応する必要がある。マイナーバージョンに比べてメジャーバージョンは注意点が多いため、開発者の負担も大きくなる傾向がある。
- 互換性のない変更が含まれる可能性がある
- ある程度のダウンタイムがある(サービスの停止が必要)
- 手順が複雑でややこしい
これまで Terraform でメジャーバージョンを上げていたが、以下の課題があった。
- クラスタやデータベースに pending maintenance(保留中のメンテナンス)がある場合は terraform apply が失敗する
- Terraform のパラメータを正しく書かないと terraform apply が失敗する。terraform plan では気づかない
- 例えば、現行バージョンと新しいバージョンの parameter group を両方指定する必要があるが、直感的には分かりにくい
- terraform apply が失敗した後の復旧手順が複雑
開発者の負担を軽減するため、Aurora API を呼び出してメジャーバージョンを上げる仕組みを用意した。具体的には、Step Functions, ECS Fargate Task, Go (aws-sdk-go-v2) で以下を実装している。
これでだいぶ体験が良くなった。頻度の低い仕事とはいえ、すべてマイクロサービスで必要になる作業なので今後も改善していきたい。
GitHub Actions self-hosted runner の改善
現職では大規模な monorepo で self-hosted runner を運用している。マイクロサービス(もはやマイクロとは呼べないアプリケーションもある)によっては Pull Request を作成した契機で数十件のジョブが並列実行されることもあるため、大量のジョブをいかに早く安く安定して捌いていくことが重要になってくる。
2021年に actions-runner-controller を導入したが、2022年は主に以下の取り組みを行っていた。
- Karpenter によるジョブ起動待ちの改善
- EC2 Instance store による安定性やパフォーマンスの改善
- Horizontal Runner Autoscaler のチューニング
- EC2 や Datadog コストの最適化
- 安定性やコストのモニタリング
いずれもチームの同僚による貢献が大きい。私は最後のモニタリングを中心に取り組んでいた。
2022年に取り組んでいた安定性の問題としては以下が挙げられる。
- actions-runner-controller や actions/runner イメージのバージョンアップによる障害
- ジョブのメモリ使用量が増えたことによる Container OOM
- ノードの集積率が上がることによる Node OOM
- EC2 spot interruption によるジョブの終了
- GitHub Actions(コントロールプレーン)の障害
2023年はチームで SLO を見ながら継続的に開発体験とコストを最適化していく仕組みを模索していきたい。
開発体験を改善していくコラボレーション
2022年はチーム外の人たちと一緒に取り組む仕事が増えた。例えば、
- プレビュー環境用に Google Cloud Pub/Sub をプロビジョニングしてくれる Kubernetes Operator を Web Developer と一緒に書く
- Kubernetes と Visual Studio Code Remote Development による開発環境を Web Developer と一緒に作る
- データパイプラインを Data Engineer と一緒に改善する
といったものがある。 仕組みを改善することで開発組織全体に役立つチャンスはまだまだあるので、2023年も引き続きコラボしていきたい。