この投稿では、controller-runtime
v0.22で導入されたServer-Side Apply(SSA)のネイティブサポートについて使い方を紹介します。
また、Kubernetesのコントローラ開発において、カスタムリソース(CRD)をSSAしやすいようにする設定などもあわせて解説します。
この投稿で知れること
- Server-Side Apply(SSA)の基本概念とメリット
- ApplyConfigurationの役割と構造
- controller-runtime v0.22以前との実装の違い
- CRDからApplyConfigurationを自動生成する方法
- 新しい
client.Apply
メソッドの実践的な使い方 - 複数のコントローラが協調動作する完全なサンプルコード
Server-Side Applyとは
Server-Side Apply(SSA)は、Kubernetes API(サーバーサイド)がフィールドの所有権を考慮しながら、マニフェストをいいかんじにマージし、衝突や上書き合戦を防ぐapply方式です。
従来のUpdate
やPatch
メソッドでは、「誰がどのフィールドを管理しているか」が曖昧でした。そのため、複数のコントローラが同じリソースを更新しようとすると、意図しない上書き(競合)が発生することがありました。
SSAでは、リソースを適用する際にマネージャ名(Field Manager)を指定します。APIサーバは、どのマネージャがどのフィールドを最後に変更したかをmetadata.managedFields
に記録します。
この仕組みで得られるメリットは次のとおりです:
- 所有権が明確になるので、複数のコントローラが同じリソースの異なるフィールドを安全に管理できる
- 異なるマネージャが同じフィールドを変更しようとすると、APIサーバが競合を検出してエラーを返えす
- 「あるべき姿」をAPIサーバに伝えるだけでよく、クライアントでパッチの差分を考える必要がない
この強力な仕組みをcontroller-runtime
から簡単に利用できるようにしたのが、v0.22で導入されたclient.Apply
になります。
ApplyConfigurationの概要
ApplyConfigurationは、SSAでリソースを適用する際の「あるべき姿」を表現するための、型付けされたGoの構造体です。
SSAは自分が関心のあるフィールドだけをメンテできるというのが特徴の一つなのですが、通常のKubernetesリソース(たとえばappsv1.Deployment
など)では、自分が管理したくない必須フィールドを省略することができず、SSAに用いるのはやや不便です。ApplyConfigurationは、各フィールドに対してビルダー形式のメソッドを提供してくれるので、調整したい項目だけを記述できるようになっています。
// DeploymentのApplyConfigurationの例
dep := appsv1ac.Deployment("nginx", "default").
WithSpec(
appsv1ac.DeploymentSpec().
WithReplicas(pointer.Int32(2)),
)
この形式により、「どのフィールドを更新したいか」だけを明示的に指定できます。指定しなかったフィールドは「管理しない」と見なされるため、他のマネージャが管理しているフィールドを誤って上書きすることがありません。
k8s.io/client-go
は、Kubernetesのビルトインリソース(apps/v1
やcore/v1
など)に対応するApplyConfiguration型をk8s.io/client-go/applyconfigurations/*
パッケージで提供しています。
そして、controller-runtime
のツールを使えば、カスタムリソース定義(CRD)に対しても、このApplyConfigurationを自動生成できます。この使い方は後述します。
v0.22以前の実装方法
新しいclient.Apply
が登場する前は、SSAを利用するために少し冗長な手順が必要でした。
ApplyConfigurationオブジェクトを一度unstructured.Unstructured
(型情報のないマップ形式)に変換し、それをclient.Patch
メソッドにパッチ種別client.Apply
を指定して渡す必要がありました。
// v0.22以前の一般的な方法
import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
appsv1ac "k8s.io/client-go/applyconfigurations/apps/v1"
)
func applyOldWay(ctx context.Context, c client.Client) error {
// 1. ApplyConfigurationを作成
depAC := appsv1ac.Deployment("nginx", "default").
WithSpec( /* ... */ )
// 2. Unstructuredに変換
u := &unstructured.Unstructured{}
var err error
u.Object, err = runtime.DefaultUnstructuredConverter.ToUnstructured(depAC)
if err != nil {
return err
}
// 3. Patchメソッドで適用
return c.Patch(ctx, u, client.Apply,
client.FieldOwner("my-controller"),
client.ForceOwnership,
)
}
この方法は機能的には問題ありませんが、Unstructuredへの変換が結構煩雑でした。(いつも書き方を忘れてググってます)
Goの型定義からApplyConfigurationを生成する
それでは、本題であるCRDからApplyConfigurationを生成し、新しいclient.Apply
を使う方法を見ていきます。
CRDの型を定義する
まず、基本となるCRDの型を定義します。ここでは、猫の情報を管理するCat
リソースを例にします。
api/v1/cat_types.go
:
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// Cat is a custom resource with Spec and Status
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Namespaced
type Cat struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
// Spec defines the desired state of Cat
Spec CatSpec `json:"spec,omitempty"`
// Status represents the status of the Cat resource
Status CatStatus `json:"status,omitempty"`
}
// CatSpec defines the desired state of Cat
type CatSpec struct {
// Breed is the breed of the cat
// +optional
Breed string `json:"breed,omitempty"`
// Color is the color of the cat
// +optional
Color string `json:"color,omitempty"`
// Age is the age of the cat in years
// +optional
Age int32 `json:"age,omitempty"`
}
// CatStatus defines the observed state of Cat
type CatStatus struct {
// Conditions represent the latest available observations of Cat's state
// +optional
// +listType=map
// +listMapKey=type
// +kubebuilder:validation:MaxItems=32
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
}
// CatList contains a list of Cat objects
// +kubebuilder:object:root=true
type CatList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Cat `json:"items"`
}
func init() {
SchemeBuilder.Register(&Cat{}, &CatList{})
}
このへんは普通のいつもどおりのCRDの作りかたと一緒です。
ApplyConfiguration生成マーカーを追加する
次に、コードジェネレータに対してApplyConfigurationを生成するよう指示するマーカーを、パッケージのドキュメントコメントに追加します。
api/v1/doc.go
:
// Package v1 is the v1 version of the API.
// +groupName=example.com
// +kubebuilder:object:generate=true
// +kubebuilder:ac:generate=true
// +kubebuilder:ac:output:package=../../client
package v1
重要なのは次の2行です。
-
// +kubebuilder:ac:generate=true
によって、このパッケージ内の型に対してApplyConfigurationの生成を有効にする -
// +kubebuilder:ac:output:package=../../client
によって、生成されたコードを指定したGoパッケージに出力する(この例では、プロジェクトルートのclient
ディレクトリを指しています)
コードジェネレータを実行する
マーカーを追加したら、controller-gen
コマンドを実行してコードを生成します。
# controller-genがインストールされていない場合
# go install sigs.k8s.io/controller-tools/cmd/controller-gen@latest
controller-gen applyconfiguration paths="./..."
実行後、client/api/v1
ディレクトリにcat.go
、catspec.go
、catstatus.go
といったファイルが生成されているはずです。中身を見てみると、ビルダー形式のメソッドを持つApplyConfigurationの型が定義されていることがわかります。
client/api/v1/cat.go
(生成されたコードの一部):
// CatApplyConfiguration represents an declarative configuration of the Cat type for use
// with apply.
type CatApplyConfiguration struct {
v1.TypeMetaApplyConfiguration `json:",inline"`
*v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"`
Spec *CatSpecApplyConfiguration `json:"spec,omitempty"`
Status *CatStatusApplyConfiguration `json:"status,omitempty"`
}
// Cat constructs an declarative configuration of the Cat type for use with
// apply.
func Cat(name, namespace string) *CatApplyConfiguration {
// ...
}
これで準備は完了です。
client.Client
での新しい使い方
生成されたApplyConfigurationを使って、実際にリソースを操作してみます。
client.Apply
でリソースを作成・更新する
新しいclient.Apply
メソッドは、ApplyConfigurationオブジェクトを直接受け取ることができます。これにより、型安全かつ直感的にSSAを実行できます。
import (
"context"
catv1 "github.com/your-org/your-repo/api/v1"
applycatv1 "github.com/your-org/your-repo/client/api/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func createOrUpdateCat(ctx context.Context, cl client.Client) error {
// 1. 生成されたApplyConfigurationを使って「あるべき姿」を定義
catAC := applycatv1.Cat("my-cat", "default").
WithSpec(applycatv1.CatSpec().
WithBreed("Maine Coon").
WithColor("Black").
WithAge(3))
// 2. client.Applyで適用
// FieldOwnerでマネージャ名を指定するのが重要
err := cl.Apply(ctx, catAC, &client.ApplyOptions{
FieldManager: "cat-owner",
Force: true, // 競合した場合に所有権を強制上書き
})
return err
}
以前の方法と比べて、Unstructured
への変換が不要になり、コードが非常にシンプルになりました。client.Apply
の第二引数には、client.FieldOwner("cat-owner")
やclient.ForceOwnership
のようなオプションを渡すこともできます。
Status
サブリソースを更新する
spec
とは異なり、status
サブリソースの更新には、まだApply
メソッドが用意されていません。なのでstatus
をSSAで更新する場合、従来通りStatus().Patch()
を使います。statusなどのサブリソースのSSA対応は下記issueにてトラッキングされているので、気になる人は見てみてください。
次のサンプルがstatusにSSAするものです。
import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
applymetav1 "k8s.io/client-go/applyconfigurations/meta/v1"
)
// unstruct はApplyConfigurationをUnstructuredに変換するヘルパー
func unstruct(apply any) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(apply)
if err != nil {
panic(err)
}
u.Object = obj
return u
}
func updateStatus(ctx context.Context, cl client.Client) error {
// 1. Status部分だけのApplyConfigurationを作成
patch := applycatv1.Cat("my-cat", "default").
WithStatus(applycatv1.CatStatus().
WithConditions(applymetav1.Condition().
WithType("Sleepy").
WithStatus("True").
WithReason("Sleepy").
WithMessage("Cat is sleepy").
WithLastTransitionTime(metav1.Now())))
// 2. Status().Patch()で適用
// パッチ種別として client.Apply を指定する
err := cl.Status().Patch(ctx,
unstruct(patch), // Unstructuredへの変換が必要
client.Apply,
client.ForceOwnership,
client.FieldOwner("sleepiness-controller"),
)
return err
}
ここでのポイントは、Status().Patch()
の第三引数にclient.Apply
を指定することです。これにより、status
サブリソースに対してもSSAが実行されます。
実践:CRDを使ったデモ
最後に、これまでの知識を総動員して、複数のコントローラ(マネージャ)が1つのCat
リソースを管理するデモを見てみます。
次の3つのマネージャがそれぞれ異なる責務を持ちます。
-
cat-owner
はspec
フィールドを管理します -
sleepiness-controller
はstatus.conditions
のうちtype: Sleepy
のものを管理します -
happiness-controller
はstatus.conditions
のうちtype: Happy
のものを管理します
demo_test.go
:
package main
import (
"context"
"encoding/json"
"fmt"
"testing"
catv1 "github.com/suinplayground/controller-runtime-playground/01-server-side-apply/api/v1"
applycatv1 "github.com/suinplayground/controller-runtime-playground/01-server-side-apply/client/api/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
applymetav1 "k8s.io/client-go/applyconfigurations/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
// NewClient(t) はテスト用のクライアントを返すヘルパー
)
func TestDemo(t *testing.T) {
cl := NewClient(t)
ctx := context.Background()
// マネージャ: cat-owner
// client.Apply で spec を設定
t.Run("Create a cat with spec", func(t *testing.T) {
cat := applycatv1.Cat("my-cat", "default").
WithSpec(applycatv1.CatSpec().
WithBreed("Maine Coon").
WithColor("Black").
WithAge(3))
err := cl.Apply(ctx, cat, client.FieldOwner("cat-owner"), client.ForceOwnership)
if err != nil {
t.Fatalf("failed to apply cat spec: %v", err)
}
})
// マネージャ: sleepiness-controller
// Status().Patch() で "Sleepy" Condition を設定
t.Run("Update status with Sleepy condition", func(t *testing.T) {
patch := applycatv1.Cat("my-cat", "default").
WithStatus(applycatv1.CatStatus().
WithConditions(applymetav1.Condition().
WithType("Sleepy").
WithStatus("True").
WithReason("Sleepy").
WithMessage("Cat is sleepy").
WithLastTransitionTime(metav1.Now())))
err := cl.Status().Patch(ctx,
unstruct(patch),
client.Apply,
client.ForceOwnership,
client.FieldOwner("sleepiness-controller"),
)
if err != nil {
t.Fatalf("failed to patch cat status (sleepy): %v", err)
}
})
// マネージャ: happiness-controller
// Status().Patch() で "Happy" Condition を設定
t.Run("Update status with Happy condition", func(t *testing.T) {
patch := applycatv1.Cat("my-cat", "default").
WithStatus(applycatv1.CatStatus().
WithConditions(applymetav1.Condition().
WithType("Happy").
WithStatus("True").
WithReason("Happy").
WithMessage("Cat is happy").
WithLastTransitionTime(metav1.Now())))
err := cl.Status().Patch(ctx,
unstruct(patch),
client.Apply,
client.ForceOwnership,
client.FieldOwner("happiness-controller"),
)
if err != nil {
t.Fatalf("failed to patch cat status (happy): %v", err)
}
})
// 最終結果の確認
t.Run("Get the final cat object", func(t *testing.T) {
cat := &catv1.Cat{}
err := cl.Get(ctx, client.ObjectKey{Name: "my-cat", Namespace: "default"}, cat)
if err != nil {
t.Fatalf("failed to get cat: %v", err)
}
// JSONで表示
out, _ := json.MarshalIndent(cat, "", " ")
fmt.Printf("Final Cat Resource:\n%s\n", string(out))
})
}
// unstruct helper function...
func unstruct(apply any) *unstructured.Unstructured {
// 上で紹介したunstruct関数と同一
}
`NewClient`関数の詳細
package serversideapply
import (
"fmt"
"net/http"
"net/http/httputil"
"os"
"path/filepath"
"testing"
"time"
catv1 "github.com/suinplayground/controller-runtime-playground/01-server-side-apply/api/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/clientcmd"
"sigs.k8s.io/controller-runtime/pkg/client"
)
type LoggingRoundTripper struct{ base http.RoundTripper }
func (l *LoggingRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
dump, _ := httputil.DumpRequestOut(r, true)
fmt.Printf("=== Outgoing Request ===\n%s\n", dump)
return l.base.RoundTrip(r)
}
func kubeconfigPath() (string, error) {
if p := os.Getenv("KUBECONFIG"); p != "" {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".kube", "config"), nil
}
func NewClient(t *testing.T) client.Client {
t.Helper()
kubeconfig, err := kubeconfigPath()
if err != nil {
t.Fatalf("resolve kubeconfig: %v", err)
}
cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
t.Fatalf("build config: %v", err)
}
cfg.ContentType = "application/json"
cfg.AcceptContentTypes = "application/json"
cfg.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { return &LoggingRoundTripper{base: rt} }
cfg.Timeout = 30 * time.Second
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatalf("add scheme: %v", err)
}
if err := catv1.AddToScheme(scheme); err != nil {
t.Fatalf("add scheme: %v", err)
}
cl, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
t.Fatalf("new client: %v", err)
}
return cl
}
このテストを実行すると、最終的に取得されるCat
リソースのmetadata.managedFields
は次のようになります。
{
"apiVersion": "example.com/v1",
"kind": "Cat",
"metadata": {
"name": "my-cat",
"namespace": "default",
"managedFields": [
{
"manager": "cat-owner",
"operation": "Apply",
"apiVersion": "example.com/v1",
"time": "...",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:spec": {
"f:age": {},
"f:breed": {},
"f:color": {}
}
}
},
{
"manager": "happiness-controller",
"operation": "Apply",
"apiVersion": "example.com/v1",
"time": "...",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
"f:conditions": {
"k:{\"type\":\"Happy\"}": {}
}
}
},
"subresource": "status"
},
{
"manager": "sleepiness-controller",
"operation": "Apply",
"apiVersion": "example.com/v1",
"time": "...",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
"f:conditions": {
"k:{\"type\":\"Sleepy\"}": {}
}
}
},
"subresource": "status"
}
]
},
"spec": {
"breed": "Maine Coon",
"color": "Black",
"age": 3
},
"status": {
"conditions": [
{
"type": "Happy",
"status": "True",
"reason": "Happy",
"message": "Cat is happy",
"lastTransitionTime": "..."
},
{
"type": "Sleepy",
"status": "True",
"reason": "Sleepy",
"message": "Cat is sleepy",
"lastTransitionTime": "..."
}
]
}
}
managedFields
を見ると、3つのマネージャがそれぞれ異なるフィールドを管理していることが明確にわかります。cat-owner
はspec
全体を管理し、happiness-controller
はstatus.conditions
のうちtype: "Happy"
の項目を管理し、sleepiness-controller
はstatus.conditions
のうちtype: "Sleepy"
の項目を管理しています。
これにより、各コントローラは他のコントローラの影響を心配することなく、自身の責務を安全に果たすことができるというわけです。
ここで紹介したデモの完全なものはこちらのリポジトリにあります:
所感
controller-runtime
v0.22で導入されたネイティブなServer-Side Applyサポートは、コントローラ開発をよりシンプルにしてくれる素晴らしい機能でした。
積極的にServer-Side Applyを使いこなしてコントローラを開発していきたいと思いました。
最後までお読みくださりありがとうございました。