# CRD를 GitOps로 관리할 때의 경계 - Karpenter로 보는 세 가지 함정

Helm으로 설치한 컴포넌트를 GitOps로 옮기다 보면 CRD에서 한 번 걸립니다. 차트 안에 있던 CRD가 업그레이드에서 갱신되지 않거나, 동기화가 "annotation too long"으로 실패하거나, 잘못 건드렸다가 커스텀 리소스가 통째로 사라집니다.

Karpenter는 이 문제들이 한꺼번에 드러나는 사례입니다. CRD가 크고, 컨트롤러 버전과 강하게 묶여 있고, 그 CRD의 리소스가 실제 노드의 생명주기를 들고 있기 때문입니다. 이 글에서는 CRD를 GitOps 경계 안에 넣을 때 무엇을 어디에 두어야 하는지 정리합니다.

# 1. Helm은 CRD를 한 번만 설치한다

먼저 출발점입니다. 차트의 crds/ 디렉터리에 CRD를 두면 설치는 되지만 그 뒤가 없습니다.

Helm does not manage the lifecycle of CRDs using this method - the tool will only install the CRD during the first installation of the Helm chart.

최초 설치 때만 적용됩니다. 이후 helm upgrade는 CRD를 건드리지 않습니다. 차트 버전을 올려 컨트롤러는 새 버전이 됐는데 CRD는 예전 스키마 그대로인 상태가 만들어집니다.

Karpenter는 이 문제를 별도 차트로 분리해 해결합니다.

helm upgrade --install karpenter-crd oci://public.ecr.aws/karpenter/karpenter-crd \
  --version x.y.z --namespace kube-system --create-namespace

그리고 결합 관계를 명시합니다.

CRDs are coupled to the version of Karpenter, and should be updated along with Karpenter.

CRD와 컨트롤러는 같이 올라가야 합니다. 이 한 문장이 뒤에 나올 설계 제약을 전부 만듭니다. 둘을 분리해서 관리하되 순서와 버전은 묶여 있어야 한다는 요구입니다.

차트 업그레이드에서 값이 반영되지 않는 문제 일반은 Helm 차트를 올렸는데 새 기본값이 안 들어오는 이유 (opens new window)에 정리했는데, CRD는 그보다 한 단계 더 나쁜 경우입니다. values가 아니라 차트의 갱신 메커니즘 자체가 CRD를 대상에서 제외하기 때문입니다.

# 2. 큰 CRD는 kubectl apply로 안 들어간다

GitOps 도구에 CRD를 맡기면 다음 벽이 나옵니다. kubectl apply는 이전 상태를 애너테이션에 적어 두는데, 여기에 크기 제한이 있습니다.

a resource spec might be too large and won't fit into the kubectl.kubernetes.io/last-applied-configuration annotation.

애너테이션 한도는 262,144바이트입니다. Karpenter의 EC2NodeClassNodePool처럼 스키마가 큰 CRD는 이 선을 넘습니다. 동기화가 metadata.annotations: Too long으로 실패합니다.

해결책이 둘 있고, 둘 중 하나는 써서는 안 됩니다.

ServerSideApply=true - 서버 사이드 어플라이는 애너테이션을 쓰지 않습니다.

Since the annotation is not used in this case, resources exceeding the 262KB limit can be successfully deployed.

부수 효과도 좋습니다. 필드 소유권을 추적하므로 컨트롤러가 쓰는 필드와 Git이 쓰는 필드가 공존할 수 있습니다.

Replace=true - kubectl apply 대신 replacecreate를 씁니다. 문서가 경고를 답니다.

this approach has the potential to be destructive and might lead to resources having to be recreated.

CRD에 이것을 쓰면 안 되는 이유가 여기 있습니다. CRD가 재생성되면 그 CRD로 만들어진 커스텀 리소스가 함께 사라집니다. Karpenter라면 NodeClaim이 사라지고, 그 뒤에 무슨 일이 일어나는지는 3절에서 봅니다.

정리하면 이렇습니다.

옵션 큰 CRD 안전성 결론
기본 (apply) 실패 안전 크기가 작을 때만
ServerSideApply=true 성공 안전 CRD의 기본값
Replace=true 성공 재생성 위험 CRD에는 쓰지 않는다
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: karpenter-crd
spec:
  syncPolicy:
    syncOptions:
      - ServerSideApply=true

# 3. CRD를 지우면 노드가 사라진다

