はじめに
CyberAgent の 27卒のサーバーサイドエンジニア内定者として内定者バイトをしていた 三村雄斗 と申します。6月と7月の2ヶ月間、 ABEMA の SRE チームで働かせてもらいました。ここでは、期間中に取り組んだ、DarkCanary の改善に向けたタスクについて紹介します。
背景
ABEMA ではバックエンドのサービスはマイクロサービスを採用しており、 Kubernetes 上で運用されています。これらは基本的にトランクベース開発であるため、以下のような要求が発生します。
- main ブランチにマージする前に動作確認をしたい
- 本番のデータを使用した検証を行いたい
- 他マイクロサービスとのデグレが発生しないか確認したい
このような要望に対して、 ABEMA では、 本番環境にユーザーリクエストの通らない Canary 環境である、 DarkCanary を構築しています。 DarkCanary を使用することで本番データを利用してユーザーに影響を出さずに動作確認ができるようになっています。こちらのツールは SRE チームが管理しており、以下のような課題がありました。
- DarkCanary が起動しているワークロードのコスト負担が大きく、コスト最適化を行う必要がある
- DarkCanary の仕組みにおいてマルチクラスタ構成に対応する必要がある
今回は上記の二つの課題に対してどのようにアプローチしたのかを紹介します。
DarkCanary について
DarkCanary は、検証環境向けのサービスで、独自に実装している Kubernetes Operator である shadow-service-operator によって管理されています。shadow-service-operator は、以下の2つのコンポーネントから構成されています。
Webhook サーバー: GitHub からの Webhook を受け付ける HTTP サーバーです。 GitHub の Pull Request からのコメントをトリガーにして ShadowService カスタムリソースを作成します。
Operator サーバー: Kubebuilder を利用して作成された Kubernetes Operator です。Reconcile 処理の中でクラスタ内のリソースを読み取り、 ShadowService カスタムリソースが要求する状態になるようにリソースの作成、削除、更新などを行います。
DarkCanary の詳しい紹介に関してはこちらの記事をご覧ください。
既存の DarkCanary の構成
shadow-service-operator は Prd / Dev 環境のそれぞれの Kubernetes クラスタ内に配置されています。 Webhook サーバーからリクエストされたカスタムリソースの情報に従って Kubernetes リソースを作成、削除、更新します。shadow-service-operator から作成される DarkCanary についても同クラスタに配置されます。この一連の操作は Ingress も例外ではありません。実際の Prd / Dev 環境の Ingress の情報を取得して、 DarkCanary 向けの Ingress を作成するという作業をしていました。

コスト最適化について
まず、コスト最適化について紹介します。
Kubernetes のコストを削減するための代表的な方法の一つに Pod の requests / limits を適切に設定することがあげられます。
従来の shadow-service-operator では DarkCanary 用の Pod を作成する際に、Prd / Dev 環境の requests / limits をそのままコピーしていました。そのため、 DarkCanary の用途に対して過剰なリソースを確保してしまっており、不要な Kubernetes リソースを消費してしまっていました。
ABEMA のマイクロサービスでは、 CPU 負荷が高い処理を行うサービスや、インメモリで大量のデータを保持しているサービスなど、サービスの特性に合わせてリソースがチューニングされています。これらの数値は本番環境では妥当ですが、動作確認を目的とする DarkCanary では同等のスペックは必要ありません。
そこで、今回は requests に着目してリソース使用量を削減しました。
requests は Pod を Node にスケジューリングする際に、必要なリソース量として考慮される値です。Kubernetes Scheduler は requests をもとに Pod を配置可能な Node を判断するため、この値を削減することで Node により多くの Pod を配置できるようになります。その結果、不要な Node の追加を抑え、クラスタ全体のリソースコストを削減できます。
ただし、一律で requests を小さくすると必要なリソースを確保できずに Pod が起動できなくなる可能性があります。そのため、 max( 本番 * 比率, 下限値) という計算式を採用しました。
これにより本番環境の設定を一定割合までに縮小しつつ、サービス毎に必要な最低限のリソースを確保することができます。結果として Pod の起動に必要なリソースを維持したまま、 DarkCanary が起動しているワークロードのコストを抑えることができました。
また、コスト削減の一環として DarkCanary の自動削除も実装しました。
DarkCanary は開発者が一時的に動作確認を行うための環境であり、多くの場合、数日間にわたって起動し続ける必要はありません。しかし、従来の実装では一度作成した DarkCanary は明示的に削除されるまでリソースを消費し続けていました。
そこで一定期間経過後に自動で削除されることをデフォルトの挙動として、長期間利用したい場合のみ明示的に自動削除の対象外にする仕組みへ変更しました。これは GitHub の Pull Request のコメントで --no-expire というフラグを追加するだけで永続化されるような実装をしています。
この変更により、不要になった DarkCanary が放置されることを防ぎ、継続的なリソース消費を抑えられるようになりました。

