CNPA — 클라우드 네이티브 플랫폼 엔지니어링 어소시에이트
쿠버네티스 API 는 왜 플랫폼의 공용어가 됐나
한 줄 요약
쿠버네티스가 이긴 것은 컨테이너 오케스트레이션 경쟁이 아니라 API 규격 경쟁 이었습니다. 선언적 리소스 + 조정 컨트롤러라는 한 쌍의 패턴이 컨테이너 바깥까지 퍼졌고, 그래서 플랫폼 API 를 새로 만들 때 CRD 가 기본 선택지가 됐습니다.
왜 이게 필요했나
플랫폼 팀이 "서비스 하나 띄우기"를 쉽게 만들려고 하면 보통 이런 길을 밟습니다.
- 위키에 절차를 적는다 → 아무도 최신으로 유지하지 않는다.
- 셸 스크립트를 만든다 → 실행 환경마다 결과가 다르고, 실패하면 중간 상태가 남는다.
- 사내 웹 앱을 만든다 → 상태 저장, 인증, 감사, 재시도, 동시성을 전부 직접 구현해야 한다. 그리고 그 앱이 새 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 규약)은 모두가 이미 아는 것을 쓰는 셈입니다.
본문은 플랫폼 API 에 두 조각이 필요하다고 말한다. WebService, replicas 의 범위는 본문의 설명용 예시다.
- CRD 만 등록한 상태group, version, kind, 스코프, OpenAPI 스키마로 어휘를 정의한다. 스키마가 빠뜨린 필드와 범위 밖 값을 즉시 거절하고, default 는 저장 시점에 서버가 채운다. 하지만 CR 을 보고 무언가를 만들어 주는 쪽이 없어 구조가 검증되는 설정 파일에 머문다.
- CRD 에 컨트롤러를 더한 상태컨트롤러가 WebService 같은 CR 을 보고 Deployment, Service, HPA, NetworkPolicy 를 만드는 조정 루프가 어휘에 의미를 부여한다. 이때 비로소 플랫폼 API 라고 부를 수 있다.
여기서 구분할 것 etcd 저장, RBAC, 감사 로그, kubectl 이 따라오는 것은 CRD 를 등록한 효과이고 하위 자원을 만드는 일은 컨트롤러의 몫이다. 컨트롤러 없이 플랫폼 API 라고 부르지 않는다.
잠깐, 예측해 보세요 WebService CRD 만 등록하고 컨트롤러는 아직 배포하지 않았다. replicas 를 20 으로 적은 WebService 는 어떻게 되고, 올바른 값으로 만든 WebService 는 Deployment 를 만들어 낼까?
설명 확인 · 채점 없는 자가 점검
스키마에 maximum 이 10 이면 20 은 API 서버가 거절한다. 올바른 값으로 만든 CR 은 저장되고 기본값도 채워지지만, 이를 보고 Deployment 를 만드는 컨트롤러가 없으므로 하위 자원은 생기지 않는다.
어떻게 동작하나
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 원본 라벨로 직접 셀렉트할 수 있습니다. 좋은 추상화는 아래 계층을 가리는 게 아니라 덮되 열어 두는 것입니다.
본문은 추상화가 새는 날이 반드시 오고, 탈출구는 있되 쓴 것이 눈에 보여야 한다고 말한다. 다섯 팀은 본문의 설명용 숫자다.
- 표준에 없는 요구가 나온다어떤 팀이 사이드카를 붙여야 하거나, 특정 노드에만 떠야 하거나, 표준과 다른 프로브 경로를 쓴다. 이때 지원하지 않는다고 답하면 그 팀은 생 YAML 로 돌아가고 한 번 나가면 돌아오지 않는다.
- 탈출구로 풀되 쓴 흔적을 남긴다오버라이드를 쓴 서비스에 라벨이나 상태 조건을 남긴다. 탈출구의 형태는 부분 오버라이드, 허용 목록이 있는 확장 지점, 렌더 후 이탈이 있고 각각 위험이 다르다.
- 흔적이 모여 정식 기능으로 올라간다플랫폼 팀이 이 기능을 다섯 팀이 오버라이드로 우회 중이라는 신호로 받아 정식 기능으로 승격한다. 본문은 이것을 플랫폼을 제품으로 굴리는 피드백 루프라고 부른다.
여기서 구분할 것 탈출구가 자유 필드이면 아무거나 들어와 추상화가 무의미해진다는 것이 본문이 든 위험이다. 이 서술은 본문의 설계 원칙이 근거이고 연결한 문서는 CRD 스키마를 다루는 개념 문서이다.
잠깐, 예측해 보세요 탈출구를 podOverrides 같은 자유 필드 하나로 열어 두었는데, 어느 서비스가 그것을 쓰는지 라벨이나 상태 조건으로 남기지 않았다. 몇 달 뒤 플랫폼 팀이 잃는 것은 무엇일까?
설명 확인 · 채점 없는 자가 점검
어떤 요구가 여러 팀에서 반복되는지 알려 주는 신호를 잃는다. 본문 기준으로 쓴 것이 눈에 보여야 플랫폼 팀이 우회가 쌓이는 기능을 정식 기능으로 승격할 수 있다. 눈에 보이지 않는 탈출구는 쓰이기만 하고 플랫폼은 배우지 못한다.
다음 실습에서 할 것
CRD webservices.platform.labhub.io 를 실제 클러스터에 만들고, 스키마 위반이 즉시 거절되는지와 기본값이 서버에서 채워지는지를 눈으로 확인합니다. 이어서 네임스페이스 + ResourceQuota + LimitRange 로 테넌트 경계를 긋고, RBAC 으로 셀프서비스 권한을 준 뒤, 다른 테넌트에는 손댈 수 없다는 것을 kubectl auth can-i 로 증명합니다.