🧐

カスタムコントローラーでServer-Side Apply - ApplyConfigurationのDeepCopyをなんとかする

2021/12/12に公開

これまでカスタムコントローラーでServer-Side Applyを利用する方法についていくつかの記事を書いてきました。

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

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

カスタムコントローラーでServer-Side Applyを利用すると非常に便利ではあるのですが、一つ問題が残っていました。

それは、カスタムリソースにApplyConfiguration型を埋め込んだときに、ApplyConfiguration型がDeepCopy関数を持たないために、controller-genで生成したコードがビルドできないというものです。

今回はその問題を解決する方法について紹介したいと思います。

なぜカスタムリソースにApplyConfigurationを埋め込むのか?

本題に入る前に、なぜカスタムリソースにApplyConfiguration型を埋め込む必要があるのかを確認しておきましょう。

カスタムコントローラーでは、DeploymentやStatefulSet, Service, PersistentVolumeClaimなど様々なKubernetesリソースを生成します。その生成されたリソースをユーザーがカスタマイズできるようにしておきたいことがあります。例えば、コンテナイメージを差し替えたいとか、特定のラベルを付与したい、特定のストレージを利用したいなど、ユーザーの要望は様々です。
そこで、それらのリソースをカスタマイズできるように、カスタムリソースにPodTemplate, ServiceTemplate, VolumeClaimTemplateなどのフィールドを持たせることがあります。

既存のカスタムコントローラーの例をいくつか見てみましょう。
Rook, VictoriaMetrics Operator, ECK(Elasticsearchオペレーター), MOCO(MySQLオペレーター)など、数多くのカスタムコントローラーで利用されていることが分かります。

PodTemplateは避けるべき?

やや話が脱線するのですが、カスタムリソースにPodTemplateを埋め込むべきなのかどうかを考えてみましょう。

先ほど紹介したように、ECKやMOCOではPodTemplateの型としてk8s.io/api/core/v1/PodTemplateSpecを利用していました。
一方、RookやVictoriaMetrics Operatorではこれを利用せず、独自に型を定義していました。

このPodTemplateSpecを埋め込む方式にはいくつか問題があります。

  1. 設定項目が複雑すぎる
  2. CRDが大きくなりすぎる

まず1つめの「設定項目が複雑すぎる」という問題です。

カスタムコントローラーやオペレーターが生成するPodには、ユーザーが自由にカスタマイズしていい部分と、カスタマイズさせたくない部分があります。
しかし、カスタムリソースにk8s.io/api/core/v1/PodTemplateSpecを持たせた場合、ユーザーからはあらゆる項目が設定できるように見えてしまいます。

下記の発表では、PodTemplateを持つことはアンチパターンであると紹介されています。

https://devconfcz2019.sched.com/event/JciH/anti-patterns-for-kubernetes-operators

カスタムコントローラーが管理するアプリケーションの適切なオプションはカスタムコントローラーが知っているはずなので、ユーザーにすべてを委ねてはいけないという主張ですね。

次に2つめは「CRDが大きくなりすぎる」という問題です。

例えば、ECKのElasticsearchリソースのCRDをみてみましょう。
ファイルのサイズが非常に大きく、特にPodTemplateSpec関連の記述が長いことが分かります。

この大きなCRDをkubectl applyすると、以下のようにエラーが起きてしまいます[1]

$ kubectl apply -f elasticsearch.k8s.elastic.co_elasticsearches.yaml
The CustomResourceDefinition "elasticsearches.elasticsearch.k8s.elastic.co" is invalid: metadata.annotations: Too long: must have at most 262144 bytes

kubectl applyでは適用したマニフェストの内容をmetadata.annotationsに格納するのですが、metadata.annotationsには256KBまでしか格納できないという制限があるため、このようなエラーが発生します。

この問題はServer-Side Applyすることで回避することができますが、可能であればCRDは小さく保ちたいものです。

$ kubectl apply --server-side -f elasticsearch.k8s.elastic.co_elasticsearches.yaml
customresourcedefinition.apiextensions.k8s.io/elasticsearches.elasticsearch.k8s.elastic.co serverside-applied

CRDを生成するためのツールcontroller-genのmaxDescLenオプションをしていすると、生成されるCRDのDescriptionの長さを制限することができます。

https://book.kubebuilder.io/reference/controller-gen.html

しかし、Descriptionの文章が途中でぶつ切りにされてしまいますし、それほど大きな削減にはなりません。

標準リソースのデータ型はServer-Side Applyしにくい問題

先ほど紹介したように、カスタムリソースにPodTemplateやServiceTemplateを持たせる場合、既存のカスタムコントローラー実装ではKubernetesが提供しているk8s.io/api/core/v1/PodTemplateSpeck8s.io/api/core/v1/ServiceSpecなどの型を利用していました。

しかし、実はこれらの型をカスタムリソースに埋め込むと、Server-Side Applyを実装しにくくなるという問題があります。

例えば、カスタムリソースのPodTemplateに以下のような設定をするケースを考えてみましょう。

apiVersion: sample.zoetrope.github.io/v1
kind: MyApp
metadata:
  name: myapp-sample
spec:
  podTemplate:
    template:
      spec:
        containers:
          - name: nginx
            livenessProbe:
              httpGet:
                path: /
                port: 8080
apiVersion: sample.zoetrope.github.io/v1
kind: MyApp
metadata:
  name: myapp-sample
spec:
  podTemplate:
    template:
      spec:
        containers:
          - name: nginx
            livenessProbe:
              httpGet:
                path: /
                port: 8080
              initialDelaySeconds: 0

