LabHub

블로그

Kyverno 정책 엔진 분석: 검증, 변형, 생성 규칙 심층 분석

한국어English日本語


1. Validate 규칙 상세

1.1 패턴 매칭(Pattern Matching)

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-run-as-non-root
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-security-context
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: 'Containers must run as non-root'
        pattern:
          spec:
            containers:
              - securityContext:
                  runAsNonRoot: true

패턴 매칭 연산자:

1.2 deny 규칙

조건 기반으로 리소스를 거부:

rules:
  - name: deny-latest-tag
    match:
      any:
        - resources:
            kinds:
              - Pod
    validate:
      message: "Using 'latest' tag is not allowed. Use a specific version tag."
      deny:
        conditions:
          any:
            - key: '{{ request.object.spec.containers[].image }}'
              operator: AnyIn
              value:
                - '*:latest'
                - '*:*'

1.3 CEL 표현식

Kubernetes 1.25+ CEL(Common Expression Language) 사용:

rules:
  - name: check-replica-count
    match:
      any:
        - resources:
            kinds:
              - Deployment
    validate:
      cel:
        expressions:
          - expression: 'object.spec.replicas >= 2'
            message: 'Deployment must have at least 2 replicas'
          - expression: 'object.spec.replicas <= 100'
            message: 'Deployment cannot exceed 100 replicas'

1.4 foreach

컬렉션의 각 요소에 대해 검증:

rules:
  - name: check-each-container
    match:
      any:
        - resources:
            kinds:
              - Pod
    validate:
      message: 'All containers must have resource limits'
      foreach:
        - list: 'request.object.spec.containers'
          deny:
            conditions:
              any:
                - key: '{{ element.resources.limits.memory }}'
                  operator: Equals
                  value: ''

2. Mutate 규칙 상세

2.1 patchStrategicMerge

Kubernetes Strategic Merge Patch 방식:

rules:
  - name: add-sidecar
    match:
      any:
        - resources:
            kinds:
              - Deployment
            selector:
              matchLabels:
                inject-sidecar: 'true'
    mutate:
      patchStrategicMerge:
        spec:
          template:
            spec:
              containers:
                - name: log-collector
                  image: fluentbit:latest
                  volumeMounts:
                    - name: shared-logs
                      mountPath: /var/log/app
              volumes:
                - name: shared-logs
                  emptyDir: {}

2.2 patchesJson6902

JSON Patch (RFC 6902) 방식:

rules:
  - name: add-annotation
    match:
      any:
        - resources:
            kinds:
              - Service
    mutate:
      patchesJson6902: |-
        - op: add
          path: /metadata/annotations/modified-by
          value: kyverno
        - op: replace
          path: /spec/type
          value: ClusterIP

2.3 foreach mutate

rules:
  - name: add-pull-secret-to-all-containers
    match:
      any:
        - resources:
            kinds:
              - Pod
    mutate:
      foreach:
        - list: 'request.object.spec.containers'
          patchStrategicMerge:
            spec:
              imagePullSecrets:
                - name: my-registry-secret

3. Generate 규칙 상세

3.1 data 기반 생성

정책에 정의된 데이터로 리소스 생성:

rules:
  - name: generate-default-limitrange
    match:
      any:
        - resources:
            kinds:
              - Namespace
    generate:
      apiVersion: v1
      kind: LimitRange
      name: default-limits
      namespace: '{{ request.object.metadata.name }}'
      synchronize: true
      data:
        spec:
          limits:
            - default:
                cpu: 500m
                memory: 512Mi
              defaultRequest:
                cpu: 100m
                memory: 128Mi
              type: Container

3.2 clone 기반 생성

기존 리소스를 복제:

rules:
  - name: clone-configmap
    match:
      any:
        - resources:
            kinds:
              - Namespace
    generate:
      apiVersion: v1
      kind: ConfigMap
      name: shared-config
      namespace: '{{ request.object.metadata.name }}'
      synchronize: true
      clone:
        namespace: default
        name: template-configmap

3.3 synchronize 옵션

synchronize: true일 때:


4. 변수와 컨텍스트

4.1 JMESPath 변수

rules:
  - name: add-ns-label
    match:
      any:
        - resources:
            kinds:
              - Deployment
    mutate:
      patchStrategicMerge:
        metadata:
          labels:
            namespace: '{{ request.object.metadata.namespace }}'
            owner: '{{ request.userInfo.username }}'

