🤔

KubernetesのReconcile処理で無駄な更新をしない方法

2021/03/28に公開

2021/04/02追記: Kubernetes 1.21以降ではServer Side Apply方式を利用するのがよさそうです。こちらの記事もご覧ください。

3行まとめ

  • kubebuilder/controller-runtimeを使ってKubernetes用のコントローラやオペレータを作るときの話
  • DeploymentやServiceを作成するとkube-apiserverがデフォルト値をセットするが、その値をコントローラで上書きしてはいけない
  • 上書きを回避する方法はいろいろあるがどれも一長一短

はじめに

本記事では単純な例として、以下のようなDeploymentを作成するコントローラを考えます。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-sample-nginx
  labels:
    component: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      component: nginx
  template:
    metadata:
      labels:
        component: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:latest

なお、本記事中で登場するコードは以下のリポジトリに置いてあります: https://github.com/zoetrope/reconcile-tips

単純なリソース作成

まずは、単純にDploymentを作成するコードを書いてみます。

func (r *MyAppReconciler) reconcileDeploymentByOverwriting(ctx context.Context, myapp *samplev1.MyApp) error {
	dep := &appsv1.Deployment{}
	dep.Namespace = myapp.Namespace
	dep.Name = myapp.Name + "-nginx"

	_, err := ctrl.CreateOrUpdate(ctx, r.Client, dep, func() error {
		dep.Labels = map[string]string{
			"component": "nginx",
		}
		dep.Spec = appsv1.DeploymentSpec{
			Replicas: pointer.Int32Ptr(1),
			Selector: &metav1.LabelSelector{
				MatchLabels: map[string]string{
					"component": "nginx",
				},
			},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{
					Labels: map[string]string{
						"component": "nginx",
					},
				},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{
						{
							Name:  "nginx",
							Image: "nginx:latest",
						},
					},
				},
			},
		}
		return ctrl.SetControllerReference(myapp, dep, r.Scheme)
	})
	return err
}

このコードは一見正しく動きそうですが、Reconcile処理が実行されるたびにkube-apiserverに無駄なリクエストが発生します。

上記の処理でDeploymentを作成すると、kube-apiserverがいくつかのフィールドのデフォルト値を埋めます。具体的には下記のような差分が発生します。

--- desired.yaml
+++ actual.yaml
@@ -6,15 +6,32 @@
   name: myapp-sample-nginx
   namespace: default
 spec:
+  progressDeadlineSeconds: 600
   replicas: 1
+  revisionHistoryLimit: 10
   selector:
     matchLabels:
       component: nginx
+  strategy:
+    rollingUpdate:
+      maxSurge: 25%
+      maxUnavailable: 25%
+    type: RollingUpdate
   template:
     metadata:
+      creationTimestamp: null
       labels:
         component: nginx
     spec:
       containers:
       - image: nginx:latest
+        imagePullPolicy: IfNotPresent
         name: nginx
+        resources: {}
+        terminationMessagePath: /dev/termination-log
+        terminationMessagePolicy: File
+      dnsPolicy: ClusterFirst
+      restartPolicy: Always
+      schedulerName: default-scheduler
+      securityContext: {}
+      terminationGracePeriodSeconds: 30

controller-runtimeが提供するクライアントは、Kubernetes上のリソースを監視しており、その値をインメモリ上にキャッシュしています。
そしてcontrollerutil#CreateOrUpdateは、インメモリキャッシュの値と、これから更新しようとしている値を比較し、差分があった場合にだけkube-apiserverにUpdateリクエストを投げます。
インメモリにキャッシュされている値はデフォルト値が埋められているため、CreateOrUpdate時に必ず差分が生じてしまい、無駄なリクエストが発生します。

このような無駄なリクエストはkube-apiserverへの負荷につながってしまうため、できる限り避けたいものです。

必要な項目だけ上書きする

では、無駄な更新作業が起きないようにコードを書き換えてみます。
Kubernetesから取得したリソースをベースに、必要な項目だけ書き換えるようにします。

