Skip to content
Published on

Operator 上級編 — Finalizer・Admission Webhook・Status/Conditions 設計

Authors

はじめに

基本的な Reconcile ループを書いたことがある方なら、次は Operator を「デモ」から「本番」へ引き上げる番です。その境界線上にある 3 つのテーマが、Finalizer、Admission Webhook、そして Status/Conditions 設計です。

この 3 つが重要な理由はシンプルです。Reconcile ループは「望ましい状態への収束」に集中しますが、実際の運用ではそれ以外の問いが絶えず生じるからです。

  • ユーザーが CR(Custom Resource)を削除したとき、外部に作成したクラウドリソース(S3 バケット、DNS レコード、外部 DB など)は誰が片付けるのでしょうか。(Finalizer)
  • 不正な spec をそもそもクラスターに入れさせない、あるいはデフォルト値を埋めるにはどうすればよいでしょうか。(Admission Webhook)
  • kubectl get でリソースの状態を一目で見せ、他のコントローラーや人間が「このリソースは準備できているか」を標準的に判断できるようにするにはどうすればよいでしょうか。(Status/Conditions)

本記事では、Kubebuilder が Kubernetes 1.36 / Go 1.26 をサポートし、controller-runtime が v0.24.x、controller-tools が v0.21.x である 2026 年基準で、各テーマを実践コードとともに説明します。また、かつてメトリクスエンドポイントの保護に使われていた kube-rbac-proxy が削除され、controller-runtime の WithAuthenticationAndAuthorization フィルターに置き換わった変更も反映しています。

まず全体像を 1 枚にまとめます。

                     +-------------------------------+
   kubectl apply --> | Admission (mutating)          |  defaulting
                     |   - デフォルト値を埋める      |
                     +---------------+---------------+
                                     |
                                     v
                     +-------------------------------+
                     | Admission (validating)        |  validation
                     |   - spec 検証・拒否           |
                     +---------------+---------------+
                                     |
                                     v
                     +-------------------------------+
                     | etcd 保存 (spec)             |
                     +---------------+---------------+
                                     |
                                     v
                     +-------------------------------+
                     | Reconcile ループ              |  desired-state 収束
                     |   - 外部リソース作成/更新     |
                     |   - finalizer 追加            |
                     |   - status/conditions 更新    |
                     +-------------------------------+

Finalizer で外部リソースをクリーンアップする

Finalizer とは何か

Finalizer は、オブジェクトの metadata.finalizers フィールドに入っている文字列のリストです。このリストが空でない限り、ユーザーが削除を要求しても API サーバーはオブジェクトを即座に消しません。代わりに metadata.deletionTimestamp を設定して「削除予定」の状態としてマークするだけです。ガベージコレクション(GC)は finalizer リストが完全に空になるまで待機します。

このメカニズムを使えば、コントローラーが「外部クリーンアップが完了した」と確信するまで、オブジェクトが実際に消えないように引き止めておけます。

ユーザー: kubectl delete myresource foo
        |
        v
+-------------------------------------------+
| API サーバー                              |
|   finalizers は空か?                       |
+--------------------+----------------------+
        | 空                      | 空でない
        v                        v
+----------------+    +-------------------------------+
| 即座に GC 削除 |    | deletionTimestamp 設定        |
+----------------+    | (オブジェクトは残る、削除予定)|
                      +---------------+---------------+
                                      |
                                      v
                      +-------------------------------+
                      | Reconcile 再呼び出し           |
                      |   deletionTimestamp != 0 検出 |
                      |   -> 外部リソースをクリーンアップ|
                      |   -> finalizer を削除          |
                      +---------------+---------------+
                                      |
                                      v
                      +-------------------------------+
                      | finalizers 空 -> GC 削除      |
                      +-------------------------------+

肝心なのは「削除 = 即座に消える」ではなく、「削除 = deletionTimestamp が刻まれ、Reconcile がもう一度呼ばれる」という点です。この 1 回の追加呼び出しが、外部リソースをクリーンアップする最後の機会です。

Finalizer フローを Reconcile に実装する

