12.3 CRD
내장 리소스는 범용 워크로드의 어휘다. Deployment, Service, ConfigMap으로 “무엇을 몇 개 띄우고 어떻게 연결하는가"는 표현되지만, “orders 데이터베이스를 매일 새벽 3시에 백업하고 7일 보관한다” 같은 도메인 개념을 담을 자리는 없다. CustomResourceDefinition(CRD)은 이럴 때 Kubernetes API 자체에 새 리소스 타입을 등록하는 확장 지점이다. CRD를 등록하면 apiserver가 새 엔드포인트를 열고, 그 타입의 객체(custom resource, CR)는 etcd 저장, kubectl 조작, RBAC, watch까지 내장 리소스와 같은 대접을 받는다.
CustomResourceDefinition 작성
CRD는 그 자체가 하나의 리소스라서 YAML로 작성해 apply한다. 백업 요청을 표현하는 Backup 타입을 등록하는 예시다.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: backups.db.example.com # <plural>.<group> 형식으로 고정
spec:
group: db.example.com
scope: Namespaced
names:
plural: backups
singular: backup
kind: Backup
shortNames: ["bk"]
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["database"]
properties:
database:
type: string
schedule:
type: string
retentionDays:
type: integer
minimum: 1
status:
type: object
properties:
lastBackupTime:
type: string
subresources:
status: {}group은 새 API 그룹의 이름이고, CR의 apiVersion 앞부분이 된다. 내장 리소스와의 충돌을 피하기 위해 소유한 도메인을 쓰는 것이 관례다. scope는 CR이 namespace에 속하는지(Namespaced) 클러스터 전역인지(Cluster)를 정한다. names는 이 타입을 부르는 이름들이다. kind는 매니페스트에 적는 타입명, plural은 REST 경로와 kubectl에서 쓰는 복수형, shortNames는 kubectl get bk처럼 쓰는 축약형이다.
등록이 끝나면 이 타입의 객체를 일반 매니페스트처럼 작성한다.
apiVersion: db.example.com/v1
kind: Backup
metadata:
name: orders-nightly
spec:
database: orders
schedule: "0 3 * * *"
retentionDays: 7스키마 검증과 CEL 규칙
apiextensions.k8s.io/v1에서 openAPIV3Schema는 선택이 아니라 필수다. apiserver는 CR을 저장하기 전에 이 스키마로 검증하므로, required 필드 누락, 타입 불일치, minimum 위반은 kubectl apply 시점에 즉시 거부된다. 잘못된 설정이 저장된 뒤 컨트롤러가 처리하다 실패하는 것보다 훨씬 이른 지점에서 문제가 드러난다.
타입과 범위만으로 표현되지 않는 규칙은 CEL(Common Expression Language) 검증으로 선언한다. 필드 사이의 관계를 검사하는 validation rule은 Kubernetes 1.29에서 GA됐다.
spec:
type: object
x-kubernetes-validations:
- rule: "self.retentionDays <= 90"
message: "retentionDays는 90 이하여야 한다"CEL 규칙도 apiserver 안에서 평가되므로 별도의 webhook 서버 없이 동작한다. 스키마와 CEL로 표현하지 못하는 검증(외부 시스템 조회 등)만 admission webhook의 몫으로 남는다.
served, storage 버전과 전환
versions는 배열이라 한 CRD가 여러 버전을 동시에 가질 수 있다. 버전마다 두 개의 스위치가 있다. served는 그 버전을 API로 제공할지, storage는 etcd에 저장할 때 어느 버전의 형식을 쓸지다. storage는 정확히 하나의 버전에만 true여야 한다.
전형적인 전환은 이렇게 진행된다. v1alpha1로 시작한 CRD에 v1을 추가하면 한동안 두 버전이 모두 served 상태로 공존하고, 클라이언트가 어느 버전으로 요청하든 apiserver가 변환해 응답한다. 두 버전의 스키마가 같으면 변환 전략 None으로 충분하고, 필드 구조가 다르면 conversion webhook을 등록해 변환 코드를 제공해야 한다. 준비가 되면 storage를 v1으로 옮기고, 마지막으로 v1alpha1의 served를 false로 바꿔 퇴역시킨다.
status 서브리소스와 kubectl 경험
subresources에 status: {}를 선언하면 spec과 status가 분리된 경로로 갱신된다. 내장 리소스와 같은 규약이다. 사용자와 배포 도구는 본 리소스를 갱신해 spec을 쓰고, 컨트롤러는 /status 서브리소스로 관측 결과를 쓴다. 본 리소스 갱신은 status 변경을 무시하고 status 갱신은 spec 변경을 무시하므로, 두 주체가 서로의 기록을 덮어쓰는 사고가 구조적으로 차단된다. RBAC도 backups와 backups/status를 다른 규칙으로 나눌 수 있다.
additionalPrinterColumns를 선언하면 kubectl get의 출력 컬럼도 타입에 맞게 바뀐다.
# versions[] 항목 안에 선언한다
additionalPrinterColumns:
- name: Database
type: string
jsonPath: .spec.database
- name: LastBackup
type: string
jsonPath: .status.lastBackupTime여기까지 갖추면 CR을 다루는 경험은 내장 리소스와 거의 구분되지 않는다. kubectl get backups로 목록을 보고, kubectl describe backup orders-nightly로 상세를 확인하고, kubectl explain backup.spec으로 스키마 문서를 조회하고, kubectl api-resources | grep backup으로 등록 여부를 확인한다. 다만 CRD가 하는 일은 여기까지다. Backup 객체는 검증되고 저장되고 조회될 뿐, 아무도 백업을 실행하지 않는다. 저장된 spec을 읽어 실제 동작을 만드는 것은 컨트롤러의 몫이고, 그 조합이 12.4의 Operator 패턴이다.
정리
- CRD는 apiserver에 새 리소스 타입을 등록하고, CR은 저장, kubectl, RBAC, watch에서 내장 리소스와 같은 취급을 받는다.
- openAPIV3Schema 검증은 필수이고, 필드 간 관계 같은 규칙은 CEL validation rule(1.29 GA)로 apiserver 안에서 검사한다.
- 버전은 served(제공)와 storage(저장 형식, 단 하나)로 관리하며, 스키마가 달라지는 전환에는 conversion webhook이 필요하다.
- status 서브리소스는 사용자의 spec과 컨트롤러의 status를 분리된 경로로 갱신하게 만든다.