kubebuilderでpodの監視してみた

## はじめに
この記事は、[3-shake Advent Calendar 2024](https://qiita.com/advent-calendar/2024/3-shake) シリーズ2の3日目の記事です。
ここ最近Kubernetesのオペレーターに触れる機会があったので、アウトプットとして本記事を書いています。

## オペレーターとは
オペレーターとは、Kubernetesの持つ **制御ループ(Reconciliation Loop)** という仕組みを用いたKubernetesの拡張機能になります。制御ループは、元々はロボット工学等の分野で使われていた用語で、現在の状態を目的の状態に近づけるように動作する仕組みのことです。オペレーターは、カスタムリソースとカスタムコントローラーを組み合わせることで作成します。
**カスタムリソース(CRD)** は、ユーザーが独自にカスタマイズできるKubernetesリソースで、アプリケーションの理想状態をspecフィールド内に記述します。
**コントローラー** は、カスタムリソースを監視し、そのspecフィールドで定義された目的の状態を実現するために必要な操作を行う制御ループを実装します。つまり、現在の状態を目的の状態に近づける責務を持ちます。
これらのカスタムリソース(CRD)とカスタムコントローラーを作成することで、Kubernetesの機能拡張を実現するものが**オペレーター**です。
## オペレーターを開発するには
オペレーターを開発するためのフレームワークにはいくつかあり、そのうちの一つが**kubebuilder** です。他にも、Operator SDKなどがあるようです。今回は、kubebuilderを使ってPodの監視を行いたいと思います。
また、オペレーター自作に関して今回の記事を書く際参考にさせていただいた、大変有益なページがあるので紹介します。

https://zoetrope.github.io/kubebuilder-training/

## Podを監視するオペレーターの作成
### 何を作るか
Kubebuilderを用いたオペレーター開発として、特定のラベルが付与されたPodに対し「理想状態のPod数」が定義できるようにし、実際のラベル付けされたPodの数が理想状態の数と異なっている場合、数が一致するよう調整するようなオペレーターを作っていきます。
つまり、理想状態の数よりも多くのPodが存在していればPodをスケールインさせ、逆に足りていなければPodをスケールアウトさせるような処理を自動で行うというオペレーターです。
以下に、簡単にですが全体の流れを示します。
![kubebuilder.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839850/7010a139-71fc-62f9-6961-0146822b816c.png)

### 準備
`kubebuilder init`コマンドでプロジェクトの雛形を作成します。
```
kubebuilder init --domain example.com --repo example.com/pod-scaler
```
次に、`kubebuilder create api`コマンドによりAPIの雛形を作成します。カスタムリソースとカスタムコントローラーが作成され、scalingグループに属するPodScalerというリソース名が生成されています。
```
kubebuilder create api --group scaling --version v1 --kind PodScaler
```
そして、`make manifests`を実行しCRDのマニフェストを更新します。
kubebuilderにより以下のような、コントローラのDeployment(マニフェストの一部です)が作成されます。
```config/manager/manager.yaml
apiVersion: v1
kind: Namespace
metadata:
  labels:
    control-plane: controller-manager
    app.kubernetes.io/name: pod-scaler
    app.kubernetes.io/managed-by: kustomize
  name: system
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: controller-manager
  namespace: system
  labels:
    control-plane: controller-manager
    app.kubernetes.io/name: pod-scaler
    app.kubernetes.io/managed-by: kustomize
```
ここまでで、ディレクトリ構成は以下のようになります。
```
tree -L 2
.
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── api
│   └── v1
├── bin
│   ├── controller-gen 
│   ├── controller-gen-v0.16.4
│   ├── kustomize 
│   └── kustomize-v5.5.0
├── cmd
│   └── main.go
├── config
│   ├── crd
│   ├── default
│   ├── manager
│   ├── network-policy
│   ├── prometheus
│   ├── rbac
│   └── samples
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
├── internal
│   └── controller
└── test
    ├── e2e
    └── utils
```
### 実際に作っていく
まず、`api/v1/podscaler_types.go` のPodScalerSpec構造体の中身を編集し、PodScalerリソースの定義を変更していきます。
```api/v1/podscaler_types.go
type PodScalerSpec struct {
	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
	// Important: Run "make" to regenerate code after modifying this file
	// 強制的に保持するPodの数
	Count int `json:"count"`
	// Podのラベルセレクタ
	Selector map[string]string `json:"selector"`
}
```
変更後、`make manifests`および`make install`により反映します。
ここで、pod-scaler/config/crd/bases内のCRDを確認すると、PodScalerSpecの定義通りCRDが更新されていることがわかります。以下がその一部です。
```pod-scaler/config/crd/bases/scaling.example.com_podscaler.yaml
spec:
    description: PodScalerSpec defines the desired state of PodScaler.
    properties:
      count:
        description: |-
          Foo is an example field of PodScaler. Edit podscaler_types.go to remove/update
          Foo string `json:"foo,omitempty"`
          強制的に保持するPodの数
        type: integer
      selector:
        additionalProperties:
          type: string
        description: Podのラベルセレクタ
        type: object
    required:
    - count
    - selector
    type: object
```
上記のようにCountやSelectorを定義すると、以下のようにカスタムソースのspecフィールド内において定義した要素を追加することができるようになります。
spec.selectorで定義したラベルと同じラベルを持つPodを監視対象にし、spec.countで定義した数を理想状態とし現実状態のPodが理想状態と数が異なる場合には同じ数のPodが起動するようなオペレーターになります。
```config/samples/podscaler-crd.yaml
apiVersion: scaling.example.com/v1
kind: PodScaler
metadata:
  name: example-podscaler
spec:
  count: 4
  selector:
    app: nginx
```

次に、コントローラーの実装をしていきます。Reconcileの部分を実装していくことになりますが、この部分は先述した制御ループに当たる部分で、オペレーターの核となる部分になります。
PodScalerReconcilerの埋め込みフィールドとしてクライアントライブラリ([client.Client](https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/client))が使用されます。Reconcile関数内でr.Getやr.Listなどを使い、クライアントを通じてリソースを取得していきます。
```internal/controller/podscaler_controller.go
type PodScalerReconciler struct {
	client.Client
	Scheme *runtime.Scheme
	Log    logr.Logger
}
```
Reconcilerは以下のように実装しました。
引数であるreqにはカスタムリソースのNameとNamespaceが入っています。([reconcile.Request](https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/reconcile#Request))
reqによりカスタムリソースであるPodScalerを取得した上で、該当するラベルのPodを列挙します。そして、カスタムリソースで定義したPodの数の理想状態と現実のラベルが一致するPodの数の比較を行います。
理想状態の数と比較してPodの数が足りていない場合は足りていない分のPodを新たに作成し、逆に現実状態のPod数が理想状態よりも多い場合は超過分の数のPodを削除しています。
```internal/controller/podscaler_controller.go
func (r *PodScalerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := log.FromContext(ctx)
	var podScaler scalingv1.PodScaler
	// 指定した名前空間と名前に基づいてPodScalerを取得
	if err := r.Get(ctx, req.NamespacedName, &podScaler); err != nil {
		log.Error(err, "unable to fetch PodScaler")
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}
	// 対象のPodをリスト
	var pods corev1.PodList
	labelSelector := labels.SelectorFromSet(podScaler.Spec.Selector)
	if err := r.List(ctx, &pods, &client.ListOptions{
		Namespace:     req.Namespace,
		LabelSelector: labelSelector,
	}); err != nil {
		log.Error(err, "unable to list pods")
		return ctrl.Result{}, err
	}
	// Podの数を調整
	currentCount := len(pods.Items)
	desiredCount := podScaler.Spec.Count

	if currentCount < desiredCount {
		for i := 0; i < (desiredCount - currentCount); i++ {
			pod := &corev1.Pod{
				ObjectMeta: metav1.ObjectMeta{
					GenerateName: "scaled-pod-",
					Namespace:    req.Namespace,
					Labels:       podScaler.Spec.Selector,
				},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{
						{
							Name:  "nginx",
							Image: "nginx:latest",
						},
					},
				},
			}
			if err := r.Create(ctx, pod); err != nil {
				log.Error(err, "unable to create Pod")
				return ctrl.Result{}, err
			}
		}
	} else if currentCount > desiredCount {
		// Podを削除
        for i := 0; i < (currentCount - desiredCount); i++ {
            pod := &pods.Items[i]
        	if err := r.Delete(ctx, pod); err != nil {
        	// 削除対象が存在しない場合はスキップ
            if client.IgnoreNotFound(err) != nil {
            	logger.Error(err, "unable to delete Pod")
        		return ctrl.Result{}, err
        		}
        	}
        }
	}
	log.Info("Reconciliation complete", "currentCount", currentCount, "desiredCount", desiredCount)
	// 15秒ごとに再起動
	return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
}
```

また、SetupWithManager関数でReconcileをトリガーしたい条件を記述することができます。デフォルトではカスタムリソースが変更された時のみReconcileがトリガーされますが、今回はクラスタ内のPodの変更を検知したいので、`Owns(&corev1.Pod{}).`を追加することでPodを監視対象とします。
コントローラの実装が完了したら、以下コマンドを実行しクラスタに反映します。
```
make docker-build
make docker-push
make deploy
```

### 動作確認
初め、カスタムリソースで定義した`nginx`というラベルの付いたnginxのPodが一つある状態だとします。
```
kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-86dcfdf4c6-7ltf4   1/1     Running   0          6m26s
```
ここで、カスタムリソースを`kubectl apply -f config/samples/scaling_v1_podscaler.yaml`によりapplyします。
すると、カスタムコントローラーが該当するラベルの付いた現在のPod数が理想状態の数と比較して少ないことを検知して追加していることがわかります！
```
kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-86dcfdf4c6-7ltf4   1/1     Running   0          7m17s
scaled-pod-75cwf         1/1     Running   0          12s
scaled-pod-w8r87         1/1     Running   0          12s
scaled-pod-xr6k5         1/1     Running   0          12s
```
別の例として、nginxというラベルの付いたPodが4つある状態です。
```
kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-86dcfdf4c6-tgslw   1/1     Running   0          4m11s
scaled-pod-h2n89         1/1     Running   0          4m11s
scaled-pod-hm2bl         1/1     Running   0          4m11s
scaled-pod-sbb79         1/1     Running   0          4m11s
```
ここで、`nginx-86dcfdf4c6-tgslw`のPodを手動で削除したとします。
そうすると、以下のようにコントローラが理想状態(4つ)と現実状態(3つ)の差分を検知して、新たにPodを作成していることがわかります。
```
kubectl get pods
NAME               READY   STATUS    RESTARTS   AGE
scaled-pod-h2n89   1/1     Running   0          4m40s
scaled-pod-hm2bl   1/1     Running   0          4m40s
scaled-pod-sbb79   1/1     Running   0          4m40s
scaled-pod-thzm5   1/1     Running   0          8s
```
さらに、この状態で`replicas=3`のnginxのDeploymentをapplyするとします。
すると、一時的にPodは7つとなってしまいますが、
```
kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-86dcfdf4c6-9dp2c   1/1     Running   0          6s
nginx-86dcfdf4c6-jsxf4   1/1     Running   0          6s
nginx-86dcfdf4c6-wmtld   1/1     Running   0          6s
scaled-pod-h2n89         1/1     Running   0          5m30s
scaled-pod-hm2bl         1/1     Running   0          5m30s
scaled-pod-sbb79         1/1     Running   0          5m30s
scaled-pod-thzm5         1/1     Running   0          58s
```
最終的には理想状態の4つになるようにオペレーターが調整してくれています！
```
kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-86dcfdf4c6-72vdk   1/1     Running   0          14s
nginx-86dcfdf4c6-7krbg   1/1     Running   0          14s
nginx-86dcfdf4c6-td5fw   1/1     Running   0          15s
scaled-pod-ktc9f         1/1     Running   0          4s
```
このように、該当するラベルのPodについて数を監視して、理想状態と異なっている場合に数を増減させるようなオペレーターを作ることができました。


## 終わりに
Kubebuilderに慣れるという目的で、Podの理想状態の数を定義しその数に現実のPod数を合わせるというシンプルなオペレーターをKubebuilderを使って作ってみました。
まだ自分はKubebuilderに入門したばかりなので、これからもさらに知見を深められるよう頑張っていきたいです！
ここまで読んでいただき、ありがとうございました。
なお、この記事の続きとして[Admission webhooksでkubebuilderを拡張する](https://qiita.com/tryu___/items/71060a11a4b5d9d91cae)という記事も書いているので是非！
動作確認を行ったソースコードは以下になります。

https://github.com/tryuuu/pod-scaler