4.2 API 호출 컨텍스트

rules:
  - name: check-namespace-labels
    match:
      any:
        - resources:
            kinds:
              - Pod
    context:
      - name: namespaceInfo
        apiCall:
          urlPath: '/api/v1/namespaces/{{ request.namespace }}'
          jmesPath: "metadata.labels.environment || 'unknown'"
    validate:
      message: 'Pods can only run in labeled namespaces'
      deny:
        conditions:
          any:
            - key: '{{ namespaceInfo }}'
              operator: Equals
              value: 'unknown'

4.3 ConfigMap 룩업

rules:
  - name: check-allowed-registries
    match:
      any:
        - resources:
            kinds:
              - Pod
    context:
      - name: allowedRegistries
        configMap:
          name: allowed-registries
          namespace: kyverno
    validate:
      message: 'Image must be from an allowed registry'
      foreach:
        - list: 'request.object.spec.containers'
          deny:
            conditions:
              all:
                - key: '{{ element.image }}'
                  operator: AnyNotIn
                  value: '{{ allowedRegistries.data.registries }}'

5. 고급 패턴

5.1 조건부 앵커(Conditional Anchors)

# () 앵커: 조건부 - 필드가 존재하면 검증
validate:
  pattern:
    spec:
      template:
        spec:
          containers:
            - (image): "*/nginx:*"  # nginx 이미지인 경우에만
              resources:
                limits:
                  memory: ">=256Mi"

# X() 부정 앵커: 필드가 존재하지 않아야 함
validate:
  pattern:
    spec:
      template:
        spec:
          containers:
            - name: "*"
              X(securityContext):
                X(privileged): true  # privileged가 true이면 안 됨

5.2 전역 앵커

# =() 동등 앵커: 값이 같아야 함
validate:
  pattern:
    spec:
      =(replicas): '>=3' # replicas가 설정되어 있으면 3 이상

6. validationFailureAction에서 failureAction으로

이 글의 예제는 전부 spec.validationFailureAction을 쓰고 있다. 지금 돌고 있는 클러스터의 정책들이 대개 그 모습이라 그대로 두었지만, 이 필드는 deprecated이고 규칙 단위 필드인 spec.rules[*].validate[*].failureAction으로 옮겨간다. 값은 Enforce와 Audit 두 가지이고, 지정하지 않으면 Audit이다. Enforce는 위반한 요청을 막고 Audit은 통과시키되 위반을 리포트에 남긴다.

왜 옮겼는지가 이 변경의 핵심이다. 정책 수준 필드였을 때는 한 정책 안의 모든 validate 규칙이 같은 강제 수준을 공유했다. 그래서 어떤 규칙은 확실하니 막고 어떤 규칙은 아직 관찰만 하고 싶으면 정책을 둘로 쪼개야 했고, 매치 블록이 복제되면서 관리해야 할 오브젝트가 늘었다. 규칙 단위로 내려오면 한 정책 안에서 규칙 하나는 Enforce로, 다른 하나는 Audit으로 둘 수 있다. 새 규칙을 기존 정책에 Audit으로 얹어 며칠 리포트를 보고 그 규칙만 Enforce로 올리는 흐름이, 정책을 쪼개지 않고도 가능해진다.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-run-as-non-root
spec:
  rules:
    - name: check-security-context
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        # 정책 수준 validationFailureAction 대신 규칙 단위로
        failureAction: Enforce
        message: 'Containers must run as non-root'
        pattern:
          spec:
            containers:
              - securityContext:
                  runAsNonRoot: true

    - name: warn-on-missing-limits
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        # 같은 정책 안에서 이 규칙만 관찰 모드
        failureAction: Audit
        message: 'Containers should declare resource limits'
        pattern:
          spec:
            containers:
              - resources:
                  limits:
                    memory: '?*'

같이 움직인 필드도 함께 알아두는 편이 낫다. webhookTimeoutSecondsfailurePolicy도 1.13부터 deprecated로 표시되고 각각 webhookConfiguration.timeoutSeconds, webhookConfiguration.failurePolicy로 옮겨간다. schemaValidation은 1.11부터 deprecated이고 문서 기준 지금은 아무 효과가 없다. 반대로 계속 쓰이는 정책 수준 필드도 있다. background는 기존 리소스를 훑어 위반을 찾고 리포트를 만드는 동작을 켜고 끄며 기본값은 true다. admission은 admission control 단계에서 규칙을 적용할지를 정하고 기본값은 true이며, false로 두면 background 전용 정책이 된다. applyRules는 매칭된 리소스에 규칙을 몇 개나 적용할지로, One이면 첫 매칭에서 멈추고 All이 기본값이다.