func (r *MyAppReconciler) reconcileDeploymentByManualMerge(ctx context.Context, myapp *samplev1.MyApp) error {
	dep := &appsv1.Deployment{}
	dep.Namespace = myapp.Namespace
	dep.Name = myapp.Name + "-nginx"

	_, err := ctrl.CreateOrUpdate(ctx, r.Client, dep, func() error {
		if dep.Labels == nil {
			dep.Labels = make(map[string]string)
		}
		dep.Labels["component"] = "nginx"
		dep.Spec.Replicas = pointer.Int32Ptr(1)
		dep.Spec.Selector = &metav1.LabelSelector{
			MatchLabels: map[string]string{
				"component": "nginx",
			},
		}
		dep.Spec.Template.Labels = map[string]string{
			"component": "nginx",
		}
		if len(dep.Spec.Template.Spec.Containers) == 0 {
			dep.Spec.Template.Spec.Containers = []corev1.Container{
				{
					Name:  "nginx",
					Image: "nginx:latest",
				},
			}
		}
		return ctrl.SetControllerReference(myapp, dep, r.Scheme)
	})
	return err
}

これで無駄な更新処理はなくなりました。
上記のような単純な例であればこれで問題ないでしょう。

必要な項目だけ上書きするより複雑な例 (podTemplateのマージ)

さて、今回の題材としているようなDeploymentを作成するコントローラでは、カスタムリソースにpodTemplateを持たせて、利用者がPodの定義をカスタマイズ可能にするというテクニックがよく用いられます。

例えば、以下のようなカスタムリソースを作成して、利用するコンテナイメージのバージョンを変更したり、デバッグ用のサイドカーコンテナを差し込んだり、様々なカスタマイズが可能になります。

apiVersion: sample.zoetrope.github.io/v1
kind: MyApp
metadata:
  name: myapp-sample
spec:
  podTemplate:
    template:
      spec:
        containers:
          - name: nginx
            image: nginx:1.19.0
	  - name: debug
	    image: ubuntu:20.04

では、このpodTemplateを利用してDeploymentを生成するコードを書いてみましょう。
先ほどの例では更新すべき項目が少なかったため単純なコードですみましたが、podTemplateが指定された場合はどの項目を更新すべきか1つずつ確認しなければなりません。

func (r *MyAppReconciler) reconcileDeploymentByManualMerge2(ctx context.Context, myapp *samplev1.MyApp) error {
	dep := &appsv1.Deployment{}
	dep.Namespace = myapp.Namespace
	dep.Name = myapp.Name + "-nginx"

	_, err := ctrl.CreateOrUpdate(ctx, r.Client, dep, func() error {
		if dep.Labels == nil {
			dep.Labels = make(map[string]string)
		}
		dep.Labels["component"] = "nginx"
		dep.Spec.Replicas = pointer.Int32Ptr(1)
		dep.Spec.Selector = &metav1.LabelSelector{
			MatchLabels: map[string]string{
				"component": "nginx",
			},
		}

		podTemplate := myapp.Spec.PodTemplate.Template.DeepCopy()
		if podTemplate.Labels == nil {
			podTemplate.Labels = make(map[string]string)
		}
		podTemplate.Labels["component"] = "nginx"
		hasNginxContainer := false
		for _, c := range podTemplate.Spec.Containers {
			if c.Name == "nginx" {
				hasNginxContainer = true
			}
		}
		if !hasNginxContainer {
			podTemplate.Spec.Containers = append(podTemplate.Spec.Containers, corev1.Container{
				Name:  "nginx",
				Image: "nginx:latest",
			})
		}
		for i, c := range podTemplate.Spec.Containers {
			for _, cur := range dep.Spec.Template.Spec.Containers {
				if c.Name == cur.Name {
					if len(c.ImagePullPolicy) == 0 && len(cur.ImagePullPolicy) > 0 {
						podTemplate.Spec.Containers[i].ImagePullPolicy = cur.ImagePullPolicy
					}
					if len(c.TerminationMessagePath) == 0 && len(cur.TerminationMessagePath) > 0 {
						podTemplate.Spec.Containers[i].TerminationMessagePath = cur.TerminationMessagePath
					}
					if len(c.TerminationMessagePolicy) == 0 && len(cur.TerminationMessagePolicy) > 0 {
						podTemplate.Spec.Containers[i].TerminationMessagePolicy = cur.TerminationMessagePolicy
					}
					/* 中略 */
				}
			}
		}
		if len(podTemplate.Spec.RestartPolicy) == 0 && len(dep.Spec.Template.Spec.RestartPolicy) > 0 {
			podTemplate.Spec.RestartPolicy = dep.Spec.Template.Spec.RestartPolicy
		}
		if len(podTemplate.Spec.SchedulerName) == 0 && len(dep.Spec.Template.Spec.SchedulerName) > 0 {
			podTemplate.Spec.SchedulerName = dep.Spec.Template.Spec.SchedulerName
		}
		if len(podTemplate.Spec.DNSPolicy) == 0 && len(dep.Spec.Template.Spec.DNSPolicy) > 0 {
			podTemplate.Spec.DNSPolicy = dep.Spec.Template.Spec.DNSPolicy
		}
		if podTemplate.Spec.TerminationGracePeriodSeconds == nil && dep.Spec.Template.Spec.TerminationGracePeriodSeconds != nil {
			podTemplate.Spec.TerminationGracePeriodSeconds = dep.Spec.Template.Spec.TerminationGracePeriodSeconds
		}
		if podTemplate.Spec.SecurityContext == nil && dep.Spec.Template.Spec.SecurityContext != nil {
			podTemplate.Spec.SecurityContext = dep.Spec.Template.Spec.SecurityContext.DeepCopy()
		}
		/* 中略 */

		podTemplate.DeepCopyInto(&dep.Spec.Template)
		return ctrl.SetControllerReference(myapp, dep, r.Scheme)
	})
	return err
}

