CNPA — 클라우드 네이티브 플랫폼 엔지니어링 어소시에이트 · 플랫폼 API 와 추상화 · 이론
쿠버네티스 API 는 왜 플랫폼의 공용어가 됐나
한 줄 요약
쿠버네티스가 이긴 것은 컨테이너 오케스트레이션 경쟁이 아니라 API 규격 경쟁 이었습니다. 선언적 리소스 + 조정 컨트롤러라는 한 쌍의 패턴이 컨테이너 바깥까지 퍼졌고, 그래서 플랫폼 API 를 새로 만들 때 CRD 가 기본 선택지가 됐습니다.
왜 이게 필요했나
플랫폼 팀이 "서비스 하나 띄우기"를 쉽게 만들려고 하면 보통 이런 길을 밟습니다.
1. 위키에 절차를 적는다 → 아무도 최신으로 유지하지 않는다.
2. 셸 스크립트를 만든다 → 실행 환경마다 결과가 다르고, 실패하면 중간 상태가 남는다.
3. 사내 웹 앱을 만든다 → 상태 저장, 인증, 감사, 재시도, 동시성을 전부 직접 구현해야 한다. 그리고 그 앱이 새 SPOF 가 된다.
3 번을 끝까지 만들어 보면 결국 무엇을 다시 만들고 있는지 깨닫습니다. 상태 저장소, 낙관적 동시성 제어, 인증·인가, 감사 로그, 감시(watch), 그리고 조정 루프 — 전부 쿠버네티스 API 서버가 이미 가진 것들입니다.
그래서 방향이 뒤집힙니다. 플랫폼 API 를 새로 만들지 말고 쿠버네티스 API 를 확장 합니다. CRD 를 등록하는 순간 공짜로 따라오는 것이 이만큼입니다.
- etcd 에 저장되고 버전과
resourceVersion으로 낙관적 잠금이 걸린다 - 기존 RBAC 이 그대로 적용된다 (
kubectl auth can-i create webservices가 바로 동작) - 감사 로그에 남는다
kubectl get/describe/edit,-o yaml,--watch가 그냥 된다- OpenAPI 스키마로 잘못된 값을 즉시 거절하고 기본값을 채워 준다
- GitOps 도구가 다른 리소스와 똑같이 다룬다
이게 "쿠버네티스 API 가 플랫폼의 공용어"라는 말의 실질입니다. 새 어휘(CRD)를 정의하되 문법(API 규약)은 모두가 이미 아는 것을 쓰는 셈입니다.
어떻게 동작하나
CRD + 컨트롤러 = 플랫폼 API
두 조각이 필요합니다.
CRD 는 어휘를 정의합니다. group, version, kind, 스코프(Namespaced 인가 Cluster 인가), 그리고 OpenAPI v3 스키마입니다. 스키마가 하는 일이 생각보다 큽니다.
spec: versions: - name: v1alpha1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object required: [image] properties: image: { type: string } replicas: { type: integer, default: 2, minimum: 1, maximum: 10 } public: { type: boolean, default: false }required로 빠뜨린 필드를 즉시 거절합니다.minimum/maximum으로 범위를 강제합니다.replicas: 20을 보내면 API 서버가 사람이 읽을 수 있는 문장으로 거절합니다.default는 서버가 채웁니다. 사용자가 안 쓰면 저장 시점에 값이 들어갑니다. 이게 "안전한 기본값"의 가장 싼 구현입니다.additionalPrinterColumns로kubectl get출력에 원하는 열을 띄웁니다. 작아 보이지만 개발자 경험에 크게 기여합니다.
컨트롤러 는 그 어휘에 의미를 부여합니다. WebService 라는 CR 을 보고 Deployment, Service, HPA, NetworkPolicy 를 만들어 주는 조정 루프입니다. CRD 만 있고 컨트롤러가 없으면 그 CR 은 "구조가 검증되는 설정 파일"에 머뭅니다 — 그것도 나름 쓸모가 있지만 플랫폼 API 는 아닙니다.
스코프 선택도 시험 단골입니다. Namespaced 는 테넌트 경계 안에 갇히고 네임스페이스 RBAC 이 그대로 먹습니다. Cluster 스코프는 이름이 전역이라 테넌트끼리 이름이 충돌하고, Role 이 아니라 ClusterRole 로만 권한을 줄 수 있습니다. 테넌트가 만드는 리소스는 거의 항상 Namespaced 여야 합니다.
추상화가 새는 순간
좋은 플랫폼 API 는 "필요한 것만 묻습니다". WebService 는 이미지와 필요하면 replicas 정도만 묻고, 나머지 — 라벨 규약, 보안 컨텍스트, 리소스 기본값, 관측 애너테이션, 네트워크 정책 — 는 컨트롤러가 채웁니다.
그런데 반드시 새는 날이 옵니다.
- 어떤 팀이 사이드카를 붙여야 한다.
- 어떤 워크로드가 특정 노드(예: 32GB VRAM GPU)에만 떠야 한다.
- 어떤 서비스가 표준과 다른 프로브 경로를 쓴다.
이때 "그건 지원하지 않습니다"라고 답하면 그 팀은 플랫폼을 버리고 생 YAML 로 돌아갑니다. 한 번 나가면 돌아오지 않습니다. 그래서 탈출구(escape hatch) 를 설계에 미리 넣어야 합니다.
| 탈출구 | 형태 | 위험 |
| --- | --- | --- |
| 부분 오버라이드 | spec.podOverrides 같은 자유 필드 | 아무거나 넣으면 추상화가 무의미해짐 |
| 확장 지점 | extraEnv, extraVolumes, nodeSelector 만 허용 | 목록 관리 비용 |
| 렌더 후 이탈 | 생성된 매니페스트를 복사해 직접 관리 | 이후 플랫폼 개선을 못 받음 |
균형점은 이렇습니다. 탈출구는 있어야 하지만, 탈출구를 쓴 것이 눈에 보여야 합니다. 오버라이드를 쓴 서비스에 라벨이나 상태 조건을 남기면 플랫폼 팀이 "이 기능은 다섯 팀이 오버라이드로 우회 중"이라는 신호를 받고 정식 기능으로 승격할 수 있습니다. 이게 플랫폼을 제품으로 굴리는 피드백 루프입니다.
현장에서 만나는 모습
저자의 홈랩에서 이 문제가 정확한 형태로 등장했습니다. GPU Feature Discovery 가 노드에 카드 정보를 자동으로 라벨링합니다 — RTX 3090(24576MB, ampere), 5090(32607MB, blackwell), 4070 Laptop(8188MB, ada-lovelace) 두 장. 그런데 파드가 nvidia.com/gpu: 1 만 요청하면 32GB 가 필요한 학습이 8GB 노트북 GPU 에 얹힐 수 있습니다. 쿠버네티스에게는 둘 다 "GPU 한 개"이기 때문입니다.
GFD 가 붙인 gpu.memory 라벨은 문자열이라 "24GB 이상" 같은 비교 셀렉터가 불가능합니다. 그래서 의미 기반 라벨을 직접 얹었습니다 — gpu.homelab/tier=xlarge|large|small, gpu.homelab/vram=32g|24g|8g. 이제 워크로드는 nodeSelector: gpu.homelab/tier: xlarge 로 자기 체급을 고릅니다.
이 한 줄이 플랫폼 API 설계의 전형입니다. 하부의 물리적 사실(카드 모델명, 메모리 바이트)을 그대로 노출하지 않고 사용자가 결정을 내릴 수 있는 어휘(tier)로 번역했습니다. 동시에 탈출구도 남아 있습니다 — 정말 특정 카드가 필요하면 GFD 원본 라벨로 직접 셀렉트할 수 있습니다. 좋은 추상화는 아래 계층을 가리는 게 아니라 덮되 열어 두는 것입니다.
다음 실습에서 할 것
CRD webservices.platform.labhub.io 를 실제 클러스터에 만들고, 스키마 위반이 즉시 거절되는지와 기본값이 서버에서 채워지는지를 눈으로 확인합니다. 이어서 네임스페이스 + ResourceQuota + LimitRange 로 테넌트 경계를 긋고, RBAC 으로 셀프서비스 권한을 준 뒤, 다른 테넌트에는 손댈 수 없다는 것을 kubectl auth can-i 로 증명합니다.