들어가며
기본적인 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
"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
"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) |
| Status | True / False / Unknown |
| Reason | 기계가 읽는 짧은 이유 코드 (CamelCase) |
| Message | 사람이 읽는 설명 |
| LastTransitionTime | 상태가 마지막으로 바뀐 시각 |
| ObservedGeneration | 이 조건이 반영한 spec 세대 |
`metav1.Condition` 타입을 그대로 사용하면 됩니다.
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`을 갱신해 주는 점이 핵심입니다.
"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가 마비될 수 있습니다.
| failurePolicy | webhook 장애 시 동작 | 적합한 경우 |
| --- | --- | --- |
| 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가 제거되는지 확인합니다.
. "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.SetStatusCondition`과 `ObservedGeneration`을 일관되게 사용하세요.
이 세 가지를 멱등성이라는 공통 원칙 위에서 구현하면, 재시도와 동시성에도 흔들리지 않는 견고한 Operator를 만들 수 있습니다.
References
- [Kubebuilder Book](https://kubebuilder.io/book/)
- [Operator SDK Documentation](https://sdk.operatorframework.io/)
- [controller-runtime GoDoc](https://pkg.go.dev/sigs.k8s.io/controller-runtime)
- [Kubernetes: Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)
- [kubernetes-sigs/kubebuilder (GitHub)](https://github.com/kubernetes-sigs/kubebuilder)
- [kubernetes-sigs/controller-runtime (GitHub)](https://github.com/kubernetes-sigs/controller-runtime)
- [Kubernetes: Dynamic Admission Control](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/)
- [cert-manager Documentation](https://cert-manager.io/docs/)
- [Kubernetes: Finalizers](https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/)
현재 단락 (1/438)
기본적인 Reconcile 루프를 작성해 본 분이라면, 이제 Operator를 "데모"에서 "프로덕션"으로 끌어올릴 차례입니다. 그 경계선에 있는 세 가지 주제가 바로 Finali...