[Controller編] Server-Side Applyを試す

# はじめに
[[kubectl編] Server-Side Applyを試す](https://qiita.com/junya19862/items/f79d4c19a619f9a277fb)にてServer-Side Apply(以降SSA)の概念をkubectlコマンドを通して理解しました。
今回は自前でのController実装を通じてをSSAを実行します。

# SSAを行う関数
SSAを行える関数は[client-goのApply()](https://github.com/kubernetes/client-go/blob/master/kubernetes/typed/apps/v1/deployment.go#L208-L232)と[controller-runtimeのPatch()](https://github.com/kubernetes-sigs/controller-runtime/blob/master/pkg/client/client.go#L229-L240)がそれぞれ存在します。
使いわけは好みや用途次第かと思いますが、個人的にはSSAがGAになった1.22で導入されたclient-goのApply()の方がシンプルで使い易いかなあと思います。
シンプルである理由は後述します。

# Controllerを介さないSSAのコード
まず、ControllerなしでSSAしてみます。
以下のコードはclient-goのApply()を使ったSSAの単純なコードです。
リソースの定義をapplyconfigurationsを使用して作成して、FieldManagerを指定して、Apply関数を実行するだけです。
なお、[applyconfigurations](https://github.com/kubernetes/client-go/tree/master/applyconfigurations)は全てのフィールドがポインタかつomitemptyとなっているので、未指定のフィールドがnilになります。
つまり、余分なフィールド更新が走らないということです。
詳細は以下の記事が非常に参考になります。

https://zenn.dev/zoetro/articles/96f30897f3e369

```golang
package main

import (
	"context"
	"flag"
	"fmt"
	"os"

	"github.com/go-logr/logr"
	"go.uber.org/zap/zapcore"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	appsv1apply "k8s.io/client-go/applyconfigurations/apps/v1"
	corev1apply "k8s.io/client-go/applyconfigurations/core/v1"
	metav1apply "k8s.io/client-go/applyconfigurations/meta/v1"
	"k8s.io/client-go/kubernetes"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"
)

var (
	log        logr.Logger
	kclient    client.Client
	kclientset *kubernetes.Clientset
)

func init() {
	opts := zap.Options{
		Development: true,
		TimeEncoder: zapcore.ISO8601TimeEncoder,
	}
	opts.BindFlags(flag.CommandLine)
	flag.Parse()
	ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
	log = ctrl.Log.WithName("test-ssa")

	_, kclientset = getClient()
}

func getClient() (client.Client, *kubernetes.Clientset) {
	scheme := runtime.NewScheme()
	_ = clientgoscheme.AddToScheme(scheme)

	kubeconfig := ctrl.GetConfigOrDie()

	kclient, err := client.New(kubeconfig, client.Options{Scheme: scheme})
	if err != nil {
		log.Error(err, "unable to create client")
		os.Exit(1)
	}

	kclientset, err := kubernetes.NewForConfig(kubeconfig)
	if err != nil {
		log.Error(err, "unable to create clientset")
		os.Exit(1)
	}

	return kclient, kclientset
}

func main() {
	var (
		ctx              = context.Background()
		fieldMgr         = "my-field-manager"
		deploymentClient = kclientset.AppsV1().Deployments("default")
	)

	deploymentApplyConfig := appsv1apply.Deployment("codecreate-nginx", "default").
		WithSpec(appsv1apply.DeploymentSpec().
			WithReplicas(3).
			WithSelector(metav1apply.LabelSelector().
				WithMatchLabels(map[string]string{"apps": "codecreate-nginx"})).
			WithTemplate(corev1apply.PodTemplateSpec().
				WithLabels(map[string]string{"apps": "codecreate-nginx"}).
				WithSpec(corev1apply.PodSpec().
					WithContainers(corev1apply.Container().
						WithName("my-nginx").
						WithImage("nginx:latest")))))

	applied, err := deploymentClient.Apply(ctx, deploymentApplyConfig, metav1.ApplyOptions{
		FieldManager: fieldMgr,
		Force:        true,
	})
	if err != nil {
		log.Error(err, "unable to apply")
		os.Exit(1)
	}

	log.Info(fmt.Sprintf("Applied: %s", applied.GetName()))

	return
}
```

次にcontroller-runtimeのPatch()を使ったSSAを見てみます。
なお、main()以外は同じであるため、省略します。
リソース定義を作成するまではいいのですが、Patch()は適用対象のpatchを、applyconfigurationsそのままで使用できず、[runtime.Object](https://github.com/kubernetes-sigs/controller-runtime/blob/master/pkg/client/object.go#L45-L48)として指定する必要があります。
そのため、その適用対象リソース定義をpatchとするため、[unstructured.Unstructured](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured#Unstructured)を使って、interfaceに変換します。
この一手間が面倒なので、client-goのApply()の方が使い易いと記載としています。
```golang
func main() {
	var (
		ctx      = context.Background()
		fieldMgr = "my-field-manager"
	)

	deploymentApplyConfig := appsv1apply.Deployment("codecreate-nginx", "default").
		WithSpec(appsv1apply.DeploymentSpec().
			WithReplicas(3).
			WithSelector(metav1apply.LabelSelector().
				WithMatchLabels(map[string]string{"apps": "codecreate-nginx"})).
			WithTemplate(corev1apply.PodTemplateSpec().
				WithLabels(map[string]string{"apps": "codecreate-nginx"}).
				WithSpec(corev1apply.PodSpec().
					WithContainers(corev1apply.Container().
						WithName("my-nginx").
						WithImage("nginx:latest")))))

	obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(deploymentApplyConfig)
	if err != nil {
		log.Error(err, "unable to convert unstructured")
		os.Exit(1)
	}

	patch := &unstructured.Unstructured{
		Object: obj,
	}

	b := true
	kclient.Patch(ctx, patch, client.Apply, &client.PatchOptions{
		FieldManager: fieldMgr,
		Force:        &b,
	})
	log.Info(fmt.Sprintf("Applied: %s", patch.GetName()))

	return
}
```
unstructured.Unstructuredを使用するくだりは、中国語ですが、以下の記事が参考になるので、見てみるのもいいかと思います。

https://morven.life/posts/k8s-api-2/

# Controller実装
CRで指定した値を使用し、deploymentリソースのspecフィールドを書き換えるSSAを行うControllerを実装します。

実際のコードは以下のリポジトリに置いています。

https://github.com/jnytnai0613/ssa-practice-controller

SSAがGAされた時のKubernetes blog記事を確認すると、`It is strongly recommended that all Custom Resource Definitions (CRDs) have a schema.`とあることから、CRDにSSA対象のリソース定義を埋め込むべきと読み取れます。

https://kubernetes.io/blog/2021/08/06/server-side-apply-ga/#server-side-apply-and-customresourcedefinitions


そこで、今回は、[DeploymentSpecApplyConfiguration](https://github.com/kubernetes/client-go/blob/ec0f33729df006747d8685920630bacb364bf3bc/applyconfigurations/apps/v1/deploymentspec.go#L26-L37)をCRの.Specフィールドに埋め込み、deploymentリソースの.specや.spec.template.specを書き換えるSSAを実装してみました。

なお、applyconfigurationsをCRに埋め込んだ場合、build時に以下のDeepCopy関数が生成されます。
```golang
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DeploymentSpecApplyConfiguration) DeepCopyInto(out *DeploymentSpecApplyConfiguration) {
	*out = *in
	if in.Replicas != nil {
		in, out := &in.Replicas, &out.Replicas
		*out = new(int32)
		**out = **in
	}
	if in.Selector != nil {
		in, out := &in.Selector, &out.Selector
		*out = new(metav1.LabelSelectorApplyConfiguration)
		(*in).DeepCopyInto(*out)
	}
	if in.Template != nil {
		in, out := &in.Template, &out.Template
		*out = new(corev1.PodTemplateSpecApplyConfiguration)
		(*in).DeepCopyInto(*out)
	}
	if in.Strategy != nil {
		in, out := &in.Strategy, &out.Strategy
		*out = new(appsv1.DeploymentStrategyApplyConfiguration)
		(*in).DeepCopyInto(*out)
	}
	if in.MinReadySeconds != nil {
		in, out := &in.MinReadySeconds, &out.MinReadySeconds
		*out = new(int32)
		**out = **in
	}
	if in.RevisionHistoryLimit != nil {
		in, out := &in.RevisionHistoryLimit, &out.RevisionHistoryLimit
		*out = new(int32)
		**out = **in
	}
	if in.Paused != nil {
		in, out := &in.Paused, &out.Paused
		*out = new(bool)
		**out = **in
	}
	if in.ProgressDeadlineSeconds != nil {
		in, out := &in.ProgressDeadlineSeconds, &out.ProgressDeadlineSeconds
		*out = new(int32)
		**out = **in
	}
}

// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentSpecApplyConfiguration.
func (in *DeploymentSpecApplyConfiguration) DeepCopy() *DeploymentSpecApplyConfiguration {
	if in == nil {
		return nil
	}
	out := new(DeploymentSpecApplyConfiguration)
	in.DeepCopyInto(out)
	return out
}
```
しかし、ApplyConfigurationはDeepCopy関数を持っていないため、ビルド時に以下のエラー発生します。
```
$ make docker-build 
:
api/v1/zz_generated.deepcopy.go:42:9: (*in).DeepCopyInto undefined (type *"k8s.io/client-go/applyconfigurations/meta/v1".LabelSelectorApplyConfiguration has no field or method DeepCopyInto)
api/v1/zz_generated.deepcopy.go:47:9: (*in).DeepCopyInto undefined (type *"k8s.io/client-go/applyconfigurations/core/v1".PodTemplateSpecApplyConfiguration has no field or method DeepCopyInto)
api/v1/zz_generated.deepcopy.go:52:9: (*in).DeepCopyInto undefined (type *"k8s.io/client-go/applyconfigurations/apps/v1".DeploymentStrategyApplyConfiguration has no field or method DeepCopyInto)
```

この問題については、以下の記事を参考に自前でDeepCopy関数を作成の上、解決しました。

https://zenn.dev/zoetro/articles/02668f00748e1a

今回のControllerでは、以下のようにCRのspecフィールドにDeploymentSpecApplyConfigurationを持たせ、CRでdeploymentの.spec配下の指定ができるようにしました。
```golang
// SSAPracticeSpec defines the desired state of SSAPractice
type SSAPracticeSpec struct {
	DepSpec *DeploymentSpecApplyConfiguration `json:"depSpec"`
}
```

また、deployment固有の設定では、relicasとstrategyのみ指定できるようにしています。

```golang
deploymentApplyConfig := appsv1apply.Deployment("ssapractice-nginx", "ssa-practice-controller-system").
	WithSpec(appsv1apply.DeploymentSpec().
		WithSelector(metav1apply.LabelSelector().
			WithMatchLabels(labels)))

if ssapractice.Spec.DepSpec.Replicas != nil {
	replicas := *ssapractice.Spec.DepSpec.Replicas
	deploymentApplyConfig.Spec.WithReplicas(replicas)
}

if ssapractice.Spec.DepSpec.Strategy != nil {
	types := *ssapractice.Spec.DepSpec.Strategy.Type
	rollingUpdate := ssapractice.Spec.DepSpec.Strategy.RollingUpdate
	deploymentApplyConfig.Spec.WithStrategy(appsv1apply.DeploymentStrategy().
		WithType(types).
		WithRollingUpdate(rollingUpdate))
}
```
deploymentにより作成されるPodの設定は、全て受け付けるようにしています。
なお、nameとimageフィールドのいずれか1つは必須としています。
```golang
errMsg := "The name or image field is required in the '.Spec.DepSpec.Template.Spec.Containers[]'."
podTemplate = ssapractice.Spec.DepSpec.Template
for _, v := range podTemplate.Spec.Containers {
	if v.Name == nil && v.Image == nil {
		return ctrl.Result{}, fmt.Errorf("Error: %s", errMsg)
	}
}

podTemplate.WithLabels(labels)
for i, v := range podTemplate.Spec.Containers {
	if v.Image == nil {
		var (
			image  string  = "nginx"
			pimage *string = &image
		)
		podTemplate.Spec.Containers[i].Image = pimage
	}
	if v.Name == nil {
		var (
			s             = strings.Split(*v.Image, ":")
			pname *string = &s[0]
		)
		podTemplate.Spec.Containers[i].Name = pname
	}
}
deploymentApplyConfig.Spec.WithTemplate(podTemplate)

owner, err := createOwnerReferences(ssapractice, r.Scheme, log)
if err != nil {
	log.Error(err, "Unable create OwnerReference")
	return ctrl.Result{}, err
}
deploymentApplyConfig.WithOwnerReferences(owner)
```

上記で、リソース定義を作成した後は、[前述のApply関数](https://qiita.com/junya19862/items/67f648525ad104ab9f34#controller%E3%82%92%E4%BB%8B%E3%81%95%E3%81%AA%E3%81%84ssa%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89)によりSSAを行います。

CRを親とするownerReferencesも設定しているので、CRが削除されるとGCによりdeploymet、replicaset,pod全て削除されるようになっています。

## 動作確認
ログのみ貼っていますが、以下手順で正常に動作していることが確認できます。
```
# CRデプロイ前の状況確認
$ kubectl -n ssa-practice-controller-system get deployment,pod
NAME                                                         READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/ssa-practice-controller-controller-manager   1/1     1            1           3m

NAME                                                              READY   STATUS    RESTARTS   AGE
pod/ssa-practice-controller-controller-manager-6bbb65b89f-64j7r   2/2     Running   0          3m

# .spec.replicas, strategy, containers[].name, imageを指定
$ cat config/samples/ssapractice_v1_ssapractice.yaml 
apiVersion: ssapractice.jnytnai0613.github.io/v1
kind: SSAPractice
metadata:
  name: ssapractice-sample
  namespace: ssa-practice-controller-system
spec:
  depSpec:
    replicas: 5
    strategy:
      type: RollingUpdate
      rollingUpdate:
        maxSurge: 30%
        maxUnavailable: 30%
    template:
      spec:
        containers:
          - name: nginx
            image: nginx:latest

# CRのapply
$ kubectl apply -f config/samples/ssapractice_v1_ssapractice.yaml 
ssapractice.ssapractice.jnytnai0613.github.io/ssapractice-sample created

# deploymentとpodが作成される
$ kubectl -n ssa-practice-controller-system get deployment,pod
NAME                                                         READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/ssa-practice-controller-controller-manager   1/1     1            1           3m55s
deployment.apps/ssapractice-nginx                            5/5     5            5           12s

NAME                                                              READY   STATUS    RESTARTS   AGE
pod/ssa-practice-controller-controller-manager-6bbb65b89f-64j7r   2/2     Running   0          3m55s
pod/ssapractice-nginx-6b855c568f-7ffx6                            1/1     Running   0          12s
pod/ssapractice-nginx-6b855c568f-bmxxj                            1/1     Running   0          12s
pod/ssapractice-nginx-6b855c568f-mmzrh                            1/1     Running   0          12s
pod/ssapractice-nginx-6b855c568f-qlm2c                            1/1     Running   0          12s
pod/ssapractice-nginx-6b855c568f-xk48m                            1/1     Running   0          12s

＃ deploymentのyamlより、CR指定の全てのフィールドが反映されていることが確認できる。
$ kubectl -n ssa-practice-controller-system get deployment ssapractice-nginx -oyaml --show-managed-fields=true
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    deployment.kubernetes.io/revision: "1"
  creationTimestamp: "2022-07-29T12:38:07Z"
  generation: 1
  managedFields:
  - apiVersion: apps/v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:ownerReferences:
          k:{"uid":"5db9ff25-f526-439d-8b06-74544a93a1a4"}: {}
      f:spec:
        f:replicas: {}
        f:selector: {}
        f:strategy:
          f:rollingUpdate:
            f:maxSurge: {}
            f:maxUnavailable: {}
          f:type: {}
        f:template:
          f:metadata:
            f:labels:
              f:apps: {}
          f:spec:
            f:containers:
              k:{"name":"nginx"}:
                .: {}
                f:image: {}
                f:name: {}
    manager: ssapractice-fieldmanager
    operation: Apply
    time: "2022-07-29T12:38:07Z"
  - apiVersion: apps/v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:annotations:
          .: {}
          f:deployment.kubernetes.io/revision: {}
      f:status:
        f:availableReplicas: {}
        f:conditions:
          .: {}
          k:{"type":"Available"}:
            .: {}
            f:lastTransitionTime: {}
            f:lastUpdateTime: {}
            f:message: {}
            f:reason: {}
            f:status: {}
            f:type: {}
          k:{"type":"Progressing"}:
            .: {}
            f:lastTransitionTime: {}
            f:lastUpdateTime: {}
            f:message: {}
            f:reason: {}
            f:status: {}
            f:type: {}
        f:observedGeneration: {}
        f:readyReplicas: {}
        f:replicas: {}
        f:updatedReplicas: {}
    manager: kube-controller-manager
    operation: Update
    subresource: status
    time: "2022-07-29T12:38:18Z"
  name: ssapractice-nginx
  namespace: ssa-practice-controller-system
  ownerReferences:
  - apiVersion: ssapractice.jnytnai0613.github.io/v1
    blockOwnerDeletion: true
    controller: true
    kind: SSAPractice
    name: ssapractice-sample
    uid: 5db9ff25-f526-439d-8b06-74544a93a1a4
  resourceVersion: "91943"
  uid: e8def3e2-2186-41b4-8d73-8ce27e2f96b6
spec:
  progressDeadlineSeconds: 600
  replicas: 5
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      apps: ssapractice-nginx
  strategy:
    rollingUpdate:
      maxSurge: 30%
      maxUnavailable: 30%
    type: RollingUpdate
  template:
    metadata:
      creationTimestamp: null
      labels:
        apps: ssapractice-nginx
    spec:
      containers:
      - image: nginx:latest
        imagePullPolicy: Always
        name: nginx
        resources: {}
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
:
```

# おわりに
kubectlでの手動のSSA、Controllerを通したSSAと、2回にわたり、SSAについて記載しました。この記事が皆様のk8sライフに少しでも役に立てると幸いです。