Skip to content

필사 모드: CRD 설계와 버저닝 — 스키마, 검증, conversion webhook

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며 — CRD는 코드가 아니라 API 계약입니다

오퍼레이터를 처음 만들 때 우리는 보통 컨트롤러 로직에 집중합니다. reconcile 루프를 어떻게 짤지, 외부 시스템을 어떻게 호출할지 고민하죠. 그런데 시간이 지나면 정작 발목을 잡는 것은 컨트롤러가 아니라 **CRD 그 자체**인 경우가 많습니다.

이유는 단순합니다. CRD는 사용자가 직접 작성하는 YAML의 스키마이고, 한 번 배포되어 클러스터에 객체가 쌓이기 시작하면 그 스키마는 사실상 영구적인 **공개 API 계약**이 됩니다. 컨트롤러 코드는 언제든 리팩터링하고 재배포할 수 있지만, 이미 etcd에 저장된 수천 개의 커스텀 리소스와 사용자가 GitOps 저장소에 커밋해 둔 매니페스트는 함부로 바꿀 수 없습니다.

그래서 CRD 설계는 라이브러리 API를 설계하는 것과 같은 무게로 다뤄야 합니다. 필드 하나를 잘못 만들면, 그 필드는 v1alpha1, v1beta1, v1을 거쳐 몇 년간 따라다니며 호환성 부담을 누적시킵니다. 이 글에서는 2026년 현재의 도구 스택(Kubebuilder, Kubernetes 1.36 / Go 1.26, controller-runtime v0.24.x, controller-tools v0.21.x)을 기준으로, 처음부터 오래 살아남을 수 있는 CRD를 설계하고 버저닝하는 방법을 다룹니다.

이 글에서 다룰 내용은 다음과 같습니다.

- OpenAPI v3 스키마 설계 원칙과 CEL 기반 검증

- v1alpha1에서 v1로 가는 다중 버전 전략과 storage version

- conversion webhook의 Hub-and-Spoke 구현

- 하위호환과 deprecation, 필드 진화 전략

- additionalPrinterColumns와 subresource(status/scale)

- 대규모 CRD에서의 성능 고려사항

- 선언적 API 설계 베스트프랙티스와 마이그레이션, 그리고 함정

CRD 스키마 설계 원칙

OpenAPI v3 structural schema

쿠버네티스 1.16 이후 모든 CRD는 **structural schema**를 요구합니다. 이는 모든 필드의 타입이 OpenAPI v3로 명시되어 있어야 하고, 임의의 키-값을 허용하는 `x-kubernetes-preserve-unknown-fields` 같은 탈출구를 남발하면 안 된다는 뜻입니다. structural schema가 있어야 서버 사이드 apply, 필드 pruning, 그리고 뒤에서 다룰 CEL 검증이 동작합니다.

Kubebuilder를 쓰면 Go 타입에 마커를 달아 이 스키마를 자동 생성합니다. 다음은 가상의 데이터베이스 오퍼레이터를 위한 타입 정의입니다.

package v1beta1

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

)

// DatabaseSpec는 사용자가 선언하는 의도(desired state)입니다.

type DatabaseSpec struct {

// Engine은 사용할 데이터베이스 엔진입니다.

// 생성 후에는 변경할 수 없습니다.

// +kubebuilder:validation:Enum=postgres;mysql;mariadb

// +kubebuilder:validation:Required

Engine string `json:"engine"`

// Version은 엔진 버전입니다.

// +kubebuilder:validation:Required

// +kubebuilder:validation:Pattern=`^[0-9]+\.[0-9]+$`

Version string `json:"version"`

// Replicas는 복제본 개수입니다. 기본값은 1입니다.

// +kubebuilder:validation:Minimum=1

// +kubebuilder:validation:Maximum=9

// +kubebuilder:default=1

Replicas int32 `json:"replicas,omitempty"`

// StorageGB는 인스턴스당 스토리지 크기입니다(GB).

// +kubebuilder:validation:Minimum=10

// +kubebuilder:default=20

StorageGB int32 `json:"storageGB,omitempty"`

// BackupPolicy는 선택적 백업 설정입니다.

// +optional

BackupPolicy *BackupPolicy `json:"backupPolicy,omitempty"`

}

