Kyverno、OPA Gatekeeper 和 Sigstore Policy Controller 等工具通常在 Kubernetes API 层拦截 Pod 创建,并检查镜像签名与证明材料。这道准入关卡很重要,但它验证的是控制面当时看到的声明。Node Resource Interface(NRI)把策略执行点推进到节点和容器运行时附近,使验证器有机会围绕实际将要启动的容器镜像做决定。
API 准入与运行时验证解决的不是同一个问题
准入 Webhook 擅长集中治理:它能检查 PodSpec、拒绝不合规字段,并在工作负载进入集群前给出清晰反馈。然而,从 API 接受 Pod 到节点真正启动容器,中间还经过调度、镜像解析、拉取和 CRI 调用。
这段路径带来几个需要关注的边界:
- PodSpec 可能使用可变标签,例如
registry.example.com/payments/api:latest。策略看到的是标签,节点最终运行的是标签当时解析出的摘要。 - API 层策略未必掌握节点本地缓存、运行时解析结果以及最终镜像身份。
- 并非所有容器都一定来自常规 Kubernetes Pod 创建路径。具体风险取决于节点权限模型和运行时配置。
- 准入成功只表示工作负载在那个时间点满足控制面策略,不能自动证明节点启动时仍满足相同条件。
因此,运行时验证不是准入策略的替代品。更合理的结构是两层控制:API 层尽早拒绝明显违规的声明,NRI 插件在容器生命周期的关键位置核对最终镜像,并在验证失败时阻止启动。
NRI 把策略放到更接近容器启动的位置
NRI 为容器运行时与外部插件之间提供接口。插件可以接收容器生命周期相关事件,并依据策略参与运行时决策。用于供应链验证时,一个插件通常需要完成以下流程:
- 从运行时事件中取得容器及镜像信息。
- 将标签解析为不可变摘要,或者直接读取运行时提供的摘要。
- 使用受信公钥、证书身份或 keyless 身份规则验证签名。
- 检查 provenance、SBOM 等证明材料是否满足策略。
- 缓存成功结果,并在策略、签名或证明材料变化时失效缓存。
- 验证失败或超时时,根据 fail-open 或 fail-closed 配置决定是否阻止容器创建。
这里最关键的工程约束是“按摘要验证”。如果插件验证 app:stable,随后运行时又独立解析一次标签,两次解析可能得到不同内容。验证器应把策略判断绑定到 registry/repository@sha256:...,并确保放行的正是该摘要。
可以这样实践:先验证不可变镜像引用
下面是一个可直接改造的最小验证脚本。它不是完整 NRI 插件,而是插件可以调用的验证核心:输入必须是带摘要的镜像引用,然后使用 Cosign 验证签名。运行前安装 cosign,并将 COSIGN_PUBLIC_KEY 指向团队的公钥文件。
#!/usr/bin/env bash
set -euo pipefail
image_ref="${1:?usage: verify-image.sh <registry/repo@sha256:digest>}"
public_key="${COSIGN_PUBLIC_KEY:-./cosign.pub}"
if [[ "${image_ref}" != *@sha256:* ]]; then
echo "refusing mutable image reference: ${image_ref}" >&2
exit 2
fi
cosign verify \
--key "${public_key}" \
"${image_ref}" >/dev/null
echo "verified: ${image_ref}"
保存为 verify-image.sh 并添加执行权限后,可以这样运行:
chmod +x verify-image.sh
COSIGN_PUBLIC_KEY=/etc/supply-chain/cosign.pub \
./verify-image.sh registry.example.com/payments/api@sha256:REPLACE_WITH_DIGEST
若采用 keyless 签名,不应只检查“存在某个有效签名”。还要约束签名者身份和 OIDC 颁发者。可以这样实践:
cosign verify \
--certificate-identity-regexp '^https://github.com/acme/payments/.github/workflows/release.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
registry.example.com/payments/api@sha256:REPLACE_WITH_DIGEST
NRI 插件的策略配置可以采用下面这种结构。字段是便于说明集成方式的示例,并非 NRI 标准配置格式;实际名称需要与所选插件实现对齐。
apiVersion: supply-chain.example.io/v1alpha1
kind: RuntimeVerificationPolicy
metadata:
name: production-images
spec:
mode: enforce
failurePolicy: FailClosed
verificationTimeout: 5s
requireDigest: true
registries:
- registry.example.com
signatures:
keyless:
oidcIssuer: https://token.actions.githubusercontent.com
identityRegexp: >-
^https://github.com/acme/.+/.github/workflows/release.yml@refs/tags/.+$
attestations:
requiredPredicateTypes:
- https://slsa.dev/provenance/v1
cache:
successTtl: 10m
maxEntries: 10000
插件收到容器创建事件后,应从事件中提取实际摘要,用该摘要执行验证,并只缓存“摘要 + 策略版本”的结果。不要仅按标签缓存,也不要让一次历史成功永久放行同一仓库的后续内容。
生产部署要处理延迟、故障与可观测性
把验证放进容器启动路径会扩大安全覆盖面,也会引入新的可用性依赖。签名存储、OCI Registry、透明日志或身份服务变慢时,容器启动可能随之变慢甚至失败。
上线前至少要明确以下决策:
- 失败策略:生产命名空间通常倾向 fail-closed;应急或低风险环境可以经过审批后 fail-open,但必须产生告警。
- 超时预算:验证超时需要小于平台允许的容器启动延迟,并避免无限重试放大 Registry 故障。
- 缓存键:至少包含镜像摘要、策略版本和信任根版本。密钥轮换或策略收紧后,旧结果必须失效。
- 旁路保护:限制节点上的 CRI socket、containerd socket 和 NRI 插件目录权限,否则高权限用户可能绕过或替换验证器。
- 审计信息:记录工作负载身份、节点、镜像摘要、签名者、证明类型、策略版本和拒绝原因,但不要把敏感令牌写入日志。
- 插件故障:验证器崩溃、升级或与运行时失联时,行为必须可预测,并通过指标区分策略拒绝与基础设施故障。
采用建议:保留准入层,再增加运行时终检
落地时可以先让 NRI 验证器运行在 audit 模式,比较 PodSpec 中的镜像、运行时解析出的摘要以及现有准入结论。确认误报率、验证延迟和 Registry 压力后,再按命名空间或镜像仓库逐步启用 enforce。
最终检查表可以压缩为五项:所有生产镜像是否使用摘要;签名身份是否被精确约束;证明材料是否验证内容而非只验证存在性;缓存能否随策略和信任根失效;节点权限是否足以防止绕过 NRI。API 准入负责尽早反馈,NRI 负责在启动前核对实际制品,两者叠加才形成更完整的供应链执行链路。