2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

controller-runtime v0.22(2025/8/27)でServer-Side Applyがサポートされた!

Last updated at Posted at 2025-09-17

この投稿では、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方式です。

従来のUpdatePatchメソッドでは、「誰がどのフィールドを管理しているか」が曖昧でした。そのため、複数のコントローラが同じリソースを更新しようとすると、意図しない上書き(競合)が発生することがありました。

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/v1core/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.gocatspec.gocatstatus.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-ownerspecフィールドを管理します
  • sleepiness-controllerstatus.conditionsのうちtype: Sleepyのものを管理します
  • happiness-controllerstatus.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`関数の詳細
client.go
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-ownerspec全体を管理し、happiness-controllerstatus.conditionsのうちtype: "Happy"の項目を管理し、sleepiness-controllerstatus.conditionsのうちtype: "Sleepy"の項目を管理しています。

これにより、各コントローラは他のコントローラの影響を心配することなく、自身の責務を安全に果たすことができるというわけです。

ここで紹介したデモの完全なものはこちらのリポジトリにあります:

所感

controller-runtime v0.22で導入されたネイティブなServer-Side Applyサポートは、コントローラ開発をよりシンプルにしてくれる素晴らしい機能でした。

積極的にServer-Side Applyを使いこなしてコントローラを開発していきたいと思いました。

最後までお読みくださりありがとうございました。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?