type BackupPolicy struct {

// Schedule은 cron 형식의 백업 주기입니다.

// +kubebuilder:validation:Required

Schedule string `json:"schedule"`

// RetentionDays는 백업 보관 일수입니다.

// +kubebuilder:validation:Minimum=1

// +kubebuilder:default=7

RetentionDays int32 `json:"retentionDays,omitempty"`

}

여기서 주목할 점은 마커 하나하나가 생성될 CRD의 OpenAPI 스키마로 번역된다는 것입니다. `+kubebuilder:default=1`은 스키마의 `default: 1`이 되고, API 서버가 admission 단계에서 자동으로 채워 줍니다.

required, default, optional의 의미

세 가지를 명확히 구분해야 합니다.

| 분류 | 마커 | 동작 | 사용 시점 |

| --- | --- | --- | --- |

| 필수 | `+kubebuilder:validation:Required` | 비어 있으면 거부 | 의미 있는 기본값이 없는 핵심 필드 |

| 기본값 | `+kubebuilder:default=N` | 비면 자동 채움 | 합리적 기본값이 존재하는 필드 |

| 선택 | `+optional` | 비어도 허용, 채우지 않음 | 진짜 선택적 기능 |

기본값을 줄 수 있는 필드를 굳이 Required로 만들지 마세요. 사용자가 매번 같은 값을 적어야 하는 보일러플레이트가 늘어나고, 나중에 그 필드를 옵셔널로 바꾸기도 어려워집니다. 반대로 핵심 식별자(위 예시의 `engine`)는 기본값이 없어야 합니다. 잘못된 기본값으로 엉뚱한 리소스가 만들어지는 것보다 명시적 거부가 낫습니다.

불변 필드와 CEL 검증

`engine` 같은 필드는 생성 후 변경되면 안 됩니다. 엔진을 postgres에서 mysql로 바꾸는 것은 새 리소스를 만드는 것과 다름없으니까요. 과거에는 이런 불변성을 validating webhook에서 직접 검사해야 했지만, 이제는 **CEL(Common Expression Language) 검증**(`x-kubernetes-validations`)으로 스키마 안에서 선언할 수 있습니다.

// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="engine은 변경할 수 없습니다"

Engine string `json:"engine"`

CEL은 단순 불변성을 넘어 필드 간 관계도 표현합니다. 다음은 타입 전체에 적용하는 복합 규칙입니다.

// +kubebuilder:validation:XValidation:rule="self.engine != 'mariadb' || self.replicas == 1",message="mariadb는 단일 복제본만 지원합니다"

// +kubebuilder:validation:XValidation:rule="!has(self.backupPolicy) || self.replicas >= 1",message="백업을 켜려면 최소 1개 복제본이 필요합니다"

type DatabaseSpec struct {

// ... 필드들 ...

}

CEL 검증은 webhook 없이 API 서버 안에서 실행되므로, 별도 서버를 띄울 필요가 없고 네트워크 홉도 없습니다. 2026년 기준 거의 모든 동기적 검증 로직은 CEL로 옮길 수 있으며, validating webhook은 외부 시스템 조회가 필요한 경우에만 남기는 것이 권장됩니다.

생성되는 CRD 스키마는 다음과 같은 형태입니다.

openAPIV3Schema:

type: object

properties:

spec:

type: object

required:

- engine

- version

properties:

engine:

type: string

enum: [postgres, mysql, mariadb]

x-kubernetes-validations:

- rule: "self == oldSelf"

message: "engine은 변경할 수 없습니다"

replicas:

type: integer

minimum: 1

maximum: 9

default: 1

x-kubernetes-validations:

- rule: "self.engine != 'mariadb' || self.replicas == 1"

message: "mariadb는 단일 복제본만 지원합니다"

다중 버전 전략

왜 버전이 여러 개여야 하는가

