개발팀은 '웹앱 하나 배포'만 요청하고 싶은데 플랫폼팀은 Deployment, Service, Ingress YAML을 매번 설명하고 있습니다. 복잡한 운영 규칙을 더 높은 수준의 API로 감싸면 사용자는 필요한 의도만 선언할 수 있습니다. Custom Resource는 Kubernetes를 조직의 플랫폼 API로 확장하는 방법입니다.
CRD와 Custom Resource — Kubernetes API 확장하기
쿠버네티스를 쓰다 보면 표준 리소스만으로는 표현하기 어려운 도메인 개념들이 생깁니다. TLS 인증서 관리, 데이터베이스 클러스터, 메시지 큐 토픽 같은 것들입니다. CRD는 Kubernetes API 서버 자체를 확장해서 이런 도메인 객체를 kubectl get certificate, kubectl get postgrescluster 처럼 표준 명령어로 다룰 수 있게 만듭니다. kubectl이 알고 있는 API가 늘어나는 것이니, 인프라 추상화 수준이 한 단계 올라가는 것입니다. cert-manager는 이 메커니즘의 교과서적인 사례로, 수십만 개 클러스터에서 TLS 인증서를 Certificate 리소스 하나로 관리합니다.
Kubernetes API 확장의 핵심인 CRD를 직접 정의하고 커스텀 리소스를 kubectl로 다루는 방법을 익힙니다. cert-manager의 Certificate 리소스를 통해 실제 프로덕션 CRD가 어떻게 설계되는지 이해합니다.
- 1CRD(Custom Resource Definition) 스키마 정의 — apiVersion, spec, validation
- 2커스텀 리소스 CRUD — kubectl create/get/describe/delete
- 3OpenAPI v3 스키마로 입력값 검증하기 — required, enum, format
- 4status 서브리소스로 spec(선언)과 status(현재 상태) 분리
- 5cert-manager Certificate 리소스 실제 예시로 이해하기
- 6CRD 버전 관리 — storage 버전과 conversion
kubectl로 클러스터에 접근할 수 있으면 됩니다. cert-manager 설치는 선택사항이며, CRD 정의와 커스텀 리소스 CRUD는 cert-manager 없이도 직접 만든 CRD로 실습합니다.
kubectl cluster-infokubectl create namespace crd-labkubectl get crd | head -20kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.0/cert-manager.yamlkubectl delete namespace crd-lab && kubectl delete crd webapps.platform.example.com
CRD 구조 — Kubernetes API 확장의 원리
플랫폼팀이 개발팀에게 "TLS 인증서가 필요하면 이 YAML 하나 작성하세요"라고 안내할 수 있는 것은 CRD 덕분입니다. cert-manager의 Certificate 리소스처럼, 복잡한 운영 절차를 단일 오브젝트로 추상화하면 사용자는 내부 구현 없이 의도만 선언할 수 있습니다. CRD가 없다면 Kubernetes의 표준 리소스(Pod, Deployment)로만 표현할 수 없는 도메인 개념을 쉘 스크립트나 외부 도구로 처리해야 합니다. 이는 클러스터 상태가 etcd 밖에 분산되어 감사와 추적이 어려워지는 원인이 됩니다. 그래서 CRD는 Kubernetes를 조직의 플랫폼 API로 확장하는 표준 방법입니다.

