GeekFactory

int128.hatenablog.com

GitHub Actions のコスト戦略

TLDR

  • 開発体験が良くなると CI のコストも減る
  • 不必要なジョブ実行を減らし、割れ窓を直すことから始めると良い
  • Self-hosted runners ではクラウドコスト最適化の一般的なプラクティスも併用する

GitHub Actions のコスト構造

  • GitHub-hosted runners
    • GitHub が提供するインフラを利用する。一般的なクラウドより高めの料金設定になっている
    • 1分単位で課金される。ジョブの実行時間が数秒間でも1分間で課金されるので注意
    • Public repository は無料、Private repository は従量課金になっている
    • Organization 内で利用料金が合算されて翌月請求される。Organization Owner なら請求レポート (CSV) をダウンロードできる
  • Self-hosted runners
    • GitHub では課金されない
    • クラウドを利用している場合はインスタンスの実行コストとして現れる
    • Kubernetes を利用している場合は1ノードで多数のジョブが実行される
    • 自前のハードウェアを利用することも可能だが CI の信頼性に影響する。例えば mac mini などで安価に macOS runner を構築できるが、安定運用に必要な労力は大きい

CI ジョブの特徴

  • リソース使用量のバラツキが大きい
    • CI ではビルドやテストなどの多様な処理が実行されるため、ジョブの CPU 時間やメモリ使用量は大きく変化する。例えば、依存関係のダウンロード中はほとんど CPU を消費しないが、その後のコンパイルでは多数のプロセスが起動して CPU やメモリが激しく消費される
    • 一般的なモニタリングツールは数秒間隔でリソース使用量を取得しているため、CPU やメモリのスパイクを正確に計測するのは難しい
    • ソースコードや依存関係の変化とともに、リソースの使用量も変化していく。気がついたら OOM kill による失敗が増えている場合もある
  • 所要時間のバラツキが大きい
    • CI では多様な処理が実行されるため、ジョブの所要時間は均一にならない。例えば、数秒で終わるジョブと数十分かかるジョブが同一ノードに配置される場合もある
  • 待ち行列の増減が激しい
    • Pull Request の作成やマージなどのアクションを契機に多数のジョブが実行される
    • 一方で、夜間や休日などほとんどジョブが実行されない時間帯もある
  • 成功するまで再実行される
    • 一般的なブランチ戦略では CI が品質ゲートとなっているので、CI が成功するまで Pull Request をマージできない
    • Flaky な CI は開発体験を阻害し、コストを浪費する

Self-hosted runners のコスト戦略

ここでは Kubernetes (AWS EKS / EC2) で actions-runner-controller を運用している前提を考える。他のパブリッククラウドでも同様の考え方が使えると思う。

AWS のコスト構造は以下のようになる。

ネットワークコストは EC2 インスタンスを Public subnet に配置することで大幅に削減できる。そのため、支配的な EC2 インスタンスの実行コストについて考えていく。

コストの計測

同一インスタンスに多数なジョブが配置されて頻繁に入れ替わるため、ジョブ単位の厳密なコスト計算は難しい。

  • インスタンスの開始から終了までの実行コストを、ジョブの所要時間やリソース割り当てで按分する
    • 例えば、インスタンスにジョブ A(メモリ 8GiB、3分)とジョブ B(メモリ 4GiB、5分)が載っていた場合、コストを A : B = 24 : 20 で按分できる
    • しかし、ジョブ A が終了してもジョブ B が終了するまではインスタンスを終了できないので、ジョブ B がコストを浪費していたという考え方もある。両方が動いていた3分間は 8 : 4 の割合で按分し、ジョブ B だけ動いていた残り2分間は100%と考えると、A : B = (8×3) : (4×3+12×2) = 24 : 36 となる
    • 現実的には計測や集計が難しい
  • インスタンスの実行コストを、各ジョブの所要時間で按分する

コストの最適化

EC2 インスタンスの実行コストは以下の契機に依存する。

コストを削減するには以下のアプローチがある。

  • ジョブの不必要な実行を減らす
    • 最も簡単にできる
    • ワークフローの paths や branches を見直す
    • Dependabot や Renovate などの Bot は大量のジョブが実行される原因になりうる
    • ワークフローはコピペで増えていくので、割れ窓を直して conftest で違反を防ぐと、長期的に効いてくる
  • Flaky なジョブの再実行を減らす
    • 一般的なブランチ戦略では CI が失敗すると Pull Request をマージできない。成功するまでジョブを再実行する必要があるので、Flaky な CI はコストを浪費する
    • テストケースの失敗率をモニタリングし、継続的にテストを改善する
    • OOM もジョブが失敗する原因になるので、OOM をモニタリングし、継続的にリソース割り当てを改善する
  • ジョブの所要時間を短くする
    • テストケースの所要時間をモニタリングし、継続的にテストを改善する
    • キャッシュが有効に活用されるように見直す
  • ジョブに割り当てるリソースを減らす
    • リソースを削減しすぎると所要時間が伸びる、OOM による再実行が増える、といった副作用がある
    • 前述のようにモニタリングツールでリソース使用量のスパイクを捕捉することは難しい
    • OOM 失敗率をモニタリングしながらメモリサイズを減らしていく
  • インスタンスサイズや集積率の最適化
    • クラウドのコスト最適化戦略と同じ
    • 前述のようにジョブのリソース利用量には大きなバラツキがあるので、大きめのインスタンスにジョブを配置してオーバーコミットする方が効率的になる
    • 集積率が上がるとノードレベルの OOM が発生したり、ディスク I/O がボトルネックになる可能性もある。ジョブが Flaky になるので、かえってコストが高くつく可能性もある

Renovate で organization private repository を参照する

Renovate の GitHub Releases Datasource で Organization 内の Private repository 間の参照を試したところ、普通にちゃんと動きました。ある Private repository で新しいバージョンをリリースした契機で、別の Private repository にあるマニフェストを自動的に更新するといった使い方ができそうです。

試したこと

以下の Private repository があるとします。

  • example-org/repo-a
  • example-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-aDependency Dashboardv1.0.0 が認識されていることを確認します。

新規リリースの作成

example-org/repo-b で新しいリリース v1.1.0 を作成します。内容はなんでも構いません。

example-org/repo-aDependency 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) で以下を実装している。

  • クラスタやデータベースの pending maintenance をすべて適用する
  • クラスタのメジャーバージョンを上げる
  • データベースに対して ANALYZE を実行する

これでだいぶ体験が良くなった。頻度の低い仕事とはいえ、すべてマイクロサービスで必要になる作業なので今後も改善していきたい。

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年はチーム外の人たちと一緒に取り組む仕事が増えた。例えば、

といったものがある。 仕組みを改善することで開発組織全体に役立つチャンスはまだまだあるので、2023年も引き続きコラボしていきたい。