CRD를 마침내 이해하게 되기까지...
터미널을 켜고 확인해보세요
문득 궁금해서 터미널에 이 명령어를 입력해봤습니다.
kubectl get crds
스크롤이 한참 내려갔습니다. 수백 개의 라인이 쏟아져 나오더라고요. ArgoCD Application, KEDA ScaledObject, cert-manager Certificate... 모두 익숙한 이름들이었는데, 이때 깨달은 게 있었습니다.
저는 수년간 쿠버네티스를 사용하면서도, 정작 쿠버네티스가 어떻게 확장되는지 제대로 이해하지 못하고 있었다는 것을요.

쿠버네티스는 플랫폼이 아니라 언어입니다
대부분 사람들이 쿠버네티스를 생각할 때 떠올리는 건 Pod, Deployment, Service 정도입니다. 저도 그랬고요. 하지만 이건 겉면일 뿐이더라고요.
실제로 쿠버네티스는 플랫폼이 아니라 언어에 가깝습니다. 그리고 CRD는 그 언어에 새로운 단어를 추가할 수 있게 해주는 도구죠.
생각해보면 신기한 일입니다. DatabaseCluster, MLModel, TenantConfig 같은 개념을 정의하는 순간, 이들은:
- 일급 API 객체가 됩니다
- kubectl로 쿼리 가능해집니다
- etcd에 저장됩니다
- RBAC로 보안이 적용됩니다
- 네이티브 리소스처럼 watch 할 수 있습니다
즉, 우리의 아이디어가 쿠버네티스 자체의 일부가 되는 거죠.
실제로 CRD를 만들어보기
이론보다는 실제로 만들어보는 게 이해가 빠를 것 같아서, DatabaseCluster라는 CRD를 작성해봤습니다.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databaseclusters.infra.example.com
spec:
group: infra.example.com
scope: Namespaced
names:
plural: databaseclusters
singular: databasecluster
kind: DatabaseCluster
shortNames:
- dbc
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Replicas
type: integer
jsonPath: .spec.replicas
- name: Region
type: string
jsonPath: .spec.region
- name: Phase
type: string
jsonPath: .status.phase
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["engine", "replicas", "region"]
properties:
engine:
type: string
enum: ["postgres", "mysql", "mariadb"]
replicas:
type: integer
minimum: 1
maximum: 9
region:
type: string
storageGB:
type: integer
minimum: 10
default: 20
status:
type: object
properties:
phase:
type: string
endpoint:
type: string
여기서 중요한 건 몇 가지입니다:
group과 names: infra.example.com/v1alpha1이라는 새로운 API 엔드포인트가 생성됩니다. 서버 재시작 없이요.
scope: Namespaced로 설정하면 네임스페이스 내에서 관리되고, Cluster로 하면 클러스터 전체에서 관리됩니다.
subresources의 status: 이게 핵심입니다. 사용자는 spec(원하는 상태)만 정의하고, 오직 컨트롤러만 status(실제 상태)를 업데이트할 수 있게 됩니다.
CRD는 아무것도 실행하지 않습니다
여기서 많은 분들이 헷갈려하는 부분이 있는데요. CRD 자체는 아무것도 실행하지 않습니다.
CRD는 단순히 의도만 정의할 뿐이에요. 이렇게 생각하면 됩니다:
- CRD = 어휘 정의
- Operator/Controller = 실제 동작
컨트롤러 없이는 kubectl get dbc를 해도 그냥 객체가 거기 있을 뿐, 실제로는 아무 일도 일어나지 않습니다.
# my-database.yaml
apiVersion: infra.example.com/v1alpha1
kind: DatabaseCluster
metadata:
name: production-postgres
namespace: databases
spec:
engine: postgres
replicas: 3
region: ap-northeast-2
storageGB: 100
이걸 적용하면:
kubectl apply -f my-database.yaml
kubectl get dbc -n databases
# NAME REPLICAS REGION PHASE AGE
# production-postgres 3 ap-northeast-2 <none> 10s
Phase가 <none>인 이유는 아직 컨트롤러가 없기 때문입니다.
status 서브리소스의 중요성
처음에는 왜 굳이 subresources에 status: {}를 넣어야 하는지 이해하지 못했었는데요. 나중에 알고 보니 이게 초보자와 실제 플랫폼 엔지니어를 구분하는 포인트더라고요.
status 서브리소스가 있으면:
- 사용자는 spec만 수정할 수 있습니다
- 오직 컨트롤러만 status를 업데이트할 수 있습니다
- API 레벨에서 이 분리가 강제됩니다
이건 단순한 관습이 아니라 강제된 아키텍처입니다. 사용자가 "원하는 상태"를 정의하고, 컨트롤러가 "실제 상태"를 보고하는 구조죠.

숨겨진 kubectl 트릭
additionalPrinterColumns를 추가하면 kubectl 출력이 완전히 달라집니다:
kubectl get dbc
# NAME REPLICAS REGION PHASE AGE
# production-postgres 3 ap-northeast-2 Ready 2d
# staging-mysql 1 ap-northeast-2 Pending 5m
UI 없이도 kubectl이 대시보드가 되는 거죠.
실무에서 피해야 할 실수들
몇 년간 CRD를 다루면서 자주 보는 실수들이 있습니다:
❌ 스키마 없이 CRD 만들기: 아무 값이나 들어가서 나중에 디버깅이 지옥이 됩니다.
❌ status 서브리소스 빼먹기: 사용자가 시스템 상태를 덮어쓸 수 있게 됩니다.
❌ 무작정 스키마 변경: 기존 리소스들이 깨집니다.
❌ 컨트롤러 로직 없이 CRD만 배포: 아무 일도 일어나지 않습니다.
대부분의 CRD 문제는 쿠버네티스 문제가 아니라 API 설계 문제더라고요.

마침내 이해하게 된 순간
CRD를 이해하고 나니 클라우드 네이티브 생태계 전체가 다르게 보이기 시작했습니다. ArgoCD, KEDA, cert-manager... 이들이 모두 같은 원리로 동작한다는 걸 깨달았거든요.
이들은 쿠버네티스를 "확장"하는 게 아니라, 쿠버네티스 언어에 새로운 어휘를 추가하고 있었던 겁니다. 플러그인도 해킹도 아닌, 그냥 새로운 명사들이었던 거죠.
그제서야 왜 쿠버네티스가 이렇게 강력한 생태계를 가지고 있는지 이해하게 됐습니다. API를 확장할 수 있는 표준화된 방법이 있으니까, 모든 도구들이 일관된 방식으로 동작할 수 있었던 거더라고요.
수년간 매일 사용하던 도구의 진짜 모습을 이제야 알게 된 기분이었습니다.