かなりめんどくさいですね。
実はこのコードはチェック処理を省略しています。initContainerslivenessProbe, readinessProbeなども考慮するともっと長いコードになってしまいます。

DeepDerivativeによる比較

1項目ずつフィールドをチェックするのはかなり手間なので、equality.Semantic.DeepDerivativeを利用して比較処理をおこない、差分が生じたときだけ更新するようにしてみましょう。
DeepDerivativeは、比較するフィールドの値がnilや空文字だったり、mapに存在しないキーだった場合に、そのフィールドを無視して比較してくれるというものです。
これにより、podTemplateでは指定されていないがkube-apiserverがデフォルト値を設定したフィールドに関しては比較時に無視されるため、更新処理をおこなわないようにできます。

func (r *MyAppReconciler) reconcileDeploymentByDeepDerivative(ctx context.Context, myapp *samplev1.MyApp) error {
	dep := &appsv1.Deployment{}
	dep.Namespace = myapp.Namespace
	dep.Name = myapp.Name + "-nginx"

	_, err := ctrl.CreateOrUpdate(ctx, r.Client, dep, func() error {
		if dep.Labels == nil {
			dep.Labels = make(map[string]string)
		}
		dep.Labels["component"] = "nginx"
		dep.Spec.Replicas = pointer.Int32Ptr(1)
		dep.Spec.Selector = &metav1.LabelSelector{
			MatchLabels: map[string]string{
				"component": "nginx",
			},
		}

		podTemplate := myapp.Spec.PodTemplate.Template.DeepCopy()
		if podTemplate.Labels == nil {
			podTemplate.Labels = make(map[string]string)
		}
		podTemplate.Labels["component"] = "nginx"
		hasNginxContainer := false
		for _, c := range podTemplate.Spec.Containers {
			if c.Name == "nginx" {
				hasNginxContainer = true
			}
		}
		if !hasNginxContainer {
			podTemplate.Spec.Containers = append(podTemplate.Spec.Containers, corev1.Container{
				Name:  "nginx",
				Image: "nginx:latest",
			})
		}
		if !equality.Semantic.DeepDerivative(*podTemplate, dep.Spec.Template) {
			podTemplate.DeepCopyInto(&dep.Spec.Template)
		}

		return ctrl.SetControllerReference(myapp, dep, r.Scheme)
	})
	return err
}

しかし、この実装にはいくつか問題があります。
DeepDerivativeはnilや空文字は無視しますが、数値型のフィールドに0が入っていた場合は比較対象となってしまいます。そのためlivenessProbeなどのフィールドは差分が生じてしまいます。
また、カスタムリソースが編集されpodTemplateから何らかの要素が削除されたときに、それを検出することができずに不要な要素が残ってしまうケースもあります。

StrategicMergePatch

