TT Lab
시작하기
배우기 러닝패스 코스

정책을 코드로

ValidatingAdmissionPolicy — 엔진 없이 API 서버가 직접 막는다

TT Lab 에서 이어서 보기

한 줄 요약

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 이다.

VAP 와 웹훅 엔진의 갈림길

간단한 규칙은 API 서버가 직접 판정하게 하자는 것이 VAP 의 출발이다. 규칙마다 자리를 정하는 비교다.

  • ValidatingAdmissionPolicyAPI 서버 안에서 CEL 식을 평가한다. 파드도 인증서도 네트워크 왕복도 없어 엔진이 죽어서 멈출 일이 없다. 대신 들어온 요청과 파라미터만 볼 수 있다.
  • 웹훅 정책 엔진임의 코드로 클러스터를 조회하고 변형과 생성도 한다. 대신 파드, 인증서, CA 번들을 운영해야 하고 Fail 아래에서는 엔진의 가용성이 곧 클러스터의 가용성이 된다.

여기서 구분할 것 둘 중 하나를 고르는 문제가 아니다. CEL 로 표현되는 검증은 VAP 로 내리고 조회나 변형이 필요한 것만 엔진에 남긴다는 정리는 본문의 지침이다. 링크한 문서는 VAP 가 검증 웹훅의 in-process 대안이라는 것의 근거다.

잠깐, 예측해 보세요 정책을 만들자 status.typeChecking 에 오류가 하나도 없다. 이 정책이 옳다는 뜻일까?

설명 확인 · 채점 없는 자가 점검

아니다. 와일드카드가 든 matchConstraints 는 검사하지 않고, 조합이 많으면 열한 번째부터 무시하며, CRD 에는 적용되지 않는다. 타입 체킹 결과는 정책의 동작을 바꾸지도 않는다.

근거 문서

어떻게 동작하나

정책 하나는 최대 세 오브젝트로 이루어진다.

오브젝트 담는 것
ValidatingAdmissionPolicy 규칙의 추상 로직. 어떤 자원을 보는가(matchConstraints), 무엇이 참이어야 하는가(validations)
파라미터 리소스 (선택) 규칙이 쓰는 값. 허용 레지스트리 목록, 최대 레플리카 수 같은 것
ValidatingAdmissionPolicyBinding 정책과 파라미터를 묶고, 어디에 적용할지 범위를 준다

공식 문서가 못 박는 문장이 하나 있다. 정책과 그에 대응하는 바인딩이 둘 다 있어야 정책이 효과를 낸다. 바인딩 없는 정책은 문법 오류도 아니고 경고도 없이 그냥 아무것도 막지 않는다. 처음 쓰는 사람이 가장 많이 걸리는 자리다.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: demo-binding-test.example.com
spec:
  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 응답으로 확인한다.

참고 문서

켜는 날의 순서에서 바뀌는 것은 바인딩뿐이다

정책 하나와 바인딩 하나로 도입하는 절차다. 본문이 권하는 순서이고 바뀌는 곳은 바인딩의 validationActions 뿐이다.

  1. 정책과 바인딩을 함께 만든다바인딩이 없으면 정책은 문법 오류도 경고도 없이 아무것도 막지 않는다. 바인딩이 정책과 적용 범위를 묶는다.
  2. Warn 과 Audit 로 며칠 센다Warn 은 요청한 클라이언트에 경고로 알리고 Audit 은 감사 이벤트에 남긴다. 걸리는 것이 정리될 때까지 요청은 막지 않는다.
  3. 바인딩만 Deny 로 고친다정책 본문은 한 글자도 바뀌지 않는다. Deny 와 Warn 은 함께 쓸 수 없으므로 목록을 바꿔 적는다.

여기서 구분할 것 같은 정책에 바인딩을 둘 달아 환경마다 범위와 파라미터를 달리할 수도 있다. 단 이 순서는 본문의 도입 절차이고 공식 문서가 규정한 것은 아니다.

잠깐, 예측해 보세요 Warn 과 Audit 로 켜 둔 며칠 동안 걸리는 요청을 세려면 각각 어디를 봐야 할까?

설명 확인 · 채점 없는 자가 점검

Warn 은 요청을 보낸 클라이언트가 받는 경고로, Audit 은 API 서버의 감사 이벤트로 남는다. 요청이 막히지 않으므로 거부 메시지로는 셀 수 없고 이 두 곳을 모아 봐야 한다.

근거 문서

다음 실습에서 할 것

kwok 이 띄운 v1.30 API 서버에 VAP 를 직접 건다. 정책만 만들어 놓고 아무것도 안 막히는 것을 먼저 눈으로 확인한 뒤 바인딩을 붙이고, validationActions 를 [Warn, Audit] 에서 [Deny] 로 옮기며 같은 요청의 응답이 어떻게 달라지는지 본다. spec.variables 와 messageExpression 으로 거부 메시지를 사람이 읽을 수 있게 고치고, matchConditions 로 특정 요청을 아예 평가 대상에서 빼고, paramKind/paramRef 로 허용 목록을 정책 밖으로 뺀 뒤 같은 정책에 바인딩 두 개를 달아 네임스페이스마다 다른 한계를 건다.