정책을 코드로 · 쿠버네티스가 스스로 검사한다 · 이론
ValidatingAdmissionPolicy — 엔진 없이 API 서버가 직접 막는다
한 줄 요약
ValidatingAdmissionPolicy(이하 VAP)는 파드도 인증서도 네트워크 왕복도 없이 API 서버 안에서 CEL 식으로 요청을 검사하는 기구이고, 규칙(정책)과 적용 범위(바인딩)를 일부러 두 오브젝트로 갈라 둔 것이 그 설계의 핵심이다.
왜 이게 필요했나
웹훅 기반 정책 엔진은 강력하지만 값이 비싸다. 엔진 파드를 띄우고, TLS 인증서를 발급해 CA 번들을 웹훅 설정에 꽂고, 레플리카와 PodDisruptionBudget 으로 가용성을 챙기고, failurePolicy: Fail 을 켠 순간부터는 그 엔진의 가용성이 곧 클러스터의 가용성이 된다. "이미지 태그가 latest 면 거부한다" 같은 한 줄짜리 규칙 하나를 걸자고 이 전부를 감당하는 것이 맞는가 하는 질문이 오래 있었다.
쿠버네티스는 이 질문에 "간단한 규칙은 API 서버가 직접 판정하게 하자"로 답했다. 공식 문서는 VAP 를 검증 웹훅의 in-process 대안이라고 못 박는다. 규칙을 CEL(Common Expression Language) 식으로 적어 두면 API 서버가 그 자리에서 평가한다. 네트워크 호출이 없으니 타임아웃도 없고, 엔진 파드가 없으니 엔진이 죽어서 클러스터가 멈출 일도 없다. 쿠버네티스 v1.30 에서 정식(stable) 기능이 됐고, 이 실습 환경의 API 서버가 바로 그 v1.30 이다.
어떻게 동작하나
정책 하나는 최대 세 오브젝트로 이루어진다.
| 오브젝트 | 담는 것 |
| --- | --- |
| ValidatingAdmissionPolicy | 규칙의 추상 로직. 어떤 자원을 보는가(matchConstraints), 무엇이 참이어야 하는가(validations) |
| 파라미터 리소스 (선택) | 규칙이 쓰는 값. 허용 레지스트리 목록, 최대 레플리카 수 같은 것 |
| ValidatingAdmissionPolicyBinding | 정책과 파라미터를 묶고, 어디에 적용할지 범위를 준다 |
공식 문서가 못 박는 문장이 하나 있다. 정책과 그에 대응하는 바인딩이 둘 다 있어야 정책이 효과를 낸다. 바인딩 없는 정책은 문법 오류도 아니고 경고도 없이 그냥 아무것도 막지 않는다. 처음 쓰는 사람이 가장 많이 걸리는 자리다.
apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicyBindingmetadata: name: demo-binding-test.example.comspec: policyName: demo-policy.example.com validationActions: [Deny] matchResources: namespaceSelector: matchLabels: environment: test나눠 둔 덕에 같은 정책을 범위를 달리해 여러 번 켤 수 있다. 테스트 네임스페이스에는 최대 레플리카 3 으로, 운영 네임스페이스에는 100 으로. 정책 YAML 은 하나이고 바인딩과 파라미터만 둘이다. 규칙을 고칠 때 값이 아니라 로직만 건드리게 되는 구조라, 리뷰가 훨씬 가벼워진다.
validationActions 는 바인딩에 붙는다. 지원하는 값은 셋이다.
| 값 | 위반일 때 |
| --- | --- |
| Deny | 요청을 거부한다 |
| Warn | 요청은 통과시키고 클라이언트에 경고로 알린다 |
| Audit | 감사 이벤트에 기록한다 |
Deny 와 Warn 은 함께 쓸 수 없다(거부된 요청은 이미 응답 본문과 HTTP 경고 헤더로 이유를 알려 주기 때문이다). 그래서 실제 도입 절차는 [Warn, Audit] 로 켜서 며칠 세어 보고, 걸리는 것이 다 정리된 뒤 바인딩만 [Deny] 로 고치는 순서가 된다. 정책 본문은 한 글자도 안 바뀐다. 이것이 정책과 바인딩을 나눈 두 번째 이유다.
failurePolicy 는 정책 쪽에 붙고 기본값이 Fail 이다. 식 평가 자체가 오류로 끝났을 때(필드가 없어 has() 없이 접근했다든지) 거부할지 통과시킬지를 정한다. 문서에 적힌 미묘한 규칙이 하나 있다. failurePolicy 가 정의하는 실패는 failurePolicy 가 Fail 일 때만 validationActions 를 따른다. Ignore 면 그 실패는 그냥 무시된다.
사람이 읽을 수 있는 거부 메시지를 만드는 것도 정책의 일이다. 아무것도 안 하면 거부 메시지는 failed expression: object.spec.replicas <= 5 처럼 식 원문이 그대로 찍힌다. messageExpression 에 CEL 식을 주면 실제 값이 박힌 문장을 만들 수 있고, spec.variables 로 긴 식에 이름을 붙여 두면 variables.<이름> 으로 여러 곳에서 다시 쓸 수 있다. 변수는 필요할 때만 평가되므로 비싼 식을 한 번만 계산하는 효과도 있다.
spec: variables: - name: environment expression: "has(namespaceObject.metadata.labels) ? namespaceObject.metadata.labels['environment'] : 'prod'" validations: - expression: "..." messageExpression: "'only ' + variables.environment + ' images are allowed'"matchConditions 는 한 걸음 더 앞이다. 여기 적은 CEL 조건이 거짓이면 API 서버가 정책을 아예 평가하지 않는다. 시스템 서비스 어카운트의 요청을 빼거나, 특정 라벨이 붙은 것만 보게 할 때 쓴다. 조건 평가가 오류로 끝나면 Fail 은 정책을 평가하지 않은 채 요청을 거부하고 Ignore 는 정책을 건너뛴 채 통과시킨다.
값을 밖으로 빼는 것이 paramKind(정책 쪽)와 paramRef(바인딩 쪽)다. paramRef 에는 name 또는 selector 중 하나만 쓸 수 있고, parameterNotFoundAction 은 필수다. Allow 면 파라미터를 못 찾았을 때 통과로 치고, Deny 면 정책의 failurePolicy 를 따른다. 이 필드를 빠뜨린 바인딩은 무시되거나 예상 못 한 동작을 한다고 문서가 경고한다.
웹훅 엔진과 견주면 무엇을 얻고 무엇을 잃나
| 축 | VAP | 웹훅 엔진 |
| --- | --- | --- |
| 설치 | 없음. API 서버 내장 | 파드·인증서·CA 번들·업그레이드 |
| 가용성 | API 서버가 곧 엔진 | 엔진이 죽으면 Fail 아래에서 쓰기가 멈춘다 |
| 지연 | 프로세스 안 평가 | 요청마다 네트워크 왕복 |
| 표현력 | CEL. 들어온 요청과 파라미터만 본다 | 임의 코드. 클러스터를 조회하고 변형·생성도 한다 |
| 리포트 | 감사 이벤트·경고 | PolicyReport 같은 전용 리포트 CRD |
정리하면 CEL 로 표현되는 검증은 VAP 로 내리고, 클러스터를 조회해야 하거나 변형·생성이 필요한 것은 엔진에 남긴다. 둘 중 하나를 고르는 문제가 아니라 규칙마다 자리를 정하는 문제다.
현장에서 만나는 모습
첫째, 아무것도 안 막힌다. 열에 아홉은 바인딩을 안 만들었거나 matchConstraints 가 실제 요청과 안 맞는 경우다. 아무것도 매칭하지 않는 정책은 잘 도는 정책과 겉으로 구분되지 않는다. 그래서 정책을 켤 때는 막혀야 할 표본과 통과해야 할 표본을 둘 다 넣어 봐야 한다.
둘째, 거부 메시지가 식 원문이라 문의가 온다. 개발자는 object.spec.template.spec.containers.all(c, ...) 를 받아 들고 무엇을 고쳐야 할지 모른다. messageExpression 으로 "컨테이너 web 의 이미지 nginx:latest 는 허용 레지스트리 밖입니다" 같은 문장을 만들어 주는 일은 친절이 아니라 정책이 실제로 지켜지게 만드는 장치다.
셋째, 타입 체킹은 참고용이다. 정책을 만들면 API 서버가 식을 미리 검사해 status.typeChecking 에 결과를 남긴다. 다만 문서가 한계를 분명히 적어 두었다. 와일드카드가 들어간 matchConstraints 는 검사하지 않고, 조합이 많으면 열한 번째부터는 무시하고, CRD 에는 적용되지 않으며, 타입 체킹 결과는 정책의 동작을 바꾸지 않는다. 여기에 오류가 없다고 정책이 옳다는 뜻은 아니다.
넷째, 이 환경의 정직한 한계. kwok 클러스터의 API 서버는 진짜라서 VAP 의 Deny 도 Warn 도 실제로 동작하고 namespaceSelector 로 범위를 가르는 것도 동작한다. 다만 파드는 실행되지 않고 Ready 로 위조되므로, 막힌 파드가 정말 안 떴는지는 컨테이너가 아니라 API 응답으로 확인한다.
참고 문서
- ValidatingAdmissionPolicy: https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/
- 쿠버네티스 API 의 CEL: https://kubernetes.io/docs/reference/using-api/cel/
- 어드미션 컨트롤러 목록: https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/
- 동적 어드미션 제어(웹훅): https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/
다음 실습에서 할 것
kwok 이 띄운 v1.30 API 서버에 VAP 를 직접 건다. 정책만 만들어 놓고 아무것도 안 막히는 것을 먼저 눈으로 확인한 뒤 바인딩을 붙이고, validationActions 를 [Warn, Audit] 에서 [Deny] 로 옮기며 같은 요청의 응답이 어떻게 달라지는지 본다. spec.variables 와 messageExpression 으로 거부 메시지를 사람이 읽을 수 있게 고치고, matchConditions 로 특정 요청을 아예 평가 대상에서 빼고, paramKind/paramRef 로 허용 목록을 정책 밖으로 뺀 뒤 같은 정책에 바인딩 두 개를 달아 네임스페이스마다 다른 한계를 건다.