집 K3s 클러스터의 kafka 네임스페이스를 GitOps 로 옮기다가, 옮길 수 없는 것 11개를 만났다. Strimzi 가 설치해 둔 CRD 들이다. “CRD 가 정확히 뭐길래 네임스페이스 앱에 못 담기나” 를 정리한다. 숫자는 전부 내 클러스터에서 실측한 값이고, 재현 명령을 같이 적는다.

1. CRD 는 API 서버에 엔드포인트를 하나 늘리는 것

쿠버네티스 공식 문서의 정의는 건조하다.

A custom resource is an extension of the Kubernetes API that is not necessarily available in a default Kubernetes installation. — Custom Resources, kubernetes.io

CRD(CustomResourceDefinition)를 만들면 API 서버가 그 종류(kind)에 대한 REST 경로를 새로 연다.

When you create a new CustomResourceDefinition (CRD), the Kubernetes API Server creates a new RESTful resource path for each version you specify. — Extend the Kubernetes API with CustomResourceDefinitions, kubernetes.io

여기서 중요한 건 CRD 는 저장소일 뿐이라는 것이다. 같은 문서가 못을 박는다.

On their own, custom resources let you store and retrieve structured data. When you combine a custom resource with a custom controller, custom resources provide a true declarative API.

즉 CRD 는 kind: Kafka 라는 명사를 등록할 뿐이고, 그 명사를 읽고 실제로 브로커를 띄우는 동사는 별개의 컨트롤러(오퍼레이터)가 한다. 이 둘을 하나로 착각하면 뒤의 함정에 전부 걸린다.

2. 등록되는 명사는 10개 — 그런데 내 클러스터엔 11개가 있다

kubectl get crd -o json | jq -r '.items[]
  | select(.spec.group | endswith("strimzi.io"))
  | [.spec.names.kind, .spec.names.plural, .spec.scope,
     ([.spec.versions[].name] | join(","))] | @tsv'

내 클러스터(오퍼레이터 이미지 quay.io/strimzi/operator:0.51.0) 실측 결과 11개:

kind plural scope 제공 버전
Kafka kafkas Namespaced v1, v1beta2
KafkaNodePool kafkanodepools Namespaced v1, v1beta2
KafkaTopic kafkatopics Namespaced v1, v1beta2, v1beta1, v1alpha1
KafkaUser kafkausers Namespaced v1, v1beta2, v1beta1, v1alpha1
KafkaConnect kafkaconnects Namespaced v1, v1beta2
KafkaConnector kafkaconnectors Namespaced v1, v1beta2
KafkaMirrorMaker2 kafkamirrormaker2s Namespaced v1, v1beta2
KafkaMirrorMaker kafkamirrormakers Namespaced v1beta2
KafkaBridge kafkabridges Namespaced v1, v1beta2
KafkaRebalance kafkarebalances Namespaced v1, v1beta2
StrimziPodSet strimzipodsets Namespaced v1, v1beta2

이 11개 객체를 JSON 으로 직렬화해 크기를 재면 합계 약 1.7MB 다(위 명령을 jq -c '.items[] | select(...)' | wc -c 로 바꾸면 같은 값이 나온다). 대부분이 Kafka(453KB)와 KafkaMirrorMaker2(345KB)의 OpenAPI v3 스키마다. apiextensions.k8s.io/v1 에서는 구조적 스키마(structural schema)가 필수라서, Kafka 의 온갖 설정 항목이 전부 스키마로 박혀 있다. 그래서 kubectl apply 시점에 오타가 API 서버에서 막힌다 — 브로커가 뜨고 나서 죽는 게 아니라.

KafkaTopicKafkaUserv1alpha1 까지 네 버전을 물고 있는 게 눈에 띈다. 가장 오래 전에 생긴 명사라서 하위 호환을 길게 끌고 가는 것이다.

그런데 공식 번들에는 10개뿐이다

여기서 실측이 밥값을 했다. 0.51.0 의 공식 CRD 번들을 받아 live 와 대조해 봤다.

curl -sSLO https://github.com/strimzi/strimzi-kafka-operator/releases/download/0.51.0/strimzi-crds-0.51.0.yaml
# 1,622,915 bytes / sha256 a11257e67985a946a6facfb17d729aac5670b89675d410d13162fe455a5ac7a8