controller-runtime は finalizer の追加・削除・確認を助けるヘルパーを提供します。sigs.k8s.io/controller-runtime/pkg/controller/controllerutil パッケージの AddFinalizerRemoveFinalizerContainsFinalizer がそれです。

package controller

import (
	"context"

	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

	cloudv1 "example.com/operator/api/v1"
)

const bucketFinalizer = "cloud.example.com/bucket-cleanup"

func (r *BucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := ctrl.LoggerFrom(ctx)

	var bucket cloudv1.Bucket
	if err := r.Get(ctx, req.NamespacedName, &bucket); err != nil {
		// NotFound はすでに削除済みなので無視する。
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 1) 削除要求かどうかを判定する。
	if !bucket.ObjectMeta.DeletionTimestamp.IsZero() {
		// 削除進行中。finalizer がまだ自分のものならクリーンアップする。
		if controllerutil.ContainsFinalizer(&bucket, bucketFinalizer) {
			if err := r.cleanupExternalBucket(ctx, &bucket); err != nil {
				// クリーンアップ失敗時は finalizer を残してリトライさせる。
				log.Error(err, "外部バケットのクリーンアップに失敗")
				return ctrl.Result{}, err
			}

			// クリーンアップ成功。finalizer を削除 -> GC がオブジェクトを消す。
			controllerutil.RemoveFinalizer(&bucket, bucketFinalizer)
			if err := r.Update(ctx, &bucket); err != nil {
				return ctrl.Result{}, err
			}
		}
		// finalizer がなければやることはない。
		return ctrl.Result{}, nil
	}

	// 2) 通常の作成/更新パス: finalizer がなければ先に追加する。
	if !controllerutil.ContainsFinalizer(&bucket, bucketFinalizer) {
		controllerutil.AddFinalizer(&bucket, bucketFinalizer)
		if err := r.Update(ctx, &bucket); err != nil {
			return ctrl.Result{}, err
		}
		// Update が新しいリソースバージョンを作ったので即座に返し、
		// 次の Reconcile で本作業を続ける。
		return ctrl.Result{}, nil
	}

	// 3) 実際の外部リソース収束ロジック。
	if err := r.reconcileExternalBucket(ctx, &bucket); err != nil {
		return ctrl.Result{}, err
	}

	return ctrl.Result{}, nil
}

上記コードの中心となるパターンをまとめると次のとおりです。

  1. DeletionTimestamp.IsZero() で削除中かどうかを最初に確認します。
  2. 削除中なら外部リソースをクリーンアップし、成功した後にのみ finalizer を削除します。クリーンアップに失敗したらエラーを返してリトライさせ、finalizer はそのまま残します。
  3. 通常パスでは本格的な収束ロジックの前に finalizer を追加します。外部リソースを作る前に finalizer が先に付いていないと、途中で削除要求が来てもクリーンアップの機会を失います。

外部クリーンアップ関数と冪等性

cleanupExternalBucket は冪等(idempotent)でなければなりません。Reconcile は同じオブジェクトに対して何度も呼ばれうるため、すでに消えたバケットを再度消そうとする試みがエラーを投げないように処理する必要があります。

func (r *BucketReconciler) cleanupExternalBucket(ctx context.Context, bucket *cloudv1.Bucket) error {
	name := bucket.Status.ExternalName
	if name == "" {
		// まだ外部リソースを作ったことがない -> 片付けるものはない。
		return nil
	}

	err := r.CloudAPI.DeleteBucket(ctx, name)
	if isNotFound(err) {
		// すでに存在しない -> 成功とみなす(冪等性)。
		return nil
	}
	return err
}

Status.ExternalName が空であるか、外部 API が「すでに存在しない」を返したら成功として扱います。こうすることでクリーンアップ処理を安全に繰り返せます。

Admission Webhook: Defaulting と Validation

Webhook の種類

Admission Webhook は、オブジェクトが etcd に保存される前に割り込むフックです。2 種類あります。