API는 진화합니다. v1alpha1에서 "이 필드는 사실 객체였어야 했다"는 것을 깨닫고, v1beta1에서 구조를 바꾸고, v1에서 안정화합니다. 쿠버네티스 API 컨벤션은 이 진화를 위한 명확한 단계를 제공합니다.

| 버전 | 의미 | 호환성 약속 | 권장 용도 |

| --- | --- | --- | --- |

| v1alpha1 | 실험적 | 언제든 깨질 수 있음, 공지 없이 제거 | 초기 피드백 수집 |

| v1beta1 | 베타 | 합리적 deprecation 기간 보장 | 프로덕션 시험 도입 |

| v1 | 안정 | 강한 하위호환 약속 | 일반 가용 |

핵심 규칙은 단 하나입니다. **한 CRD의 모든 버전은 서로 무손실 변환이 가능해야 합니다.** etcd에는 단 하나의 표현(storage version)으로만 저장되고, 다른 버전으로의 요청은 변환을 거치기 때문입니다.

storage version과 served version

CRD에서 각 버전은 두 가지 플래그를 가집니다.

- `served`: 이 버전으로 API 요청을 받을 것인가

- `storage`: 이 버전으로 etcd에 저장할 것인가 (정확히 하나만 true)

다음은 세 버전을 동시에 제공하면서 v1beta1을 storage로 쓰는 CRD 일부입니다.

apiVersion: apiextensions.k8s.io/v1

kind: CustomResourceDefinition

metadata:

name: databases.example.com

spec:

group: example.com

names:

kind: Database

plural: databases

scope: Namespaced

versions:

- name: v1alpha1

served: true

storage: false

schema:

openAPIV3Schema: { }

- name: v1beta1

served: true

storage: true

schema:

openAPIV3Schema: { }

- name: v1

served: false

storage: false

schema:

openAPIV3Schema: { }

요청과 저장의 흐름은 다음과 같습니다.

사용자가 v1alpha1로 GET

API 서버가 etcd에서 v1beta1(storage)로 읽음

conversion webhook: v1beta1 → v1alpha1 변환

사용자에게 v1alpha1 응답

사용자가 v1으로 CREATE

conversion webhook: v1 → v1beta1(storage) 변환

etcd에 v1beta1로 저장

버전 승격 흐름

새 버전을 도입하고 옛 버전을 걷어내는 표준 절차는 다음과 같습니다.

1단계: v1beta1 추가 (served=true, storage=false)

storage는 여전히 v1alpha1

2단계: storage를 v1beta1로 전환 (storage=true)

storage-version-migrator로 기존 객체 재저장

3단계: v1alpha1을 served=false로

더 이상 새 요청을 받지 않음

4단계: CRD status.storedVersions에서 v1alpha1 제거

모든 객체가 v1beta1로 재저장된 뒤에만

5단계: v1alpha1 스키마 정의 삭제

여기서 가장 자주 발생하는 실수는 2단계의 재저장(storage migration)을 건너뛰는 것입니다. storage version만 바꾸고 기존 객체를 다시 쓰지 않으면, etcd에는 여전히 옛 버전으로 저장된 객체가 남아 있습니다. 나중에 옛 버전 스키마를 지우면 이 객체들을 읽을 수 없게 됩니다.

conversion webhook 구현

변환 전략: Hub-and-Spoke

버전이 N개일 때 모든 쌍을 변환하려면 N×(N-1)개의 변환 함수가 필요합니다. controller-runtime은 이를 줄이는 **Hub-and-Spoke** 패턴을 제공합니다. 하나의 버전을 Hub로 지정하고, 나머지(Spoke)는 Hub와의 변환만 구현하면 됩니다. 그러면 임의의 두 버전 간 변환은 항상 Hub를 경유합니다.

v1alpha1 (Spoke) v1 (Spoke)

│ │

│ ConvertTo/From │ ConvertTo/From

▼ ▼

└────► v1beta1 (Hub) ◄┘

Hub는 보통 storage version으로 지정합니다. 변환이 storage를 거치면 추가 변환 비용이 줄어들기 때문입니다.

Hub 버전 정의

Hub는 마커 인터페이스 하나만 구현하면 됩니다.