CRD 기본 구조
# crd-webapp.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
# CRD 이름: <plural>.<group> 형식이 규칙
name: webapps.platform.example.com
spec:
group: platform.example.com # API 그룹 (보통 회사 도메인 역순)
names:
kind: WebApp # 리소스 타입 이름 (CamelCase)
plural: webapps # URL에 사용되는 복수형
singular: webapp # kubectl에서 사용되는 단수형
shortNames: # 축약형 (선택)
- wa
scope: Namespaced # Namespaced | Cluster
versions:
- name: v1alpha1
served: true # API 서버가 이 버전을 서빙할지 여부
storage: true # etcd에 저장되는 버전 (하나만 true)
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required:
- image
- port
properties:
image:
type: string
description: "컨테이너 이미지 (repository:tag)"
port:
type: integer
minimum: 1
maximum: 65535
replicas:
type: integer
default: 1
minimum: 1
maximum: 10
env:
type: array
items:
type: object
required: ["name", "value"]
properties:
name:
type: string
value:
type: string
status:
type: object
properties:
phase:
type: string
enum: ["Pending", "Running", "Failed"]
availableReplicas:
type: integer
message:
type: string
# status 서브리소스 활성화 (Controller가 status만 별도 업데이트 가능)
subresources:
status: {}
# kubectl get webapps 출력에 표시될 추가 컬럼
additionalPrinterColumns:
- name: Image
type: string
jsonPath: .spec.image
- name: Replicas
type: integer
jsonPath: .spec.replicas
- name: Phase
type: string
jsonPath: .status.phase
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
CRD 적용 및 확인
# CRD 적용
kubectl apply -f crd-webapp.yaml
# customresourcedefinition.apiextensions.k8s.io/webapps.platform.example.com created
# CRD 등록 확인
kubectl get crd webapps.platform.example.com
# NAME CREATED AT
# webapps.platform.example.com 2026-05-16T10:00:00Z
# API 그룹 확인
kubectl api-resources | grep platform.example.com
# webapps wa platform.example.com/v1alpha1 true WebApp
# API 버전 확인
kubectl api-versions | grep platform
# platform.example.com/v1alpha1
- NAME—조회 대상 리소스 이름이 예상한 대상과 일치하는지 확인합니다.
- STATUS/READY—Running, Ready, Available처럼 정상 상태를 나타내는 필드가 있는지 봅니다.
- RESTARTS/EVENTS—재시작 횟수나 Warning 이벤트가 증가하지 않는지 확인합니다.
커스텀 리소스 CRUD — kubectl로 도메인 객체 다루기
CRD를 정의했는데 커스텀 리소스를 어떻게 생성하고 조회하는지 모르면 실습에서 바로 막힙니다. 표준 리소스(Pod, Service)는 kubectl이 기본으로 알고 있지만, 커스텀 리소스는 CRD가 클러스터에 등록된 후에야 kubectl이 인식합니다. CRD 적용 순서를 잘못 맞추면 "no kind 'WebApp' is registered" 오류가 나는데, 이 관계를 이해해야 Operator 패턴의 전체 흐름을 파악할 수 있습니다. CRD 등록 후에는 get, describe, apply, delete 등 모든 kubectl 명령이 표준 리소스와 동일하게 동작하기 때문에, 기존 kubectl 사용법 그대로 커스텀 리소스를 다룰 수 있습니다.