번들에 든 CRD 는 10개다. 그리고 그 10개는 live 의 .spec해시까지 전부 일치한다 (10/10, 불일치 0). 즉 지금 깔린 건 정확히 0.51.0 번들이다.

차이는 딱 하나 — kafkamirrormakers.kafka.strimzi.io 가 live 에만 있다. kind KafkaMirrorMaker(위 표에서 혼자 v1beta2 만 제공하던 그것)는 MirrorMaker 1 이 제거되기 전 버전에서 깔렸다가 남아 버린 잔재다. CR 은 0건이라 아무도 안 쓴다.

kubectl get kafkamirrormakers -A   # No resources found

이게 CRD 의 성질을 그대로 보여 준다. 오퍼레이터를 올려도 옛 CRD 는 저절로 안 사라진다. 번들 apply 는 있는 걸 더하거나 고칠 뿐, 번들에서 빠진 걸 지우지는 않기 때문이다. 아무도 안 쓰는 명사가 API 서버에 조용히 남고, “Strimzi CRD 가 몇 개냐”를 클러스터에만 물으면 틀린 답을 얻는다. 버전이 박힌 번들과 대조해야 안다.

3. CR 하나가 자식 23개를 낳는다

정의만으로는 감이 안 온다. 내 kafka 네임스페이스에는 사람이 쓴 CR 이 딱 셋이다 — Kafka/lemuel, KafkaNodePool/dual-role, KafkaTopic/notification-topic. 오퍼레이터가 이걸 보고 만들어 낸 것들은 ownerReferences 로 구분된다.

kubectl -n kafka get deploy,svc,cm,secret,sa,role,rolebinding,strimzipodset -o json \
  | jq -r '.items[] | select(.metadata.ownerReferences)
    | "\(.kind)/\(.metadata.name)  <- \(.metadata.ownerReferences[0].kind)/\(.metadata.ownerReferences[0].name)"'

세어 보면 23개다. Kafka/lemuel 직계 21개(Service 2, Secret 9, ConfigMap 2, ServiceAccount 2, Role 2, RoleBinding 3, Deployment 1) + KafkaNodePool/dual-role 직계 2개(StrimziPodSet/lemuel-dual-role, ConfigMap 1). 브로커 Pod 는 다시 그 StrimziPodSet 의 자식이다.

Secret 9개 중 lemuel-cluster-ca / lemuel-clients-ca 계열이 CA 와 인증서다. Kafka 를 직접 StatefulSet 으로 짰다면 mTLS 용 CA 를 만들고 돌리고 갱신하는 걸 전부 손으로 해야 한다. CRD + 오퍼레이터가 가져가는 건 결국 이 도메인 지식이다. 공식 문서 표현대로 “encode domain knowledge for specific applications into an extension of the Kubernetes API”.

여기서 처음 사람을 놀라게 하는 지점: kubectl get all 에 브로커가 안 보인다. 정확히는 Pod 로는 보이는데 그걸 낳은 컨트롤러가 안 보인다. Strimzi 는 StatefulSet 을 쓰지 않고 자기 CRD 인 StrimziPodSet 으로 브로커 Pod 를 관리하기 때문이다. kubectl get all 은 빌트인 kind 만 나열하므로, CRD 로 늘린 명사는 이름을 알고 물어봐야 나온다.

kubectl -n kafka get strimzipodset

4. 그래서 GitOps 로 못 옮겼다

문제의 발단은 이것이었다. kafka 네임스페이스를 ArgoCD Application 으로 채택하면서 “이 앱만으로 빈 클러스터에 Kafka 를 재구축할 수 있는가”를 물었더니, 답은 아니오 였다. CRD 가 빠져 있기 때문이다. 그리고 CRD 는 원리적으로 그 앱에 담을 수 없다.

CustomResourceDefinitions themselves are non-namespaced and are available to all namespaces. — kubernetes.io

CRD 는 클러스터 스코프다. 네임스페이스 하나에 스코프된 앱의 소유물이 될 수 없다. Strimzi 문서도 같은 경고를 한다 — 네임스페이스를 나눠도 “certain resources managed by the Strimzi operator, such as Custom Resource Definitions (CRDs) and roles, have a cluster-wide scope” 라서 오퍼레이터를 여러 개 깔면 충돌한다고.