package v1beta1

// Hub는 이 버전이 변환 허브임을 표시합니다.

func (*Database) Hub() {}

// 컴파일 타임에 인터페이스 충족을 보장합니다.

var _ conversion.Hub = (*Database)(nil)

Spoke 버전의 ConvertTo / ConvertFrom

Spoke 버전(v1alpha1)은 Hub로 가는 변환과 Hub에서 오는 변환을 구현합니다.

package v1alpha1

"sigs.k8s.io/controller-runtime/pkg/conversion"

v1beta1 "example.com/api/v1beta1"

)

// ConvertTo는 v1alpha1을 Hub(v1beta1)로 변환합니다.

func (src *Database) ConvertTo(dstRaw conversion.Hub) error {

dst := dstRaw.(*v1beta1.Database)

// 메타데이터는 그대로 복사합니다.

dst.ObjectMeta = src.ObjectMeta

// 동일하게 유지되는 필드

dst.Spec.Engine = src.Spec.Engine

dst.Spec.Version = src.Spec.Version

dst.Spec.Replicas = src.Spec.Replicas

// v1alpha1의 storageGB(int)가 v1beta1에서도 동일하다고 가정

dst.Spec.StorageGB = src.Spec.StorageGB

// v1alpha1에는 없던 필드: 합리적 기본값으로 채움

if src.Spec.Backup != "" {

dst.Spec.BackupPolicy = &v1beta1.BackupPolicy{

Schedule: src.Spec.Backup,

RetentionDays: 7,

}

}

dst.Status.Phase = src.Status.Phase

return nil

}

// ConvertFrom은 Hub(v1beta1)를 v1alpha1로 변환합니다.

func (dst *Database) ConvertFrom(srcRaw conversion.Hub) error {

src := srcRaw.(*v1beta1.Database)

dst.ObjectMeta = src.ObjectMeta

dst.Spec.Engine = src.Spec.Engine

dst.Spec.Version = src.Spec.Version

dst.Spec.Replicas = src.Spec.Replicas

dst.Spec.StorageGB = src.Spec.StorageGB

// v1beta1의 구조화된 백업을 v1alpha1의 단순 문자열로 축약

if src.Spec.BackupPolicy != nil {

dst.Spec.Backup = src.Spec.BackupPolicy.Schedule

}

dst.Status.Phase = src.Status.Phase

return nil

}

여기서 중요한 통찰이 두 가지 있습니다. 첫째, **다운컨버전(Hub → 옛 버전)에서는 정보 손실이 불가피한 경우가 있습니다.** 위 예시에서 v1beta1의 `BackupPolicy.RetentionDays`는 v1alpha1으로 내려갈 때 사라집니다. 이런 손실을 막으려면 손실되는 정보를 어노테이션에 보존하는 라운드트립 패턴을 쓰기도 합니다. 둘째, **모든 변환은 라운드트립이 일관되어야 합니다.** A에서 B로 갔다가 다시 A로 돌아왔을 때 원본과 같아야 하며, controller-runtime은 이를 검증하는 fuzz 테스트 유틸리티를 제공합니다.

conversion webhook 등록

CRD에 webhook 변환 전략을 선언합니다. Kubebuilder는 이 부분을 대부분 생성해 줍니다.

apiVersion: apiextensions.k8s.io/v1

kind: CustomResourceDefinition

metadata:

name: databases.example.com

spec:

conversion:

strategy: Webhook

webhook:

conversionReviewVersions:

- v1

clientConfig:

service:

namespace: db-operator-system

name: db-operator-webhook

path: /convert

port: 443

caBundle: <cert-manager가 주입>

매니저 코드에서는 각 타입에 대해 변환 webhook을 설정합니다.

func (r *Database) SetupWebhookWithManager(mgr ctrl.Manager) error {

return ctrl.NewWebhookManagedBy(mgr).

For(r).

Complete()

}

caBundle은 보통 cert-manager의 CA Injector가 주입합니다. webhook이 다운되면 변환이 실패하고, 변환에 의존하는 모든 요청(심지어 옛 버전 GET)이 막히므로, webhook 가용성은 컨트롤 플레인 가용성과 직결됩니다.

