k8sのIngressを動的に変えるOperatorを作っている話

この記事は[CyberAgent Group SRE Advent Calendar 2023](https://qiita.com/advent-calendar/2023/ca-sre)の22日目の投稿になります。

## はじめに

はじめまして。SREをしている[@toro_ponz](https://twitter.com/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の作成例を見てみましょう。

```yaml
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ではなく存在しないリソースを指定する必要があります。

```yaml
spec:
  target:
    namespace: fuga
    name: hogehoge-ingress
```

次はpassiveおよびactiveのIngressテンプレート指定部分です。DynamicIngressはpassiveとactiveの二種類の状態(後述)があり、passiveの場合は対象のIngressがpassiveIngressの状態になり、activeの場合もまた然りという感じです。nullを指定した場合はIngressが削除されます。null以外の場合は指定された状態にするようにCreateOrUpdateを行います。

```yaml
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となります。

```yaml
spec:
  # 省略
  state: hogehoge-state # 対象のDynamicIngressState名(後述)
  expected:
    statusCode: '200'       # activeとするステータスコード
    body: '{"status":"ok"}' # activeとするレスポンスボディ
```

### DynamicIngressState

前述の比較対象のStateで、複数のDynamicIngressから参照され得ます。Fixedモードと外部のAPIをたたくProbeモードがあります。

#### Fixedモード

その名の通り、指定した定義がそのままStateになります。

```yaml
apiVersion: ingress.toroponz.io/v1beta1
kind: DynamicIngressState
metadata:
  namespace: fuga
  name: hogehoge-state
spec:
  fixedResponse:
    status: '200'
    body: |
      {
        "status": "ok"
      }
```

#### Probeモード

指定したURLに定期的にリクエストを送り、そのレスポンスをStateに保存します。

```yaml
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が返却されるようになります。

```yaml
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](https://github.com/kubernetes-sigs/aws-load-balancer-controller)のIngressGroupを利用してより簡素に書くことができます。IngressGroupは複数のIngressリソースをグループ名ごとにマージして複数ルールのALBを作成してくれる機能です。

デフォルトのルーティングルールは単純なIngressとして管理しておき、DynamicIngressで作成するIngressと同じグループ名にしておけば、activeのときのみ既存のIngressにルールが追加されるようにできます。

```yaml
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](https://github.com/kubernetes-sigs/kubebuilder)を活用しています。Goのコーディング自体にも明るいわけではないので、周辺の整備がscaffoldingできるのはありがたいですね。また、[つくって学ぶKubebuilder](https://zoetrope.github.io/kubebuilder-training/)を大変参考にさせていただいております。
まだ実装中なのですが、実現方法の方針だけ記載することとします。

### StateをWatchする

DynamicIngresは指定されたDynamicIngressStateをWatchします。しかしStateは子リソースではなく、かつ共有されるものなのでOwns()で管理することができません。他のリソースを監視するために用意されているWatches()メソッドを使い変更を監視します。[公式のリファレンス](https://kubebuilder.io/reference/watching-resources/externally-managed)を参考に実装していっています。

```go
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で実装しようと考えています。

https://zenn.dev/hhiroshell/articles/custom-controller-for-out-of-cluster-events

## おわりに

当面の目標は、関わっているプロダクトのサービスメンテナンス用にこのOperatorを本番導入することです。正式に公開できるようにまずは基本機能を実装していきます。