Admission webhooksでkubebuilderを拡張する

## はじめに
この記事は、前回書いた「[kubebuilderでpodの監視してみた](https://qiita.com/tryu___/items/8fff1edd57e8b0758512)」の続き的な立ち位置で書いた記事となっています。
前回の記事では、主にKubebuilderを用いたKubernetesオペレータの説明と基本的な動作確認がメインとなっていました。今回は、同じオペレータを「Admission webhooks」を用いて機能拡張してみました。
## Admission Webhooksについて
Admission Webhookは、リクエストがKubernetesのAPIサーバに送信された際に発火されるwebhookで、**Mutating Admission webhook** と**Validating Admission webhook** の2種類があります。
特定のカスタムリソースの作成・更新時に、リクエストはKubernetesのAPIサーバに送信されますが、その際にwebhook APIを呼び出して変更内容の検証や書き換えを行うようにすることができます。
まず、Mutating Admission webhookが先に発火されます。そこで、APIサーバに送信されるオブジェクトを必要に応じて強制的に変更しデフォルトの値に書き換えることができます。変更があれば変更が行われた後、Validating Admission webhookが発火され、オブジェクトの検証を行います。バリデーション違反があればエラーを返します。
簡単にまとめを行うと、Admission webhooksはカスタムリソースの検証を行い、その中でもMutating Admission webhookは先に発火され必要に応じてオブジェクトを変更します。Validating Admission webhookはオブジェクトの検証を行い、変更を行うことはないというものです。
## kubebuilderでの実装
### コードの流れ
kubebuilderでオペレータを開発している場合にこれらのwebhookを導入する手順を説明します。
以下のように、まずはwebhookを作成します。ここでは`--defaulting`と`--programmatic-validation`というオプションを指定して、必要に応じてリソースにデフォルト値を設定するwebhookとリソースのバリデーションを行うwebhookを追加します。
```
$ kubebuilder create webhook --group scaling --version v1 --kind PodScaler --defaulting --programmatic-validation
```
まずはコードの流れを確認しておきます。
cmd/main.goにて、webhookが有効化されていることを確認しwebhook の処理を呼び出す部分は以下の部分です:
```cmd/main.go
// nolint:goconst
if os.Getenv("ENABLE_WEBHOOKS") != "false" {
    if err = webhookscalingv1.SetupPodScalerWebhookWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create webhook", "webhook", "PodScaler")
        os.Exit(1)
    }
}
```
`SetupPodScalerWebhookWithManager`が呼び出されており、この処理が記述されている場所を確認してみます:
```bash
$ git grep "SetupPodScalerWebhookWithManager"
cmd/main.go:            if err = webhookscalingv1.SetupPodScalerWebhookWithManager(mgr); err != nil {
internal/webhook/v1/podscaler_webhook.go:// SetupPodScalerWebhookWithManager registers the webhook for PodScaler in the manager.
internal/webhook/v1/podscaler_webhook.go:func SetupPodScalerWebhookWithManager(mgr ctrl.Manager) error {
internal/webhook/v1/webhook_suite_test.go:      err = SetupPodScalerWebhookWithManager(mgr)
```
このことから、internal/webhook/v1/podscaler_webhook.go内を変更していけば良さそうです。
該当の関数は以下のようになっています。この関数では、`PodScalerCustomValidator` と `PodScalerCustomDefaulter`が `webhook.CustomValidator` と `webhook.CustomDefaulter` というインターフェースをそれぞれ実装しており、それらが呼び出されるように登録されています。
```internal/webhook/v1/podscaler_webhook.go
// SetupPodScalerWebhookWithManager registers the webhook for PodScaler in the manager.
func SetupPodScalerWebhookWithManager(mgr ctrl.Manager) error {
	return ctrl.NewWebhookManagedBy(mgr).For(&scalingv1.PodScaler{}).
		WithValidator(&PodScalerCustomValidator{}).
		WithDefaulter(&PodScalerCustomDefaulter{}).
		Complete()
}

type PodScalerCustomDefaulter struct {}
var _ webhook.CustomDefaulter = &PodScalerCustomDefaulter{}

type PodScalerCustomValidator struct {}
var _ webhook.CustomValidator = &PodScalerCustomValidator{}

```
KubernetesのAPIサーバがリソースの作成や更新リクエストを受け取ると、これらのインターフェースを実装した関数(後に確認しますが、`Default`, `ValidateCreate`, `ValidateUpdate`, `ValidateDelete`)が呼び出されます。そのため、これらの関数内に必要なバリデーションやデフォルト値の適用ロジックを記述していけばよい形となっています。
### Mutating Admission webhookの実装
以下に、Mutating Admission webhookの設定をしました。
カスタムリソースのspec.countフィールドにおいて理想のPod数を記述できるようにしていましたが、この値が0など、不正な値の時にデフォルト値の5を設定するようにしています。このように、条件に応じて自動でカスタムリソースを変更できます。
```internal/webhook/v1/podscaler_webhook.go
// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind PodScaler.
func (d *PodScalerCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error {
	podscaler, ok := obj.(*scalingv1.PodScaler)
	if !ok {
		return fmt.Errorf("expected an PodScaler object but got %T", obj)
	}

	// Mutating Admission Webhookのデフォルト値を設定(spec.count)
	if podscaler.Spec.Count < 1 {
		podscaler.Spec.Count = 5
	}

	podscalerlog.Info("Defaulting for PodScaler", "name", podscaler.GetName())
	return nil
}
```
### Validating Admission webhookの実装
次に、Validating Admission webhookの設定を実装します。
カスタムリソースのspec.countフィールドやspec.selectorフィールドに不正な値が入っている場合にエラーを返しリクエストを拒否します。なお、一つ目のspec.countのバリデーションは、Mutating Admission webhookが正しく反映されていればデフォルト値が反映されるはずなので、本来は実行されないはずの箇所です。後で確認をしてみます。
```internal/webhook/v1/podscaler_webhook.go
// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type PodScaler.
func (v *PodScalerCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
	podscaler, ok := obj.(*scalingv1.PodScaler)
	if !ok {
		return nil, fmt.Errorf("expected a PodScaler object but got %T", obj)
	}

	// validating admission webhookの設定
	if podscaler.Spec.Count < 1 {
		return nil, fmt.Errorf("count must be greater than 0")
	}

	if len(podscaler.Spec.Selector) == 0 {
		return nil, fmt.Errorf("selector must be specified")
	}

	podscalerlog.Info("Validation for PodScaler upon creation", "name", podscaler.GetName())

	return nil, nil
}
```
## 動作確認
### 準備
config/default/kustomization.yamlファイルを開き、resourcesの`../webhook`と`../certmanager`のコメントアウトを外すと共に、replacements配下のコメントアウトを外します。(Uncomment the following block if you have a ValidatingWebhookなどと書かれている部分)
次に、webhookを利用するための証明書を発行するcert-managerをデプロイします。
```
$ kubectl apply --validate=false -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
```
ここまでできたら、マニフェストの更新とイメージビルド・クラスタへの反映を行います。
```
make manifests
make docker-build
make docker-push
make deploy
```
反映後、webhookの設定が正しく適用されているかどうかを確認します。
```
$ kubectl get mutatingwebhookconfigurations
NAME                                        WEBHOOKS   AGE
pod-scaler-mutating-webhook-configuration   1          17s

$ kubectl get validatingwebhookconfigurations
NAME                                          WEBHOOKS   AGE
pod-scaler-validating-webhook-configuration   1          17s
```
### Mutating Admission webhookの動作確認
ここで、以下のようなカスタムリソースを反映したとします。
```podscaler.yaml
apiVersion: scaling.example.com/v1
kind: PodScaler
metadata:
  name: example-podscaler
spec:
  count: 0
  selector:
    app: nginx

```
spec.count(理想のPod数)が0であり、Mutating Admission webhookによりデフォルト値の5となることを確認したいと思います。同時に、Validating Admission webhookによりエラーが返されないことも確認します。
`$ kubectl get podscaler example-podscaler -o yaml`でカスタムリソースの状態を確認すると、spec.countが5に変わっていることが確認できます。

```yaml
apiVersion: scaling.example.com/v1
kind: PodScaler
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"scaling.example.com/v1","kind":"PodScaler","metadata":{"annotations":{},"name":"example-podscaler","namespace":"default"},"spec":{"count":0,"selector":{"app":"nginx"}}}
  creationTimestamp: "2024-12-01T11:42:26Z"
  generation: 8
  name: example-podscaler
  namespace: default
  resourceVersion: "1163838"
  uid: d0b15d62-af16-45f0-865f-36f423bf1a4d
spec:
  count: 5
  selector:
    app: nginx
```
また、実際にPodも5個立っています。元々3つだったので追加で2つのPodがスケールして5個になっています。
```
$ kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-86dcfdf4c6-2sqs2   1/1     Running   0          58s
nginx-86dcfdf4c6-4zztf   1/1     Running   0          43s
nginx-86dcfdf4c6-99s97   1/1     Running   0          13s
scaled-pod-28pkl         1/1     Running   0          72s
scaled-pod-bh9fz         1/1     Running   0          3m27s
```
### Validating Admission webhookの動作確認
次に、以下のようにspec.selectorフィールドに不正な値を入れてみます。
```podscaler.yaml
apiVersion: scaling.example.com/v1
kind: PodScaler
metadata:
  name: example-podscaler
spec:
  count: 0
  selector:
    app: 

```
このカスタムリソースを反映すると以下のエラーが出ます。**admission webhook denied the requests** とあり、Validating Admission webhookがリクエストを拒否できていることがわかります。

```bash
Error from server (Forbidden): error when applying patch:
{"metadata":{"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"scaling.example.com/v1\",\"kind\":\"PodScaler\",\"metadata\":{\"annotations\":{},\"name\":\"example-podscaler\",\"namespace\":\"default\"},\"spec\":{\"count\":0,\"selector\":{\"app\":null}}}\n"}},"spec":{"count":0,"selector":{"app":null}}}
to:
Resource: "scaling.example.com/v1, Resource=podscalers", GroupVersionKind: "scaling.example.com/v1, Kind=PodScaler"
Name: "example-podscaler", Namespace: "default"
for: "config/samples/test.yaml": error when patching "config/samples/test.yaml": admission webhook "vpodscaler-v1.kb.io" denied the request: selector must be specified
```
## 終わりに
簡単な実装でAdmission Webhooksの動作確認をしてみました。Istioのsidecar injectionも同様の仕組みを使っており、かなり使われている場面は多いのでこれを機にさらに理解を深めていければと思っています。
ここまで読んでいただき、ありがとうございました。