CRD를 마침내 이해하게 되기까지
터미널을 켜고 확인해보세요
어느 날 문득 궁금해서 터미널에 이 명령어를 입력해봤습니다.
kubectl get crds
스크롤이 한참 내려갔습니다. 수백 개의 라인이 쏟아져 나오더라고요. ArgoCD Application, KEDA ScaledObject, cert-manager Certificate... 죄다 매일 보던 이름들이었습니다. 그런데 그 목록을 멍하니 바라보다가 좀 이상한 기분이 들었습니다.
저는 수년간 쿠버네티스를 만져왔고, 클러스터 운영도, 전환도 해봤습니다. 그런데 정작 쿠버네티스가 어떻게 확장되는지를 제대로 설명하라고 하면 못 하겠더라고요. 매일 쓰는 도구의 작동 원리를 모르고 있었던 셈입니다.

쿠버네티스는 플랫폼이 아니라 언어입니다
쿠버네티스를 떠올리면 보통 Pod, Deployment, Service가 먼저 나옵니다. 저도 한참을 그 수준에서 머물렀습니다. 그런데 이건 표면일 뿐이더군요.
조금 더 들어가 보면 쿠버네티스는 플랫폼이라기보다 언어에 가깝습니다. 그리고 CRD는 그 언어에 새 단어를 등록하는 도구죠.
생각해보면 이게 꽤 신기한 구조입니다. DatabaseCluster, MLModel, TenantConfig 같은 개념을 정의하는 순간, 이들은 바로 다음 성질을 갖게 됩니다.
- 일급 API 객체가 된다
- kubectl로 쿼리할 수 있다
- etcd에 저장된다
- RBAC로 권한 제어가 적용된다
- 네이티브 리소스처럼 watch할 수 있다
즉 내가 머릿속에서 정의한 추상 개념이, 쿠버네티스 자체의 일부가 되는 겁니다. 데이터 모델을 직접 짜본 개발자라면 이게 얼마나 강력한 얘기인지 감이 올 겁니다. 보통 새로운 도메인 객체를 시스템에 추가하려면 스토리지 설계, API 엔드포인트, 권한 체크, 조회 인터페이스를 전부 손으로 붙여야 하는데, 그 일을 선언 한 번으로 끝내는 거니까요.
실제로 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 엔드포인트가 생깁니다. 서버 재시작 없이요. 이 부분이 처음엔 좀 비현실적으로 느껴졌습니다. 보통 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를 채워줄 컨트롤러가 없기 때문입니다. 선언은 받아들여졌지만, 그 선언을 현실로 만드는 주체가 없는 상태죠. 이 지점을 짚고 나면 오퍼레이터 패턴이 왜 항상 CRD와 세트로 등장하는지 자연스럽게 이해됩니다.
status 서브리소스의 중요성
처음에는 왜 굳이 subresources에 status: {}를 넣어야 하는지 몰랐습니다. 없어도 동작은 하니까 한동안 빼고 쓰기도 했고요. 그런데 나중에 보니 이게 그냥 CRD를 쓰는 사람과 플랫폼을 설계하는 사람을 가르는 포인트더군요.
status 서브리소스가 있으면:
- 사용자는 spec만 수정할 수 있다
- status는 오직 컨트롤러만 업데이트할 수 있다
- 이 분리가 API 레벨에서 강제된다
이건 단순한 관습이 아니라 강제된 아키텍처입니다. 사용자가 "원하는 상태"를 선언하고, 컨트롤러가 "실제 상태"를 보고하는 구조. 책임 경계가 코드 규약이 아니라 API 스펙으로 못 박혀 있다는 게 핵심입니다. 운영을 해보면 압니다. 규약은 깨지지만, 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 설계 문제더라고요. 이건 OpenAPI를 설계하면서 겪던 고민과 본질이 똑같습니다. 스키마를 느슨하게 풀면 당장은 편하지만, 그 비용은 나중에 디버깅과 호환성 깨짐으로 돌아옵니다.

마침내 이해하게 된 순간
CRD를 제대로 이해하고 나니 클라우드 네이티브 생태계 전체가 다르게 보이기 시작했습니다. ArgoCD, KEDA, cert-manager... 이들이 전부 같은 원리로 돌아간다는 게 보이더군요.
이들은 쿠버네티스를 "확장"한 게 아니었습니다. 쿠버네티스 언어에 새 어휘를 추가하고 있었던 거죠. 플러그인도 해킹도 아닌, 그냥 새로운 명사들. 다른 도구처럼 보였지만 작동 방식은 한 뿌리였던 겁니다.
그제서야 왜 쿠버네티스가 이렇게 두꺼운 생태계를 갖게 됐는지 납득이 됐습니다. API를 확장하는 표준화된 길이 하나 있으니까, 수많은 도구들이 제각각 만들어졌어도 결국 같은 방식으로 동작할 수 있었던 거죠. 표준 인터페이스 하나가 생태계 전체의 일관성을 떠받치고 있는 구조입니다.
매일 만지던 도구의 진짜 얼굴을 이제야 본 기분이었습니다. 도구를 쓰는 자리와 그 도구가 왜 그렇게 설계됐는지 보는 자리는, 역시 보이는 게 다릅니다.