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라면, 실제 동작은 오퍼레이터와 컨트롤러의 몫입니다. 컨트롤러가 없으면 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를 확장하는 표준화된 길이 하나 있으니까, 수많은 도구들이 제각각 만들어졌어도 같은 방식으로 동작할 수 있었던 거죠. 표준 인터페이스 하나가 생태계 전체의 일관성을 떠받치고 있는 구조입니다.
매일 만지던 도구의 진짜 얼굴을 이제야 본 기분이었습니다. 도구를 쓰는 자리와 그 도구가 왜 그렇게 설계됐는지 보는 자리는, 역시 보이는 게 다릅니다.
이 글이 도움이 되셨나요?
버튼 하나가 다음 글을 쓰는 힘이 됩니다