Skip to content
Published on

Operator 고급 — Finalizer, Admission Webhook, Status·Conditions 설계

Authors

들어가며

기본적인 Reconcile 루프를 작성해 본 분이라면, 이제 Operator를 "데모"에서 "프로덕션"으로 끌어올릴 차례입니다. 그 경계선에 있는 세 가지 주제가 바로 Finalizer, Admission Webhook, 그리고 Status·Conditions 설계입니다.

이 세 가지가 중요한 이유는 단순합니다. Reconcile 루프는 "원하는 상태로 수렴"하는 데에 집중하지만, 실제 운영에서는 그 외의 질문들이 끊임없이 발생하기 때문입니다.

  • 사용자가 CR(Custom Resource)을 삭제했을 때, 우리가 외부에 만들어 둔 클라우드 리소스(S3 버킷, DNS 레코드, 외부 DB 등)는 누가 정리하나요? (Finalizer)
  • 잘못된 스펙이 애초에 클러스터에 들어오지 못하도록 막거나, 기본값을 채워 넣으려면 어떻게 하나요? (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 필터로 대체된 변화도 함께 반영했습니다.

먼저 전체 그림을 한 장으로 정리하겠습니다.

                     +-------------------------------+
   kubectl apply --> | Admission (mutating)          |  defaulting
                     |   - 기본값 채우기              |
                     +---------------+---------------+
                                     |
                                     v
                     +-------------------------------+
                     | Admission (validating)        |  validation
                     |   - 스펙 검증, 거부            |
                     +---------------+---------------+
                                     |
                                     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이 한 번 더 호출됨"이라는 점입니다. 이 한 번의 추가 호출이 외부 리소스를 정리할 마지막 기회입니다.

Finalizer 흐름을 Reconcile에 구현하기

controller-runtime은 finalizer 추가·제거·확인을 돕는 헬퍼를 제공합니다. sigs.k8s.io/controller-runtime/pkg/controller/controllerutil 패키지의 AddFinalizer, RemoveFinalizer, ContainsFinalizer가 그것입니다.

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에 저장되기 전에 가로채는 훅입니다. 두 종류가 있습니다.

종류목적객체를 수정할 수 있나대표 용도
Mutating객체 변형가능기본값 채우기, 라벨 주입
Validating객체 검증불가스펙 거부, 정책 강제

순서는 항상 Mutating이 먼저, Validating이 나중입니다. 즉 기본값을 채운 뒤 검증하므로, 검증 로직은 항상 완성된 객체를 본다고 가정할 수 있습니다.

Kubebuilder로 Webhook 생성하기

Kubebuilder는 webhook 스캐폴딩을 명령 한 줄로 제공합니다. 아래는 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

이제 사용자는 다음처럼 상태를 한 줄로 확인할 수 있습니다.

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

흔한 함정

Finalizer 누락으로 인한 리소스 leak

외부 리소스를 만들기 전에 finalizer를 추가하지 않으면, 빠른 삭제 요청 시 Reconcile이 "정리할 기회"를 얻지 못한 채 객체가 GC됩니다. 그 결과 클라우드에 고아 리소스가 남아 비용이 새어 나갑니다. 반드시 외부 리소스 생성 전에 finalizer를 먼저 붙이세요.

failurePolicy로 인한 클러스터 마비

Validating Webhook의 failurePolicy: Fail은 webhook 서버가 응답하지 못하면 해당 리소스의 모든 생성·갱신을 거부합니다. webhook이 자기 자신이 관리하는 리소스에 의존하거나, webhook Pod가 죽으면 클러스터의 일부 API가 마비될 수 있습니다.

failurePolicywebhook 장애 시 동작적합한 경우
Fail요청 거부보안·정책 강제가 중요할 때
Ignore요청 통과가용성이 더 중요할 때

핵심 시스템 네임스페이스는 namespaceSelector로 webhook 대상에서 제외해, webhook 장애가 클러스터 부트스트랩을 막지 않도록 하는 것이 안전합니다.

Webhook timeout

webhook은 기본 타임아웃(timeoutSeconds)이 짧습니다. 외부 API 호출처럼 느린 작업을 webhook 안에서 하면 타임아웃으로 요청이 거부될 수 있습니다. webhook은 빠른 인메모리 검증·기본값 처리에만 쓰고, 무거운 작업은 Reconcile로 미루세요.

처리 순서 혼동

Mutating이 항상 Validating보다 먼저 실행됩니다. 따라서 Validating에서는 기본값이 이미 채워졌다고 가정해도 됩니다. 반대로 Mutating에서 어떤 필드를 채우는 것을 잊으면, Validating이 "빈 필드" 때문에 거부할 수 있으니 두 훅의 책임을 명확히 나누세요.

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를 "동작하는 데모"에서 "운영 가능한 컨트롤러"로 끌어올리는 세 기둥입니다. 각각을 정리하면 다음과 같습니다.

  • Finalizer는 삭제를 가로채 외부 리소스를 안전하게 정리할 마지막 기회를 보장합니다. 외부 리소스를 만들기 전에 붙이고, 정리에 성공한 뒤에만 제거하세요.
  • Webhook은 잘못된 스펙을 클러스터 진입 단계에서 막고 기본값을 채웁니다. Mutating은 빠르게, Validating은 엄격하게, 그리고 failurePolicy와 timeout을 신중히 설정하세요.
  • Status·Conditions는 리소스 상태를 표준 방식으로 노출해, 사람과 다른 컨트롤러가 동일한 방식으로 "준비됨"을 판단하게 합니다. meta.SetStatusConditionObservedGeneration을 일관되게 사용하세요.

이 세 가지를 멱등성이라는 공통 원칙 위에서 구현하면, 재시도와 동시성에도 흔들리지 않는 견고한 Operator를 만들 수 있습니다.

References