CRD를 마침내 이해하게 되기까지

|Platform Decision|11분 읽기

터미널을 켜고 확인해보세요

어느 날 문득 궁금해서 터미널에 이 명령어를 입력해봤습니다.

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 서브리소스의 중요성

처음에는 왜 굳이 subresourcesstatus: {}를 넣어야 하는지 몰랐습니다. 없어도 동작은 하니까 한동안 빼고 쓰기도 했고요. 그런데 나중에 보니 이게 그냥 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를 확장하는 표준화된 길이 하나 있으니까, 수많은 도구들이 제각각 만들어졌어도 결국 같은 방식으로 동작할 수 있었던 거죠. 표준 인터페이스 하나가 생태계 전체의 일관성을 떠받치고 있는 구조입니다.

매일 만지던 도구의 진짜 얼굴을 이제야 본 기분이었습니다. 도구를 쓰는 자리와 그 도구가 왜 그렇게 설계됐는지 보는 자리는, 역시 보이는 게 다릅니다.

#kubernetes#CRD#CustomResourceDefinition#인프라#데브옵스