管道會簽章,不等於叢集會檢查簽章。只要有人 oc apply 一個自己 build 的映像,
它照樣跑起來——簽章那一步等於白做。
這篇用 Kyverno 在 admission 層把這個洞補上。
讀完你能做到:在 OpenShift 上裝好 Kyverno、讓它信任你自架的 registry,
然後寫一條政策把沒有簽章的映像擋在叢集外——而且不授權任何 SCC,
也不關掉 TLS 驗證。
這篇假設你已經有:
① 一個私有 registry,走 HTTPS,用的是內部 CA 或自簽憑證(本文用 Nexus + OpenShift route)
② 映像已經用 cosign 的金鑰對簽過,而且你手上有公鑰
③ 簽章沒有上傳 transparency log(cosign sign --tlog-upload=false)
④ 一個 OpenShift 叢集,你有 cluster-admin
第 ③ 點會影響政策怎麼寫,§4 有專門說明。如果你有上傳 tlog,那一段可以跳過。
環境:OpenShift 4.21.14、Kubernetes v1.34.6、Kyverno chart 3.9.0 / v1.19.0。
Kyverno 1.19 官方測試的 Kubernetes 範圍是 v1.33–v1.35。
① helm install kyverno,把 runAsUser 設成 null → restricted-v2 就收,零 SCC 授權
② global.caCertificates.data 放你的 CA → Kyverno 才連得到 registry
③ 複製 registry 憑證 + 寫 ImageValidatingPolicy → 先 Audit,不擋
④ 拿一個沒簽章的映像當反例 → 沒有它,「全 pass」跟「沒在跑」長一樣
⑤ 轉 Deny + Fail + namespaceSelector → 收窄範圍,否則半徑是全叢集
三個關鍵決定,理由都在 Part 2:
| 決定 | 為什麼 |
|---|---|
| 一個 SCC 都不給 | pod spec 要了不該要的 UID,換 SCC 收得下,代價是授權(§8) |
CA 用 data 不用 volume |
volume 走 hostPath,而 restricted-v2 不允許 hostPath(§10) |
| 政策收窄到一個 namespace | webhook 按資源攔,不按映像攔——Fail 的半徑是全叢集(§11) |
catalog 裡沒有 Kyverno operator,走 Helm。
版本以 repo 為準,不要照文件頁抄:
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update kyverno
helm search repo kyverno/kyverno --versions | head
values-openshift.yaml——只做一件事:把 runAsUser 拿掉:
admissionController:
initContainer:
securityContext: { runAsUser: null }
container:
securityContext: { runAsUser: null }
backgroundController:
securityContext: { runAsUser: null }
cleanupController:
securityContext: { runAsUser: null }
reportsController:
securityContext: { runAsUser: null }
# 三個 helm hook 的 Job 用同一份 securityContext,會踩同一個坑
crds:
migration:
securityContext: { runAsUser: null }
webhooksCleanup:
securityContext: { runAsUser: null }
test:
securityContext: { runAsUser: null }
helm install kyverno kyverno/kyverno --version 3.9.0 \
-n kyverno --create-namespace -f values-openshift.yaml
結果:
kyverno-admission-controller scc=restricted-v2 uid=1000690000
kyverno-background-controller scc=restricted-v2 uid=1000690000
kyverno-cleanup-controller scc=restricted-v2 uid=1000690000
kyverno-reports-controller scc=restricted-v2 uid=1000690000
restricted-v2 是 OpenShift 權限最低的預設 SCC,UID 由它自己配。
四個 ServiceAccount 的 privileged 和 anyuid 都是 no:
oc auth can-i use scc/privileged \
--as=system:serviceaccount:kyverno:kyverno-admission-controller
# no
⚠️ webhooksCleanup 那個不要漏。它是 pre-delete hook。
漏掉的話 helm uninstall 會失敗,而 ValidatingWebhookConfiguration
會留在叢集裡指向一個已經不存在的 Service——之後每一個符合它 rules 的
API 呼叫都會被這個死 webhook 擋住。這比裝不起來嚴重,因為它是在你以為
已經移除乾淨之後才發作。
Kyverno 預設不信任自架 registry 的憑證,也拿不到節點的信任鏈。
節點拉得到映像,不代表 Kyverno 拉得到——這兩條路是分開的。
憑證——本文的 registry 掛在 OpenShift route 上(edge termination),
所以要的是叢集的 ingress CA:
oc get cm default-ingress-cert -n openshift-config-managed \
-o jsonpath='{.data.ca-bundle\.crt}'
⚠️ 多行 PEM 貼進 YAML 區塊純量是這篇最容易失敗的機械步驟。
直接產生對齊好的輸出,不要手動縮排:
oc get cm default-ingress-cert -n openshift-config-managed \
-o jsonpath='{.data.ca-bundle\.crt}' | sed 's/^/ /'
把輸出整段貼到 data: | 底下:
# values-ca.yaml
global:
caCertificates:
data: |
-----BEGIN CERTIFICATE-----
MIIDWzCCAkOgAwIBAgIICzyVCuEDdHwwDQYJKoZIhvcNAQELBQAwJjEkMCIGA1UE
...
-----END CERTIFICATE-----
你的 registry 如果是別的形式,就換成對應的 CA。關鍵是這個值會取代
Kyverno 的整個信任區,不是附加——細節見 §10。
帳密——registry 如果不允許匿名讀(多數不允許),要給憑證:
oc get secret <REGISTRY_SECRET> -n <應用的 namespace> -o json \
| jq 'del(.metadata.namespace, .metadata.resourceVersion,
.metadata.uid, .metadata.creationTimestamp)' \
| oc apply -n kyverno -f -
helm upgrade kyverno kyverno/kyverno --version 3.9.0 -n kyverno \
-f values-openshift.yaml -f values-ca.yaml
oc rollout status deploy/kyverno-admission-controller -n kyverno
這一步不驗的話,CA 貼錯會到下一步才以「政策沒過」的形式出現——
而那時 CA、帳密、glob、公鑰四個候選因子同時在場,很難分。
oc get cm kyverno-admission-controller-ca-certificates -n kyverno \
-o jsonpath='{.data.ca-certificates}' | openssl x509 -noout -subject -enddate
正常:
subject=CN=*.apps-crc.testing
notAfter=May 12 16:29:40 2028 GMT
PEM 貼歪或縮排錯了的話:
Could not find certificate from <stdin>
順帶一提,notAfter 就是你得重新做一次這一步的日期(見 §10)。
再確認一次 Kyverno 沒有在抱怨憑證或憑証:
oc logs -n kyverno deploy/kyverno-admission-controller --tail=500 \
| grep -iE 'x509|certificate signed by unknown|401|unauthorized'
# 沒有輸出就對了
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: verify-web-image
spec:
validationActions: [Audit] # 先不擋
failurePolicy: Ignore
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
matchImageReferences:
- glob: "registry.example.com/docker-hosted/web*"
credentials:
secrets:
- <REGISTRY_SECRET> # 跟 §3 複製過去的那把同名
attestors:
- name: cosign
cosign:
key:
data: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEDo3ENsjfDWtJ4wyauF7J5NiOPLSH
fVzYHOt7wMUnmx0Bsh7HplEqjFeq9v60c+8LPCKvxIeCL2iPAg36liaVzg==
-----END PUBLIC KEY-----
ctlog:
insecureIgnoreTlog: true
validations:
- expression: >-
(images.containers +
images.?initContainers.orValue([]) +
images.?ephemeralContainers.orValue([])
).map(image,
verifyImageSignatures(image, [attestors.cosign])
).all(e, e > 0)
message: "web 映像未通過 cosign 簽章驗證"
四個容易寫錯的地方:
matchImageReferences 要寫 manifest 裡實際出現的那個字串。credentials.secrets 指的 secret 必須在 Kyverno 的 namespace。insecureIgnoreTlog: true 只有在你的簽章沒上傳 transparency log 時才需要。--tlog-upload=false,insecureIgnoreSCT 對純公鑰驗簽沒有作用——SCT 是 keyless/Fulcioattestors[].name 就是 CEL 裡 attestors.cosign 的那個名字。images 分成 containers、initContainers、ephemeralContainersimages.containers 也不會直接放行,因為validationConfigurations.required 預設是 true:匹配了matchImageReferences 卻沒被任何政策檢查過的映像會退件。no policy performed a signature or attestation check on it,required 一旦關掉,就是真的安靜放行了。images.initContainer 少一個 s.orValue([]) 得到一個永遠是空的清單。套用後看背景掃描的結果:
$ oc get policyreport -n demo
KIND NAME PASS FAIL ERROR
Pod web-6fc78cdf56-9gbr2 1 0 0
「全部 pass」跟「政策根本沒載入」長得一模一樣。 需要一個「路徑對、
但沒有簽章」的映像。
不一定要另外造。接上簽章之前建的舊 tag,天然就沒有 .sig:
# 用 registry API 逐個 tag 對照有沒有 sha256-<digest>.sig
11152d1334aec6... 簽章=no ← 拿它
58495277b376ac... 簽章=YES
apiVersion: v1
kind: Pod
metadata: { name: unsigned-probe, namespace: demo }
spec:
imagePullSecrets: [{ name: <REGISTRY_SECRET> }]
containers:
- name: web
image: registry.example.com/docker-hosted/web:11152d1334aec6...
Audit 模式下 pod 會建成功,但報告分岔了:
Pod web-6fc78cdf56-9gbr2 PASS 1 FAIL 0
Pod unsigned-probe PASS 0 FAIL 1
message: web 映像未通過 cosign 簽章驗證
這時候才知道政策是活的。
三個改動:
validationActions: [Audit] → [Deny]
failurePolicy: Ignore → Fail
matchConstraints:
+ namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: demo
resourceRules:
- apiGroups: [""]
...
⚠️ matchConstraints 在 §4 已經存在了,是往裡面加一把 namespaceSelector,
不是另外再寫一個 matchConstraints:——重複的 key 會讓 YAML 直接不合法。
第三個是前兩個能安全打開的前提,理由見 §11。
測四次:
① 未簽章 + demo → Error from server: admission webhook ... denied the request:
Policy verify-web-image failed: web 映像未通過 cosign 簽章驗證
② 已簽章 + demo → pod/signed-probe created
③ 未簽章 + 別的 ns → 放行(收窄的代價,見 §12)
④ oc set image 塞未簽章 → error: failed to patch image update to pod template:
admission webhook ... denied the request
第 ④ 個值得注意:政策的 resourceRules 只寫了 pods,但 Kyverno 的 autogen
自己把 webhook 展開到 apps/{deployments,replicasets,statefulsets,daemonsets}
和 batch/{jobs,cronjobs}。所以 Deployment 層就擋住了,不是等到建 pod 才失敗。
Enforce 出事的時候,你會在一個連 oc apply 都下不了的叢集裡。
這兩行要在轉 Enforce 之前就記下來,不是出事了再查:
# ① 停止阻擋,政策留著
oc patch ivpol verify-web-image --type=merge \
-p '{"spec":{"validationActions":["Audit"],"failurePolicy":"Ignore"}}'
# ② 或直接移除政策,webhook 會跟著收掉
oc delete ivpol verify-web-image
兩個都實測過:① 之後未簽章的 pod 立刻建得出來;
② 之後 kyverno-resource-validating-webhook-cfg 的 webhook 清單變空。
「什麼都建不出來」有兩個成因,處置完全不同——
下手之前要先分清楚是哪一種:
| 錯誤訊息 | 成因 | 處置 |
|---|---|---|
Policy verify-web-image failed: ... |
政策在擋 | 上面兩行 |
webhook 連不上/context deadline exceeded,而 Kyverno 已經不在了 |
§2 那個死 webhook | 下面那行 |
# 僅限「Kyverno 已移除但 webhook 殘留」的情況
oc delete validatingwebhookconfiguration,mutatingwebhookconfiguration \
-l webhook.kyverno.io/managed-by=kyverno
這個標籤實測精確選到 Kyverno 自己的 10 個 webhook,不會誤傷別人的。
跑一次完整的建置→簽章→部署,確認正常流程沒有被自己的政策擋住:
demo 部署 web@sha256:05d42a95773b...
GitOps sync=Synced health=Healthy
Kyverno web-6dbf9f7c69-8fw94 pass success
最後那個 pass 很重要——它證明新 pod 是通過驗證進來的,不是被跳過。
Kyverno 的 Helm chart 要求容器跑在 runAsUser: 65534。
OpenShift 的 restricted-v2 用的是 MustRunAsRange——UID 必須落在
namespace 被配到的區間裡,而那個區間長這樣:
demo 1000680000/10000
kyverno 1000690000/10000
65534 不在裡面,所以 pod 建不出來。
往上找一個更寬鬆的 SCC 能解決,但代價不對:
| SCC | priority | runAsUser 策略 |
seccomp |
|---|---|---|---|
restricted-v2 |
– | MustRunAsRange |
runtime/default |
anyuid |
10 | RunAsAny |
沒有 seccompProfiles 欄位 |
nonroot-v2 |
– | MustRunAsNonRoot |
runtime/default |
privileged |
– | RunAsAny |
全部 |
anyuid 的 priority 是 10,在這幾個裡排最前面,照理輪得到它。
但 chart 除了 UID 之外還設了 seccompProfile: RuntimeDefault,而 anyuid
連 seccompProfiles 欄位都沒有,所以它出局。拿兩份只差這個欄位的 spec 去問:
oc adm policy scc-subject-review -f pod.yaml -n demo
有 seccompProfile → nonroot-v2
沒有 seccompProfile → anyuid
所以實際會收下 chart 的是 nonroot-v2。它不是 privileged,
但仍然是為了一個寫死的 UID 去授權一個 SCC。
把 runAsUser 設成 null 就好。 OpenShift 會從區間裡配一個給它。
其他欄位——runAsNonRoot: true、privileged: false、allowPrivilegeEscalation: false、readOnlyRootFilesystem: true、capabilities.drop: [ALL]、seccompProfile: RuntimeDefault——restricted-v2 全部接受。
helm install 會回報 STATUS: deployed,四個 Deployment 也都建得出來,
所以從 helm 的輸出看不出有問題。
SCC 是在建 pod 那一層擋的,錯誤在 ReplicaSet 的 ReplicaFailure 條件裡:
oc get rs -n kyverno -o json | jq -r \
'.items[].status.conditions[]? | select(.type=="ReplicaFailure") | .message'
pods "kyverno-admission-controller-..." is forbidden:
unable to validate against any security context constraint: [
provider "anyuid": Forbidden: not usable by user or serviceaccount,
provider restricted-v2: .containers[0].runAsUser: Invalid value: 65534:
must be in the ranges: [1000690000, 1000699999],
... provider "privileged": Forbidden: not usable by user or serviceaccount]
它把每一個 SCC 為什麼不行都列出來,分兩類:
Forbidden: not usable by user or serviceaccount → 你沒有這個 SCC 的權限
Invalid value: ... → 你有權限,但 pod spec 不符合它的規則
restricted-v2 屬於第二類。看到 Invalid value 就往 pod spec 找,不要往權限找。
anyuid 屬於第一類——這裡它是因為沒授權才出局的。
這跟 §8 說的 seccomp 是兩件事,而兩件都成立:沒授權過它,
而且就算授權了,seccompProfile 那一關也過不了。
data 不是 volumechart 提供兩條路,官方文件的小節標題字面上就叫 Replace 和 Host Mount。
它們在 OpenShift 上不等價:
restricted-v2 allowHostDirVolumePlugin=False volumes 裡沒有 hostPath
hostmount-anyuid allowHostDirVolumePlugin=True volumes=[..., hostPath, ...]
privileged allowHostDirVolumePlugin=True volumes=[*]
global.caCertificates.volume(Host Mount)的官方範例是 hostPath。
走那條路就得退回去要 hostmount-anyuid 或 privileged——把第 1 步的成果整個吐回去。
⚠️ 而且 data 是「取代」不是「附加」。chart 把值做成 ConfigMap,
再用 subPath 掛到 /etc/ssl/certs/ca-certificates.crt——直接蓋掉映像裡的
系統信任檔。放進去的就是 Kyverno 信任的全部。
只放一張內部 CA 可行,是因為在這條鏈路上 Kyverno 只需要連你的 registry:
公鑰驗簽不需要 Fulcio,關掉 tlog 之後也不需要 Rekor。
如果你的政策還要連別的公開服務,就得把公開根憑證一起放進去。
這是專案明確不做的設計。issue #3985(支援內部 CA 簽發的
registry)提過三個選項,其中第三個正是「掛 ConfigMap 來補充而非取代既有的
CA bundle」——Kyverno 把那個 issue closed as not planned。
⚠️ 手動放進去的 CA 不會跟著輪替。憑證換掉那天,症狀是「部署被擋」,
很難聯想到 CA 過期。把重新產生 values 的那段寫成腳本,並且留一個提醒。
政策套用後,kyverno-resource-validating-webhook-cfg 從 0 個 webhook 變成 1 個。
在加 namespaceSelector 之前,它長這樣:
rules:
"" pods CREATE, UPDATE
apps daemonsets, deployments, replicasets, statefulsets CREATE, UPDATE
batch cronjobs, jobs CREATE, UPDATE
namespaceSelector: 除了 kube-system 和 kyverno 以外的全部
objectSelector : {}
matchImageReferences 的 glob 過濾是在 Kyverno 內部做的——
請求已經送到它面前了才被過濾掉。所以:
failurePolicy: Fail + Kyverno 掛掉
= 全叢集(除 kube-system / kyverno)所有 workload 的 CREATE/UPDATE 全部被拒
≠ 只有你鎖的那些映像受影響
單副本的 Kyverno 尤其危險。加上 namespaceSelector 之後,
最壞情況縮到一個 namespace。
代價是真的,不是理論上的:
oc run scope-probe -n other-ns --image=<未簽章的映像> --dry-run=server
→ pod/scope-probe created (server dry run)
同一個未簽章映像,換個 namespace 就放行。
要補這個洞就得放寬 namespaceSelector,而那會把 Fail 的半徑拉回全叢集。
標準解法是配 HA(Kyverno 的 HA 要求 admission controller 剛好三副本),
但單節點叢集上三副本一起掛,結果一樣。
先想清楚你要的是哪一種失效:擋住太多,還是放行太多。
Enforce 開著之後,管道的順序不再只是好習慣:
簽章 → 更新部署 manifest → 等同步
簽章時用的位址跟 manifest 裡的不一樣,不會讓驗簽失敗。
cosign 把簽章推成 sha256-<digest>.sig,位置由 digest 決定,名字只決定去哪個 repo 找。
要對齊 manifest 的是 §4 那個 matchImageReferences,不是簽章。
ImageValidatingPolicyKyverno 有兩套政策 API。舊的是 ClusterPolicy + verifyImages,
新的是 CEL-based 的 ImageValidatingPolicy(policies.kyverno.io/v1)。
1.17 ClusterPolicy / CleanupPolicy 標記 Deprecated,進維護模式
CEL 政策型別(含 ImageValidatingPolicy 及 namespaced 變體)升上 v1
1.19 CEL 型別與舊型別達成功能對等 ← 本文用的版本
最後一個「完整支援」ClusterPolicy / Policy 的版本
發布於 2026 年 8 月
1.20 移除 ClusterPolicy / Policy,預計 2026 年 11 月
用 ImageValidatingPolicy 的理由是時程:1.19 是最後一個完整支援 ClusterPolicy
的版本,1.20 會移除它。裝完 chart 自己就會印淘汰警告。
上游兩邊說法不一致。1.17 的 release blog 說那時就標記淘汰、
之後只修重大問題;kyverno.io 的 migration-to-cel 頁寫的是
「deprecated as of Kyverno v1.19, removed in v1.20」。
實務上不影響決定——兩種說法都指向 1.20 移除。
那個日期是「預計」,而且已經滑過一次——
1.17 發布當時的說法是 10 月,現在 releases 頁寫 11 月(查於 2026-08-23)。
上游時程寫進文件時要連查詢日期一起綁,否則它會在你沒注意時變成錯的。
代價是 CEL 運算式比宣告式欄位囉唆(見 §4 的 validations),多半頁。
換掉的是一個很快會消失的 API。
helm upgrade。spec.attestations 可以驗 SBOM 和掃描結果的key.expression 搭配 resource.Get(...)。global.caCertificates 在這裡。兩個小節的標題就叫 Replace 和 Host Mount
ClusterPolicy 搬過來的欄位對照ClusterPolicy 那一套,1.20 會移除allowInsecureRegistry,也就是本文刻意沒用的那個旗標MustRunAsRange 跟 namespace UID 區間的關係namespaceSelector / objectSelector 的語意,Kyverno 直接沿用