정책을 코드로 · 정책이 고장 나면 클러스터가 멈춘다 · 실습
웹훅 하나를 Fail 로 바꿨더니 클러스터가 멈췄다
목표
어드미션 웹훅의 적용 범위를 세 층에서 직접 좁혀 보고, 정책 서버가 죽은 상태를 진짜로 만들어 failurePolicy 와 timeoutSeconds 가 무엇을 맞바꾸는지 시간을 재서 확인하고, 클러스터의 모든 웹훅에 대해 '이것이 죽으면 무엇이 멈추는가' 를 답하는 보고서와 검사기를 만듭니다.
왜 중요한가
정책 도입 사고의 기록을 모아 보면 규칙이 틀려서 난 사고는 드뭅니다. 대부분은 둘 중 하나입니다 — 너무 넓게 걸었거나, 고장 났을 때의 동작을 정하지 않았거나. 넓게 거는 비용은 정책 한 번의 실행 시간이 아니라 들어오는 모든 쓰기 요청에 붙는 지연세이고, 클러스터에는 사람이 보내는 요청보다 컨트롤러가 보내는 요청이 훨씬 많습니다. 더 무서운 것은 두 번째입니다. 정책이 kube-system 까지 보도록 걸어 두고 정책 서버가 죽으면, failurePolicy: Fail 아래에서 클러스터는 자기 자신을 복구할 수 없게 됩니다 — 서버를 되살리려는 배포도 그 정책을 지나야 하기 때문입니다. 범위와 고장 모드는 따로 고르는 값이 아니라 한 쌍으로 고르는 값이고, 이 실습은 그 한 쌍을 손으로 돌려 봅니다. 마지막에 만드는 폭발 반경 보고서는 정책을 켜기 전이 아니라 이미 켜 둔 클러스터에서 지금 당장 만들어 봐야 하는 물건입니다.
단계
1. /root/polscope 에서 작업합니다(export KUBECONFIG=/root/.kube/config, kubectl config use-context kwok-lab). 먼저 네임스페이스 scope-app·scope-ops·scope-builtin 을 만들고, 세 곳 모두에 kubectl create sa default -n <네임스페이스> 로 default 서비스 계정을 직접 만드세요(이 클러스터에는 컨트롤러 매니저가 없어 저절로 생기지 않습니다). /root/polscope/webhook-wide.yaml 에 ValidatingWebhookConfiguration scope-wide-audit 을 쓰세요 — 웹훅 이름은 wide.polscope.local, admissionReviewVersions 는 ["v1"], sideEffects 는 None, failurePolicy 는 Ignore, timeoutSeconds 는 2, clientConfig.url 은 https://192.0.2.77:8443/validate 입니다. rules 는 가장 넓게 — apiGroups: ["*"], apiVersions: ["*"], operations: ["CREATE", "UPDATE"], resources: ["*"], scope: "*" 로 두고 셀렉터는 하나도 두지 않습니다. 시험용 매니페스트 셋도 만드세요 — /root/polscope/pod.yaml(파드 probe-app, 컨테이너 web, 이미지 registry.internal/app:1.0), /root/polscope/pod-guard.yaml(파드 probe-guard, 라벨 polscope.io/guard: "on", 같은 컨테이너), /root/polscope/cm.yaml(ConfigMap probe-cm, 데이터 a: b). 적용한 뒤 네 가지 요청의 소요 시간을 재세요 — kubectl create -n scope-app -f pod.yaml --dry-run=server, kubectl create -n scope-app -f cm.yaml --dry-run=server, kubectl create ns probe-ns --dry-run=server, kubectl get pods -n scope-app. 1초 이상 걸렸으면 SLOW, 아니면 FAST 로 /root/polscope/01-reach.txt 에 pod-create·configmap-create·namespace-create·pod-list 네 줄을 <이름> <낱말> 꼴로 적으세요.
2. /root/polscope/webhook-strict.yaml 에 두 번째 설정 scope-strict 를 쓰세요 — 웹훅 이름은 strict.polscope.local, rules 는 scope-wide-audit 과 똑같이 가장 넓게, 다른 것은 셋뿐입니다: failurePolicy 는 Fail, timeoutSeconds 는 3, clientConfig.url 은 연결이 거부되는 주소 https://127.0.0.1:19443/validate 입니다. scope-wide-audit 은 지우지 말고 그대로 둡니다 — 같은 범위에 고장 모드만 다른 둘이 나란히 서 있는 것이 이 실습의 대조군입니다. 적용한 뒤 네 가지를 시도해 막히는지 보고, 막히면 BLOCK 통과하면 PASS 로 /root/polscope/02-blocked.txt 에 <이름> <낱말> 네 줄을 적으세요 — pod-create-scope-app(kubectl create -n scope-app -f pod.yaml --dry-run=server), configmap-kube-system(kubectl create -n kube-system -f cm.yaml --dry-run=server), namespace-create(kubectl create ns probe-ns --dry-run=server), webhookconfig-edit(kubectl apply -f webhook-strict.yaml 을 한 번 더).
3. 먼저 면제 라벨로 빠져나가 보세요 — kubectl label ns kube-system polscope.io/admission=exempt --overwrite 를 돌려 그 출력을 표준 오류까지 함께 /root/polscope/03-lockout.txt 에 저장하세요(막힙니다). 그다음 /root/polscope/webhook-strict.yaml 의 scope-strict 에 namespaceSelector.matchExpressions 두 개를 더해 다시 적용하세요 — (1) 키 kubernetes.io/metadata.name, 연산자 NotIn, 값 ["kube-system", "kube-node-lease", "kube-public"], (2) 키 polscope.io/admission, 연산자 NotIn, 값 ["exempt"]. 이제 라벨이 붙습니다 — kube-system 과 scope-ops 에 polscope.io/admission=exempt 를 거세요. 마지막으로 /root/polscope/pod-guard.yaml 을 세 곳에 --dry-run=server 로 보내 /root/polscope/03-escape.txt 에 kube-system·scope-ops·scope-app 세 줄을 <네임스페이스> <BLOCK|PASS> 꼴로 적으세요.
4. /root/polscope/webhook-strict.yaml 의 scope-strict 를 두 층에서 좁히세요 — rules 는 코어 그룹("")의 v1 pods 에 대한 CREATE·UPDATE 만, scope 는 Namespaced 로 바꾸고, objectSelector.matchLabels 로 polscope.io/guard: "on" 라벨이 붙은 객체만 보게 합니다. 3단계의 namespaceSelector 는 그대로 둡니다. 적용한 뒤 네 가지 요청을 다시 보내 /root/polscope/04-narrow.txt 에 <이름> <좁히기전> <좁힌뒤> 네 줄을 적으세요 (좁히기 전 값은 2단계에서 본 것입니다) — pod-guard-scope-app(pod-guard.yaml 을 scope-app 에), pod-plain-scope-app(pod.yaml 을 scope-app 에), configmap-scope-app(cm.yaml 을 scope-app 에), namespace-create(kubectl create ns probe-ns --dry-run=server). 판정은 BLOCK 또는 PASS 입니다.
5. /root/polscope/webhook-strict.yaml 의 scope-strict 에 matchConditions 두 개를 더해 다시 적용하세요 — skip-kube-system-sa 는 요청자가 system:serviceaccount:kube-system: 으로 시작하는 서비스 계정이면 웹훅을 부르지 않게 하고, skip-break-glass 는 요청자의 그룹에 polscope:break-glass 가 있으면 부르지 않게 합니다. 적용한 뒤 같은 /root/polscope/pod-guard.yaml 을 scope-app 에 세 가지 주체로 보내 /root/polscope/05-conditions.txt 에 kube-system-sa·break-glass·normal 세 줄을 <이름> <BLOCK|PASS> 꼴로 적으세요 — kubectl --as=system:serviceaccount:kube-system:replicaset-controller create -n scope-app -f pod-guard.yaml --dry-run=server, kubectl --as=oncall --as-group=polscope:break-glass --as-group=system:masters create -n scope-app -f pod-guard.yaml --dry-run=server, 그리고 지금 계정 그대로 한 번.
6. /root/polscope/webhook-slow.yaml 에 세 번째 설정 scope-slow 를 쓰세요 — 웹훅 이름 slow.polscope.local, clientConfig.url 은 응답이 아예 오지 않는 주소 https://192.0.2.77:8443/validate, sideEffects 는 None, namespaceSelector.matchExpressions 는 키 kubernetes.io/metadata.name 을 In ["scope-ops"] 로, objectSelector.matchLabels 는 polscope.io/slow: "on", rules 는 코어 그룹의 v1 pods 의 CREATE·UPDATE(scope: "Namespaced"), 최종 상태는 failurePolicy: Fail 과 timeoutSeconds: 3 입니다. /root/polscope/pod-slow.yaml 에 파드 probe-slow(라벨 polscope.io/slow: "on", 컨테이너 web, 이미지 registry.internal/app:1.0)를 만드세요. 그리고 /root/polscope/timeout-probe.sh <Fail|Ignore> <초> 를 만드세요 — scope-slow 를 그 두 값으로 잠시 바꾸고, scope-ops 에 pod-slow.yaml 을 --dry-run=server 로 한 번 보내 걸린 시간을 재고, 원래 값으로 반드시 되돌린 뒤, BLOCK <초> 또는 PASS <초> 한 줄만 출력합니다(초는 반올림한 정수). 이 스크립트로 세 번 재어 /root/polscope/06-timeout.txt 에 <failurePolicy> <타임아웃> <BLOCK|PASS> <걸린초> 세 줄을 적으세요 — Fail 6, Ignore 6, Fail 4 로 각각 한 번씩입니다.
7. 웹훅 서버가 없는 지금 이대로, 같은 자리에 내장 정책을 세워 비교합니다. 네임스페이스 scope-builtin 에 라벨 pod-security.kubernetes.io/enforce=baseline 을 거세요. /root/polscope/vap-owner.yaml 에 ValidatingAdmissionPolicy polscope-require-owner(코어 그룹 v1 pods 의 CREATE·UPDATE 를 잡고, 파드 메타데이터에 owner 라벨이 있을 것을 요구)와 같은 이름의 ValidatingAdmissionPolicyBinding(validationActions 는 ["Deny"], matchResources.namespaceSelector 는 kubernetes.io/metadata.name 이 In ["scope-builtin"])을 함께 쓰고 적용하세요. 시험용 파드 셋을 더 만드세요 — /root/polscope/pod-owned.yaml(파드 probe-owned, 라벨 owner: platform), /root/polscope/pod-host.yaml(파드 probe-host, 같은 라벨에 spec.hostNetwork: true), /root/polscope/pod-both.yaml(파드 probe-both, 라벨 owner: platform 과 polscope.io/guard: "on"). 컨테이너는 셋 다 web/registry.internal/app:1.0 입니다. 네 매니페스트를 scope-builtin 에 --dry-run=server 로 보내 무엇이 판정했는지를 /root/polscope/07-builtin.txt 에 <파일이름> <낱말> 네 줄로 적으세요 — 낱말은 VAP(ValidatingAdmissionPolicy 가 거부), PSA(PodSecurity 가 거부), WEBHOOK(웹훅 호출 실패), PASS(통과) 중 하나이고, 대상은 pod.yaml·pod-host.yaml·pod-both.yaml·pod-owned.yaml 입니다.
8. /root/polscope/blast-radius.sh 를 만드세요 — 클러스터의 모든 ValidatingWebhookConfiguration 을 훑어 웹훅 하나마다 객체 하나를 담은 JSON 배열을 표준 출력으로 냅니다. 키는 정확히 여덟 개입니다: config(설정 이름) · webhook(웹훅 이름) · failurePolicy(없으면 "Fail") · timeoutSeconds(없으면 10) · resources(그 웹훅의 모든 rules[].resources 를 중복 없이 정렬한 배열) · wildcard(resources 에 "*" 가 있으면 참) · excludesKubeSystem(namespaceSelector.matchExpressions 에 키가 kubernetes.io/metadata.name, 연산자가 NotIn, 값에 kube-system 이 든 항목이 있으면 참) · risk. risk 는 wildcard 가 참이고 failurePolicy 가 Fail 이고 excludesKubeSystem 이 거짓이면 "high", 그 밖에 failurePolicy 가 Fail 이면 "medium", 나머지는 "low" 입니다. 배열은 config · webhook 순으로 정렬합니다. 그 출력을 /root/polscope/blast-radius.json 에 저장하세요. 그리고 /root/polscope/risky.sh 를 만드세요 — risk 가 high 인 것만 <config>/<webhook> 꼴로 한 줄씩 내고, 하나라도 있으면 종료 코드 1, 하나도 없으면 아무것도 내지 않고 0 으로 끝납니다. 두 스크립트 모두 저장된 파일이 아니라 지금 클러스터에 서 있는 오브젝트를 읽어야 합니다.
참고
export KUBECONFIG=/root/.kube/config와kubectl config use-context kwok-lab로 시작합니다. 파드 안 kwok 이 띄우는 진짜 kube-apiserver v1.30.4 이고, 모든 산출물은/root/polscope아래에 둡니다.- kwok 은 파드를 실제로 실행하지 않습니다. 이 실습이 보는 것은 어드미션 단계뿐이라
kubectl create -f <파일> --dry-run=server로 충분합니다 — 어드미션은 그대로 태우고 오브젝트는 남지 않습니다. - 이 클러스터에는 컨트롤러 매니저가 없어 새 네임스페이스에
default서비스 계정이 생기지 않습니다. 1단계에서kubectl create sa default -n <네임스페이스>로 직접 만들어 두지 않으면, 파드 요청이 웹훅까지 가지도 못하고error looking up service account로 먼저 거절됩니다. - 웹훅 설정을 고친 직후에는 한두 번의 왕복만큼 늦게 반영됩니다. 결과가 예전 같으면 몇 초 뒤 다시 보내 보세요.
- 1단계의 감사 웹훅은 실습이 끝날 때까지 살아 있습니다. 그래서 이 클러스터의 모든 쓰기가 2초쯤 늦습니다 — 그 느림 자체가 '넓게 건 정책의 지연세' 입니다. 여러 웹훅은 병렬로 불리므로 걸린 시간은 합이 아니라 큰 쪽입니다.
- 흔한 실수: 넓은
Fail웹훅을 세운 뒤 라벨로 빠져나가려는 것. 라벨을 붙이는 것도 쓰기라서 그 요청이 먼저 막힙니다.admissionregistration.k8s.io의 오브젝트만은 어드미션 웹훅을 타지 않으니, 탈출구는 언제나 웹훅 설정 자체입니다. - 흔한 실수:
--as로 주체를 흉내 내면서 인가를 잊는 것. 흉내 낸 주체의 권한만 갖게 되므로--as-group=system:masters를 함께 주어야 인가를 지나 어드미션까지 갑니다. - [Dynamic Admission Control](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/) · [Admission Controllers Reference](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/) · [Validating Admission Policy](https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/) · [Pod Security Admission](https://kubernetes.io/docs/concepts/security/pod-security-admission/) · [Kubernetes API Concepts](https://kubernetes.io/docs/reference/using-api/api-concepts/)
단계 8개
- 웹훅 하나를 넓게 걸었더니 모든 쓰기가 2초씩 늦어졌다
- 고장 모드만 Fail 로 바꿨더니 그 범위의 쓰기가 전부 멈췄다
- 라벨로 빼려 했더니 라벨 붙이는 일까지 막혀 있었다
- 범위를 좁히자 걸리던 넷 중 셋이 웹훅을 지나가지 않게 됐다
- 컨트롤러와 당번만 지나가는 문을 낸다
- 타임아웃을 6초로 두었더니 장애가 요청마다 6초씩 길어졌다
- 웹훅이 전부 죽은 클러스터에서 내장 정책만 판정을 내놓았다
- 이것이 죽으면 무엇이 멈추는가를 한 장으로 답한다