spec:
  background: true # 기본 true — 기존 리소스 스캔과 리포트 생성
  admission: true # 기본 true — false면 background 전용
  applyRules: All # All(기본) 또는 One(첫 매칭에서 중단)
  webhookConfiguration:
    timeoutSeconds: 20 # 1.13부터 webhookTimeoutSeconds 대신
    failurePolicy: Fail # 1.13부터 spec.failurePolicy 대신

버전에 따라 어느 쪽 필드가 실제로 읽히는지가 다르므로, 정확한 필드는 사용 중인 버전의 문서에서 확인하세요.


7. 규칙은 어디서, 어떤 순서로 실행되는가

Kyverno는 하나의 엔진처럼 보이지만 규칙 종류마다 실행되는 자리가 다르다. mutate 규칙은 mutating webhook에서, validate 규칙은 validating webhook에서 돈다. 쿠버네티스 API 서버는 mutating admission을 먼저 호출하고 그다음 validating admission을 호출하므로, validate 규칙이 보는 오브젝트는 이미 mutate 규칙이 손을 댄 뒤의 오브젝트다. 이 순서를 모르면 자기 정책에 자기가 걸린다. 사이드카를 주입하는 mutate 정책과 모든 컨테이너에 리소스 리밋을 요구하는 validate 정책을 같이 걸어두면 주입된 사이드카도 리밋 검사 대상이 되고, 주입 스펙에 리밋을 넣어두지 않았다면 배포가 막히면서 로그에는 사용자가 쓰지도 않은 컨테이너 이름이 찍힌다. 사이드카 주입 정책을 만들 때 리소스 리밋을 같이 넣는 것은 취향이 아니라 요구사항이다.

generate 규칙은 자리가 아예 다르다. admission에서 리소스를 직접 만들지 않고 UpdateRequest를 남기며, 실제 생성은 background controller가 뒤이어 수행한다. 그래서 네임스페이스를 만든 직후 곧바로 생성물을 확인하면 아직 없을 수 있고, 이것은 버그가 아니라 설계다. 생성이 안 될 때는 정책을 의심하기 전에 UpdateRequest가 남았는지부터 본다. 요청 자체가 없으면 매치가 안 된 것이고, 요청은 있는데 리소스가 없으면 background controller 쪽 문제다. 이 한 번의 분기로 문제 범위가 절반으로 줄어든다.

kubectl -n kyverno get updaterequests
kubectl auth can-i create helmrepositories --as system:serviceaccount:kyverno:kyverno-background-controller

synchronize: true도 공짜가 아니다. 소스가 바뀌면 생성물을 따라 바꾸고, 생성물이 손으로 수정되면 되돌린다. 그 말은 대상 네임스페이스 수만큼 감시와 쓰기가 늘어난다는 뜻이다. 네임스페이스 다섯 개짜리 클러스터에서는 아무 느낌이 없지만 수백 개짜리에서는 background controller의 상시 부하가 된다. 그리고 이 컨트롤러는 최소 권한만 갖고 설치된다. 추가 권한은 쓰는 쪽이 붙여야 하므로, 표준 리소스가 아닌 것을 generate하기 시작할 때는 위의 auth can-i를 그 리소스 이름으로 바꿔 먼저 확인한다. 권한이 없으면 admission은 성공하고 리소스만 조용히 안 생긴다. 가장 알아채기 어려운 종류의 실패다.

마지막으로 applyRules가 One이면 매칭된 리소스에 첫 규칙만 적용되고 멈춘다. 규칙을 순서대로 나열해 두었는데 뒤쪽 규칙이 왜 안 도는지 찾고 있다면 이 필드부터 확인한다.


8. 정책 하나를 로컬에서 돌려보고 클러스터에 올리기

