GeekFactory

int128.hatenablog.com

2023年のお仕事まとめ

2023年のお仕事をまとめてみた。

複雑化するデータパイプラインへの対応

クロスアカウントのリストア

現職では、開発、テスト、カスタマーサポート、データ分析などに使うデータベースを日次もしくは不定期で本番環境から同期する仕組みを整備している。今年は検証環境を独立した AWS アカウントに移行する作業を進めていた。データパイプラインもクロスアカウントに対応した。

本番環境のデータベースには個人情報が含まれるものがあるため、本番環境の AWS アカウント内で個人情報のマスク処理を完結させる必要がある。そのため、本番環境の AWS アカウントでマスク済みの EBS スナップショットや RDS スナップショットを作成し、検証環境の AWS アカウントに共有する構成にしている。以下のはまりどころがあった。

  • スナップショットの内容は共有されるが、スナップショットのタグは共有されない
  • AWS Managed Key で暗号化されているスナップショットは共有できない。代わりに CMK (Customer Managed Key) で暗号化し、別のアカウントから CMK へのアクセスを許可する必要がある
  • EC2 API や RDS API でスナップショットを検索する場合は共有されたものを含めるオプションが必要になる

本番環境の AWS アカウントでスナップショットを作成したら、検証環境の AWS アカウントでスナップショットをリストアする。スナップショットの作成やリストアはもともと Step Functions と ECS Fargate Task で実装しているため、AWS アカウントごとに Step Functions と ECS Fargate Task を配置し、EventBridge を経由して Step Functions が連携する構成にした。以下のはまりどころがあった。

  • EventBridge を経由してデータを送ることも可能だが、キーの間違いや再実行時の考慮不足などで Step Functions の実行時エラーが多発するおそれがある。Step Functions のエラー詳細は Sentry や Datadog などで捕捉できず、AWS コンソールから調査する必要がある。運用負荷を下げるため、基本的にタスクを冪等にして、入出力の受け渡しのないシンプルな設計にしている。
  • クロスアカウントでイベントをやり取りする場合は Event Rule や IAM の設定が難しい。あらかじめ練習用の AWS アカウントで試行錯誤してから Terraform を書いた

2023年の春ごろにクロスアカウントのパイプラインが完成した。当初はマイクロサービスのデータベースごとに大量の Step Functions 定義をコピペしてしまったので、後から Terraform module でリファクタリングした。マイクロサービスのオーナーチームは Terraform module の設定値を埋めるだけでよくなったので、認知負荷もだいぶ下がったと思う。

同時に実行できない制約の解消

現職では基本的に Aurora を利用しているが、歴史的経緯で EC2 self-hosted MongoDB も利用している。MongoDB では EBS スナップショットを経由してデータベースをリストアしている。開発やカスタマーサポートなどの環境によってマスク処理が異なるため、複数のスナップショットが必要になる。

これまで1台の EC2 で EBS スナップショットを作成していたため、同時に複数のスナップショットを作成できない制約があった。Step Functions を直列実行することで対応していたが、問題が起きた場合の手動運用が複雑になる課題があった。また、バージョンアップなどで異なるバージョンの MongoDB サーバが混在する状況に対応できない課題もあった。

そのため、一時的な使い捨ての EC2 を起動してスナップショットを作成する実装に改善した。

  • スナップショット作成処理はもともと ECS Fargate Task で実行しているが、Fargate では EBS スナップショットを扱えない。そこで、ECS Fargate Task から一時的な EC2 を起動し、Systems Manager を利用してコマンドを実行する構成にしている
  • 独自の AMI を用意するとメンテナンスの負荷が発生する。そこで、AWS が提供している AMI に Docker をインストールし、mongo コンテナを実行することで、メンテナンスフリーにしている
  • Systems Manager や Docker で複雑なシェルスクリプトを流すと実行時エラーが起きやすい。なるべく ECS Fargate Task 側の Go で複雑な処理を表現するようにしている

これにより、スナップショット作成の Step Functions を同時に実行できるようになり、実行を直列化したり、実行時刻をずらすといった変なハックが不要になった。しかしながら、CloudWatch Logs のログが ECS Fargate Task、 Systems Manager、EC2 上のコンテナで分散してしまうため、問題が起きた場合の調査がつらい課題を抱えている。Datadog APM で一連のトレースを可視化して調査しやすいように工夫しているが、今後の改善としたい。

オーナーシップの明確化による運用負荷の改善

現職ではデータ基盤に Airflow (Cloud Composer) が採用されており、Data Engineer がオーナーシップを持っている。データパイプラインで問題が起きるとデータ分析に影響が出てしまうため、Data Engineer だけで速やかに復旧できることが望ましい。しかし、AWS 上のデータベースについては私が所属する SRE がオーナーシップを持っているので、問題が起きた場合のやり取りが複雑になる傾向があった。

そこで、以下の構成に移行することで運用負荷の改善を図った。

  • データ基盤用のデータベースをリストア/エクスポートする Step Functions を用意し、Airflow から Step Functions を呼び出す構成にする
  • Airflow の構成は Data Engineer がオーナーシップを持つ。問題が起きた場合の判断や再実行は Data Engineer で完結できるようにしている
  • データベースをリストアする実装は SRE がオーナーシップを持つ。アラートの調査や継続的なバージョンアップなどの運用を担う

このようにオーナーシップを明確にすることで、問題が起きた場合に Slack 上で速やかに復旧対応を進めやすくなっている。まだ移行できていないデータベースもあるので、引き続き改善を進めたい。

CI/CD パイプラインの改善

actions-runner-controller の移行

