🦾

Go 1.18のGenericsを利用してServer-Side Applyに挑戦

2021/12/19に公開

以前の記事で、カスタムコントローラーでServer-Side Applyを実装するための方法を紹介しました。

https://zenn.dev/zoetro/articles/85cdfe35ccba02

筆者はいつもこの記事のコードをコピペして使っているのですが、リソースの種類ごとにコードを書き換えて再利用する必要があるため面倒に感じています。
そのためこのコードを共通化したいと考えていました。

そんな折にGo 1.18 beta1がリリースされGenericsが利用できるようになりました。

https://go.dev/blog/go1.18beta1

今回はこのGenericsを利用して、Server-Side Apply処理を型の安全性を保ったまま共通化できないか考えてみたいと思います。

共通化する方法について検討

この記事のコードを共通化する上でネックになりそうなのがExtract関数(ExtractDeploymentなど)です。
リソースごとにそれぞれ関数が用意されていて共通のインタフェースが存在しないため、共通化がが難しそうですね。

では、この関数の中身をを共通化できないか実装を見てみましょう。

https://github.com/kubernetes/client-go/blob/v0.23.0/applyconfigurations/apps/v1/deployment.go

func ExtractDeployment(deployment *apiappsv1.Deployment, fieldManager string) (*DeploymentApplyConfiguration, error) {
	return extractDeployment(deployment, fieldManager, "")
}
    
func ExtractDeploymentStatus(deployment *apiappsv1.Deployment, fieldManager string) (*DeploymentApplyConfiguration, error) {
	return extractDeployment(deployment, fieldManager, "status")
}
    
func extractDeployment(deployment *apiappsv1.Deployment, fieldManager string, subresource string) (*DeploymentApplyConfiguration, error) {
	b := &DeploymentApplyConfiguration{}
	err := managedfields.ExtractInto(deployment, internal.Parser().Type("io.k8s.api.apps.v1.Deployment"), fieldManager, b, subresource)
	if err != nil {
		return nil, err
	}
	b.WithName(deployment.Name)
	b.WithNamespace(deployment.Namespace)

	b.WithKind("Deployment")
	b.WithAPIVersion("apps/v1")
	return b, nil
}

internalなパッケージを使ったりしているので、このコードを外から呼び出して共通化することは難しそうに見えます。
共通化するためにはリソースごとに補助関数を生成して、共通のインタフェースで呼び出せるようにする必要がありそうです。

そういえば前回の記事で、ApplyConfiguration型用のDeepCopy関数を自動生成しました。
このコードにExtract用の補助関数も付け加えることにしましょう。
例えば、DeploymentApplyConfiguration型であれば、以下のような補助関数を追加します[1]

func (b *DeploymentApplyConfiguration) Extract(obj client.Object, fieldManager string, subresource string) (*DeploymentApplyConfiguration, error) {
	return extractDeployment(obj.(*apiappsv1.Deployment), fieldManager, subresource)
}

Genericsを利用したコードの共通化

上記で生成した補助関数Extractを制約として、以下のようなインタフェースを定義します。

type Extractor[T any, U client.Object] interface {
	Extract(obj U, fieldManager string, subresource string) (T, error)
}

そして、Server-Side Applyを実行するためのApply関数は以下のように定義します。

func Apply[T Extractor[T, U], U client.Object](ctx context.Context, k8sClient client.Client, expected T, fieldManager string) error

しかし、この関数を以下のように呼び出すとcannot infer Uと言われてビルドできません。

err := applyconfigurations.Apply(ctx, client, dep, "field-manager-name")

以下のように型パラメータを明示的に指定して呼びだせばコンパイルエラーは回避できますが、記述が長いですね…。

err := applyconfigurations.Apply[appsv1.DeploymentApplyConfiguration, apiappsv1.Deployment](ctx, client, dep, "field-manager-name")

以下のようにApplyにU型の変数を引数に渡せばU型を推論できますが、使わない変数を渡すのも微妙ですね。

func Apply[T Extractor[T, U], U client.Object](ctx context.Context, k8sClient client.Client, expected T, current U, fieldManager string) error
err := applyconfigurations.Apply(ctx, client, dep, &apiappsv1.Deployment{}, "field-manager-name")

やや型の制約は弱くなりますが、U型を扱うのはやめてclient.Object型を利用しましょう。

type Extractor[T any] interface {
	Extract(obj client.Object, fieldManager string, subresource string) (T, error)
}

これでApply関数は以下のように実装することができました。

func Apply[T Extractor[T]](ctx context.Context, k8sClient client.Client, expected T, fieldManager string) error {
	obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(expected)
	if err != nil {
		return err
	}
	patch := &unstructured.Unstructured{
		Object: obj,
	}

	res := expected.Original()
	key, err := expected.ObjectKey()
	if err != nil {
		return err
	}
	err = k8sClient.Get(ctx, key, res)
	if err != nil && !errors.IsNotFound(err) {
		return err
	}

	current, err := expected.Extract(res, fieldManager, "")
	if err != nil {
		return err
	}

	if equality.Semantic.DeepEqual(expected, current) {
		return nil
	}

	return k8sClient.Patch(ctx, patch, client.Apply, &client.PatchOptions{
		FieldManager: fieldManager,
		Force:        pointer.Bool(true),
	})
}

ssa-helper

上記のApply関数と、Kubernetesの標準リソース向けに補助関数を生成したコードが以下においてあります。

https://github.com/zoetrope/ssa-helper

これを利用すると、以下のようにクライアント側で差分計算をするServer-Side Applyが非常に簡単に実装できるようになっています。

package main

import (
	"context"
	"fmt"

	"github.com/zoetrope/ssa-helper/applyconfigurations"
	appsv1 "github.com/zoetrope/ssa-helper/applyconfigurations/apps/v1"
	corev1 "github.com/zoetrope/ssa-helper/applyconfigurations/core/v1"
	metav1 "github.com/zoetrope/ssa-helper/applyconfigurations/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/client-go/kubernetes/scheme"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/client/config"
)

func main() {
	// Prepare
	ctx := context.Background()
	scm := runtime.NewScheme()
	err := scheme.AddToScheme(scm)
	if err != nil {
		fmt.Println(err)
		return
	}
	k8sClient, err := getClient(scm)
	if err != nil {
		fmt.Println(err)
		return
	}

	// Server-Side Apply
	dep := appsv1.Deployment("nginx", "default").
		WithLabels(map[string]string{"component": "nginx"}).
		WithSpec(appsv1.DeploymentSpec().
			WithReplicas(1).
			WithSelector(metav1.LabelSelector().WithMatchLabels(map[string]string{"component": "nginx"})).
			WithTemplate(corev1.PodTemplateSpec().
				WithLabels(map[string]string{"component": "nginx"}).
				WithSpec(corev1.PodSpec().
					WithContainers(corev1.Container().
						WithName("nginx").
						WithImage("nginx:latest"),
					),
				),
			),
		)
	err = applyconfigurations.Apply(ctx, k8sClient, dep, "field-manager-name")
	if err != nil {
		fmt.Println(err)
		return
	}
}

まとめ

本記事ではGo 1.18で導入されるGenericsを利用して、KubernetesのカスタムコントローラのServer-Side Apply処理を共通化する方法について紹介しました。
今回はGenericsを使うことを目的として実装したため、ややいびつな設計となってしまいました。

将来的には、client-goやcontroller-runtimeがGenericsを使ってより使いやすいライブラリを提供してくれることを期待したいですね。

脚注
  1. 他にもいくつか補助関数を生成していますが、ここでは説明を省略します。 ↩︎

Discussion