種類目的オブジェクトを変更できるか代表的な用途
Mutatingオブジェクト変形可能デフォルト値の充填、ラベル注入
Validatingオブジェクト検証不可spec の拒否、ポリシー強制

順序は常に Mutating が先、Validating が後です。つまりデフォルト値を埋めた後に検証するため、検証ロジックは常に完成したオブジェクトを見ると仮定できます。

Kubebuilder で Webhook を生成する

Kubebuilder は webhook のスキャフォールディングをコマンド 1 行で提供します。以下は defaulting(mutating)と validation(validating)の両方を有効にする例です。

kubebuilder create webhook \
  --group cloud \
  --version v1 \
  --kind Bucket \
  --defaulting \
  --programmatic-validation

このコマンドは internal/webhook/v1/bucket_webhook.go のようなファイルと、登録のためのマーカー、そして config/webhook/ 配下のマニフェストパッチを生成します。

Defaulting (Mutating) の実装

最新の controller-runtime の webhook API は admission.CustomDefaulter インターフェースを使います。Default メソッドの中でオブジェクトにデフォルト値を埋めます。

package webhookv1

import (
	"context"
	"fmt"

	"k8s.io/apimachinery/pkg/runtime"
	ctrl "sigs.k8s.io/controller-runtime"
	logf "sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/webhook"
	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

	cloudv1 "example.com/operator/api/v1"
)

// +kubebuilder:webhook:path=/mutate-cloud-example-com-v1-bucket,mutating=true,failurePolicy=fail,sideEffects=None,groups=cloud.example.com,resources=buckets,verbs=create;update,versions=v1,name=mbucket-v1.kb.io,admissionReviewVersions=v1

type BucketCustomDefaulter struct {
	DefaultRegion string
}

var _ admission.CustomDefaulter = &BucketCustomDefaulter{}