가장 위험한 지점입니다. Karpenter의 NodeClaim은 단순한 설정 오브젝트가 아니라 실제 EC2 인스턴스의 소유권을 들고 있습니다. Karpenter는 NodeClaim에 finalizer를 붙이고, 그것이 삭제되면 대응하는 인스턴스를 정리합니다.

그래서 다음 경로가 실제 장애가 됩니다.

CRD가 GitOps 대상에서 빠짐 (경로 변경, 필터 실수 등)
  → prune 대상으로 판정
  → CRD 삭제
  → 모든 NodeClaim 삭제
  → 노드 종료

한 번의 잘못된 동기화가 클러스터의 데이터 플레인을 걷어 냅니다. Application 하나의 path를 잘못 고치는 것으로 충분합니다.

방어는 두 겹입니다.

# 1) CRD 리소스 자체에 prune·delete 금지를 새긴다
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: nodeclaims.karpenter.sh
  annotations:
    argocd.argoproj.io/sync-options: Prune=false,Delete=false
# 2) CRD Application 자체에는 자동 prune을 켜지 않는다
spec:
  syncPolicy:
    automated:
      prune: false          # CRD Application은 수동 prune
      selfHeal: true

"자동 동기화 + 자동 prune"을 CRD Application에 켜지 않는 것이 핵심 규칙입니다. 다른 Application에서는 prune이 편의 기능이지만, CRD Application에서는 삭제 버튼에 가깝습니다.

# 4. CRD와 커스텀 리소스를 같은 Application에 두면 순서가 꼬인다

CRD와 그 CRD를 쓰는 리소스가 한 Application에 있으면 첫 동기화에서 실패합니다. NodePool을 적용하려는 시점에 아직 nodepools.karpenter.sh CRD가 없기 때문입니다. ArgoCD는 드라이런 단계에서 이미 걸립니다.

Argo CD cannot find the CRD in the sync and will fail with the error the server could not find the requested resource.

선택지가 둘입니다.

(A) 동기화 웨이브로 순서를 준다. 같은 Application 안에서 CRD를 먼저 적용합니다.

metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "-2"     # CRD
---
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "0"      # NodePool

(B) Application을 나눈다. CRD, 컨트롤러, 커스텀 리소스를 각각 두고 Application 레벨의 웨이브로 순서를 강제합니다.

Application 내용 웨이브 자동 prune
karpenter-crd CRD만 -2
karpenter 컨트롤러 차트 -1
karpenter-nodepools NodePool, EC2NodeClass 0

(B)가 나은 이유는 삭제 정책을 서로 다르게 줄 수 있기 때문입니다. NodePool은 지우면 다시 만들면 되지만 CRD는 그렇지 않습니다. 같은 Application에 두면 두 리소스가 같은 prune 정책을 공유하게 되고, 3절의 사고 경로가 열립니다.

대신 (B)에는 대가가 있습니다. 1절의 결합("CRD와 컨트롤러는 같이 올라간다")을 자동으로 보장하지 못합니다. Application이 둘이므로 각각 다른 버전을 가리킬 수 있습니다. 웨이브가 순서는 잡아 주지만 버전 일치는 잡아 주지 않습니다.

실무적인 보완은 버전을 한 곳에서 주입하는 것입니다. ApplicationSet이나 상위 차트에서 두 Application의 targetRevision을 같은 변수로 채우면, 버전을 바꿀 때 한 군데만 고치게 됩니다.

# 5. 컨트롤러가 쓰는 필드는 Git이 되돌리지 않게 한다

Karpenter는 자기 리소스의 상태 필드를 계속 갱신합니다. NodePoolstatus, NodeClaim의 조건 같은 것들입니다. 이것들이 Git에 없으면 ArgoCD가 계속 차이로 잡습니다.

ServerSideApply를 쓰면 필드 소유권 추적 덕분에 상당수가 자연스럽게 해결되지만, 전부는 아닙니다. 남는 것은 무시 규칙으로 처리합니다.

spec:
  ignoreDifferences:
    - group: karpenter.sh
      kind: NodePool
      jsonPointers:
        - /status

이 부류의 충돌이 왜 생기고 어떤 규칙이 실제로 먹는지는 ArgoCD ignoreDifferences가 동작하지 않는 세 가지 이유 (opens new window)에 정리했습니다.