次にStrategicMergePatch方式で書いてみましょう。
これは古くからkubectl applyで利用されてきた方式で、前回の適用内容をアノテーションに保持しておき、いい感じに差分を計算して適用してくれます。
詳しくはKubernetes: kubectl apply の動作が参考になります。

func (r *MyAppReconciler) reconcileDeploymentByStrategicMergePatch(ctx context.Context, myapp *samplev1.MyApp) error {
	dep := &appsv1.Deployment{}
	dep.Namespace = myapp.Namespace
	dep.Name = myapp.Name + "-nginx"

	if dep.Labels == nil {
		dep.Labels = make(map[string]string)
	}
	dep.Labels["component"] = "nginx"
	dep.Spec.Replicas = pointer.Int32Ptr(1)
	dep.Spec.Selector = &metav1.LabelSelector{
		MatchLabels: map[string]string{
			"component": "nginx",
		},
	}
	podTemplate := myapp.Spec.PodTemplate.Template.DeepCopy()
	if podTemplate.Labels == nil {
		podTemplate.Labels = make(map[string]string)
	}
	podTemplate.Labels["component"] = "nginx"
	hasNginxContainer := false
	for _, c := range podTemplate.Spec.Containers {
		if c.Name == "nginx" {
			hasNginxContainer = true
		}
	}
	if !hasNginxContainer {
		podTemplate.Spec.Containers = append(podTemplate.Spec.Containers, corev1.Container{
			Name:  "nginx",
			Image: "nginx:latest",
		})
	}
	podTemplate.DeepCopyInto(&dep.Spec.Template)
	err := ctrl.SetControllerReference(myapp, dep, r.Scheme)
	if err != nil {
		return err
	}

	depEncoded, err := runtime.Encode(unstructured.UnstructuredJSONScheme, dep)
	if err != nil {
		return err
	}
	if dep.Annotations == nil {
		dep.Annotations = make(map[string]string)
	}
	dep.Annotations["last-applied"] = string(depEncoded)

	var current appsv1.Deployment
	err = r.Get(ctx, client.ObjectKey{Namespace: myapp.Namespace, Name: myapp.Name + "-nginx"}, &current)
	if err != nil && !errors.IsNotFound(err) {
		return fmt.Errorf("failed to get deployment: %w", err)
	}
	if errors.IsNotFound(err) {
		err = r.Create(ctx, dep)
		return err
	}
	current.SetCreationTimestamp(metav1.Time{})
	currentBytes, err := json.Marshal(current)
	if err != nil {
		return err
	}

	modified, err := json.Marshal(dep)
	if err != nil {
		return err
	}
	lastApplied := []byte(dep.Annotations["last-applied"])
	if len(lastApplied) == 0 {
		lastApplied = modified
	}

	patchMeta, err := strategicpatch.NewPatchMetaFromStruct(current)
	patch, err := strategicpatch.CreateThreeWayMergePatch(lastApplied, modified, currentBytes, patchMeta, true)
	if err != nil {
		return err
	}
	return r.Patch(ctx, &current, client.RawPatch(types.StrategicMergePatchType, patch))
}

実装は少し難しいですが、なかなか良さそうです。

Server Side Apply

StrategicMergePatchは、クライアントサイドで差分計算をおこなうため、コンフリクトの検出ができない問題などがありました。
そこで次はServer Side Apply方式で書いてみます。
詳しくはKubernetes 1.14: Server-side Apply (alpha)が参考になります。

