IT/DevSecOps

Kyverno ClusterPolicy를 ValidatingPolicy(CEL)로 옮기는 법

gfrog 2026. 6. 5. 03:13
반응형

Kyverno ClusterPolicy를 ValidatingPolicy(CEL)로 옮기는 법

Kyverno가 3월 KubeCon EU 암스테르담에서 CNCF graduated 프로젝트로 승격됐다. 같은 흐름에서 1.17부터 CEL 기반의 새 정책 타입(ValidatingPolicy, MutatingPolicy 등)이 GA로 바뀌었고, 기존 ClusterPolicy API는 2026년 10월 제거 예정이라는 공지가 같이 나왔다. 우리 팀은 그동안 ClusterPolicy로 30개 가까운 정책을 운영하고 있었는데, 일정상 7월까지는 옮겨야 한다. 이번 글은 그 마이그레이션 작업을 하면서 정리한 가이드다.

대상 독자는 Kyverno를 이미 한 번이라도 운영해 본 사람이다. CEL 자체가 처음이라면 마지막 섹션의 추가 리소스 쪽을 먼저 보는 게 낫다.

왜 ValidatingPolicy로 옮기는가

세 가지 이유로 압축된다.

첫째, ClusterPolicy가 곧 사라진다. 1.17에서 deprecation 표시가 붙었고, 10월에 API 자체가 빠진다. 7월쯤부터는 kubectl get cpol에 deprecation 경고가 떠서 운영팀이 신경 쓰기 시작한다.

둘째, ValidatingPolicy는 내부적으로 쿠버네티스의 ValidatingAdmissionPolicy(VAP)로 컴파일된다. VAP는 kube-apiserver 안에서 직접 CEL 평가가 일어나기 때문에 webhook 왕복이 없다. 우리 dev 클러스터에서 admission 레이턴시를 재봤더니 ClusterPolicy 기준 p99가 18ms였는데, 같은 정책을 ValidatingPolicy로 옮긴 뒤 6ms로 떨어졌다. 부하가 큰 클러스터에서 차이가 더 벌어진다.

셋째, Kyverno controller가 다운돼도 admission 자체는 계속 동작한다. 이건 운영 입장에서 꽤 크다. 작년에 Kyverno 메모리 누수 이슈로 deployment가 죽었을 때 새 파드가 한동안 못 떴던 기억이 있다.

단, JMESPath 기반 정책은 자동 변환이 안 된다. 표현식을 직접 CEL로 바꿔야 하는데, 이게 이번 작업의 핵심이다.

1단계: 변환 대상 목록 뽑기

kubectl get clusterpolicy 결과부터 정리한다. 우리는 약 30개였는데, 분류해보니 이렇게 나뉘었다.

  • 단순 validate(이미지 레지스트리 제한, label 강제 등): 18개. 직접 변환 가능.
  • mutate(annotation/sidecar 주입): 6개. MutatingPolicy로 옮김. 단, GA는 ValidatingPolicy 먼저고 MutatingPolicy는 beta라 일부는 보류.
  • generate(Namespace 만들 때 NetworkPolicy 자동 생성): 3개. GeneratingPolicy로. 이것도 beta.
  • verifyImages: 3개. ImageVerificationPolicy로 분리됐다.

이번 글에서는 validate 케이스만 다룬다. 마이그레이션 일정에서 가장 비중이 크기 때문이다.

각 정책에 다음 메타데이터를 매겨두는 게 좋다.

이름 / mode (Enforce vs Audit) / 적용 리소스 / JMESPath 복잡도(상/중/하) / 우선순위

복잡도 "상"은 [?starts_with(...)] 같은 필터링이나 length(), to_number() 같은 함수가 섞인 것들이다. 이 친구들이 시간을 다 잡아먹는다.

2단계: 간단한 케이스부터 변환 — "이미지 레지스트리 화이트리스트"

가장 흔한 정책 하나로 손을 풀자. 기존 ClusterPolicy는 이렇게 생겼다.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: restrict-image-registries
spec:
  validationFailureAction: Enforce
  rules:
  - name: validate-registries
    match:
      any:
      - resources:
          kinds:
          - Pod
    validate:
      message: "허용된 레지스트리(harbor.example.com, ghcr.io/example)만 사용 가능"
      pattern:
        spec:
          containers:
          - image: "harbor.example.com/* | ghcr.io/example/*"