한 가지 원칙만 짚으면, 컨트롤러가 만드는 리소스는 Git에 두지 않습니다. NodeClaim은 Karpenter가 만들므로 Git에 있으면 안 됩니다. Git에 두어야 하는 것은 NodePoolEC2NodeClass처럼 사람이 정하는 정책뿐입니다. 이 경계가 흐려지면 GitOps 도구와 컨트롤러가 같은 리소스를 두고 싸웁니다.

# 6. 직접 확인하는 방법

먼저 CRD 버전과 컨트롤러 버전이 실제로 맞는지 봅니다.

# CRD에 새겨진 차트 버전
kubectl get crd nodepools.karpenter.sh \
  -o jsonpath='{.metadata.labels}{"\n"}'

# 컨트롤러가 실제로 도는 이미지
kubectl -n kube-system get deploy karpenter \
  -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'

# CRD가 지원하는 버전 목록 - 컨트롤러가 쓰는 버전이 있어야 한다
kubectl get crd nodepools.karpenter.sh \
  -o jsonpath='{range .spec.versions[*]}{.name}{" served="}{.served}{" storage="}{.storage}{"\n"}{end}'

동기화가 실패할 때는 크기 문제인지부터 확인합니다.

# 매니페스트 크기 - 262144에 근접하면 2절 문제
kubectl get crd ec2nodeclasses.karpenter.k8s.aws -o json | wc -c

# 문제의 애너테이션이 붙어 있는지
kubectl get crd ec2nodeclasses.karpenter.k8s.aws \
  -o jsonpath='{.metadata.annotations.kubectl\.kubernetes\.io/last-applied-configuration}' | wc -c

두 번째 명령의 결과가 0이면 서버 사이드 어플라이로 적용된 것입니다. 큰 값이 나오면 아직 클라이언트 사이드이고, 언젠가 한도에 걸립니다.

삭제 위험이 실제로 막혀 있는지도 미리 확인해야 합니다.

# prune 대상으로 잡히는 리소스 미리 보기 - CRD가 여기 있으면 안 된다
argocd app sync karpenter-crd --dry-run --prune

# 보호 애너테이션이 붙어 있는가
kubectl get crd -l app.kubernetes.io/part-of=karpenter \
  -o custom-columns='NAME:.metadata.name,SYNCOPT:.metadata.annotations.argocd\.argoproj\.io/sync-options'

# 7. 트러블슈팅

증상 원인 조치
차트를 올렸는데 CRD가 그대로 Helm이 CRD를 갱신하지 않음 별도 CRD 차트/Application
metadata.annotations: Too long 262KB 애너테이션 한도 ServerSideApply=true
Replace=true 후 리소스 소실 CRD 재생성으로 CR 삭제 CRD에는 사용 금지
첫 동기화가 리소스 없음으로 실패 CRD보다 CR이 먼저 적용 웨이브 또는 Application 분리
노드가 한꺼번에 사라짐 CRD prune → NodeClaim 삭제 Prune=false,Delete=false, 자동 prune 해제
계속 OutOfSync 컨트롤러가 status를 갱신 ignoreDifferences 또는 SSA
CRD와 컨트롤러 버전 불일치 Application이 분리돼 각자 갱신 버전을 한 변수로 주입
컨트롤러가 만든 리소스가 계속 지워짐 그 리소스를 Git에 둠 Git에서 제외

# 8. 마무리

  • Helm은 CRD를 최초 설치 때만 적용합니다. 차트만 올리면 컨트롤러는 새 버전, CRD는 옛 버전인 상태가 됩니다.
  • 큰 CRD는 kubectl apply의 애너테이션 한도에 걸립니다. 답은 ServerSideApply=true이고, Replace=true재생성 위험 때문에 CRD에 쓰면 안 됩니다.
  • Karpenter의 커스텀 리소스는 실제 인스턴스의 생명주기를 들고 있습니다. CRD가 prune되면 노드가 사라집니다. CRD Application에는 자동 prune을 켜지 않습니다.
  • CRD, 컨트롤러, 정책 리소스를 별도 Application으로 나누면 삭제 정책을 다르게 줄 수 있습니다. 대신 버전 일치를 따로 보장해야 합니다.
  • 경계를 정하는 기준은 하나입니다. 사람이 정하는 것만 Git에 둡니다. 컨트롤러가 만드는 리소스를 Git에 두면 둘이 싸웁니다.

Karpenter가 만든 노드가 클러스터에 붙지 않는 문제는 Karpenter 노드가 클러스터에 붙지 않을 때 (opens new window)에 따로 정리했습니다.

# 참고