하위호환과 deprecation

안전한 변경과 위험한 변경

스키마 변경에는 무해한 것과 파괴적인 것이 있습니다.

| 변경 | 안전한가 | 비고 |

| --- | --- | --- |

| 선택 필드 추가 | 안전 | 기존 객체는 영향 없음 |

| 기본값 추가 | 대체로 안전 | 기존 객체 의미가 바뀌지 않도록 주의 |

| enum 값 추가 | 안전 | 옛 클라이언트는 모를 뿐 |

| 필드 제거 | 위험 | 새 버전에서만, conversion으로 보존 |

| 필수로 변경 | 위험 | 기존 객체가 무효화될 수 있음 |

| 타입 변경 | 매우 위험 | 새 버전 + conversion 필수 |

| enum 값 제거 | 위험 | 해당 값을 쓰던 객체가 깨짐 |

황금률은 이것입니다. **같은 버전 안에서는 절대 파괴적 변경을 하지 마세요.** 필드 제거나 타입 변경이 필요하면 반드시 새 API 버전을 만들고 conversion webhook으로 다리를 놓습니다.

deprecation 신호

옛 버전을 걷어낼 때는 사용자에게 미리 경고해야 합니다. CRD 버전에 deprecation 마커를 달 수 있습니다.

versions:

- name: v1alpha1

served: true

storage: false

deprecated: true

deprecationWarning: "example.com/v1alpha1 Database는 deprecated되었습니다. v1beta1을 사용하세요."

이렇게 하면 kubectl로 해당 버전을 다룰 때 경고가 출력되어, 사용자가 마이그레이션할 시간을 줍니다.

additionalPrinterColumns와 subresource

printer columns

`kubectl get databases`를 쳤을 때 보이는 컬럼을 정의할 수 있습니다. 운영자에게 가장 중요한 정보를 노출하세요.

// +kubebuilder:printcolumn:name="Engine",type=string,JSONPath=`.spec.engine`

// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`

// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`

// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`

type Database struct {

metav1.TypeMeta `json:",inline"`

metav1.ObjectMeta `json:"metadata,omitempty"`

Spec DatabaseSpec `json:"spec,omitempty"`

Status DatabaseStatus `json:"status,omitempty"`

}

status subresource

`+kubebuilder:subresource:status` 마커는 status를 별도 subresource로 만듭니다. 이는 단순한 편의가 아니라 중요한 의미를 가집니다.

- 사용자는 spec만 수정하고, 컨트롤러는 status만 수정합니다. 둘이 충돌하지 않습니다.

- status 업데이트가 metadata.generation을 올리지 않으므로, 컨트롤러는 spec 변경(generation 증가)과 자신의 status 쓰기를 구분할 수 있습니다.

// +kubebuilder:subresource:status

type Database struct { /* ... */ }

type DatabaseStatus struct {

// Phase는 사람이 읽기 위한 요약 상태입니다.

Phase string `json:"phase,omitempty"`

// Conditions는 표준 상태 조건 목록입니다.

// +optional

// +patchMergeKey=type

// +patchStrategy=merge

Conditions []metav1.Condition `json:"conditions,omitempty"`

// ObservedGeneration은 마지막으로 처리한 spec 세대입니다.

ObservedGeneration int64 `json:"observedGeneration,omitempty"`

}

scale subresource

복제본을 가진 리소스라면 scale subresource를 노출해 `kubectl scale`과 HPA가 동작하게 만들 수 있습니다.

// +kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.replicas,selectorpath=.status.labelSelector

type Database struct { /* ... */ }

이렇게 하면 표준 `kubectl scale database/mydb --replicas=3`이 그대로 동작하고, HorizontalPodAutoscaler가 이 CRD를 대상으로 삼을 수 있습니다.

대규모 CRD 성능 고려사항

CRD가 수천, 수만 개의 객체로 확장되면 설계 결정이 성능에 직접 영향을 줍니다.