ValidatingPolicy로 바꾸면 이렇게 된다.

apiVersion: policies.kyverno.io/v1alpha1
kind: ValidatingPolicy
metadata:
  name: restrict-image-registries
spec:
  validationActions: [Deny]
  matchConstraints:
    resourceRules:
    - apiGroups: [""]
      apiVersions: ["v1"]
      operations: ["CREATE", "UPDATE"]
      resources: ["pods"]
  validations:
  - expression: >-
      object.spec.containers.all(c,
        c.image.startsWith('harbor.example.com/') ||
        c.image.startsWith('ghcr.io/example/'))
    message: "허용된 레지스트리(harbor.example.com, ghcr.io/example)만 사용 가능"

바뀐 포인트가 몇 개 있다.

  • apiVersionpolicies.kyverno.io/v1alpha1로 달라진다(현 시점). 1.18쯤 v1으로 승격될 듯한데 지금은 alpha다.
  • validationFailureAction: EnforcevalidationActions: [Deny]. 이름이 좀 헷갈리지만 의미는 같다. Audit만 하려면 [Warn, Audit].
  • matchmatchConstraints로 바뀌고 명시적으로 operations를 지정해야 한다. 기존엔 기본값으로 다 처리됐는데 이건 명시적이라 차라리 낫다.
  • pattern 매칭 대신 CEL 표현식. .all()로 배열 전체를 순회한다.

initContainers도 검사하고 싶으면 expression에 .concat()을 쓴다.

expression: >-
  (object.spec.containers + (object.spec.initContainers.orValue([])))
    .all(c, c.image.startsWith('harbor.example.com/') ||
            c.image.startsWith('ghcr.io/example/'))

.orValue([])는 필드가 없을 때를 위한 가드다. CEL에서는 nil 안전성을 직접 챙겨야 한다.

3단계: 중간 난이도 — "리소스 limits/requests 강제"

이건 우리가 가장 자주 부딪치는 정책 중 하나였다. 기존엔 이렇게 짰다.

validate:
  message: "모든 컨테이너에 memory limits/requests 필요"
  pattern:
    spec:
      containers:
      - resources:
          limits:
            memory: "?*"
          requests:
            memory: "?*"

CEL로 옮길 때는 has() 매크로를 알아야 한다.

validations:
- expression: >-
    object.spec.containers.all(c,
      has(c.resources) &&
      has(c.resources.limits) && has(c.resources.limits.memory) &&
      has(c.resources.requests) && has(c.resources.requests.memory))
  message: "모든 컨테이너에 memory limits/requests 필요"

좀 장황하다. 그런데 한번 짜놓으면 동작이 명확해서 디버깅은 오히려 편하다. JMESPath의 ?* wildcard처럼 묵시적으로 "존재하면 통과"가 아니라, 명시적으로 has() 체크가 들어가니까.

요청량과 한계값 비율을 강제하는 정책도 자주 쓰는데 — 예를 들어 limit이 request의 4배를 못 넘게 한다든지 — 이건 ClusterPolicy 시절엔 deny 룰로 좀 억지로 짰던 것을 CEL에서 깔끔하게 표현할 수 있다. quantity() 함수 덕분에 "100Mi", "2Gi" 같은 단위 비교가 가능해졌다. 이건 ClusterPolicy 시절엔 직접 변환해야 해서 굉장히 거슬렸던 부분이다.

4단계: context와 외부 데이터 조회

기존 ClusterPolicy에서 자주 쓰던 context(다른 리소스 조회) 기능은 CEL에서 paramKindvariables로 나뉘어 들어간다. 예를 들어 ConfigMap에서 허용 리스트를 가져오는 케이스.

spec:
  paramKind:
    apiVersion: v1
    kind: ConfigMap
  matchConstraints: { ... }
  variables:
  - name: allowedRegistries
    expression: params.data.registries.split(',')
  validations:
  - expression: >-
      object.spec.containers.all(c,
        variables.allowedRegistries.exists(r, c.image.startsWith(r)))
    message: "ConfigMap에 등록된 레지스트리만 허용"

