策略已经应用,却什么都没拦下来
한국어 원문으로 표시합니다.
목표
쿠버네티스에 기본으로 들어 있는 ValidatingAdmissionPolicy(CEL)로 파드를 검사하는 규칙을 만들고, 경고에서 차단으로 올리고, 메시지에 위반한 값을 담고, 예외와 범위와 파라미터까지 직접 붙여 봅니다.
왜 중요한가
외부 정책 엔진을 설치하지 않고도 API 서버 안에서 검증을 할 수 있게 된 것이 ValidatingAdmissionPolicy 입니다. 웹훅과 달리 네트워크 왕복도 인증서도 없고, 엔진이 죽어서 클러스터가 멈추는 일도 없습니다. 대신 배워야 할 것이 하나 늘어납니다 — 판정 규칙과 적용 범위가 서로 다른 오브젝트라는 점입니다. 이 분리 덕분에 같은 규칙을 어떤 팀에는 경고로, 어떤 팀에는 차단으로 붙일 수 있고, 규칙 값만 ConfigMap 으로 빼서 정책을 고치지 않고 바꿀 수 있습니다. 반대로 이 분리를 모르면 '정책은 분명히 적용했는데 아무것도 안 막힌다' 는 자리에서 오래 헤맵니다. 실무에서 정책을 처음 넣을 때는 항상 Warn 으로 시작해 무엇이 걸리는지 보고, 시스템 컴포넌트를 matchConditions 로 빼 둔 다음, 범위를 좁게 잡아 Deny 로 올립니다. 이 순서를 건너뛴 정책은 배포를 멈추고 되돌려지고, 그러면 아무도 다시 켜지 않습니다.
단계
/root/vap에서 작업합니다(export KUBECONFIG=/root/.kube/config,kubectl config use-context kwok-lab)./root/vap/policy.yaml에admissionregistration.k8s.io/v1의 ValidatingAdmissionPolicyrequire-cpu-limit을 쓰세요.matchConstraints.resourceRules는 코어 그룹("")의v1pods에 대한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에 저장하세요.- 네임스페이스
team-warn을 만들고 라벨cpu-limit=warn을 붙이세요./root/vap/binding-warn.yaml에 ValidatingAdmissionPolicyBindingcpu-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에 저장하세요(표준 오류까지 함께). 경고가 뜨는데도 파드는 만들어져야 합니다. - 네임스페이스
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)은 지우지 말고 그대로 둡니다 — 두 범위가 동시에 살아 있는 것이 실제 전개 모습입니다. /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는 나오면 안 됩니다./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은 여전히 거부돼야 합니다.- 네임스페이스
vap-params와team-images(라벨registry-check=on)를 만드세요.vap-params에 ConfigMapallowed-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에 저장하세요. /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에<네임스페이스> <낱말>꼴로 한 줄씩 저장하세요./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 · Common Expression Language in Kubernetes · Admission Controllers Reference
정책만 적용하면 아무 일도 일어나지 않는다
/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 에 저장하세요.
정책은 '판정하는 방법' 만 정의하고, '어디에 적용할지' 는 별도의 바인딩 오브젝트가 정합니다. 그래서 정책만 적용하면 API 서버는 그 식을 평가조차 하지 않습니다. CEL 에서 목록 전체를 검사할 때는 all(...) 을 쓰고, 없을 수도 있는 필드는 has(...) 로 먼저 확인해야 합니다. kubectl create -f <파일> --dry-run=server 는 어드미션을 그대로 통과시키되 오브젝트를 남기지 않습니다. 거부 메시지도, 경고도 진짜 요청과 똑같이 나옵니다.
바인딩을 붙이자 경고가 뜨기 시작한다
네임스페이스 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 에 저장하세요(표준 오류까지 함께). 경고가 뜨는데도 파드는 만들어져야 합니다.
바인딩은 '이 정책을 어디에, 어떤 강도로' 를 정합니다. Warn 은 응답 헤더로 경고만 돌려주고 요청은 통과시키고, Audit 은 감사 로그에 주석을 남깁니다. 경고는 표준 출력이 아니라 표준 오류로 나오므로 2>&1 을 붙여야 파일에 함께 담깁니다. namespaceSelector 는 네임스페이스 오브젝트의 라벨을 봅니다.
같은 정책을 Deny 로 올리자 요청이 막힌다
네임스페이스 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)은 지우지 말고 그대로 둡니다 — 두 범위가 동시에 살아 있는 것이 실제 전개 모습입니다.
정책 하나에 바인딩을 여러 개 붙일 수 있고, 각 바인딩이 자기 범위와 자기 강도를 가집니다. 그래서 '같은 규칙을 어떤 팀에는 경고로, 어떤 팀에는 차단으로' 가 정책을 복사하지 않고 됩니다. 거부 메시지에는 어느 정책과 어느 바인딩이 막았는지가 함께 찍힙니다 — 여러 규칙이 겹칠 때 원인을 찾는 단서입니다.
거부 메시지가 어느 컨테이너인지 말해 준다
/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 는 나오면 안 됩니다.
variables 는 식 하나를 이름으로 묶어 두고 variables.<이름> 으로 다시 쓰는 장치입니다. 지연 평가라 validation 에서 쓰지 않으면 계산되지 않습니다. filter(...) 로 위반한 것만 남기고 map(...) 으로 이름만 뽑으면 목록이 됩니다. 문자열 목록은 join(', ') 으로 이어 붙일 수 있습니다. messageExpression 이 오류를 내면 API 서버는 조용히 message 로 되돌아갑니다 — 메시지가 바뀌지 않으면 식을 의심하세요.
정책이 아예 보지 않는 요청을 만든다
/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 은 여전히 거부돼야 합니다.
matchConditions 는 matchConstraints 가 고른 요청을 한 번 더 거릅니다. 조건이 모두 참일 때만 validations 가 평가되므로, '건너뛰고 싶다' 는 조건을 부정으로 씁니다. request 에는 요청자 정보(request.userInfo.username)가 들어 있습니다. 컨트롤러가 만드는 파드까지 막으면 클러스터가 스스로를 복구하지 못하게 되므로, 시스템 주체를 빼 두는 것이 전개의 첫 안전장치입니다. kubectl --as 로 다른 주체인 척할 수 있습니다.
규칙 값을 ConfigMap 으로 빼서 정책을 고치지 않고 바꾼다
네임스페이스 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 에 저장하세요.
paramKind 는 '이 정책이 파라미터로 무엇을 읽는가' 를, 바인딩의 paramRef 는 '그중 어느 오브젝트인가' 를 정합니다. 그래서 같은 정책을 팀마다 다른 값으로 붙일 수 있습니다. CEL 의 문자열에는 split 과 startsWith 가 있고, 목록에는 exists 가 있습니다. 파라미터를 못 찾았을 때 어떻게 할지는 parameterNotFoundAction 이 정합니다 — 보안 규칙이라면 여는 쪽이 아니라 막는 쪽이 기본이어야 합니다. 이 실습의 뒷부분에서 채점기가 ConfigMap 값을 잠시 바꿔 보고 되돌립니다. 허용 목록을 식 안에 적어 두면 그때 들통납니다.
같은 요청이 네임스페이스마다 다른 답을 받는다
/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 에 <네임스페이스> <낱말> 꼴로 한 줄씩 저장하세요.
경고는 표준 오류로 나오므로 출력을 합쳐 받아야 보입니다. 거부는 종료 코드가 0 이 아니고, 경고는 종료 코드가 0 입니다 — 이 둘을 먼저 가르고 나서 경고 여부를 봅니다. set -e 를 걸면 거부에서 스크립트가 먼저 죽습니다. 어드미션은 네임스페이스의 라벨을 보고 범위를 정하므로, 같은 매니페스트가 자리에 따라 다른 답을 받습니다.
규칙 둘을 한 정책에 넣자 Deny 와 Warn 이 서로 다른 것을 알려 준다
/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 쪽은 하나만 알려 줍니다.
validations 는 목록이고 각 항목이 자기 expression 과 자기 messageExpression 을 가집니다. 변수는 여러 validation 이 나눠 씁니다. Deny 는 처음 실패한 항목에서 요청을 끝내지만 Warn 은 실패한 것을 모두 경고로 돌려줍니다 — 그래서 전개 초기의 Warn 단계가 '무엇을 다 고쳐야 하는지' 를 한 번에 보여 줍니다. 두 메시지가 구분되지 않으면 어느 규칙이 걸렸는지 아무도 모릅니다.