- **객체 크기를 작게 유지하세요.** etcd의 객체 크기 상한은 1.5MB이지만, 실제로는 그보다 훨씬 작아야 합니다. 큰 데이터(예: 전체 설정 파일)는 CR에 인라인하지 말고 ConfigMap이나 외부 저장소를 참조하세요.

- **상태에 로그나 이벤트를 누적하지 마세요.** status에 끝없이 커지는 배열을 두면 매 업데이트마다 전체 객체가 다시 쓰입니다. Conditions처럼 크기가 고정된 구조를 쓰세요.

- **불필요한 watch와 인덱스를 피하세요.** 컨트롤러가 모든 객체를 메모리에 캐시(informer)하므로, 객체가 많고 크면 메모리가 선형으로 증가합니다. label/field selector로 watch 범위를 좁히세요.

- **conversion webhook 비용을 의식하세요.** storage migration이나 대량 list 시 모든 객체가 webhook을 통과합니다. 변환 함수는 가볍게 유지하고, 외부 호출을 넣지 마세요.

- **printer column 계산을 단순하게.** 복잡한 JSONPath나 priority가 높은 컬럼이 많으면 list 응답 직렬화가 느려집니다.

API 설계 베스트프랙티스

선언적이어야 합니다

spec은 "무엇을 원하는가(desired state)"를 기술해야지, "무엇을 하라(명령)"를 담아서는 안 됩니다. 예를 들어 `spec.restart: true` 같은 명령형 필드는 안티패턴입니다. 한 번 실행하고 나면 그 필드는 무의미해지고, 누가 언제 껐는지 추적도 안 됩니다. 대신 의도를 선언하게 하세요.

나쁨: spec.restartNow: true (명령, 일회성)

좋음: spec.version: "16.2" (의도, 컨트롤러가 reconcile)

좋음: spec.paused: true (상태, 지속적 의미)

API 표면을 작게

처음부터 모든 옵션을 노출하려는 유혹을 참으세요. 일단 추가한 필드는 영원히 지원해야 합니다. 필드가 정말 필요한지, 합리적 기본값으로 대체할 수 없는지 자문하세요. 작은 API는 배우기 쉽고, 진화시키기 쉽고, 깨뜨릴 여지가 적습니다.

spec과 status의 명확한 분리

spec은 사용자의 입력이고 status는 컨트롤러의 출력입니다. 이 둘을 절대 섞지 마세요. 컨트롤러가 spec에 값을 써넣는 순간, 사용자의 GitOps 저장소와 클러스터 실제 상태가 어긋나기 시작합니다.

Conditions를 표준 형식으로

상태 표현은 `metav1.Condition` 표준을 따르세요. `type`, `status`, `reason`, `message`, `lastTransitionTime`을 가진 이 구조는 kubectl, 대시보드, 다른 컨트롤러가 모두 이해하는 공통 언어입니다.

마이그레이션

이미 운영 중인 CRD에 새 버전을 도입하고 storage를 옮기는 작업은 신중한 순서가 필요합니다. 다음은 storage version migrator를 활용한 실전 절차입니다.

1. 현재 저장된 버전들을 확인

kubectl get crd databases.example.com -o jsonpath='{.status.storedVersions}'

2. 새 버전을 served=true로 배포 (storage는 아직 옛 버전)

kubectl apply -f crd-with-new-version.yaml

3. storage를 새 버전으로 전환

kubectl patch crd databases.example.com --type=merge \

-p '{"spec":{"versions":[{"name":"v1beta1","storage":true}]}}'

4. 모든 객체를 새 storage 버전으로 재저장 (no-op 패치로 트리거)

kubectl get databases.example.com --all-namespaces -o name | \

xargs -I{} kubectl patch {} --type=merge -p '{}'

5. status.storedVersions에서 옛 버전이 빠졌는지 확인

kubectl get crd databases.example.com -o jsonpath='{.status.storedVersions}'

6. 옛 버전을 served=false로, 이후 스키마 제거

프로덕션에서는 4단계를 직접 patch로 돌리기보다 쿠버네티스 storage-version-migrator 컨트롤러나 그에 준하는 도구를 쓰는 것이 안전합니다. 수만 개 객체를 한 번에 patch하면 API 서버에 부하가 몰리기 때문입니다.