従来は DarkCanary 環境を削除するためにはエンジニアが手動で cleanup コメントをする必要がありましたが、自動的にリソースが削除されるようになりました。
以下は、 GKE のコストを確認するために利用しているダッシュボードの画像です。今回の改善により、Prd 環境でのコストを削減前と比べて、半分程度まで削減させることができました。また、不要になった DarkCanary が継続的に削除されるようになったため、一時的な削減にとどまらず、継続してコストを抑えられる状態を実現できました。

マルチクラスタ構成対応について
ABEMA では以前からマルチクラスタ構成への移行が進んでいます。それに伴い、 DarkCanary もマルチクラスタ対応の構成に移行する必要がありました。最終的なマルチクラスタの構成は以下のようになります。

マルチクラスタに移行するために、 Gateway API を操作するクラスタ (config-cluster) を新たに作成しています。config-cluster 上の Gateway API リソース をもとに Cloud Load Balancing が作成されます。アプリケーションのユーザーが ABEMA のバックエンドにリクエストをした場合には、ロードバランサが cluster-1 と cluster-2 の両方をバックエンドとしてトラフィックを分散します。
一方、DarkCanary を作成する Webhook サーバーと Operator サーバー 、およびそれらが作成するリソースは従来通り abema-cluster-1 に配置されるようになります。これは開発者向けのツールであり、アプリケーションの実行基盤とは異なるため必要以上にマルチクラスタ化しない方針としました。
この構成に移行しながら、 DarkCanary を引き続き利用するために、 config-cluster に配置されている Gateway API に関連する情報、特に HTTPRoute に関する path などの情報が必要になります。つまり、クラスタを跨いだリソースの取得を行わなければならなくなりました。
実現手法
DarkCanary のリクエストを捌く仕組みとして、ヘッダーベースルーティングを採用しています。 Ingress より内側のマイクロサービス間の通信では Istio を利用しています。Istio の VirtualService を用いてヘッダーベースルーティングが実現されていました。そこで、今回導入する Gateway API においても同様にヘッダーベースでバックエンドのサービスを決定することで実現します。
Gateway API の HTTPRoute リソースでは、リクエストパスやヘッダーを matches で条件指定し、その条件に対応するバックエンドを backendRefs で指定できます。今回の実装では、 hostnames と parentRefs を固定値としているため、 config-cluster に登録されている path と、それに紐づいている backendRefs に指定された Service 名と ポート番号が取得できれば Operator サーバーで HTTPRoute のリソースを生成できます。
今回、私のタスクとしては
- config-cluster からの HTTPRoute の情報を取得
- Webhook サーバーから受け取ったリクエストをもとに HTTPRoute の rules を作成
- Webhook サーバーから受け取ったリクエストをもとに HTTPRoute の rules を削除
上記の、HTTPRoute の中の rules に関する一連のライフサイクルを実装することでした。
なお、現在はバックエンド全体が移行時期ということもあり、今まで動作していた Ingress は動かしつつ、 HTTPRoute も立ち上げるという方針で作業を進めました。
config-cluster からの情報の取得について
ここでは特に設計上の判断が必要だった config-cluster からの HTTPRoute の情報の取得について紹介します。
config-cluster からの情報の取得に関して、いくつかの方法が考えられました。人間の手で修正するのではなく、機械的に運用できることを前提にしながら実装方法を模索しました。
- config-cluster のマニフェストが更新されたタイミングで Operator サーバーが参照する ConfigMap の path 情報を GitHub Actions 経由で更新する
- RBAC と認証情報を用いて、 config-cluster の情報を直接読み取り、そこからリソースを組み立てる
- Argo CD API の読み取り権限を付与して、Argo CD が管理しているマニフェストから取得する
などが挙げられました。
最終的には、path とそれに対応する backendRefs の情報を、 config-cluster への読み取り権限のみで取得できることから、2番目の案を採用することになりました。
実装方法
Operator サーバーの Pod に紐づく KSA が Workload Identity を介して GSA として認証され、 GKE API から config-cluster のエンドポイントを取得して HTTPRoute を読み取ります。認可は config-cluster の RBAC で HTTPRoute の読み取り権限のみに制限できる点が利点としてあげられます。
func NewRESTConfig(ctx context.Context, project, location, name string) (*rest.Config, error) {
// Workload Identity 経由で GSA のトークンソースを取得
ts, _ := google.DefaultTokenSource(ctx, container.CloudPlatformScope)
svc, _ := container.NewService(ctx, option.WithTokenSource(ts))
// GKE API からクラスタの endpoint と CA 証明書を取得
// GSA には roles/container.clusterViewer のみ付与)
clusterPath := fmt.Sprintf("projects/%s/locations/%s/clusters/%s", project, location, name)
cluster, _ := svc.Projects.Locations.Clusters.Get(clusterPath).Context(ctx).Do()
ca, _ := base64.StdEncoding.DecodeString(cluster.MasterAuth.ClusterCaCertificate)
return &rest.Config{
Host: "https://" + cluster.Endpoint,
TLSClientConfig: rest.TLSClientConfig{CAData: ca},
WrapTransport: transport.TokenSourceWrapTransport(ts),
QPS: defaultQPS,
Burst: defaultBurst,
}, nil
}
紹介の都合上、エラーハンドリングは省略しています。
この rest.Config から呼び出し元で controller-runtime パッケージの client.New() を使用して実際のクライアントを作成することで接続を実現できます。
この実装で、 API サーバーには、 Workload Identity により GSA の認証情報を付与したリクエストが送信されるので、 config-cluster 側で RBAC で認可を制御できるようになります。先述の通り、今回付与したのは HTTPRoute の読み取り権限のみです。ClusterRole を GSA にバインドするための ClusterRoleBinding を定義しています。
kind: ClusterRole
metadata:
name: shadow-service-operator-controller-httproute-view
rules:
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- get
- list
- watch
---
kind: ClusterRoleBinding
subjects:
- kind: User
# GSA のメールアドレスがそのまま Kubernetes の User になる
name: shadow-service-operator@<GCP_PROJECT_ID>.iam.gserviceaccount.com
roleRef:
kind: ClusterRole
name: shadow-service-operator-controller-httproute-view
この実装により、クラスタを跨いで HTTPRoute の path 情報の取得ができるようになりました。Operator サーバーは取得した情報をもとに config-cluster の最新状態を参照しながら Gateway API を用いた DarkCanary を作成できます。
なお、HTTPRoute を生成・更新する具体的な実装については本記事では割愛します。
このような仕組みで、クラスタを跨ぎながら必要な情報を取得し、開発者ツールを移行期間中でも安全に運用できるような実装を行いました。
まとめ
この記事では、 DarkCanary のコスト最適化への取り組みと、 RBAC による認可を取り入れて他クラスタの情報を取得しながらチームの移行状態に合わせて柔軟に DarkCanary を立ち上げる方法を紹介させてもらいました。
コスト最適化の部分では、サービス特性を考慮しつつ、必要最低限のリソースで立ち上げるということを意識しました。
Gateway API 導入のタスクはアルバイト配属期間中に全てのサービスが Gateway API に移行されておらず、 Ingress を制御している実装部分を削除するなどの全てのタスクを終わらせることができませんでした。しかし、移行途中のチームの中でどのようにしたら人間の介入を最低限にして運用できるか考えることができました。