그럼 CRD 는 누가 소유하나. 오퍼레이터 설치 번들이다. Strimzi 배포 문서는 install/cluster-operator 한 디렉터리를 kubectl apply 하면 CRD · RBAC · Deployment · ServiceAccount 가 한꺼번에 깔린다고 적는다. CRD 는 그 번들의 일부이지, Kafka 클러스터의 일부가 아니다.

내 클러스터의 CRD 가 실제로 번들로 깔렸다는 것도 확인된다.

kubectl get crd kafkas.kafka.strimzi.io -o json | jq '.metadata.labels, (.metadata.annotations | keys)'

라벨은 app: strimzi, strimzi.io/crd-install: "true" 이고, 어노테이션에는 kubectl.kubernetes.io/last-applied-configuration 하나뿐이다. Helm 이 설치했다면 meta.helm.sh/release-name 이 붙어 있어야 하는데 없다. 즉 helm release 가 아니라 YAML 번들을 직접 apply 한 것이고, 되돌리려면 helm 이 아니라 0.51.0 번들을 다시 적용해야 한다. 이게 내가 남긴 “아직 안 메운 구멍” 의 정확한 형태다.

여기서 흔한 오답이 있다. CRD YAML 을 kubectl get crd -o yaml 로 떠서 git 에 넣으면 되지 않나? 된다. 그리고 썩는다. 그 스냅샷은 0.51.0 시점의 스키마고, 오퍼레이터를 0.52 로 올리는 순간 git 의 CRD 가 오퍼레이터보다 낡아진다. CRD 는 오퍼레이터의 버전과 한 몸이라, 버전이 붙은 설치 번들을 참조로 기록하는 게 맞지 손으로 뜬 스냅샷을 두는 게 아니다.

5. 가장 비싼 함정 — CRD 를 지우면 CR 이 같이 죽는다

공식 문서가 한 문장으로 경고한다.

When you delete a CustomResourceDefinition, the server will uninstall the RESTful API endpoint and delete all custom objects stored in it.

kubectl delete crd kafkas.kafka.strimzi.io 는 “정의를 지운다” 가 아니라 “이 클러스터의 모든 Kafka 클러스터를 지운다” 이다. 확인 프롬프트도 없고, Kafka CR 이 사라지면 그 자식 23개가 ownerReference 따라 연쇄로 GC 된다. CA Secret 까지 포함해서.

그래서 CRD 를 GitOps 에 넣을 때 prune 을 켜는 건 위험하다. 내가 채택한 앱들을 prune: false 로 둔 이유가 이것이다 — 파일 하나를 잘못 옮기면 삭제가 실물에 전파된다. 자동 정리는 실수를 자동으로 증폭한다.

정리

  • CRD 는 API 서버에 명사를 하나 등록하는 것이다. 동사는 오퍼레이터가 한다.
  • Strimzi 0.51.0 번들은 명사를 10개 등록한다. 부피의 대부분은 apply 시점 검증을 해 주는 OpenAPI 스키마다. 내 클러스터엔 11개가 있는데, 11번째는 구버전이 남긴 잔재다 — CRD 는 오퍼레이터를 올려도 저절로 청소되지 않는다.
  • Kafka CR 하나가 ownerReferences 로 연결된 자식 23개를 만든다. 그중 9개가 인증서다 — 이 도메인 지식의 이전이 CRD 를 쓰는 실질적 이유다.
  • CRD 는 클러스터 스코프라 네임스페이스 GitOps 앱의 소유물이 될 수 없다. 오퍼레이터 설치 번들이 소유자다.
  • CRD 삭제 = 그 kind 의 모든 객체 삭제. 이 한 줄이 prune: false 의 근거다.

References

본문의 수치(클러스터 CRD 11개 / 번들 10개, 직렬화 합계 약 1.7MB, 자식 리소스 23개, 오퍼레이터 0.51.0)는 필자의 K3s 클러스터에서 위에 적은 명령으로 직접 측정한 값이다. 다른 Strimzi 버전·구성에서는 달라진다.