カスタムコントローラーでServer-Side Apply - ApplyConfigurationのDeepCopyをなんとかする
これまでカスタムコントローラーでServer-Side Applyを利用する方法についていくつかの記事を書いてきました。
カスタムコントローラーで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オペレーター)など、数多くのカスタムコントローラーで利用されていることが分かります。
- https://rook.io/docs/rook/v1.8/ceph-cluster-crd.html
- https://github.com/VictoriaMetrics/operator/blob/master/docs/api.MD
- https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-customize-pods.html
- https://cybozu-go.github.io/moco/crd_mysqlcluster.html#podtemplatespec
PodTemplateは避けるべき?
やや話が脱線するのですが、カスタムリソースにPodTemplateを埋め込むべきなのかどうかを考えてみましょう。
先ほど紹介したように、ECKやMOCOではPodTemplateの型としてk8s.io/api/core/v1/PodTemplateSpec
を利用していました。
一方、RookやVictoriaMetrics Operatorではこれを利用せず、独自に型を定義していました。
このPodTemplateSpecを埋め込む方式にはいくつか問題があります。
- 設定項目が複雑すぎる
- CRDが大きくなりすぎる
まず1つめの「設定項目が複雑すぎる」という問題です。
カスタムコントローラーやオペレーターが生成するPodには、ユーザーが自由にカスタマイズしていい部分と、カスタマイズさせたくない部分があります。
しかし、カスタムリソースにk8s.io/api/core/v1/PodTemplateSpec
を持たせた場合、ユーザーからはあらゆる項目が設定できるように見えてしまいます。
下記の発表では、PodTemplateを持つことはアンチパターンであると紹介されています。
カスタムコントローラーが管理するアプリケーションの適切なオプションはカスタムコントローラーが知っているはずなので、ユーザーにすべてを委ねてはいけないという主張ですね。
次に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/PodTemplateSpec
やk8s.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となっています。
この型を利用すると、カスタムコントローラーからはどちらもinitialDelaySeconds
の値は0に見えます。
そのためユーザーが明示的にinitialDelaySeconds
に0を指定したのか、それとも何も指定しなかったのか区別することができません。
Server-Side Applyする際には、カスタムコントローラーがどのフィールドを管理するのかを明確に指定しなければならないのですが、上記のようなフィールドの扱いには困ります。
一方、ApplyConfigurationの型定義を見てみましょう。
こちらはinitialDelaySeconds
は*int32となっているため、ユーザーが定義しなければnull、明示的に0を指定した場合は0となるので、カスタムコントローラーが判別可能となります。
ApplyConfigurationのDeepCopyをなんとかする
さて、カスタムリソースにはApplyConfigurationを埋め込むのがよさそうだということが分かったので、さっそく試してみましょう。
以下のように、k8s.io/client-go/applyconfigurations/core/v1
のPodTemplateApplyConfiguration
をカスタムリソースのフィールドに追加します。
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を自動生成してしまいましょう。
詳しい使い方は説明しませんが、下記のリポジトリを参考にDeepCopy関数を生成してみてください。
そうすれば以下のように、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を利用している事例はまだそれほど多くないようですが、とても便利なのでぜひ使ってみてください。
Discussion