정책을 코드로 · 쿠버네티스가 스스로 검사한다 · 실습
정책을 적용했는데 아무것도 막히지 않았다
목표
쿠버네티스에 기본으로 들어 있는 ValidatingAdmissionPolicy(CEL)로 파드를 검사하는 규칙을 만들고, 경고에서 차단으로 올리고, 메시지에 위반한 값을 담고, 예외와 범위와 파라미터까지 직접 붙여 봅니다.
왜 중요한가
외부 정책 엔진을 설치하지 않고도 API 서버 안에서 검증을 할 수 있게 된 것이 ValidatingAdmissionPolicy 입니다. 웹훅과 달리 네트워크 왕복도 인증서도 없고, 엔진이 죽어서 클러스터가 멈추는 일도 없습니다. 대신 배워야 할 것이 하나 늘어납니다 — 판정 규칙과 적용 범위가 서로 다른 오브젝트라는 점입니다. 이 분리 덕분에 같은 규칙을 어떤 팀에는 경고로, 어떤 팀에는 차단으로 붙일 수 있고, 규칙 값만 ConfigMap 으로 빼서 정책을 고치지 않고 바꿀 수 있습니다. 반대로 이 분리를 모르면 '정책은 분명히 적용했는데 아무것도 안 막힌다' 는 자리에서 오래 헤맵니다. 실무에서 정책을 처음 넣을 때는 항상 Warn 으로 시작해 무엇이 걸리는지 보고, 시스템 컴포넌트를 matchConditions 로 빼 둔 다음, 범위를 좁게 잡아 Deny 로 올립니다. 이 순서를 건너뛴 정책은 배포를 멈추고 되돌려지고, 그러면 아무도 다시 켜지 않습니다.
단계
1. /root/vap 에서 작업합니다(export KUBECONFIG=/root/.kube/config, kubectl config use-context kwok-lab). /root/vap/policy.yaml 에 admissionregistration.k8s.io/v1 의 ValidatingAdmissionPolicy require-cpu-limit 을 쓰세요. matchConstraints.resourceRules 는 코어 그룹("")의 v1 pods 에 대한 CREATE·UPDATE 를 잡고, validation 은 모든 컨테이너가 resources.limits.cpu 를 가질 것을 요구합니다. 시험용 파드 둘도 만드세요 — /root/vap/pod-nocpu.yaml 은 컨테이너 web-tier 가 memory 제한만 갖고, /root/vap/pod-ok.yaml 은 컨테이너 web-full 가 cpu·memory 제한을 모두 갖습니다. 정책을 적용한 뒤 라벨이 하나도 없는 네임스페이스 team-open 을 만들고, 거기에 pod-nocpu.yaml 을 kubectl create --dry-run=server 로 보내 그 출력을 /root/vap/01-nobinding.txt 에 저장하세요.
2. 네임스페이스 team-warn 을 만들고 라벨 cpu-limit=warn 을 붙이세요. /root/vap/binding-warn.yaml 에 ValidatingAdmissionPolicyBinding cpu-limit-warn 을 쓰세요 — policyName 은 require-cpu-limit, validationActions 는 ["Warn", "Audit"], matchResources.namespaceSelector.matchLabels 는 cpu-limit: warn 입니다. 적용한 뒤 team-warn 에 pod-nocpu.yaml 을 --dry-run=server 로 보내 출력을 /root/vap/02-warn.txt 에 저장하세요(표준 오류까지 함께). 경고가 뜨는데도 파드는 만들어져야 합니다.
3. 네임스페이스 team-deny 를 만들고 라벨 cpu-limit=deny 를 붙이세요. /root/vap/binding-deny.yaml 에 두 번째 바인딩 cpu-limit-deny 를 쓰세요 — 같은 policyName 에 validationActions 는 ["Deny"], 셀렉터는 cpu-limit: deny 입니다. 적용한 뒤 team-deny 에 pod-nocpu.yaml 을 보내 출력을 /root/vap/03-deny.txt 에 저장하고, 이어서 pod-ok.yaml 도 보내 통과하는지 확인하세요. 경고용 바인딩(cpu-limit-warn)은 지우지 말고 그대로 둡니다 — 두 범위가 동시에 살아 있는 것이 실제 전개 모습입니다.
4. /root/vap/policy.yaml 을 고쳐 spec.variables 에 noCpu 를 두세요 — cpu 제한이 없는 컨테이너의 이름 목록을 계산합니다. validation 은 size(variables.noCpu) == 0 으로 바꾸고, message 대신 messageExpression 으로 그 이름들과 파드 이름을 담은 메시지를 만드세요. 그리고 /root/vap/pod-two.yaml 에 컨테이너 둘을 가진 파드 probe-two 를 만드세요 — web-nolimit 은 resources 자체가 없고, cache-ok 는 cpu·memory 제한을 모두 가집니다. 정책을 다시 적용한 뒤 team-deny 에 보내 거부 메시지를 /root/vap/04-message.txt 에 저장하세요. 메시지에는 web-nolimit 만 나와야 하고 cache-ok 는 나오면 안 됩니다.
5. /root/vap/policy.yaml 에 spec.matchConditions 를 더하세요. 조건 두 개입니다 — skip-kube-system-sa 는 요청자가 system:serviceaccount:kube-system: 으로 시작하는 서비스 계정이면 정책을 건너뛰게 하고, skip-exempt-pods 는 파드에 라벨 cpu-limit-exempt: "true" 가 있으면 건너뛰게 합니다. /root/vap/pod-exempt.yaml 에 그 라벨이 붙은 파드 probe-exempt(컨테이너 web-tier, memory 제한만)를 만드세요. 정책을 다시 적용한 뒤 두 가지를 team-deny 에서 확인해 출력을 /root/vap/05-skipped.txt 에 저장하세요 — (1) kubectl --as=system:serviceaccount:kube-system:replicaset-controller create -n team-deny -f pod-nocpu.yaml --dry-run=server, (2) pod-exempt.yaml 을 평소대로 보내기. 둘 다 만들어져야 합니다. 라벨 없는 pod-nocpu.yaml 은 여전히 거부돼야 합니다.
6. 네임스페이스 vap-params 와 team-images(라벨 registry-check=on)를 만드세요. vap-params 에 ConfigMap allowed-registries 를 만들고 키 allowed 의 값을 정확히 registry.internal/,ghcr.io/labhub/ 로 두세요. /root/vap/policy-registries.yaml 에 두 번째 정책 allowed-registries 를 쓰세요 — spec.paramKind 는 apiVersion: v1, kind: ConfigMap 이고, 같은 matchConstraints 로 파드를 잡고, params.data['allowed'] 를 쉼표로 나눈 목록 중 어느 것으로도 시작하지 않는 이미지를 거부합니다. /root/vap/binding-registries.yaml 에 바인딩 allowed-registries 를 쓰세요 — validationActions 는 ["Deny"], paramRef 는 그 ConfigMap(name·namespace, parameterNotFoundAction: Deny), 셀렉터는 registry-check: "on" 입니다. /root/vap/pod-img-bad.yaml 에 이미지가 docker.io/nginx:1.27 인 파드 probe-img-bad(컨테이너 web-img, cpu·memory 제한 포함)를 만들고, team-images 에 pod-ok.yaml 과 pod-img-bad.yaml 을 차례로 보내 두 출력을 /root/vap/06-param.txt 에 저장하세요.
7. /root/vap/scope.sh <네임스페이스> 를 만드세요. 그 네임스페이스에 /root/vap/pod-nocpu.yaml 을 --dry-run=server 로 보내 보고, 거부되면 DENY 한 줄과 종료 코드 3, 경고만 뜨고 만들어지면 WARN 과 2, 아무 말 없이 만들어지면 OPEN 과 0 으로 끝냅니다. 출력은 그 낱말 한 줄뿐입니다. 네임스페이스 이름이나 라벨을 읽어 판단하지 말고 실제 요청의 결과로 판정하세요 — 채점기는 team-open 의 라벨을 잠시 바꿔 가며 이 스크립트를 부르고 원래대로 되돌립니다. 만든 뒤 team-deny·team-warn·team-open 세 곳에 차례로 돌려 결과를 /root/vap/07-scope.txt 에 <네임스페이스> <낱말> 꼴로 한 줄씩 저장하세요.
8. /root/vap/policy.yaml 에 두 번째 규칙을 더하세요 — 변수 noMem(memory 제한이 없는 컨테이너 이름 목록)과, size(variables.noMem) == 0 을 검사하는 두 번째 validation 입니다. 이 validation 의 messageExpression 에는 낱말 memory 와 그 컨테이너 이름이 들어가야 하고, 첫 번째 validation 의 메시지에는 낱말 cpu 와 그 컨테이너 이름이 들어가야 합니다. /root/vap/pod-mixed.yaml 에 파드 probe-mixed 를 만드세요 — 컨테이너 web-tier 는 memory 제한만, cache-tier 은 cpu 제한만 가집니다. 정책을 다시 적용한 뒤 이 파드를 team-warn 과 team-deny 에 차례로 보내 두 출력을 /root/vap/08-two-rules.txt 에 저장하세요. Warn 쪽은 두 규칙을 모두 알려 주고 Deny 쪽은 하나만 알려 줍니다.
참고
export KUBECONFIG=/root/.kube/config와kubectl config use-context kwok-lab로 시작합니다. 파드 안 kwok 이 띄우는 진짜 kube-apiserver v1.30.4 이고, 모든 산출물은/root/vap아래에 둡니다.- kwok 은 파드를 실제로 실행하지 않습니다. 이 실습이 보는 것은 어드미션 단계뿐이라
kubectl create -f <파일> --dry-run=server로 충분합니다 — 어드미션은 그대로 태우고 오브젝트는 남지 않습니다. - 정책·바인딩을 바꾼 직후에는 한두 번의 왕복만큼 늦게 반영됩니다. 결과가 예전 같으면 몇 초 뒤 다시 보내 보세요.
- 흔한 실수: 정책만 적용하고 바인딩을 빠뜨리는 것. 정책은 바인딩이 가리킬 때까지 평가조차 되지 않습니다.
- 흔한 실수: 경고를
2>&1없이 파일로 받는 것. 경고는 표준 오류로 나옵니다. - 흔한 실수: matchConditions 를 긍정으로 쓰는 것. 조건이 참일 때 정책이 요청을 보므로, 건너뛰고 싶은 조건은 부정으로 적습니다.
- [Validating Admission Policy](https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/) · [Common Expression Language in Kubernetes](https://kubernetes.io/docs/reference/using-api/cel/) · [Admission Controllers Reference](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/)
단계 8개
- 정책만 적용하면 아무 일도 일어나지 않는다
- 바인딩을 붙이자 경고가 뜨기 시작한다
- 같은 정책을 Deny 로 올리자 요청이 막힌다
- 거부 메시지가 어느 컨테이너인지 말해 준다
- 정책이 아예 보지 않는 요청을 만든다
- 규칙 값을 ConfigMap 으로 빼서 정책을 고치지 않고 바꾼다
- 같은 요청이 네임스페이스마다 다른 답을 받는다
- 규칙 둘을 한 정책에 넣자 Deny 와 Warn 이 서로 다른 것을 알려 준다