func (r *MyAppReconciler) reconcileDeploymentBySSA2(ctx context.Context, myapp *samplev1.MyApp) error {
	dep := &appsv1.Deployment{}
	dep.Kind = "Deployment"
	dep.APIVersion = appsv1.SchemeGroupVersion.String()
	dep.Namespace = myapp.Namespace
	dep.Name = myapp.Name + "-nginx"

	if dep.Labels == nil {
		dep.Labels = make(map[string]string)
	}
	dep.Labels["component"] = "nginx"
	dep.Spec.Replicas = pointer.Int32Ptr(1)
	dep.Spec.Selector = &metav1.LabelSelector{
		MatchLabels: map[string]string{
			"component": "nginx",
		},
	}
	podTemplate := myapp.Spec.PodTemplate.Template.DeepCopy()
	if podTemplate.Labels == nil {
		podTemplate.Labels = make(map[string]string)
	}
	podTemplate.Labels["component"] = "nginx"
	hasNginxContainer := false
	for _, c := range podTemplate.Spec.Containers {
		if c.Name == "nginx" {
			hasNginxContainer = true
		}
	}
	if !hasNginxContainer {
		podTemplate.Spec.Containers = append(podTemplate.Spec.Containers, corev1.Container{
			Name:  "nginx",
			Image: "nginx:latest",
		})
	}
	podTemplate.DeepCopyInto(&dep.Spec.Template)
	err := ctrl.SetControllerReference(myapp, dep, r.Scheme)
	if err != nil {
		return err
	}

	return r.Patch(ctx, dep, client.Apply, &client.PatchOptions{
		FieldManager: "myapp-operator",
	})
}

上記のコードは、差分がないケースでもReconcileが実行されるたびにresourceVersionが更新されてしまう問題があります。どうやら、パッチで送信しているデータに更新する必要のないフィールド(metadata.creationTimestampなど)が含まれていることが問題のようです。

そこで、パッチの内容を一度Unstructured型に変換し、更新する必要のないフィールドを削除してみましょう。

func (r *MyAppReconciler) reconcileDeploymentBySSA3(ctx context.Context, myapp *samplev1.MyApp) error {
	dep := &appsv1.Deployment{}
	dep.Namespace = myapp.Namespace
	dep.Name = myapp.Name + "-nginx"

	if dep.Labels == nil {
		dep.Labels = make(map[string]string)
	}
	dep.Labels["component"] = "nginx"
	dep.Spec.Replicas = pointer.Int32Ptr(1)
	dep.Spec.Selector = &metav1.LabelSelector{
		MatchLabels: map[string]string{
			"component": "nginx",
		},
	}
	podTemplate := myapp.Spec.PodTemplate.Template.DeepCopy()
	if podTemplate.Labels == nil {
		podTemplate.Labels = make(map[string]string)
	}
	podTemplate.Labels["component"] = "nginx"
	hasNginxContainer := false
	for _, c := range podTemplate.Spec.Containers {
		if c.Name == "nginx" {
			hasNginxContainer = true
		}
	}
	if !hasNginxContainer {
		podTemplate.Spec.Containers = append(podTemplate.Spec.Containers, corev1.Container{
			Name:  "nginx",
			Image: "nginx:latest",
		})
	}
	podTemplate.DeepCopyInto(&dep.Spec.Template)
	err := ctrl.SetControllerReference(myapp, dep, r.Scheme)
	if err != nil {
		return err
	}

	obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(dep)
	if err != nil {
		return err
	}
	delete(obj["metadata"].(map[string]interface{}), "creationTimestamp")
	delete(obj["spec"].(map[string]interface{}), "strategy")
	delete(obj["spec"].(map[string]interface{})["template"].(map[string]interface{}), "creationTimestamp")
	for i, co := range podTemplate.Spec.Containers {
		if len(co.Resources.Limits) == 0 && len(co.Resources.Requests) == 0 {
			delete(obj["spec"].(map[string]interface{})["template"].(map[string]interface{})["spec"].(map[string]interface{})["containers"].([]interface{})[i].(map[string]interface{}), "resources")
		}
	}

	patch := &unstructured.Unstructured{
		Object: obj,
	}
	patch.SetGroupVersionKind(schema.GroupVersionKind{
		Group:   "apps",
		Version: "v1",
		Kind:    "Deployment",
	})
	return r.Patch(ctx, patch, client.Apply, &client.PatchOptions{
		FieldManager: "myapp-operator",
	})
}

これで、Server Side Applyでも無駄な更新処理がなくなりました。
しかし、更新不要なフィールドを発見するのが少々面倒ですね…

また、Server Side Applyではkube-apiserverで差分チェックをおこなうという特性上、必ずkube-apiserverにリクエストを投げる必要があります。

まとめ

Reconcileで無駄な更新処理をおこなわない実装方式をいくつか紹介しました。
どの方式も一長一短ですが、強いて言うならStrategicMergePatch方式がもっともデメリットが少なくてよさそうです。
他にもよい実装方法があれば教えてください。

Discussion