只把一个 Webhook 改成 Fail,集群就停了
한국어 원문으로 표시합니다.
목표
어드미션 웹훅의 적용 범위를 세 층에서 직접 좁혀 보고, 정책 서버가 죽은 상태를 진짜로 만들어 failurePolicy 와 timeoutSeconds 가 무엇을 맞바꾸는지 시간을 재서 확인하고, 클러스터의 모든 웹훅에 대해 '이것이 죽으면 무엇이 멈추는가' 를 답하는 보고서와 검사기를 만듭니다.
왜 중요한가
정책 도입 사고의 기록을 모아 보면 규칙이 틀려서 난 사고는 드뭅니다. 대부분은 둘 중 하나입니다 — 너무 넓게 걸었거나, 고장 났을 때의 동작을 정하지 않았거나. 넓게 거는 비용은 정책 한 번의 실행 시간이 아니라 들어오는 모든 쓰기 요청에 붙는 지연세이고, 클러스터에는 사람이 보내는 요청보다 컨트롤러가 보내는 요청이 훨씬 많습니다. 더 무서운 것은 두 번째입니다. 정책이 kube-system 까지 보도록 걸어 두고 정책 서버가 죽으면, failurePolicy: Fail 아래에서 클러스터는 자기 자신을 복구할 수 없게 됩니다 — 서버를 되살리려는 배포도 그 정책을 지나야 하기 때문입니다. 범위와 고장 모드는 따로 고르는 값이 아니라 한 쌍으로 고르는 값이고, 이 실습은 그 한 쌍을 손으로 돌려 봅니다. 마지막에 만드는 폭발 반경 보고서는 정책을 켜기 전이 아니라 이미 켜 둔 클러스터에서 지금 당장 만들어 봐야 하는 물건입니다.
단계
/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에 ValidatingWebhookConfigurationscope-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(ConfigMapprobe-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네 줄을<이름> <낱말>꼴로 적으세요./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을 한 번 더).- 먼저 면제 라벨로 빠져나가 보세요 —
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>꼴로 적으세요. /root/polscope/webhook-strict.yaml의scope-strict를 두 층에서 좁히세요 —rules는 코어 그룹("")의v1pods에 대한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입니다./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, 그리고 지금 계정 그대로 한 번./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는 코어 그룹의v1pods의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로 각각 한 번씩입니다.- 웹훅 서버가 없는 지금 이대로, 같은 자리에 내장 정책을 세워 비교합니다. 네임스페이스
scope-builtin에 라벨pod-security.kubernetes.io/enforce=baseline을 거세요./root/polscope/vap-owner.yaml에 ValidatingAdmissionPolicypolscope-require-owner(코어 그룹v1pods의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입니다. /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 · Admission Controllers Reference · Validating Admission Policy · Pod Security Admission · Kubernetes API Concepts
웹훅 하나를 넓게 걸었더니 모든 쓰기가 2초씩 늦어졌다
/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 네 줄을 <이름> <낱말> 꼴로 적으세요.
웹훅의 주소가 192.0.2.0/24(TEST-NET-1) 이면 연결이 거부되지도 않고 응답도 오지 않습니다. API 서버는 timeoutSeconds 만큼 기다렸다가 failurePolicy 로 넘어가므로, Ignore 로 두면 요청은 전부 통과하지만 걸린 시간이 그 요청이 범위 안에 있었다는 증거가 됩니다. 범위 밖 요청은 기다림 없이 끝납니다. 어드미션은 쓰기에만 걸립니다 — 읽기 요청은 웹훅을 아예 지나지 않습니다. 시간은 s=$(date +%s%N) 과 e=$(date +%s%N) 사이를 빼면 나노초로 잽니다. kubectl create -f <파일> --dry-run=server 는 어드미션을 그대로 태우되 오브젝트를 남기지 않습니다. 웹훅 호출도 거부 메시지도 진짜 요청과 똑같이 나옵니다.
고장 모드만 Fail 로 바꿨더니 그 범위의 쓰기가 전부 멈췄다
/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 을 한 번 더).
연결이 거부되는 주소는 기다림 없이 즉시 실패로 돌아옵니다. Fail 은 그 실패를 거부로 옮기고 Ignore 는 통과로 옮깁니다 — 같은 범위, 같은 죽은 서버인데 답이 반대입니다. 이것이 failurePolicy 가 맞바꾸는 것입니다. 네 번째 줄이 이 단계의 핵심입니다. 자원 하나가 어드미션 웹훅을 타지 않는다면 무엇이 막혀도 그것만은 고칠 수 있습니다 — 웹훅 설정을 고치는 요청이 그 웹훅에게 물어보러 간다면 아무도 클러스터를 되살릴 수 없을 테니까요. 결과를 예측하지 말고 네 가지를 직접 돌려 보세요.
라벨로 빼려 했더니 라벨 붙이는 일까지 막혀 있었다
먼저 면제 라벨로 빠져나가 보세요 — 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> 꼴로 적으세요.
두 조건은 같은 일을 하는 두 가지 방법입니다. 첫째는 API 서버가 1.21부터 모든 네임스페이스에 자동으로 붙여 주는 kubernetes.io/metadata.name 라벨을 쓰는 것이라 아무 준비가 필요 없고, 둘째는 면제 목록을 직접 붙인 라벨로 관리하는 것이라 나중에 네임스페이스를 늘릴 때 웹훅 설정을 고치지 않아도 됩니다. 그런데 이미 막힌 클러스터에서는 둘째를 쓸 수 없습니다 — 라벨을 붙이는 것이 네임스페이스 UPDATE 이고, 그 요청이 바로 막혀 있으니까요. 그래서 순서가 정해집니다: 먼저 첫째 방법으로 탈출구를 내고, 그다음에 둘째 방법을 얹습니다. namespaceSelector 의 여러 matchExpressions 는 모두 만족해야 범위 안입니다.
범위를 좁히자 걸리던 넷 중 셋이 웹훅을 지나가지 않게 됐다
/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 입니다.
범위를 좁히는 일은 보안을 줄이는 일이 아니라 묻는 횟수를 줄이는 일입니다. rules 에서 걸러진 요청은 API 서버가 이 웹훅을 떠올리지도 않고, namespaceSelector 와 objectSelector 가 그다음입니다. 와일드카드로 받아 놓고 웹훅 서버 안에서 걸러 내면 결과는 같지만 비용은 전부 치릅니다. scope 는 Namespaced·Cluster·* 중 하나이고, 네임스페이스 범위 자원만 보겠다고 적으면 클러스터 범위 자원의 요청은 아예 오지 않습니다. objectSelector 는 요청 안의 객체 라벨을 보고, namespaceSelector 는 그 객체가 든 네임스페이스의 라벨을 봅니다 — 서로 다른 층입니다.
컨트롤러와 당번만 지나가는 문을 낸다
/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, 그리고 지금 계정 그대로 한 번.
범위(rules·셀렉터)와 조건(matchConditions)은 다른 층입니다. 셀렉터는 무엇에 대한 요청인가만 보지만, 조건은 CEL 로 요청 전체를 봅니다 — 누가 보냈는지(request.userInfo), 어떤 동사인지, 어떤 네임스페이스인지까지. 대신 조건은 셀렉터보다 뒤에서 평가되므로, 셀렉터로 걸러 낼 수 있는 것을 조건으로 미루면 비용만 늡니다. 조건이 모두 참일 때 웹훅을 부르므로 '건너뛰고 싶다' 는 부정으로 적습니다. --as 로 다른 주체인 척할 수 있는데, 흉내 낸 주체의 권한만 갖게 되므로 인가까지 통과하려면 --as-group=system:masters 를 함께 줍니다 (인가는 어드미션보다 앞에 섭니다). 컨트롤러가 보내는 요청까지 막으면 클러스터가 스스로를 복구하지 못하게 되고, 당번을 위한 문이 없으면 장애 때 웹훅 설정을 지우는 것 말고는 길이 없습니다.
타임아웃을 6초로 두었더니 장애가 요청마다 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 로 각각 한 번씩입니다.
timeoutSeconds 는 '웹훅 서버가 정상일 때 걸리는 시간보다 조금 길게' 정하는 값입니다. 넉넉하게 잡으면 장애 때 API 서버가 요청마다 그만큼 붙잡히고, 짧게 잡으면 느린 응답이 실패로 처리되어 failurePolicy 로 넘어갑니다. 그래서 두 손잡이는 따로 고르는 값이 아닙니다 — Fail 은 타임아웃이 길수록 장애가 길어지고, Ignore 는 타임아웃이 길수록 통과는 하되 느려집니다. 어느 쪽도 공짜가 아닙니다. 스크립트에는 trap ... EXIT 로 되돌리기를 걸어 두세요. 중간에 죽어도 웹훅이 이상한 값으로 남으면 안 됩니다. 설정을 바꾼 뒤 한두 번의 왕복만큼 늦게 반영되니 재기 전에 잠시 기다립니다. 이 클러스터에는 1단계의 2초짜리 감사 웹훅이 계속 살아 있고 웹훅은 병렬로 호출되므로, 걸린 시간은 둘 중 큰 쪽으로 나옵니다.
웹훅이 전부 죽은 클러스터에서 내장 정책만 판정을 내놓았다
웹훅 서버가 없는 지금 이대로, 같은 자리에 내장 정책을 세워 비교합니다. 네임스페이스 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 입니다.
VAP 는 API 서버 프로세스 안에서 CEL 을 평가하고 PSA 는 내장 어드미션 플러그인입니다. 둘 다 네트워크 왕복도 인증서도 엔진 파드도 없어서, 웹훅이 전부 죽은 지금도 평소와 똑같이 판정합니다. 고장은 '식 평가 오류' 나 '잘못 붙인 라벨' 같은 국소적 형태로만 옵니다. 그래서 실무의 배치는 엔진 없이 표현되는 검증은 내장 기구로 내려 보내 고장 표면을 줄이고, 꼭 필요한 것만 웹훅에 남긴다 가 됩니다. 판정 순서도 눈여겨보세요 — 내장 어드미션이 앞에 서므로, 내장 정책에서 이미 거부된 요청은 웹훅까지 가지도 않습니다. 그래서 웹훅의 고장을 보려면 내장 정책을 모두 통과하는 파드를 보내야 합니다. baseline 수준이 무엇을 막는지는 거부 메시지가 직접 알려 줍니다.
이것이 죽으면 무엇이 멈추는가를 한 장으로 답한다
/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 으로 끝납니다. 두 스크립트 모두 저장된 파일이 아니라 지금 클러스터에 서 있는 오브젝트를 읽어야 합니다.
이 보고서가 답하는 질문은 하나입니다 — 이 웹훅 서버가 죽으면 클러스터에서 무엇이 멈추는가. failurePolicy: Fail 이면 그 범위의 쓰기가 멈추고, 그 범위가 resources 와 셀렉터입니다. 그래서 위험한 조합은 언제나 같습니다: 넓은 범위 + Fail + 시스템 네임스페이스 미제외. 셋이 동시에 맞으면 엔진을 되살리려는 요청까지 막혀 클러스터가 스스로 복구하지 못합니다. kubectl get validatingwebhookconfigurations -o json 의 .items[].webhooks[] 를 jq 로 펼치면 한 번에 만들 수 있습니다. jq 의 // 는 값이 false 일 때도 오른쪽으로 새므로, 기본값은 has("키") 로 있는지 먼저 물어 채우세요. 채점기는 위험한 설정을 하나 잠시 세워 두고 risky.sh 를 다시 부릅니다 — 이름을 외워 둔 답은 그때 떨어집니다.