함정 모음

마지막으로 현장에서 반복적으로 마주치는 함정들을 정리합니다.

- **storage migration을 잊는 함정.** storage version만 바꾸고 기존 객체를 재저장하지 않으면, 옛 스키마를 지우는 순간 객체를 읽을 수 없게 됩니다. `status.storedVersions`가 단일 값이 될 때까지 옛 버전 스키마를 지우지 마세요.

- **무손실 변환을 깨는 함정.** 라운드트립 테스트 없이 conversion을 배포하면, 다운컨버전에서 조용히 데이터가 사라집니다. controller-runtime의 fuzz 기반 라운드트립 테스트를 CI에 넣으세요.

- **같은 버전을 파괴적으로 바꾸는 함정.** v1beta1에서 필드 타입을 바꾸고 재배포하면, 기존 객체가 디코드되지 않거나 pruning으로 데이터가 날아갑니다. 파괴적 변경은 언제나 새 버전으로.

- **webhook을 단일 장애점으로 두는 함정.** conversion webhook이 죽으면 옛 버전 GET조차 실패합니다. 복제본을 두 개 이상 두고, PodDisruptionBudget과 적절한 readiness probe를 설정하세요.

- **명령형 필드 함정.** `spec.action`, `spec.restartNow` 같은 일회성 명령은 reconcile 모델과 충돌합니다. 모든 것을 desired state로 표현하세요.

- **status에 spec을 섞는 함정.** 컨트롤러가 spec을 수정하면 GitOps 드리프트가 생깁니다. 컨트롤러는 status만 씁니다.

- **거대 객체 함정.** 큰 데이터를 CR에 인라인하면 informer 메모리와 etcd가 모두 고통받습니다. 참조로 빼세요.

- **defaulting 의미 변경 함정.** 이미 배포된 필드의 기본값을 바꾸면, 그 값을 명시하지 않은 기존 객체의 의미가 바뀝니다. 기본값 변경은 사실상 동작 변경임을 기억하세요.

마치며

CRD 설계는 처음에는 컨트롤러의 부속물처럼 보이지만, 시간이 지날수록 시스템에서 가장 바꾸기 어려운 부분이 됩니다. 좋은 출발점은 명확합니다. structural schema와 CEL로 검증을 스키마 안에 담고, 버전 진화를 처음부터 염두에 두며, conversion을 Hub-and-Spoke로 단순하게 유지하고, 모든 변환을 라운드트립 테스트로 보호하는 것입니다.

무엇보다 CRD를 코드가 아니라 **API 계약**으로 대하세요. 한 번 사용자에게 공개된 필드는 되돌리기 어렵습니다. 작게 시작하고, 선언적으로 설계하고, 진화할 길을 미리 닦아 두면, 오퍼레이터는 몇 년이 지나도 깨지지 않고 함께 성장할 수 있습니다.

References

- [Kubebuilder Book](https://kubebuilder.io/)

- [Operator SDK Documentation](https://sdk.operatorframework.io/)

- [controller-runtime package reference](https://pkg.go.dev/sigs.k8s.io/controller-runtime)

- [Kubernetes — Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)

- [Kubernetes — Custom Resources / CustomResourceDefinitions](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/)

- [Kubernetes — Versions in CustomResourceDefinitions](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/)

- [Kubernetes — Extend the Kubernetes API with CustomResourceDefinitions](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/)

- [Kubernetes — Validation rules (CEL)](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules)

- [kubernetes-sigs/kubebuilder](https://github.com/kubernetes-sigs/kubebuilder)

- [kubernetes-sigs/controller-tools](https://github.com/kubernetes-sigs/controller-tools)

현재 단락 (1/338)

오퍼레이터를 처음 만들 때 우리는 보통 컨트롤러 로직에 집중합니다. reconcile 루프를 어떻게 짤지, 외부 시스템을 어떻게 호출할지 고민하죠. 그런데 시간이 지나면 정작 발목...

작성 글자: 0원문 글자: 14,112작성 단락: 0/338