func (d *BucketCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error {
	bucket, ok := obj.(*cloudv1.Bucket)
	if !ok {
		return fmt.Errorf("expected a Bucket object but got %T", obj)
	}
	log := logf.FromContext(ctx)

	// region が空ならデフォルト値を埋める。
	if bucket.Spec.Region == "" {
		bucket.Spec.Region = d.DefaultRegion
		log.Info("defaulting region", "region", d.DefaultRegion)
	}

	// versioning が明示されていなければ安全にオンにする。
	if bucket.Spec.Versioning == nil {
		enabled := true
		bucket.Spec.Versioning = &enabled
	}

	return nil
}

Validation (Validating) の実装

検証は admission.CustomValidator インターフェースを実装します。作成・更新・削除それぞれのメソッドを持ち、拒否するにはエラーを返します。

// +kubebuilder:webhook:path=/validate-cloud-example-com-v1-bucket,mutating=false,failurePolicy=fail,sideEffects=None,groups=cloud.example.com,resources=buckets,verbs=create;update,versions=v1,name=vbucket-v1.kb.io,admissionReviewVersions=v1

type BucketCustomValidator struct{}

var _ admission.CustomValidator = &BucketCustomValidator{}

func (v *BucketCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
	bucket, ok := obj.(*cloudv1.Bucket)
	if !ok {
		return nil, fmt.Errorf("expected a Bucket object but got %T", obj)
	}
	return v.validate(bucket)
}

func (v *BucketCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
	oldBucket := oldObj.(*cloudv1.Bucket)
	newBucket := newObj.(*cloudv1.Bucket)

	// region は不変フィールドとして強制する。
	if oldBucket.Spec.Region != newBucket.Spec.Region {
		return nil, fmt.Errorf("spec.region is immutable")
	}
	return v.validate(newBucket)
}

func (v *BucketCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
	return nil, nil
}

func (v *BucketCustomValidator) validate(bucket *cloudv1.Bucket) (admission.Warnings, error) {
	var warnings admission.Warnings

	if bucket.Spec.Region == "" {
		return nil, fmt.Errorf("spec.region must not be empty")
	}

	allowed := map[string]bool{"ap-northeast-2": true, "us-east-1": true}
	if !allowed[bucket.Spec.Region] {
		return nil, fmt.Errorf("spec.region %q is not allowed", bucket.Spec.Region)
	}

	if bucket.Spec.RetentionDays > 365 {
		warnings = append(warnings, "retentionDays が 365 を超えています。コストに注意してください。")
	}

	return warnings, nil
}

admission.Warnings を返すと、拒否せずにユーザーへ警告を表示できます。ポリシー上ブロックすべきならエラーを、勧告のみなら warning を使います。

Webhook の登録

webhook はマネージャーに登録される必要があります。最新の Kubebuilder スタイルでは、SetupWebhookWithManager 関数を通じてビルダーで接続します。

func SetupBucketWebhookWithManager(mgr ctrl.Manager) error {
	return ctrl.NewWebhookManagedBy(mgr).
		For(&cloudv1.Bucket{}).
		WithDefaulter(&BucketCustomDefaulter{DefaultRegion: "ap-northeast-2"}).
		WithValidator(&BucketCustomValidator{}).
		Complete()
}

cert-manager で証明書を管理する

Admission Webhook は HTTPS で呼ばれるため、API サーバーが信頼する TLS 証明書が必要です。手動での管理は煩雑なので、cert-manager を使うのが標準です。Kubebuilder は config/certmanager/ 配下に関連マニフェストを生成してくれます。

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: selfsigned-issuer
  namespace: system
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: serving-cert
  namespace: system
spec:
  dnsNames:
    - webhook-service.system.svc
    - webhook-service.system.svc.cluster.local
  issuerRef:
    kind: Issuer
    name: selfsigned-issuer
  secretName: webhook-server-cert

そして webhook 設定で cert-manager.io/inject-ca-from アノテーションにより CA バンドルを自動注入します。

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: bucket-validating-webhook
  annotations:
    cert-manager.io/inject-ca-from: system/serving-cert
webhooks:
  - name: vbucket-v1.kb.io
    failurePolicy: Fail
    sideEffects: None
    admissionReviewVersions: ["v1"]
    clientConfig:
      service:
        name: webhook-service
        namespace: system
        path: /validate-cloud-example-com-v1-bucket
    rules:
      - apiGroups: ["cloud.example.com"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["buckets"]

Status サブリソースと Conditions 標準

Status サブリソースを使う理由

status をサブリソースとして宣言すると、spec と status が別々のエンドポイントで管理されます。こうすると、コントローラーが status だけを更新するときに spec の resourceVersion 競合を避けられ、ユーザーの spec 編集とコントローラーの status 更新が互いを上書きしません。

CRD の型にマーカーを追加します。

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

type Bucket struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   BucketSpec   `json:"spec,omitempty"`
	Status BucketStatus `json:"status,omitempty"`
}

Conditions の標準構造

Kubernetes エコシステムは、status に conditions 配列を置くことを標準的な慣習としています。各 Condition は次のフィールドを持ちます。

フィールド意味
Type条件名(例: Ready, Progressing)
StatusTrue / False / Unknown
Reason機械が読む短い理由コード(CamelCase)
Message人間が読む説明
LastTransitionTime状態が最後に変わった時刻
ObservedGenerationこの条件が反映した spec 世代

metav1.Condition 型をそのまま使えます。

import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

type BucketStatus struct {
	// ObservedGeneration はコントローラーが最後に処理した spec 世代。
	ObservedGeneration int64 `json:"observedGeneration,omitempty"`

	// ExternalName は実際に作成された外部バケット名。
	ExternalName string `json:"externalName,omitempty"`

	// +patchMergeKey=type
	// +patchStrategy=merge
	// +listType=map
	// +listMapKey=type
	Conditions []metav1.Condition `json:"conditions,omitempty"`
}

meta.SetStatusCondition の使用

k8s.io/apimachinery/pkg/api/meta パッケージの SetStatusCondition は、同じ Type の Condition がすでにあれば更新し、なければ追加します。Status が変わったときだけ LastTransitionTime を更新する点が肝心です。

import (
	"k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func (r *BucketReconciler) markReady(ctx context.Context, bucket *cloudv1.Bucket, ready bool, reason, msg string) error {
	status := metav1.ConditionFalse
	if ready {
		status = metav1.ConditionTrue
	}

	meta.SetStatusCondition(&bucket.Status.Conditions, metav1.Condition{
		Type:               "Ready",
		Status:             status,
		Reason:             reason,
		Message:            msg,
		ObservedGeneration: bucket.Generation,
	})

	// observedGeneration も合わせて更新し「この世代を処理した」と記録する。
	bucket.Status.ObservedGeneration = bucket.Generation

	// 必ず Status().Update() を使い status サブリソースだけを更新する。
	return r.Status().Update(ctx, bucket)
}

r.Update() ではなく r.Status().Update() を使う点に注意してください。前者は spec を、後者は status サブリソースを更新します。

Reconcile で Conditions を埋める

func (r *BucketReconciler) reconcileExternalBucket(ctx context.Context, bucket *cloudv1.Bucket) error {
	// 進行中の状態を記録する。
	if err := r.markCondition(ctx, bucket, "Progressing", metav1.ConditionTrue,
		"Creating", "外部バケット作成中"); err != nil {
		return err
	}

	name, err := r.CloudAPI.EnsureBucket(ctx, bucket.Spec.Region)
	if err != nil {
		_ = r.markCondition(ctx, bucket, "Ready", metav1.ConditionFalse,
			"CreateFailed", err.Error())
		return err
	}

	bucket.Status.ExternalName = name
	return r.markCondition(ctx, bucket, "Ready", metav1.ConditionTrue,
		"Created", "外部バケット準備完了")
}

additionalPrinterColumns で kubectl 出力を整える

kubectl get で status を一目で見せるには、printer column のマーカーを追加します。controller-tools が CRD に additionalPrinterColumns を生成してくれます。

// +kubebuilder:printcolumn:name="Region",type=string,JSONPath=`.spec.region`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="External",type=string,JSONPath=`.status.externalName`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`

type Bucket struct {
	// ...
}

生成された CRD には次のような YAML が入ります。

additionalPrinterColumns:
  - name: Region
    type: string
    jsonPath: .spec.region
  - name: Ready
    type: string
    jsonPath: .status.conditions[?(@.type=="Ready")].status
  - name: External
    type: string
    jsonPath: .status.externalName
  - name: Age
    type: date
    jsonPath: .metadata.creationTimestamp

これでユーザーは次のように状態を 1 行で確認できます。

NAME      REGION           READY   EXTERNAL          AGE
my-data   ap-northeast-2   True    my-data-9af31     12m

よくある落とし穴

Finalizer 漏れによるリソースリーク

外部リソースを作る前に finalizer を追加しないと、素早い削除要求の際に Reconcile が「クリーンアップの機会」を得られないままオブジェクトが GC されます。その結果、クラウドに孤立リソースが残り、コストが漏れ出します。必ず外部リソースの作成前に finalizer を先に付けてください。

failurePolicy によるクラスター麻痺

Validating Webhook の failurePolicy: Fail は、webhook サーバーが応答できないと該当リソースのすべての作成・更新を拒否します。webhook が自身が管理するリソースに依存していたり、webhook Pod が落ちたりすると、クラスターの一部 API が麻痺する可能性があります。

failurePolicywebhook 障害時の動作適する場合
Fail要求を拒否セキュリティ・ポリシー強制が重要なとき
Ignore要求を通過可用性がより重要なとき

コアなシステム名前空間は namespaceSelector で webhook 対象から除外し、webhook 障害がクラスターのブートストラップを妨げないようにするのが安全です。

Webhook タイムアウト

webhook はデフォルトのタイムアウト(timeoutSeconds)が短いです。外部 API 呼び出しのような遅い作業を webhook の中で行うと、タイムアウトで要求が拒否されることがあります。webhook は高速なインメモリ検証・デフォルト処理だけに使い、重い作業は Reconcile に回してください。

処理順序の混同

Mutating は常に Validating より先に実行されます。したがって Validating ではデフォルト値がすでに埋まっていると仮定できます。逆に Mutating であるフィールドを埋め忘れると、Validating が「空フィールド」を理由に拒否することがあるので、2 つのフックの責務を明確に分けてください。

Status 更新の競合

status を r.Update() で更新すると、spec と競合したり、サブリソース設定が無視されたりすることがあります。常に r.Status().Update() を使い、ObservedGeneration を合わせて更新して「どの世代を処理したか」を明確に記録してください。

テスト

Finalizer のテスト

envtest を使うと実際の API サーバーを立てて finalizer フローを検証できます。オブジェクトを作り、削除要求を送った後、外部クリーンアップが呼ばれて finalizer が削除されるかを確認します。

import (
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

var _ = Describe("Bucket finalizer", func() {
	It("削除時に外部リソースをクリーンアップする", func() {
		bucket := &cloudv1.Bucket{
			ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
			Spec:       cloudv1.BucketSpec{Region: "ap-northeast-2"},
		}
		Expect(k8sClient.Create(ctx, bucket)).To(Succeed())

		// finalizer が追加されるまで待つ。
		Eventually(func() bool {
			_ = k8sClient.Get(ctx, client.ObjectKeyFromObject(bucket), bucket)
			return len(bucket.Finalizers) > 0
		}).Should(BeTrue())

		// 削除要求。
		Expect(k8sClient.Delete(ctx, bucket)).To(Succeed())

		// 最終的にオブジェクトが消えなければならない。
		Eventually(func() bool {
			err := k8sClient.Get(ctx, client.ObjectKeyFromObject(bucket), bucket)
			return client.IgnoreNotFound(err) == nil && err != nil
		}).Should(BeTrue())

		// モックのクラウド API で削除が呼ばれたかを確認する。
		Expect(fakeCloud.DeletedBuckets()).To(ContainElement("test"))
	})
})

Webhook のテスト

webhook テストは、defaulter と validator を直接呼び出す単体テストで十分にカバーできます。envtest に webhook サーバーを付けて統合テストも可能です。

var _ = Describe("Bucket validator", func() {
	It("許可されていない region を拒否する", func() {
		v := &BucketCustomValidator{}
		bucket := &cloudv1.Bucket{Spec: cloudv1.BucketSpec{Region: "mars-1"}}
		_, err := v.ValidateCreate(ctx, bucket)
		Expect(err).To(HaveOccurred())
	})

	It("デフォルト region を埋める", func() {
		d := &BucketCustomDefaulter{DefaultRegion: "ap-northeast-2"}
		bucket := &cloudv1.Bucket{}
		Expect(d.Default(ctx, bucket)).To(Succeed())
		Expect(bucket.Spec.Region).To(Equal("ap-northeast-2"))
	})
})

運用チェックリスト

項目確認
外部リソース作成前に finalizer 追加必須
クリーンアップ関数の冪等性必須
Mutating は高速なデフォルト処理のみ推奨
Validating で不変フィールドを強制推奨
failurePolicy と namespaceSelector の検討必須
status は Status().Update() のみで更新必須
ObservedGeneration の記録推奨
printer columns で可視性を確保推奨
cert-manager で webhook 証明書を自動化推奨

おわりに

Finalizer、Admission Webhook、Status/Conditions は、Operator を「動くデモ」から「運用できるコントローラー」へ引き上げる 3 本の柱です。それぞれをまとめると次のとおりです。

  • Finalizer は削除に割り込み、外部リソースを安全にクリーンアップする最後の機会を保証します。外部リソースを作る前に付け、クリーンアップに成功した後にのみ削除してください。
  • Webhook は不正な spec をクラスター進入段階でブロックし、デフォルト値を埋めます。Mutating は高速に、Validating は厳格に、そして failurePolicy と timeout を慎重に設定してください。
  • Status/Conditions はリソースの状態を標準的な方法で公開し、人間と他のコントローラーが同じ方法で「準備完了」を判断できるようにします。meta.SetStatusConditionObservedGeneration を一貫して使ってください。

この 3 つを冪等性という共通原則の上で実装すれば、リトライと並行性にも揺らがない堅牢な Operator を作れます。

References