この記事はCyberAgent Group SRE Advent Calendar 2023の22日目の投稿になります。
はじめに
はじめまして。SREをしている@toro_ponzです。普段はEKSの運用やキャパシティプランニングなどをしています。その傍らサービス開発としてサーバーサイドアプリケーションの設計・開発のタスクをすることもあるのですが、やはりTerraformやHelm、YAMLなどを記述する時間のほうが長いのは言うまでもありません。ソフトウェアエンジニアとして今後取り残されないためにも、何らかのカスタムコントローラーでも作ってみたくなりました。
今日は、現在個人的に開発中のDynamic Ingress Operatorのコンセプトについてご紹介したいと思います。
本当は公開できるレベルまで実装しておきたかったのですが、まだほどんどが未実装です。Goを使ったソフトウェア開発もそこまで経験がないので、探り探り実装していっております。
https://github.com/toro-ponz/dynamic-ingress-operator
概要
Dynamic Ingress Operatorはその名の通りIngressを動的に変更するカスタムコントローラーおよびそのCRDです。特定のステート(K8sリソース自体もしくは外部HTTP API)の状態によって複数のIngressのルールやアノテーションを一度に変更することができます。
今かかわっているサービスでは外部に公開しているIngressが数多くあり、サービスメンテナンス時などに複数のIngressを更新するオペレーションがまれに発生します。何らかの作業一つでそれらのIngressをまとめて更新することができたらうれしいなというのが開発の動機です。
CRD
このOperatorで定義しているCRDはDynamicIngressとDynamicIngressStateの二つです。一つずつ見ていきます。
DynamicIngress
本Operatorのメインリソースです。1つのIngressを対象にとり、ルールやアノテーションを二種類定義してそれらを動的に切り替えます。何はともあれCustomResourceの作成例を見てみましょう。
apiVersion: ingress.toroponz.io/v1beta1
kind: DynamicIngress
metadata:
namespace: fuga
name: hogehoge-di
spec:
target:
namespace: fuga
name: hogehoge-ingress
passiveIngress: null
activeIngress:
template:
metadata:
annotation:
kubernetes.io/ingress.class: alb
spec:
rules:
- http:
paths:
- backend:
service:
name: xxxx
port:
number: 80
pathType: ImplementationSpecific
state: hogehoge-state
expected:
statusCode: '200'
body: '{"status":"ok"}'
まずはターゲット指定部分です。nameおよびnamespaceを指定して対象のIngressを一意にします。なお、DynamicIngressはIngressリソースを作成するため、既存のIngressではなく存在しないリソースを指定する必要があります。
spec:
target:
namespace: fuga
name: hogehoge-ingress
次はpassiveおよびactiveのIngressテンプレート指定部分です。DynamicIngressはpassiveとactiveの二種類の状態(後述)があり、passiveの場合は対象のIngressがpassiveIngressの状態になり、activeの場合もまた然りという感じです。nullを指定した場合はIngressが削除されます。null以外の場合は指定された状態にするようにCreateOrUpdateを行います。
spec:
# 省略
passiveIngress: null # passive状態の場合はIngressを削除する
activeIngress: # active 状態の場合はIngressを以下の状態に更新する
template:
metadata:
annotation:
kubernetes.io/ingress.class: alb
spec:
rules:
- http:
paths:
- backend:
service:
name: xxxx
port:
number: 80
pathType: ImplementationSpecific
最後に状態のチェック部分です。指定したDynamicIngressStateと、expectedな状態を比較して、一致する場合はDynamicIngressのstatusがactiveと判定されます。一致しない場合はpassiveとなります。
spec:
# 省略
state: hogehoge-state # 対象のDynamicIngressState名(後述)
expected:
statusCode: '200' # activeとするステータスコード
body: '{"status":"ok"}' # activeとするレスポンスボディ
DynamicIngressState
前述の比較対象のStateで、複数のDynamicIngressから参照され得ます。Fixedモードと外部のAPIをたたくProbeモードがあります。
Fixedモード
その名の通り、指定した定義がそのままStateになります。
apiVersion: ingress.toroponz.io/v1beta1
kind: DynamicIngressState
metadata:
namespace: fuga
name: hogehoge-state
spec:
fixedResponse:
status: '200'
body: |
{
"status": "ok"
}
Probeモード
指定したURLに定期的にリクエストを送り、そのレスポンスをStateに保存します。
apiVersion: ingress.toroponz.io/v1beta1
kind: DynamicIngressState
metadata:
namespace: fuga
name: hogehoge-state
spec:
probe:
type: HTTP
method: GET
url: https://api.toroponz.io/status
interval: 60s
ユースケース
想定されるユースケースとしては、以下のような場合があります。
- EnvoyなどのProxyレベルでのトラフィック切り替えの手段を持たず、Ingressレベルでのルーティング制御を行いたい場合
- サービスメンテナンスなどのため、ProxyやPodの処理が正常に動作しない、もしくはPodが無い状態になる場合
- 何らかの理由でIngressのルールを頻繁に、かつ迅速に変更する必要がある場合
- 複数のIngressの変更をまとめて処理したい場合
具体的な設定ベースで見ていきます。例えばALBで特定の場合のみメンテナンスページにリダイレクトする際は以下のようになります。フィーチャーフラグ基盤を持っていて、そこでkillswitchというフラグを管理している想定です。それをONにすれば全リクエストでHTTP 302が返却されるようになります。
apiVersion: ingress.toroponz.io/v1beta1
kind: DynamicIngress
metadata:
namespace: fuga
name: hogehoge-di
spec:
target:
namespace: fuga
name: hogehoge-ingress
passiveIngress: ## 通常のリクエストルーティング
template:
metadata:
annotation:
# 省略
kubernetes.io/ingress.class: alb
spec:
rules:
- http:
paths:
- backend:
service:
name: xxxx
port:
number: 80
pathType: ImplementationSpecific
activeIngress: # メンテナンス中
template:
metadata:
annotation:
# 省略
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/actions.redirect-to-maintenance: >
{"Type":"redirect","RedirectConfig":{"Host":"sorry.toroponz.io","Path":"/maintenance.html","Port":"443","Protocol":"HTTPS","Query":"","StatusCode":"HTTP_302"}}
spec:
rules:
- http:
paths:
- backend:
service:
name: redirect-to-maintenance
port:
name: use-annotation
pathType: ImplementationSpecific
state: killswitch-state
expected:
statusCode: '200'
body: '{"enabled":"ok"}'
---
apiVersion: ingress.toroponz.io/v1beta1
kind: DynamicIngressState
metadata:
namespace: fuga
name: killswitch-state
spec:
probe:
type: HTTP
method: GET
url: https://featureflags.toroponz.io/api/v1/flags/killswitch
interval: 60s
また、ALBの場合はAWS Load Balancer ControllerのIngressGroupを利用してより簡素に書くことができます。IngressGroupは複数のIngressリソースをグループ名ごとにマージして複数ルールのALBを作成してくれる機能です。
デフォルトのルーティングルールは単純なIngressとして管理しておき、DynamicIngressで作成するIngressと同じグループ名にしておけば、activeのときのみ既存のIngressにルールが追加されるようにできます。
spec:
# 省略
passiveIngress: null # passive中はIngressを削除する
activeIngress: # メンテナンス中
template:
metadata:
annotation:
# 省略
alb.ingress.kubernetes.io/group.name: hogehoge # 追加: グループ名
alb.ingress.kubernetes.io/group.order: '10' # 追加: ルールの評価順
spec:
rules:
- http:
paths:
- backend:
service:
name: redirect-to-maintenance
port:
name: use-annotation
pathType: ImplementationSpecific
# 省略
実装
実装にはKubebuilderを活用しています。Goのコーディング自体にも明るいわけではないので、周辺の整備がscaffoldingできるのはありがたいですね。また、つくって学ぶKubebuilderを大変参考にさせていただいております。
まだ実装中なのですが、実現方法の方針だけ記載することとします。
StateをWatchする
DynamicIngresは指定されたDynamicIngressStateをWatchします。しかしStateは子リソースではなく、かつ共有されるものなのでOwns()で管理することができません。他のリソースを監視するために用意されているWatches()メソッドを使い変更を監視します。公式のリファレンスを参考に実装していっています。
func (r *DynamicIngressReconciler) SetupWithManager(mgr ctrl.Manager) error {
// 前後のStatus.LastUpdateTimeを比較し、差分がある場合変更をフックする
p := predicate.Funcs{
UpdateFunc: func(e event.UpdateEvent) bool {
old := e.ObjectOld.(*ingressv1beta1.DynamicIngressState)
new := e.ObjectNew.(*ingressv1beta1.DynamicIngressState)
if new.Status.LastUpdateTime == nil {
return false
}
return old.Status.LastUpdateTime.Equal(*new.Status.LastUpdateTime)
},
CreateFunc: func(e event.CreateEvent) bool {
return true
},
}
return ctrl.NewControllerManagedBy(mgr).
For(&ingressv1beta1.DynamicIngress{}).
Owns(&networkingv1.Ingress{}).
Watches(
&ingressv1beta1.DynamicIngressState{}, // 監視対象
handler.EnqueueRequestsFromMapFunc(r.findObjectsForDynamicIngressState),
builder.WithPredicates(p),
).
Complete(r)
}
const (
dynamicIngressStateField = ".spec.state" // 参照するリソースの名前があるフィールドパス
)
func (r *DynamicIngressReconciler) findObjectsForDynamicIngressState(ctx context.Context, state client.Object) []reconcile.Request {
attachedDynamicIngresses := &ingressv1beta1.DynamicIngressList{}
listOps := &client.ListOptions{
FieldSelector: fields.OneTermEqualSelector(dynamicIngressStateField, state.GetName()),
Namespace: state.GetNamespace(),
}
err := r.List(context.TODO(), attachedDynamicIngresses, listOps)
if err != nil {
return []reconcile.Request{}
}
requests := make([]reconcile.Request, len(attachedDynamicIngresses.Items))
for i, item := range attachedDynamicIngresses.Items {
requests[i] = reconcile.Request{
NamespacedName: types.NamespacedName{
Name: item.GetName(),
Namespace: item.GetNamespace(),
},
}
}
return requests
}
Probeを定期実行させる
DynamicIngressStateのProbeモードではintervalに従いn秒おきにHTTPリクエストを送信します。デフォルトのReconcileでは自分自身(CustomResource)と子リソースなどの変更を受け取って処理をフックしますが、このCRでは定期的に実行する必要があります。下記の記事を参考に、同じくWatchesで実装しようと考えています。
おわりに
当面の目標は、関わっているプロダクトのサービスメンテナンス用にこのOperatorを本番導入することです。正式に公開できるようにまずは基本機能を実装していきます。