정책은 클러스터에 올리기 전에 로컬에서 돌려볼 수 있고, 이 습관 하나가 사고의 대부분을 막는다. kyverno apply에 정책 파일과 --resource로 검사할 매니페스트를 준다. 변수를 쓰는 정책이면 --set으로 하나씩 주입하거나 -f로 값 파일을 넘긴다. 출력은 -t로 표, --detailed-results로 상세, -p로 리포트 형태를 고른다. 실패나 에러가 있으면 종료 코드가 1이므로 CI 게이트로 그대로 쓸 수 있다. 정책을 고칠 때마다 이 명령을 돌리면, 매치 블록을 잘못 써서 아무것도 매칭되지 않는 정책을 만들어 놓고 통과했다고 착각하는 사고를 막을 수 있다. 아무것도 매칭되지 않는 정책은 클러스터에서 조용히 통과만 시키므로 사람 눈으로는 잘 도는 정책과 구분되지 않는다.

kyverno apply policy.yaml --resource pod.yaml
kyverno apply policy.yaml --resource pod.yaml --set namespace=prod,team=payments
kyverno apply policy.yaml --resource pod.yaml -f values.yaml
kyverno apply policy.yaml --resource pod.yaml -t --detailed-results
kyverno apply policy.yaml --resource pod.yaml --policy-report

# 실패나 에러가 있으면 1
echo $?

로컬 검증을 통과했다면 같은 명령을 클러스터를 상대로 돌릴 수 있다. -c는 현재 컨텍스트의 클러스터에 붙어 검사하고, 정책을 git 소스에서 바로 가져오는 형태도 문서가 예시로 든다. 기존 리소스가 새 정책에 얼마나 걸리는지 미리 보고 싶을 때 이 조합이 유용하다. 여기까지 통과하면 정책을 실제로 적용하고 정책 목록과 리포트로 클러스터에서의 모습을 확인한다. generate 규칙이 있다면 UpdateRequest까지 같이 본다.

kyverno apply policy.yaml --cluster
kyverno apply https://github.com/kyverno/policies/openshift/ --git-branch main --cluster

kubectl apply -f policy.yaml
kubectl get cpol,pol -A
kubectl -n kyverno get updaterequests

9. 실패 사례와 진단 순서

정책이 기대대로 동작하지 않을 때 가장 흔한 실수는 정책 YAML부터 들여다보는 것이다. 정책 문법이 문제인 경우는 생각보다 적고, 대부분은 정책이 아예 평가되지 않았거나 변수가 빈 값으로 치환된 경우다. 순서를 정해 두면 훨씬 빨리 끝난다.

첫째, 정책이 준비되었는가. 정책 목록의 ready 열이 전부 true인지 본다. 문서가 진단의 첫 단계로 드는 것이 이것이다. 둘째, 웹훅이 등록되었는가. Kyverno는 두 종류의 웹훅으로 등록되며, 등록이 안 되어 있으면 정책은 존재하지만 아무 요청도 타지 않는다. 아무것도 막히지 않는데 정책은 멀쩡해 보이는 상황의 대부분이 여기다.

kubectl -n kyverno get po
kubectl get cpol,pol -A
kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations

셋째, 변수가 조용히 빈 값으로 치환되는 경우다. apiCall의 jmesPath가 실제 응답 구조와 맞지 않으면 에러가 아니라 빈 값이 나오고, 그 빈 값이 조건에 들어가면 조건이 항상 참이거나 항상 거짓이 된다. 정책은 정상으로 보이는데 결과만 이상한 전형적인 형태다. 이걸 눈으로 보려면 로그 상세도를 올려야 한다. 문서는 -v=4를 변수 치환을 볼 수 있는 수준으로, -v=6을 최대 상세도로 안내한다. 그래도 안 잡히면 dumpPayload=true로 AdmissionReview 전문을 찍어 실제로 들어온 오브젝트가 무엇인지 확인한다.

kubectl -n kyverno edit deploy kyverno-admission-controller
kubectl -n kyverno logs <pod_name> -f

# 클라이언트 측 스로틀링이 보이면 QPS와 버스트를 올린다
# --clientRateLimitQPS=500 --clientRateLimitBurst=500

넷째, foreach의 list를 중괄호로 감싼 경우다. list는 JMESPath 표현식 자체를 받으므로 변수 표기로 감싸지 않는다. 이 글의 foreach 예제들이 따옴표만 두르고 있는 이유가 그것이다. 다섯째, 앵커 오용이다. 조건부 (), 동등 =(), 존재 ^(), 부정 X()가 각각 다른 의미를 갖는데, 특히 부정 앵커를 값 비교로 착각하는 경우가 잦다. 부정 앵커는 그 키가 존재하면 안 된다는 뜻이지 값이 다르면 된다는 뜻이 아니다.