2023年5月ごろから新しい actions-runner-controller が Public Beta になった。新しい actions-runner-controller に移行することで、以下のメリットがあった。

  • オートスケーリングが Pull 方式に変わるため、GitHub Actions のジョブが急激に増えた場合でも安定してスケールアウトできるようになった。現職では大規模な monorepo を運用しているので、開発体験が大きく改善された
  • オートスケーリングのために GitHub から Webhook を受け取る必要がなくなった

しかしながら、公式で用意されている actions/runner イメージには以下の課題があった。

  • インストールされているパッケージが最小限になっている。git や build-essentials すら入っていない
  • Runner と Docker daemon が別のコンテナに分かれている。例えば、テストジョブでは Runner コンテナのリソースが大量に消費されるが、ビルドジョブで Docker daemon コンテナのリソースが大量に消費される、といった不均衡が生じてしまう。Pod のリソース割り当てを最適化するには両者を同じコンテナで実行することが望ましい
  • 当初は arm64 のイメージが提供されていなかった(後に amd64, arm64 の両対応になった)
  • 当初はベースイメージが Debian であった。GitHub-hosted runners は Ubuntu なので環境差異が発生する(後にベースイメージが Ubuntu になった)

現在は独自の runner イメージを用意している。詳細は https://github.com/quipper/actions-runner を参照されたい。

Reusable workflows によるリファクタリング

今年になって Reusable workflows の制限が大きく緩和されたため、抽象化されたワークフローを再利用する構成を採用しやすくなった。現職では、以下のようなユースケースリファクタリングを進めている。

  • コンテナイメージのビルド
  • コンテナイメージの multi-architectures ビルド
  • 言語ごとの共通的な検査(golangci-lint, eslint, standardrb など)
  • マイクロサービスのデプロイ
  • Kubernetes クラスタでのジョブの実行

Reusable workflows には認知負荷やメンテナンスを減らせるだけでなくガバナンスを効かせる効果もある。GitHub Actions から AWS にアクセスしている場合、特定の Reusable workflow を経由している場合だけ IAM で許可することも可能だ。デプロイパイプラインのセキュリティ改善を今後進めていきたい。

Buildx への移行

現職ではコンテナイメージのビルドに Kaniko を採用していた。Buildx は特殊なキャッシュマニフェストを採用しているため ECR にキャッシュを保存できないが、Kaniko は通常のイメージなので ECR にキャッシュを保存できる。multi-stage build で効率的にキャッシュを活用できるため、ビルド時間の短縮に大きく貢献していた。

github.com

今年の夏ごろに ECR が Buildx のキャッシュに対応したため、Kaniko から Buildx への移行を検討することにした。Buildx への移行で、キャッシュが効いている場合の所要時間が大きく短縮された。しかし、ECR リポジトリの構成を以下のように変えた影響の可能性もあるので、要因はよく分かっていない。

  • 移行前:イメージと Kaniko キャッシュを別の ECR リポジトリで管理する
  • 移行後:イメージと Buildx キャッシュを同じ ECR リポジトリで管理する

開発体験の改善

Google Cloud Pub/Sub の Kubernetes operator

現職では、一部のマイクロサービスで非同期のイベント連携に Google Cloud Pub/Sub を採用している。Pub/Sub の Topic や Subscription は基本的に Terraform で管理している。Pull Request ごとにデプロイされるプレビュー環境では同じ Topic や Subscription を共有するため、イベント連携がうまく動かない課題がある。ある Pull Request 環境でイベントを流しても他の Pull Request 環境に吸い取られてしまうためだ。

そこで、Pull Request 環境ごとに独立した Topic や Subscription を用意できるように Kubernetes operator を提供している。私は Kubebuilder の開発経験があったが、プロダクトチームで仕事をしていないので開発体験の課題には詳しくない。そこで、プロダクトチームの経験が豊富でかつ Go のエキスパートである同僚と協力して、Kubernetes operator の開発に取り組むことにした。実際にプロダクト開発で Operator を利用してみると以下の課題が見つかった。

  • マイクロサービスのデプロイと Topic や Subscription の作成は並行で実行される。マイクロサービスの起動時に Topic が存在しない場合はエラーが出てしまう。Crash loop backoff で最終的には起動するが、エラーの通知が荒れてしまう
  • Topic と Subscription は独立した Kubernetes リソースで定義しているので、それぞれ並行で作成される。Pub/Sub の仕様では Subscription の作成時に Topic がないとエラーになる。Operator の reconciliation loop で最終的には作成に成功するが、Kubernetes events が荒れてしまう。一定時間が経過してもエラーになった場合だけ Kubernetes events に通知することで、アラートハンドリングを改善した

また、Kubernetes や Kubebuilder のバージョンアップで今後も定常的にメンテナンスは必要になる。引き続き mob programming を続けていきたい。

指標やフィードバックに基づく改善

デプロイパイプラインの運用では以下のメトリクスを継続的に見ている。

  • GitHub Actions のジョブキュー数
  • actions-runner-controller のジョブ起動待ち時間(ただし、v0.8.1 から取れなくなっている)
  • Self-hosted runners ノードの OOM 発生率
  • Self-hosted runners ノードの EC2 月間予測コスト
  • ワークフローやジョブごとの所要時間リスト
  • ワークフローの実行契機(特に Renovate, Dependabot など)

また、本番環境へのデプロイ時に Google Forms でフィードバックを集める取り組みも実験的に進めている。来年はフィードバックを広く集めて、デプロイパイプラインの開発に反映するプロセスを考えていきたい。

昨年の記事はこちら。

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 が現れることを確認します。