그리고 binding을 따로 만들어 어떤 ConfigMap을 참조할지 묶어준다.

apiVersion: policies.kyverno.io/v1alpha1
kind: ValidatingPolicyBinding
metadata:
  name: restrict-registries-binding
spec:
  policyName: restrict-image-registries
  paramRef:
    name: allowed-registries
    namespace: kyverno
    parameterNotFoundAction: Deny

여기서 살짝 헷갈렸던 게 — binding이 "어떤 정책을, 어떤 클러스터/네임스페이스에, 어떤 파라미터로" 적용할지를 분리해서 표현한다는 점. ClusterPolicy 시절엔 정책 한 덩어리에 다 들어있었는데 이젠 둘로 쪼개진 거다. 처음엔 번거롭게 느껴지지만, 같은 정책을 환경별로 다르게 적용할 때 오히려 깔끔하다.

5단계: Audit 모드로 안전하게 롤아웃

ClusterPolicy를 바로 지우고 ValidatingPolicy를 enforce로 켜면 사고난다. 우리 팀은 이 순서로 갔다.

  1. ValidatingPolicy를 validationActions: [Audit, Warn]으로 먼저 적용. 기존 ClusterPolicy는 그대로 둔다.
  2. 1주일간 Kyverno PolicyReport와 VAP 리포트를 같이 모니터링. kubectl get policyreports -A로 위반 건수가 동일한지 비교한다.
  3. 차이가 나는 케이스를 잡아낸다. 여기서 거의 항상 차이가 난다. CEL 표현식 미세한 차이 — 예를 들어 has() 체크를 빠뜨려서 nil 액세스로 거짓 통과시키는 경우.
  4. 차이가 0이 될 때까지 수정.
  5. ClusterPolicy를 Audit로 내리고, ValidatingPolicy를 Deny로 올린다.
  6. 1주일 더 모니터링 후 ClusterPolicy 삭제.

이 과정에서 우리는 18개 중 4개 정책에서 행동 차이를 발견했다. 그중 하나는 ClusterPolicy의 pattern 매칭이 nil 필드를 묵시적으로 통과시키고 있었는데, CEL로 옮기니 명시적으로 막혀서 갑자기 위반이 늘었다. 정책 의도 자체가 모호했던 경우라, 이참에 명확히 잡았다.

자주 걸리는 함정

마지막으로 첫 마이그레이션 때 우리가 밟은 지뢰 몇 개.

  • Subresource를 잊지 말 것. Pod의 ephemeralcontainers subresource를 막지 않으면 디버그 컨테이너가 정책을 우회한다. matchConstraints에 명시적으로 추가하거나 정책 expression에 포함시켜야 한다.
  • object가 null일 수 있다. DELETE operation일 때는 object가 nil이고 oldObject만 있다. operations를 CREATE/UPDATE로 제한하거나 expression에서 has() 가드를 넣는다.
  • CEL 표현식 길이 제한. 한 expression이 너무 길어지면 가독성도 나쁘고 디버깅도 힘들다. variables로 쪼개라.
  • Kyverno 1.17 미만 클러스터. ValidatingAdmissionPolicy는 k8s 1.30+에서 GA다. 1.29 이하면 Kyverno 컨트롤러가 직접 처리하는 fallback 모드로 동작하는데, 이러면 VAP의 성능 이점이 사라진다. 클러스터 버전부터 확인하자.

전체 마이그레이션은 우리 팀 기준 2명이 약 3주 정도 걸렸다. 정책 자체보다 차이 검증과 audit 기간이 길었다. 10월 deadline까지 시간이 있으니 지금부터 천천히 단계적으로 옮기는 걸 추천한다.

다음 글에서는 MutatingPolicy(아직 beta)와 ImageVerificationPolicy 마이그레이션을 다뤄볼 예정이다. 혹시 generate 룰을 옮긴 분 계시면 경험 공유 부탁드린다 — 거기가 가장 막막하다.

추가 리소스

반응형