커스텀 리소스 생성
# webapp-frontend.yaml
apiVersion: platform.example.com/v1alpha1
kind: WebApp
metadata:
name: frontend
namespace: crd-lab
spec:
image: "nginx:1.25-alpine"
port: 80
replicas: 2
env:
- name: API_URL
value: "http://backend:8080"
- name: NODE_ENV
value: "production"
kubectl apply -f webapp-frontend.yaml
# webapp.platform.example.com/frontend created
# 스키마 검증 확인 (잘못된 값)
cat > webapp-invalid.yaml << 'EOF'
apiVersion: platform.example.com/v1alpha1
kind: WebApp
metadata:
name: invalid
namespace: crd-lab
spec:
port: 99999 # maximum: 65535 초과
replicas: 20 # maximum: 10 초과
# image 누락 (required 필드)
EOF
kubectl apply -f webapp-invalid.yaml
# The WebApp "invalid" is invalid:
# * spec.image: Required value
# * spec.port: Invalid value: 99999: spec.port in body should be less than or equal to 65535
# * spec.replicas: Invalid value: 20: spec.replicas in body should be less than or equal to 10
커스텀 리소스 조회
# 목록 조회 (additionalPrinterColumns 활용)
kubectl get webapps -n crd-lab
# NAME IMAGE REPLICAS PHASE AGE
# frontend nginx:1.25-alpine 2 <none> 30s
# 상세 조회
kubectl describe webapp frontend -n crd-lab
# Name: frontend
# Namespace: crd-lab
# Labels: <none>
# API Version: platform.example.com/v1alpha1
# Kind: WebApp
# Spec:
# Image: nginx:1.25-alpine
# Port: 80
# Replicas: 2
# Env:
# Name: API_URL
# Value: http://backend:8080
# Status:
# <nil> ← Controller 없으면 status는 비어 있음
# YAML로 전체 출력
kubectl get webapp frontend -n crd-lab -o yaml
# 축약형 사용
kubectl get wa -n crd-lab
status 업데이트 (Controller 역할 시뮬레이션)
# status 서브리소스를 활성화했을 때 status만 별도 업데이트
# 실제 Controller는 client-go의 Status().Update()를 사용
# kubectl patch로 직접 테스트할 수 있음
kubectl patch webapp frontend -n crd-lab \
--subresource=status \
--type=merge \
-p '{"status":{"phase":"Running","availableReplicas":2,"message":"All pods running"}}'
# 결과 확인
kubectl get webapp frontend -n crd-lab
# NAME IMAGE REPLICAS PHASE AGE
# frontend nginx:1.25-alpine 2 Running 2m
커스텀 리소스 수정 및 삭제
# 수정 (spec 변경)
kubectl patch webapp frontend -n crd-lab \
--type=merge \
-p '{"spec":{"replicas":3}}'
# 또는 직접 편집
kubectl edit webapp frontend -n crd-lab
# 삭제
kubectl delete webapp frontend -n crd-lab
# webapp.platform.example.com "frontend" deleted
cert-manager Certificate 리소스 — 실제 프로덕션 CRD 사례
Let's Encrypt 인증서가 만료됐다는 알림을 받고 수동으로 갱신하다가 실수로 서비스를 잠깐 중단시킨 경험이 있다면, cert-manager가 왜 필요한지 바로 납득할 수 있습니다. cert-manager는 TLS 인증서 발급과 90일 주기 자동 갱신을 Certificate 리소스 하나로 선언적으로 처리합니다. 전 세계 수십만 클러스터에서 사용되는 이 도구가 CRD + Operator 패턴의 대표 사례입니다. cert-manager의 코드를 보지 않아도 kubectl get certificate로 발급 상태를 확인하고, YAML 한 파일로 인증서를 선언할 수 있다는 것이 CRD 추상화의 가치를 보여줍니다.
cert-manager의 CRD 목록
# cert-manager 설치 후 생성되는 CRD들
kubectl get crd | grep cert-manager.io
# NAME CREATED AT
# certificaterequests.cert-manager.io 2026-05-16T09:00:00Z
# certificates.cert-manager.io 2026-05-16T09:00:00Z
# clusterissuers.cert-manager.io 2026-05-16T09:00:00Z
# issuers.cert-manager.io 2026-05-16T09:00:00Z
# orders.acme.cert-manager.io 2026-05-16T09:00:00Z
# challenges.acme.cert-manager.io 2026-05-16T09:00:00Z
ClusterIssuer 설정 (Let's Encrypt)
# cluster-issuer-prod.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-prod-private-key
solvers:
- http01:
ingress:
class: nginx
Certificate 리소스 생성
# certificate-example.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: example-com-tls
namespace: production
spec:
# 발급된 인증서가 저장될 Secret 이름
secretName: example-com-tls-secret
# 어떤 Issuer를 사용할지
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
group: cert-manager.io
# 인증서에 포함될 도메인
dnsNames:
- example.com
- www.example.com
- api.example.com
# 갱신 기간 (만료 30일 전 자동 갱신)
renewBefore: 720h # 30일
duration: 2160h # 90일 (Let's Encrypt 기본)
발급 상태 확인
kubectl apply -f certificate-example.yaml
# Certificate 상태 확인
kubectl get certificate example-com-tls -n production
# NAME READY SECRET AGE
# example-com-tls True example-com-tls-secret 5m
# 상세 상태 (Conditions 확인)
kubectl describe certificate example-com-tls -n production
# Status:
# Conditions:
# Last Transition Time: 2026-05-16T10:00:00Z
# Message: Certificate is up to date and has not expired
# Observed Generation: 1
# Reason: Ready
# Status: True
# Type: Ready
# Not After: 2026-08-14T10:00:00Z
# Not Before: 2026-05-16T10:00:00Z
# Renewal Time: 2026-07-15T10:00:00Z ← 자동 갱신 예정 시각
# 생성된 Secret (TLS 인증서 파일)
kubectl get secret example-com-tls-secret -n production
# NAME TYPE DATA AGE
# example-com-tls-secret kubernetes.io/tls 3 5m
# Ingress에서 TLS 참조
# spec.tls[].secretName: example-com-tls-secret
CRD 스키마 복잡성 — 실제 Certificate CRD 일부
# cert-manager의 Certificate CRD 스키마 확인 (매우 상세함)
kubectl get crd certificates.cert-manager.io -o yaml | \
python3 -c "
import sys, yaml, json
d = yaml.safe_load(sys.stdin)
spec_props = d['spec']['versions'][0]['schema']['openAPIV3Schema']['properties']['spec']['properties']
print('Certificate spec의 주요 필드:')
for key in list(spec_props.keys())[:10]:
print(f' {key}: {spec_props[key].get(\"type\", \"object\")}')
"
# Certificate spec의 주요 필드:
# commonName: string
# dnsNames: array
# duration: string
# emailAddresses: array
# encodeUsagesInRequest: boolean
# ipAddresses: array
# isCA: boolean
# issuerRef: object
# keystores: object
# literalSubject: string
실습 — 커스텀 리소스 CRUD 전체 흐름
1단계: CRD 정의 파일 생성
mkdir -p ~/crd-lab && cd ~/crd-lab
cat > crd-webapp.yaml << 'EOF'
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: webapps.platform.example.com
spec:
group: platform.example.com
names:
kind: WebApp
plural: webapps
singular: webapp
shortNames:
- wa
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required:
- image
- port
properties:
image:
type: string
port:
type: integer
minimum: 1
maximum: 65535
replicas:
type: integer
default: 1
minimum: 1
maximum: 10
status:
type: object
properties:
phase:
type: string
enum: ["Pending", "Running", "Failed"]
availableReplicas:
type: integer
subresources:
status: {}
additionalPrinterColumns:
- name: Image
type: string
jsonPath: .spec.image
- name: Phase
type: string
jsonPath: .status.phase
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
EOF
kubectl apply -f crd-webapp.yaml
2단계: 커스텀 리소스 생성 및 검증 테스트
# 올바른 리소스
kubectl apply -f - << 'EOF'
apiVersion: platform.example.com/v1alpha1
kind: WebApp
metadata:
name: my-frontend
namespace: crd-lab
spec:
image: "nginx:1.25"
port: 80
replicas: 2
EOF
# 스키마 위반 테스트 (오류 확인)
kubectl apply -f - << 'EOF'
apiVersion: platform.example.com/v1alpha1
kind: WebApp
metadata:
name: bad-webapp
namespace: crd-lab
spec:
port: 99999
EOF
# The WebApp "bad-webapp" is invalid:
# * spec.image: Required value
# * spec.port: Invalid value: 99999: ...should be less than or equal to 65535
3단계: CRUD 실습
# 조회
kubectl get wa -n crd-lab
kubectl describe webapp my-frontend -n crd-lab
# status 업데이트
kubectl patch webapp my-frontend -n crd-lab \
--subresource=status \
--type=merge \
-p '{"status":{"phase":"Running","availableReplicas":2}}'
kubectl get wa -n crd-lab
# NAME IMAGE PHASE AGE
# my-frontend nginx:1.25 Running 3m
# spec 수정
kubectl patch webapp my-frontend -n crd-lab \
--type=merge \
-p '{"spec":{"replicas":3}}'
# 삭제
kubectl delete webapp my-frontend -n crd-lab
문제 상황
$ kubectl apply -f webapp-frontend.yaml
error: unable to recognize "webapp-frontend.yaml": \
no kind "WebApp" is registered for version "platform.example.com/v1alpha1"
# 또는
$ kubectl get webapps -n crd-lab
error: the server doesn't have a resource type "webapps"
원인 1: CRD가 아직 적용되지 않음
# CRD 존재 여부 확인
kubectl get crd | grep platform.example.com
# (출력 없음) ← CRD가 없음
# 해결: CRD를 먼저 적용
kubectl apply -f crd-webapp.yaml
# CRD 준비 상태 확인 (Established 조건이 True여야 함)
kubectl wait crd/webapps.platform.example.com \
--for=condition=Established \
--timeout=30s
# customresourcedefinition.apiextensions.k8s.io/webapps.platform.example.com condition met
원인 2: CRD의 validation schema 오류로 적용 실패
# CRD 자체의 상태 확인
kubectl get crd webapps.platform.example.com -o yaml | \
grep -A 10 "conditions:"
# Established: False가 보이면 schema 오류
# status:
# conditions:
# - lastTransitionTime: "2026-05-16T10:00:00Z"
# message: 'spec.versions[0].schema.openAPIV3Schema.properties[spec].properties[port]:
# Invalid value: "string": must be integer'
# reason: ValidationError
# status: "False"
# type: Established
# 해결: CRD YAML의 openAPIV3Schema type 오류 수정 후 재적용
원인 3: apiVersion 또는 group 이름 불일치
# CRD의 실제 group 확인
kubectl get crd webapps.platform.example.com -o jsonpath='{.spec.group}'
# platform.example.com
# 리소스 YAML의 apiVersion이 일치하는지 확인
# 올바름: apiVersion: platform.example.com/v1alpha1
# 틀림: apiVersion: platform.io/v1 ← group이 다름
# 사용 가능한 API 버전 전체 목록
kubectl api-versions | grep platform
# platform.example.com/v1alpha1
원인 4: CRD가 클러스터 범위인데 네임스페이스 지정
# scope: Cluster인 CRD에 -n 옵션 사용 시
kubectl get webapps -n crd-lab
# Error from server (BadRequest): ...namespaces are not valid for this resource type
# 해결: -n 없이 조회
kubectl get webapps
# 또는 scope 확인
kubectl get crd webapps.platform.example.com -o jsonpath='{.spec.scope}'
# Cluster ← Namespaced가 아님
배경
플랫폼 엔지니어링 팀이 개발팀에게 Kubernetes를 직접 노출하지 않고, 간단한 커스텀 리소스로 배포를 가능하게 만들었습니다. 개발자는 Deployment, Service, HPA 등 복잡한 리소스를 모르고도 WebApp 리소스 하나만 작성하면 됩니다.
개발자가 작성하는 것 (단순)
# 개발자가 관리하는 파일 — 복잡한 Kubernetes 지식 불필요
apiVersion: platform.mycompany.com/v1
kind: WebApp
metadata:
name: payment-service
namespace: production
spec:
image: "gcr.io/myproject/payment:v2.3.1"
port: 8080
replicas: 3
env:
- name: DATABASE_URL
value: "postgres://..."
플랫폼 팀의 Controller가 생성하는 것 (복잡)
WebApp "payment-service" 생성 감지
├── Deployment 생성 (리소스 제한, probe, affinity 포함)
├── Service 생성
├── HPA 생성 (CPU 80% 기준 자동 스케일링)
├── PodDisruptionBudget 생성 (최소 2개 파드 보장)
└── NetworkPolicy 생성 (필요한 포트만 허용)
효과
개발팀: "yaml 한 파일로 배포할 수 있어서 좋다"
인프라팀: "표준 정책이 모든 배포에 자동으로 적용된다"
보안팀: "NetworkPolicy가 빠진 배포가 없어졌다"
CRD는 복잡한 인프라 패턴을 캡슐화하여 도메인 언어로 노출하는 강력한 추상화 도구입니다.
핵심 요약
| 개념 | 설명 |
|---|---|
| CRD | Kubernetes API 서버에 새로운 리소스 타입을 등록하는 설정 |
group | API 그룹 이름 (보통 company.domain.com 형식) |
scope | Namespaced (네임스페이스 범위) 또는 Cluster (클러스터 전체) |
openAPIV3Schema | 커스텀 리소스의 spec/status 필드 타입과 제약 검증 |
required | 필수 필드 목록 — 없으면 적용 거부 |
status 서브리소스 | Controller만 status를 업데이트하도록 엔드포인트 분리 |
additionalPrinterColumns | kubectl get 출력에 표시할 추가 컬럼 |
served | API 서버가 해당 버전을 서빙할지 여부 |
storage | etcd에 저장되는 버전 (하나만 true 가능) |
| cert-manager | Certificate CRD로 TLS 인증서를 선언적으로 관리하는 대표 사례 |