ここでk8s.io/apiの型定義を見てみると、initialDelaySecondsはint32となっています。

https://pkg.go.dev/k8s.io/api/core/v1#Probe

この型を利用すると、カスタムコントローラーからはどちらもinitialDelaySecondsの値は0に見えます。
そのためユーザーが明示的にinitialDelaySecondsに0を指定したのか、それとも何も指定しなかったのか区別することができません。

Server-Side Applyする際には、カスタムコントローラーがどのフィールドを管理するのかを明確に指定しなければならないのですが、上記のようなフィールドの扱いには困ります。

一方、ApplyConfigurationの型定義を見てみましょう。

https://pkg.go.dev/k8s.io/client-go/applyconfigurations/core/v1#ProbeApplyConfiguration

こちらはinitialDelaySecondsは*int32となっているため、ユーザーが定義しなければnull、明示的に0を指定した場合は0となるので、カスタムコントローラーが判別可能となります。

ApplyConfigurationのDeepCopyをなんとかする

さて、カスタムリソースにはApplyConfigurationを埋め込むのがよさそうだということが分かったので、さっそく試してみましょう。

以下のように、k8s.io/client-go/applyconfigurations/core/v1PodTemplateApplyConfigurationをカスタムリソースのフィールドに追加します。

package v1

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	corev1 "k8s.io/client-go/applyconfigurations/core/v1"
)

// MyAppSpec defines the desired state of MyApp
type MyAppSpec struct {
	PodTemplate *corev1.PodTemplateApplyConfiguration `json:"podTemplate"`
}

// MyAppStatus defines the observed state of MyApp
type MyAppStatus struct {
}

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status

// MyApp is the Schema for the myapps API
type MyApp struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   MyAppSpec   `json:"spec,omitempty"`
	Status MyAppStatus `json:"status,omitempty"`
}

カスタムコントローラーの実装時には、goの構造体からCRDやDeepCopy関数を生成するためにcontroller-genというツールを利用します。
ここで上記の構造体をcontroller-genで処理すると、以下のようなコードが生成されます。

func (in *MyAppSpec) DeepCopyInto(out *MyAppSpec) {
	*out = *in
	if in.PodTemplate != nil {
		in, out := &in.PodTemplate, &out.PodTemplate
		*out = new(corev1.PodTemplateApplyConfiguration)
		(*in).DeepCopyInto(*out)
	}
}

しかし、PodTemplateApplyConfigurationはDeepCopyInto関数を持っていないため、コードをビルドすることができません。

回避策その1

一つ目の回避策は非常にシンプルです。
type PodTemplateApplyConfiguration corev1.PodTemplateApplyConfigurationのように型を定義し直して、自前でDeepCopy関数を実装するというものです。

package v1

import (
	"encoding/json"

	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	corev1 "k8s.io/client-go/applyconfigurations/core/v1"
)

type PodTemplateApplyConfiguration corev1.PodTemplateApplyConfiguration

func (c *PodTemplateApplyConfiguration) DeepCopy() *PodTemplateApplyConfiguration {
	out := new(PodTemplateApplyConfiguration)
	bytes, err := json.Marshal(c)
	if err != nil {
		panic("Failed to marshal")
	}
	err = json.Unmarshal(bytes, out)
	if err != nil {
		panic("Failed to unmarshal")
	}
	return out
}

// MyAppSpec defines the desired state of MyApp
type MyAppSpec struct {
	PodTemplate *PodTemplateApplyConfiguration `json:"podTemplate"`
}

// MyAppStatus defines the observed state of MyApp
type MyAppStatus struct {
}

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status

// MyApp is the Schema for the myapps API
type MyApp struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   MyAppSpec   `json:"spec,omitempty"`
	Status MyAppStatus `json:"status,omitempty"`
}

ここではJSONのMarshal/Unmarshalによりコピー処理を実現していますが、実装方法はなんでも構いません。

これだけで問題なくカスタムリソースにApplyConfigurationを埋め込むことが可能となります。

回避策その2

自前でDeepCopyを実装するのは面倒だという場合は、Kubernetes向けのDeepCopy関数生成ツールであるdeepcopy-genを利用して、ApplyConfiguration用のDeepCopyを自動生成してしまいましょう。

https://github.com/kubernetes/code-generator

詳しい使い方は説明しませんが、下記のリポジトリを参考にDeepCopy関数を生成してみてください。

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

そうすれば以下のように、DeepCopy関数を持つApplyConfiguration型をカスタムリソースに埋め込むことが可能になります。

package v1

import (
	corev1 "github.com/zoetrope/ssa-helper/applyconfigurations/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// MyAppSpec defines the desired state of MyApp
type MyAppSpec struct {
	PodTemplate *corev1.PodTemplateApplyConfiguration `json:"podTemplate"`
}

// MyAppStatus defines the observed state of MyApp
type MyAppStatus struct {
}

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status

// MyApp is the Schema for the myapps API
type MyApp struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   MyAppSpec   `json:"spec,omitempty"`
	Status MyAppStatus `json:"status,omitempty"`
}

まとめ

長々と書いてしまいましたが、今回紹介した方法でApplyConfigurationをカスタムリソースに埋め込むことができます。

カスタムコントローラーでServer-Side Applyを利用している事例はまだそれほど多くないようですが、とても便利なのでぜひ使ってみてください。

脚注
  1. ECKではこのパッチを適用することでpodTemplateのプロパティを削除してリリースしています。 ↩︎

Discussion