The tag never changed, but what came up was not yesterday's
한국어 원문으로 표시합니다.
목표
이미지 참조를 모양별로 가르는 눈을 만들고, 다이제스트 고정을 요구하는 규칙을 어드미션에 경고로 붙였다가 좁은 자리에만 차단으로 올리고, 태그를 다이제스트로 바꿔 적는 잠금 파일과 해석기를 쓰고, 같은 규칙을 클러스터 밖 빌드 산출물에도 걸어 봅니다.
왜 중요한가
태그는 옮길 수 있는 이름표이고 다이제스트는 내용의 해시라 옮길 수 없습니다. 이미지 정책의 거의 전부가 이 한 문장의 따름정리입니다. 같은 태그에 새 이미지를 밀어 넣는 것을 레지스트리가 막지 않으므로, 태그로만 배포한 팀은 '어제와 오늘이 다른' 사고를 재현하지 못하고 '되돌릴 대상이 사라진' 롤백을 만납니다. 그래서 규칙의 첫 줄은 latest 금지가 아니라 다이제스트 고정입니다. 다만 고정이 보장하는 것은 내용 동일성 하나뿐입니다 — 그 내용이 안전한지, 누가 만들었는지, 어떻게 만들었는지는 해시 안에 없고, 취약점 검사와 서명과 출처 증명이 각각 다른 축에서 그 자리를 채웁니다. 규칙을 쓰는 쪽에도 함정이 둘 있습니다. 컨테이너 목록은 spec.containers 하나가 아니라 initContainers 와 ephemeralContainers 까지 셋이고, 이미지 참조는 파드에만 있는 것이 아니라 워크로드의 파드 템플릿에도 한 단계 깊은 경로로 들어 있습니다. 두 가지를 빠뜨린 정책은 켜 놓고도 뚫립니다. 그리고 고정은 갱신 절차와 한 쌍입니다. 잠금 파일이 그 절차가 사는 자리입니다.
단계
-
/root/imgpolicy에서 작업합니다./root/imgpolicy/manifests/에 매니페스트 넷을 만드세요 —web.yaml은 파드probe-web(initContainersmigrate는registry.internal/migrate:2.1, containersweb은registry.internal/api:1.4.0),cache.yaml은 파드probe-cache(containerscache는registry.internal/sidecar@sha256:0e53d844ccfccd2bbb572085b68f6b170cdcfa4bc86cb7cf407c52fc64f11266),batch.yaml은 파드probe-batch(containersbatch는 태그 없는registry.internal/runtime,tail은busybox:latest),api.yaml은 Deploymentprobe-api(템플릿 컨테이너api는registry.internal/api@sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc). 그다음/root/imgpolicy/classify.sh <디렉터리>를 만드세요. 그 디렉터리의.yaml·.yml에서 이미지 참조를 모두 뽑아 중복 없이 정렬해 한 줄에<참조> <모양>으로 냅니다. 모양은 정확히 네 낱말digest·latest·tag·notag중 하나입니다. 마지막으로./classify.sh manifests > /root/imgpolicy/01-shapes.txt로 결과를 저장하세요. -
export KUBECONFIG=/root/.kube/config와kubectl config use-context kwok-lab로 시작합니다./root/imgpolicy/policy.yaml에admissionregistration.k8s.io/v1의 ValidatingAdmissionPolicyrequire-image-digest를 쓰세요 —matchConstraints.resourceRules는 코어 그룹("")의v1pods에 대한CREATE·UPDATE를 잡고, 변수floating은object.spec.containers의 이미지 중@sha256:이 없는 것들의 목록이며, validation 은size(variables.floating) == 0이고 messageExpression 이 그 목록을 담습니다. 네임스페이스img-warn을 만들고 라벨image-policy=warn을 붙이세요./root/imgpolicy/binding-warn.yaml에 ValidatingAdmissionPolicyBindingimage-digest-warn을 쓰세요 —policyName은require-image-digest,validationActions는["Warn", "Audit"],matchResources.namespaceSelector.matchLabels는image-policy: warn입니다. 시험용 파드/root/imgpolicy/pod-tag.yaml을 만드세요 — 파드probe-tag, initContainersmigrate는registry.internal/migrate:2.1, containersweb은registry.internal/api:1.4.0입니다. 정책과 바인딩을 적용한 뒤img-warn에pod-tag.yaml을--dry-run=server로 보내 출력을/root/imgpolicy/02-warn.txt에 표준 오류까지 함께 저장하세요. 경고가 뜨는데도 파드는 만들어져야 합니다. -
먼저
/root/imgpolicy/pod-init.yaml을 만드세요 — 파드probe-init, initContainersmigrate는registry.internal/migrate:2.1(고정 안 됨), containersapp은registry.internal/api@sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc입니다. 이것을img-warn에--dry-run=server로 보내 보면 경고가 뜨지 않습니다. 그다음/root/imgpolicy/policy.yaml을 고치세요 — 변수allImages가spec.containers와spec.initContainers와spec.ephemeralContainers를 모두 이어 붙인 이미지 목록이 되게 하고(없을 수 있는 필드는has(...)로 먼저 확인),floating은 그중@sha256:이 없는 것만 남깁니다.matchConstraints의resources에pods/ephemeralcontainers도 더하세요. 그리고/root/imgpolicy/pod-pinned.yaml에 파드probe-pinned(containersapp은registry.internal/api@sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc)를 만들어img-warn에 실제로 만드세요(kubectl apply -n img-warn -f pod-pinned.yaml). 임시 컨테이너는 파드를 만들 때 붙일 수 없으므로 하위 자원으로 보냅니다:kubectl get -n img-warn pod probe-pinned -o json | jq '.spec.ephemeralContainers = [{"name":"dbg","image":"busybox:latest"}]' | kubectl replace --raw "/api/v1/namespaces/img-warn/pods/probe-pinned/ephemeralcontainers?dryRun=All" -f -
고친 정책을 적용한 뒤 (1) pod-init.yaml 을 img-warn 에 다시 보내고 (2) 위 임시 컨테이너 요청을 보내, 두 출력을 /root/imgpolicy/03-lists.txt 에 표준 오류까지 함께 저장하세요. 이번에는 둘 다 경고가 떠야 합니다.
4. 네임스페이스 둘을 더 만드세요 — img-prod 에는 라벨 image-policy=enforce 를 붙이고, img-legacy 에는 아무 라벨도 붙이지 않습니다(범위 밖). 경고용 바인딩은 지우지 말고 그대로 둡니다. /root/imgpolicy/binding-deny.yaml 에 두 번째 바인딩 image-digest-deny 를 쓰세요 — 같은 policyName 에 validationActions 는 ["Deny"], 셀렉터는 image-policy: enforce 입니다. 이어서 /root/imgpolicy/policy.yaml 의 matchConstraints 에 apps 그룹 v1 의 deployments 에 대한 CREATE·UPDATE 규칙을 더하고, 변수 podSpec 을 두어 has(object.spec.template) 이면 object.spec.template.spec 을, 아니면 object.spec 을 고르게 하세요. allImages 는 이제 variables.podSpec 의 세 목록에서 뽑습니다. /root/imgpolicy/dep-tag.yaml 에 Deployment probe-dep(템플릿 컨테이너 api 는 registry.internal/api:1.4.0)를 만들고, img-prod 와 img-legacy 에 차례로 --dry-run=server 로 보내 두 출력을 /root/imgpolicy/04-deny.txt 에 저장하세요. img-prod 에서는 거부되고 img-legacy 에서는 그대로 만들어져야 합니다.
5. /root/imgpolicy/images.lock 에 잠금 파일을 JSON 객체 하나로 쓰세요. 열쇠는 태그까지 붙은 참조이고 값은 다이제스트입니다 — registry.internal/api:1.4.0 은 sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc, registry.internal/migrate:2.1 은 sha256:3e7c7af932c7b5e39c7812b321bbbd291da943717e96860e5976ac609ef84030, registry.internal/runtime:3.20 은 sha256:5d964cb8b51a568d1048bc41dc78327a4bd4548cbbb8bee3c1072e9318c5ca2c, registry.internal/sidecar:1.0 은 sha256:0e53d844ccfccd2bbb572085b68f6b170cdcfa4bc86cb7cf407c52fc64f11266 입니다. 그다음 해석기 /root/imgpolicy/resolve.sh <매니페스트> [잠금파일] 을 만드세요. 잠금 파일 인자를 생략하면 /root/imgpolicy/images.lock 을 씁니다. 매니페스트를 읽어 태그 참조를 <이름>@<다이제스트> 로 바꾼 전체 매니페스트를 표준 출력에 내고, 이미 다이제스트로 고정된 줄은 그대로 흘려보냅니다. 잠금 파일에 없는 참조는 다이제스트를 지어내지 말고 표준 오류에 LOCK MISS <참조> 를 한 줄씩 내고 종료 코드 3 으로 끝냅니다(그때 표준 출력에는 아무것도 내지 않습니다). 만든 뒤 ./resolve.sh manifests/web.yaml > /root/imgpolicy/resolved/web.yaml 로 해석 결과를 저장하고, ./resolve.sh manifests/batch.yaml 의 표준 오류를 /root/imgpolicy/05-miss.txt 에 저장한 다음 그 파일 끝에 EXIT <종료코드> 한 줄을 덧붙이세요.
6. /root/imgpolicy/Dockerfile 을 쓰세요 — 첫 단계는 FROM registry.internal/builder:3.2 AS build, 두 번째 단계는 FROM registry.internal/runtime@sha256:5d964cb8b51a568d1048bc41dc78327a4bd4548cbbb8bee3c1072e9318c5ca2c 입니다. /root/imgpolicy/build.json 에 빌드 산출물 목록을 쓰세요. 열쇠는 셋입니다 — build(문자열 api-2026-09-17), base_images(Dockerfile 의 FROM 참조를 적힌 순서 그대로 담은 문자열 배열), artifacts(name 과 image 를 가진 객체 배열: api 는 registry.internal/api@sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc, worker 는 registry.internal/worker:2.0). /root/imgpolicy/policies/image-refs.yaml 에 apiVersion: json.kyverno.io/v1alpha1 · kind: ValidatingPolicy 인 정책을 쓰세요. 규칙 둘입니다 — base-pinned 는 base_images 중 @sha256: 이 없는 것의 수가 0 이어야 하고, artifact-pinned 는 artifacts[].image 중 @sha256: 이 없는 것의 수가 0 이어야 합니다. 그다음 게이트 /root/imgpolicy/jsongate.sh <페이로드> [정책] 을 만드세요(정책 인자를 생략하면 /root/imgpolicy/policies/image-refs.yaml). kyverno json scan 의 사람이 읽는 출력을 파싱해 위반 한 줄마다 FAIL <그 줄> 을 내고, 마지막에 RESULT failed=<개수> 를 낸 뒤 위반이 있으면 종료 코드 1, 없으면 0 으로 끝냅니다. 판정 줄(PASSED·FAILED·ERROR:)이 하나도 없으면 통과로 보지 말고 종료 코드 2 로 끝냅니다. 마지막으로 /root/imgpolicy/06-gate.txt 를 만드세요 — 첫 줄은 정책을 그대로 돌렸을 때의 종료 코드를 적은 SCAN EXIT <코드>, 그 아래에 ./jsongate.sh build.json 의 출력, 마지막 줄은 GATE EXIT <코드> 입니다.
7. 다이제스트가 보장하는 것은 내용 동일성 하나입니다. 보장하지 않는 세 가지를 다른 축의 기구가 채웁니다. /root/imgpolicy/limits.tsv 에 정확히 세 줄을 쓰세요. 각 줄은 탭 하나로 나뉜 두 칸이고, 첫 칸은 content-safety·author·build-path 셋이 한 번씩, 둘째 칸은 그 자리를 채우는 기구로 scan·signature·provenance 중 알맞은 것 하나입니다(첫 칸의 순서는 이 순서대로 적습니다). 그리고 /root/imgpolicy/revoked.txt 에 폐기된 다이제스트를 한 줄에 하나씩 적으세요 — 지금은 한 줄뿐입니다. registry.internal/sidecar:1.0 이 가리키는 다이제스트가 취약한 것으로 밝혀졌으니, 그 값을 /root/imgpolicy/images.lock 에서 찾아 그대로 적습니다(@ 앞의 이름은 적지 않고 sha256: 으로 시작하는 값만 적습니다).
8. /root/imgpolicy/audit.sh <매니페스트디렉터리> [폐기목록] 을 만드세요. 폐기 목록 인자를 생략하면 /root/imgpolicy/revoked.txt 를 씁니다. 그 디렉터리의 .yaml·.yml 에서 이미지 참조를 모두 뽑아 다이제스트로 고정되지 않은 참조마다 FLOATING <파일이름> <참조> 를, 고정은 됐지만 그 다이제스트가 폐기 목록에 있으면 REVOKED <파일이름> <참조> 를 한 줄씩 냅니다(그 줄들은 정렬해 냅니다). 마지막 줄은 정확히 RESULT floating=<수> revoked=<수> ready=<yes|no> 이고, 둘 다 0 일 때만 ready=yes 입니다. 종료 코드는 ready=yes 면 0, 아니면 1 입니다. 만든 뒤 ./audit.sh manifests 의 출력을 /root/imgpolicy/audit.txt 에 저장하고 그 파일 끝에 EXIT <종료코드> 한 줄을 덧붙이세요. 1단계에서 만든 매니페스트 묶음이 그대로 대상입니다.
참고
export KUBECONFIG=/root/.kube/config와kubectl config use-context kwok-lab로 시작합니다. 파드 안 kwok 이 띄우는 진짜 kube-apiserver v1.30.4 이고, 모든 산출물은/root/imgpolicy아래에 둡니다.- kwok 은 파드를 실제로 실행하지 않습니다. 이 실습이 보는 것은 어드미션 단계뿐이라
kubectl create -f <파일> --dry-run=server로 충분합니다 — 어드미션은 그대로 태우고 오브젝트는 남지 않습니다. - 이미지를 실제로 내려받을 수 있는 런타임이 없습니다. 그래서 태그와 다이제스트의 대응은 여러분이 만드는 잠금 파일(태그 -> 다이제스트 표)로 다룹니다. 실무의 이미지 잠금 파일과 같은 모양입니다.
- 이 이미지에는
cosign·skopeo·conftest·opa가 없고 인터넷도 없습니다. 그래서 서명 검증과 출처 증명은 실제로 돌려 볼 수 없고 7단계에서 개념과 축의 구분으로만 다룹니다. 클러스터 밖 검사는kyverno json scan으로 합니다. - 정책·바인딩을 바꾼 직후에는 한두 번의 왕복만큼 늦게 반영됩니다. 결과가 예전 같으면 몇 초 뒤 다시 보내 보세요.
- 흔한 실수:
spec.containers만 보는 것.initContainers와ephemeralContainers가 남아 있으면 정책은 켜 둔 채로 뚫립니다. - 흔한 실수: 태그 유무를 참조 전체에서
:로 판정하는 것.registry.internal:5000/team/app이 태그 있는 참조로 잘못 분류됩니다. - 흔한 실수:
kyverno json scan의 종료 코드를 믿는 것. 위반이 있어도 0 으로 끝납니다 — 6단계에서 직접 찍어 확인합니다. - 쿠버네티스 이미지 · Validating Admission Policy · Kubernetes 의 CEL · OCI descriptor · OCI distribution spec · SLSA provenance · kyverno-json
같은 묶음 안에 네 가지 모양의 이미지 참조가 있다
/root/imgpolicy 에서 작업합니다. /root/imgpolicy/manifests/ 에 매니페스트 넷을 만드세요 — web.yaml 은 파드 probe-web(initContainers migrate 는 registry.internal/migrate:2.1, containers web 은 registry.internal/api:1.4.0), cache.yaml 은 파드 probe-cache(containers cache 는 registry.internal/sidecar@sha256:0e53d844ccfccd2bbb572085b68f6b170cdcfa4bc86cb7cf407c52fc64f11266), batch.yaml 은 파드 probe-batch(containers batch 는 태그 없는 registry.internal/runtime, tail 은 busybox:latest), api.yaml 은 Deployment probe-api(템플릿 컨테이너 api 는 registry.internal/api@sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc). 그다음 /root/imgpolicy/classify.sh <디렉터리> 를 만드세요. 그 디렉터리의 .yaml·.yml 에서 이미지 참조를 모두 뽑아 중복 없이 정렬해 한 줄에 <참조> <모양> 으로 냅니다. 모양은 정확히 네 낱말 digest·latest·tag·notag 중 하나입니다. 마지막으로 ./classify.sh manifests > /root/imgpolicy/01-shapes.txt 로 결과를 저장하세요.
이미지 참조는 <레지스트리>/<이름>[:태그][@sha256:...] 입니다. 다이제스트가 붙어 있으면 태그가 함께 있어도 가져올 때 쓰이는 것은 다이제스트뿐이므로 digest 로 봅니다. 태그가 있는지 없는지를 참조 전체에서 : 를 찾아 판정하면 registry.internal:5000/team/app 처럼 호스트에 포트가 붙은 참조를 태그 있는 것으로 잘못 봅니다 — 마지막 / 뒤쪽만 보고 판정하세요. 셸에서 마지막 조각은 ${ref##*/} 로 얻습니다.
다이제스트를 요구하는 규칙을 경고로 붙인다
export KUBECONFIG=/root/.kube/config 와 kubectl config use-context kwok-lab 로 시작합니다. /root/imgpolicy/policy.yaml 에 admissionregistration.k8s.io/v1 의 ValidatingAdmissionPolicy require-image-digest 를 쓰세요 — matchConstraints.resourceRules 는 코어 그룹("")의 v1 pods 에 대한 CREATE·UPDATE 를 잡고, 변수 floating 은 object.spec.containers 의 이미지 중 @sha256: 이 없는 것들의 목록이며, validation 은 size(variables.floating) == 0 이고 messageExpression 이 그 목록을 담습니다. 네임스페이스 img-warn 을 만들고 라벨 image-policy=warn 을 붙이세요. /root/imgpolicy/binding-warn.yaml 에 ValidatingAdmissionPolicyBinding image-digest-warn 을 쓰세요 — policyName 은 require-image-digest, validationActions 는 ["Warn", "Audit"], matchResources.namespaceSelector.matchLabels 는 image-policy: warn 입니다. 시험용 파드 /root/imgpolicy/pod-tag.yaml 을 만드세요 — 파드 probe-tag, initContainers migrate 는 registry.internal/migrate:2.1, containers web 은 registry.internal/api:1.4.0 입니다. 정책과 바인딩을 적용한 뒤 img-warn 에 pod-tag.yaml 을 --dry-run=server 로 보내 출력을 /root/imgpolicy/02-warn.txt 에 표준 오류까지 함께 저장하세요. 경고가 뜨는데도 파드는 만들어져야 합니다.
정책은 '무엇을 판정하는가' 만 정하고 '어디에 어떤 강도로' 는 바인딩이 정합니다. Warn 은 응답 헤더로 경고만 돌려주고 요청은 통과시킵니다. 전개의 첫 단계를 Warn 으로 두는 이유는 무엇이 걸리는지를 먼저 세기 위해서입니다. CEL 의 문자열에는 contains·startsWith·endsWith 가 있고 목록에는 map·filter·join 이 있습니다. 경고는 표준 오류로 나오므로 2>&1 을 붙여야 파일에 함께 담깁니다. kubectl create -f <파일> --dry-run=server 는 어드미션을 그대로 통과시키되 오브젝트를 남기지 않습니다. 거부 메시지도 경고도 진짜 요청과 똑같이 나옵니다.
컨테이너 목록을 하나만 보면 그대로 뚫린다
먼저 /root/imgpolicy/pod-init.yaml 을 만드세요 — 파드 probe-init, initContainers migrate 는 registry.internal/migrate:2.1(고정 안 됨), containers app 은 registry.internal/api@sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc 입니다. 이것을 img-warn 에 --dry-run=server 로 보내 보면 경고가 뜨지 않습니다. 그다음 /root/imgpolicy/policy.yaml 을 고치세요 — 변수 allImages 가 spec.containers 와 spec.initContainers 와 spec.ephemeralContainers 를 모두 이어 붙인 이미지 목록이 되게 하고(없을 수 있는 필드는 has(...) 로 먼저 확인), floating 은 그중 @sha256: 이 없는 것만 남깁니다. matchConstraints 의 resources 에 pods/ephemeralcontainers 도 더하세요. 그리고 /root/imgpolicy/pod-pinned.yaml 에 파드 probe-pinned(containers app 은 registry.internal/api@sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc)를 만들어 img-warn 에 실제로 만드세요(kubectl apply -n img-warn -f pod-pinned.yaml). 임시 컨테이너는 파드를 만들 때 붙일 수 없으므로 하위 자원으로 보냅니다:
kubectl get -n img-warn pod probe-pinned -o json | jq '.spec.ephemeralContainers = [{"name":"dbg","image":"busybox:latest"}]' | kubectl replace --raw "/api/v1/namespaces/img-warn/pods/probe-pinned/ephemeralcontainers?dryRun=All" -f -
고친 정책을 적용한 뒤 (1) pod-init.yaml 을 img-warn 에 다시 보내고 (2) 위 임시 컨테이너 요청을 보내, 두 출력을 /root/imgpolicy/03-lists.txt 에 표준 오류까지 함께 저장하세요. 이번에는 둘 다 경고가 떠야 합니다.
파드에는 컨테이너 목록이 셋 있습니다 — containers·initContainers·ephemeralContainers. 실무에서 가장 흔한 정책 구멍이 첫 번째 목록만 보는 것입니다. CEL 에서 목록은 + 로 이어 붙일 수 있고, 없을 수도 있는 필드는 has(x) ? x : [] 꼴의 삼항식으로 감쌉니다. ephemeralContainers 는 파드 CREATE 로는 설정할 수 없고(Forbidden: cannot be set on create) pods/ephemeralcontainers 하위 자원으로만 들어옵니다 — 그래서 정책의 resources 에 그 하위 자원을 적어야 kubectl debug 같은 경로까지 같은 규칙이 봅니다. ?dryRun=All 을 붙였으므로 이 요청은 판정만 받고 파드에 아무것도 남기지 않습니다.
차단은 넓게 켜지 않고 좁은 자리에만 켠다
네임스페이스 둘을 더 만드세요 — img-prod 에는 라벨 image-policy=enforce 를 붙이고, img-legacy 에는 아무 라벨도 붙이지 않습니다(범위 밖). 경고용 바인딩은 지우지 말고 그대로 둡니다. /root/imgpolicy/binding-deny.yaml 에 두 번째 바인딩 image-digest-deny 를 쓰세요 — 같은 policyName 에 validationActions 는 ["Deny"], 셀렉터는 image-policy: enforce 입니다. 이어서 /root/imgpolicy/policy.yaml 의 matchConstraints 에 apps 그룹 v1 의 deployments 에 대한 CREATE·UPDATE 규칙을 더하고, 변수 podSpec 을 두어 has(object.spec.template) 이면 object.spec.template.spec 을, 아니면 object.spec 을 고르게 하세요. allImages 는 이제 variables.podSpec 의 세 목록에서 뽑습니다. /root/imgpolicy/dep-tag.yaml 에 Deployment probe-dep(템플릿 컨테이너 api 는 registry.internal/api:1.4.0)를 만들고, img-prod 와 img-legacy 에 차례로 --dry-run=server 로 보내 두 출력을 /root/imgpolicy/04-deny.txt 에 저장하세요. img-prod 에서는 거부되고 img-legacy 에서는 그대로 만들어져야 합니다.
정책 하나에 바인딩을 여럿 붙일 수 있고 각 바인딩이 자기 범위와 자기 강도를 가집니다. 그래서 '넓게 걸고 예외를 파는' 대신 '좁게 걸고 넓혀 가는' 전개가 됩니다 — 예외 목록은 시간이 지나면 아무도 지우지 않지만, 좁은 범위는 넓힐 때마다 결정이 필요합니다. 이미지 참조는 파드에만 있는 것이 아닙니다. Deployment·StatefulSet·Job 은 파드 템플릿 안에 같은 문자열을 갖고 있고 경로가 한 단계 깊습니다. 파드만 잡는 정책도 결국 컨트롤러가 만든 파드를 막지만, 그때는 이미 배포가 시작된 뒤라 사람이 원인을 찾기 어렵습니다. CEL 의 삼항식으로 두 모양을 한 변수에 담으면 validation 은 하나로 둘 수 있습니다.
태그를 다이제스트로 바꿔 적어 두고 그것만 보고 고친다
/root/imgpolicy/images.lock 에 잠금 파일을 JSON 객체 하나로 쓰세요. 열쇠는 태그까지 붙은 참조이고 값은 다이제스트입니다 — registry.internal/api:1.4.0 은 sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc, registry.internal/migrate:2.1 은 sha256:3e7c7af932c7b5e39c7812b321bbbd291da943717e96860e5976ac609ef84030, registry.internal/runtime:3.20 은 sha256:5d964cb8b51a568d1048bc41dc78327a4bd4548cbbb8bee3c1072e9318c5ca2c, registry.internal/sidecar:1.0 은 sha256:0e53d844ccfccd2bbb572085b68f6b170cdcfa4bc86cb7cf407c52fc64f11266 입니다. 그다음 해석기 /root/imgpolicy/resolve.sh <매니페스트> [잠금파일] 을 만드세요. 잠금 파일 인자를 생략하면 /root/imgpolicy/images.lock 을 씁니다. 매니페스트를 읽어 태그 참조를 <이름>@<다이제스트> 로 바꾼 전체 매니페스트를 표준 출력에 내고, 이미 다이제스트로 고정된 줄은 그대로 흘려보냅니다. 잠금 파일에 없는 참조는 다이제스트를 지어내지 말고 표준 오류에 LOCK MISS <참조> 를 한 줄씩 내고 종료 코드 3 으로 끝냅니다(그때 표준 출력에는 아무것도 내지 않습니다). 만든 뒤 ./resolve.sh manifests/web.yaml > /root/imgpolicy/resolved/web.yaml 로 해석 결과를 저장하고, ./resolve.sh manifests/batch.yaml 의 표준 오류를 /root/imgpolicy/05-miss.txt 에 저장한 다음 그 파일 끝에 EXIT <종료코드> 한 줄을 덧붙이세요.
잠금 파일은 '지금 이 태그가 무엇을 가리키는가' 를 사람이 읽을 수 있게 적어 둔 표입니다. 배포는 다이제스트로 하고 갱신은 이 표를 고치는 것으로 합니다 — 고정과 갱신은 한 쌍입니다. jq -r --arg k "$ref" '.[$k] // empty' <파일> 로 열쇠 하나를 안전하게 찾을 수 있습니다. 참조에서 태그를 떼려면 마지막 / 뒤의 : 부터를 지워야 합니다 — sed 's/:[^:/]*$//' 가 호스트 포트를 건드리지 않습니다. 해석기가 잠금 파일을 정말 읽는지는 다른 잠금 파일을 주면 바로 드러납니다. 이 실습의 채점기가 그렇게 합니다.
같은 규칙을 클러스터 밖 빌드 산출물에도 건다
/root/imgpolicy/Dockerfile 을 쓰세요 — 첫 단계는 FROM registry.internal/builder:3.2 AS build, 두 번째 단계는 FROM registry.internal/runtime@sha256:5d964cb8b51a568d1048bc41dc78327a4bd4548cbbb8bee3c1072e9318c5ca2c 입니다. /root/imgpolicy/build.json 에 빌드 산출물 목록을 쓰세요. 열쇠는 셋입니다 — build(문자열 api-2026-09-17), base_images(Dockerfile 의 FROM 참조를 적힌 순서 그대로 담은 문자열 배열), artifacts(name 과 image 를 가진 객체 배열: api 는 registry.internal/api@sha256:34b9b8b35a4fa7fd1d0e7a8b5736738c7b9d3ae21dfc1eec733bc2122cabeffc, worker 는 registry.internal/worker:2.0). /root/imgpolicy/policies/image-refs.yaml 에 apiVersion: json.kyverno.io/v1alpha1 · kind: ValidatingPolicy 인 정책을 쓰세요. 규칙 둘입니다 — base-pinned 는 base_images 중 @sha256: 이 없는 것의 수가 0 이어야 하고, artifact-pinned 는 artifacts[].image 중 @sha256: 이 없는 것의 수가 0 이어야 합니다. 그다음 게이트 /root/imgpolicy/jsongate.sh <페이로드> [정책] 을 만드세요(정책 인자를 생략하면 /root/imgpolicy/policies/image-refs.yaml). kyverno json scan 의 사람이 읽는 출력을 파싱해 위반 한 줄마다 FAIL <그 줄> 을 내고, 마지막에 RESULT failed=<개수> 를 낸 뒤 위반이 있으면 종료 코드 1, 없으면 0 으로 끝냅니다. 판정 줄(PASSED·FAILED·ERROR:)이 하나도 없으면 통과로 보지 말고 종료 코드 2 로 끝냅니다. 마지막으로 /root/imgpolicy/06-gate.txt 를 만드세요 — 첫 줄은 정책을 그대로 돌렸을 때의 종료 코드를 적은 SCAN EXIT <코드>, 그 아래에 ./jsongate.sh build.json 의 출력, 마지막 줄은 GATE EXIT <코드> 입니다.
스캔은 KYVERNO_EXPERIMENTAL=true kyverno json scan --payload <JSON> --policy <YAML> 입니다 — 환경변수를 빠뜨리면 실험 기능이라 명령 자체가 거부됩니다. 이 도구는 위반을 찾아도 종료 코드가 0 이고 --output json 에도 위반이 기록되지 않습니다. 그래서 if kyverno json scan ...; then 으로 쓴 게이트는 언제나 통과합니다 — 이번 단계에서 직접 찍어 확인하는 것이 그것입니다. spec.rules[].assert.all[].check 의 열쇠는 괄호로 감싼 JMESPath 식이고 값이 기대값입니다. JMESPath 의 필터에서 '들어 있지 않은 것' 은 contains 결과를 거짓과 견주어 고릅니다 — JSON 리터럴은 역따옴표로 감싸므로 문자열도 거짓값도 역따옴표가 필요합니다. 식만 따로 시험해 보려면 kyverno jp query -i <파일> '<식>' 이 가장 빠릅니다. 어드미션은 마지막 방어선이고 여기는 사람이 PR 에서 고칠 수 있는 자리입니다 — 같은 규칙을 양쪽에 걸어 두는 이유가 그것입니다.
고정은 신원을 정할 뿐 안전을 정하지 않는다
다이제스트가 보장하는 것은 내용 동일성 하나입니다. 보장하지 않는 세 가지를 다른 축의 기구가 채웁니다. /root/imgpolicy/limits.tsv 에 정확히 세 줄을 쓰세요. 각 줄은 탭 하나로 나뉜 두 칸이고, 첫 칸은 content-safety·author·build-path 셋이 한 번씩, 둘째 칸은 그 자리를 채우는 기구로 scan·signature·provenance 중 알맞은 것 하나입니다(첫 칸의 순서는 이 순서대로 적습니다). 그리고 /root/imgpolicy/revoked.txt 에 폐기된 다이제스트를 한 줄에 하나씩 적으세요 — 지금은 한 줄뿐입니다. registry.internal/sidecar:1.0 이 가리키는 다이제스트가 취약한 것으로 밝혀졌으니, 그 값을 /root/imgpolicy/images.lock 에서 찾아 그대로 적습니다(@ 앞의 이름은 적지 않고 sha256: 으로 시작하는 값만 적습니다).
같은 다이제스트를 두 번 받으면 같은 바이트입니다. 그것이 전부입니다 — 그 바이트가 안전한지, 누가 만들었는지, 어떤 소스에서 어떤 파이프라인이 만들었는지는 해시 안에 들어 있지 않습니다. 취약점 검사는 '내용이 안전한가' 를, 서명은 '누가 보증하는가' 를, 출처 증명(SLSA provenance)은 '어떻게 만들어졌는가' 를 답합니다. 세 축은 서로를 대신하지 못하고, 셋 다 무엇을 가리킬지 를 다이제스트에 기댑니다. 폐기 목록은 네 번째 축입니다 — 고정을 잘 해 두면 '그 하나를 지목해 막는' 일이 가능해집니다. 탭 문자는 printf 'a\tb\n' 으로 넣는 것이 안전합니다. 편집기가 탭을 빈칸으로 바꾸는 일이 흔합니다. 잠금 파일에서 값을 꺼내려면 jq -r '.["registry.internal/sidecar:1.0"]' /root/imgpolicy/images.lock 을 쓰세요.
지금 차단으로 올려도 되는지 저장소가 답하게 한다
/root/imgpolicy/audit.sh <매니페스트디렉터리> [폐기목록] 을 만드세요. 폐기 목록 인자를 생략하면 /root/imgpolicy/revoked.txt 를 씁니다. 그 디렉터리의 .yaml·.yml 에서 이미지 참조를 모두 뽑아 다이제스트로 고정되지 않은 참조마다 FLOATING <파일이름> <참조> 를, 고정은 됐지만 그 다이제스트가 폐기 목록에 있으면 REVOKED <파일이름> <참조> 를 한 줄씩 냅니다(그 줄들은 정렬해 냅니다). 마지막 줄은 정확히 RESULT floating=<수> revoked=<수> ready=<yes|no> 이고, 둘 다 0 일 때만 ready=yes 입니다. 종료 코드는 ready=yes 면 0, 아니면 1 입니다. 만든 뒤 ./audit.sh manifests 의 출력을 /root/imgpolicy/audit.txt 에 저장하고 그 파일 끝에 EXIT <종료코드> 한 줄을 덧붙이세요. 1단계에서 만든 매니페스트 묶음이 그대로 대상입니다.
이 단계가 답하는 질문은 '규칙이 맞는가' 가 아니라 '지금 켜도 되는가' 입니다. 정책을 넓히기 전에 걸릴 것을 먼저 세는 것이 전개 순서의 두 번째 칸이고, 그 숫자가 0 이 되기 전에 Deny 로 올리면 배포가 멈추고 정책이 되돌려집니다. 한 번 되돌려진 정책은 대개 다시 켜지지 않습니다 — 그래서 이 숫자를 세는 일이 규칙을 쓰는 일보다 중요할 때가 많습니다. 참조에서 다이제스트만 떼려면 ${ref#*@} 를 쓰고, 목록과 정확히 한 줄이 같은지는 grep -qxF 로 봅니다. 게이트가 언제나 0 으로 끝나면 아무도 그것을 신고하지 않습니다 — 깨끗한 묶음과 더러운 묶음 둘 다로 시험해 보세요.