여섯째, background controller의 권한이다. generate가 안 될 때 7절의 auth can-i를 대상 리소스로 바꿔 확인한다. 일곱째, admission report가 쌓이는 경우다. 문서는 reports controller가 제대로 동작하지 않거나 집계 속도가 요청을 못 따라갈 때 리포트가 누적된다고 설명한다. reports controller 상태를 먼저 보고, 클라이언트 스로틀링이 보이면 QPS와 버스트를 올린다.

마지막은 최악의 경우다. 정책이 API 서버를 막아 아무것도 배포할 수 없게 되면 웹훅 설정을 지우거나 admission controller를 0으로 줄여 클러스터를 먼저 되살린다. 이 두 명령은 정책 강제를 통째로 끄는 것이므로 원인을 고친 뒤 반드시 원복해야 한다.


10. 언제 쓰지 않나

검사하려는 것이 필드 하나의 값이고 그 이상 아무것도 필요 없다면, 쿠버네티스에 내장된 ValidatingAdmissionPolicy와 CEL로 충분하다. 컴포넌트를 하나 덜 운영한다는 것은 업그레이드 대상이 하나 줄고, 장애 시 의심할 곳이 하나 줄고, 웹훅 인증서 갱신을 신경 쓸 일이 하나 준다는 뜻이다. Kyverno에도 CEL 기반 validate가 있지만 그건 이미 Kyverno를 운영하고 있을 때 유용한 선택지이지, Kyverno를 도입할 이유는 되지 못한다. Kyverno를 정당화하는 것은 generate와 mutate, 그리고 이미지 검증처럼 in-tree 정책이 못 하는 일들이다.

mutate로 채워 넣는 값이 사실은 차트의 기본값이어야 하는 경우도 흔하다. Helm values에 넣으면 git에서 보이고 리뷰되고 태그로 롤백되지만, mutate로 넣으면 클러스터에서만 보인다. 반년 뒤에 왜 이 어노테이션이 붙어 있는지 아무도 모르는 상태가 되고, 매니페스트와 실제 오브젝트가 다르다는 사실이 배포 도구의 드리프트 감지와 계속 싸운다. 조직 전체에 강제해야 하는 값만 mutate로 두고, 팀이 바꿀 수 있어야 하는 값은 차트에 두는 편이 낫다.

와일드카드로 전 리소스를 매칭하는 정책은 특히 조심해야 한다. 모든 요청이 웹훅을 타면서 클러스터 전체에 지연세를 매기는 셈이 되는데, 이 비용은 정책 하나의 성능이 아니라 API 서버로 오는 모든 요청에 붙는다. 기본 타임아웃 안에 응답하지 못하면 failurePolicy의 기본값 Fail에 따라 요청이 거부되므로, Kyverno가 느려지는 순간 클러스터가 느려지는 것이 아니라 클러스터가 멈춘다. 매치를 필요한 종류와 네임스페이스로 좁히는 작업은 성능 튜닝이 아니라 가용성 작업이다.

generate와 synchronize로 수백 개 네임스페이스의 리소스를 관리하는 것도 다시 생각해 볼 만하다. 그 일은 GitOps 도구가 더 잘하고, 무엇보다 git에 남는다. Kyverno의 generate는 네임스페이스 생성처럼 admission 시점에만 알 수 있는 이벤트에 반응해야 할 때 값어치가 있다.


11. 참고 자료


12. 정리

Kyverno 정책 엔진의 핵심:

  1. validate: 패턴 매칭, deny 조건, CEL 표현식, foreach로 다양한 검증
  2. mutate: Strategic Merge Patch, JSON Patch로 리소스 자동 수정
  3. generate: data/clone 기반 리소스 자동 생성, synchronize로 동기화
  4. 변수 시스템: JMESPath, API 호출, ConfigMap 룩업으로 동적 정책
  5. 앵커 시스템: 조건부, 부정, 동등 앵커로 세밀한 패턴 매칭
  6. 강제 수준: 정책 수준 validationFailureAction에서 규칙 수준 failureAction으로 이동
  7. 실행 위치: mutate는 mutating webhook, validate는 validating webhook, generate는 background controller

다음 글에서는 Kyverno의 이미지 검증 기능과 공급망 보안을 다룹니다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다