在容器化交付链路中,镜像作为一个不可变制品会在多个仓库、集群与边缘节点之间流转。Notary是一套由Docker发起并捐赠给CNCF的项目,专门解决「如何确认某个镜像标签背后内容确实来自可信构建方」的问题。它并不负责传输镜像,而是为镜像的标签到摘要(digest)映射提供密码学背书,使客户端在拉取前就能判断内容是否被替换。

Notary的信任模型与TUF基础
Notary的实现根基是TUF(The Update Framework)规范。TUF将信任分散到多个角色密钥中,避免单点私钥泄露导致整个仓库被伪造。在Notary里,主要包含root、targets、snapshot、timestamp四种角色。root密钥用来签署其他角色的公钥,targets密钥负责签署具体的镜像标签元数据,snapshot对整体元数据做汇总签名,timestamp则提供短期有效的元数据时间戳以防过期攻击。
这种分层结构意味着即便某个targets私钥意外泄露,攻击者也只能伪造部分标签,且会被root角色的公钥轮换机制快速废止。用户在客户端初始化时会先获取root.json,里面包含了顶级信任锚。之后所有元数据校验都从root开始逐级验证签名链,任何一层签名不匹配就会拒绝拉取。相较于直接将镜像交给单一HTTPS证书保护,TUF模型对密钥泄露和仓库服务端被攻破有更强的容忍度。
在具体使用中,Notary服务通常独立部署,与镜像仓库解耦。仓库只存 blob 数据,Notary存的是签名过的元数据。当执行docker pull且配置了内容信任时,Docker客户端会先向Notary查询该镜像标签对应的可信digest,再去仓库下载对应层并校验sha256,实现双因子确认。
镜像签名的具体操作流程
要对一个镜像做签名,首先需要在构建机上初始化Notary客户端并导入targets私钥。通常我们在CI阶段通过环境变量注入密钥,然后利用notary signer或者Docker内容信任模式完成。下面是一段简化的命令行签名示例,展示如何把本地构建出的镜像推送到仓库并自动触发Notary签名。
# 开启内容信任环境变量 export DOCKER_CONTENT_TRUST=1 # 登录镜像仓库 docker login registry.ipipp.com # 构建并推送,推送时会用本地notary密钥对标签签名 docker build -t registry.ipipp.com/app/demo:1.0.0 . docker push registry.ipipp.com/app/demo:1.0.0 # 查看notary上的签名状态 notary -s https://notary.ipipp.com list registry.ipipp.com/app/demo
上述过程里,DOCKER_CONTENT_TRUST=1会让push命令在上传镜像层之后,调用Notary客户端把标签1.0.0和镜像manifest的digest写入targets元数据并签名。如果本地没有targets密钥,Docker会尝试使用由Docker Hub或自建Notary提供的密钥托管服务,但在企业内网更推荐自带硬件加密机或文件密钥。
另一种更细粒度的方式是使用notary命令行直接操作。比如先notary init创建仓库信任条目,再notary add绑定标签与哈希,最后notary publish。这种流程适合非Docker场景,比如对OCI artifact、Helm chart做签名。无论哪种方式,核心动作都是:计算内容摘要、构造带角色签名的JSON元数据、上传到Notary服务器。
客户端验证机制与离线场景
镜像验证发生在拉取侧。当客户端开启内容信任,它会向Notary服务器请求对应镜像的targets元数据,利用本地缓存的root公钥校验snapshot与timestamp,再确认标签指向的digest。随后从镜像仓库下载内容并比对实际sha256。下面代码展示了在Go程序中利用Notary库做手动验证的思路。
package main
import (
"fmt"
"github.com/theupdateframework/notary/client"
"github.com/theupdateframework/notary/trustpinning"
)
func verifyImage(repo string) error {
// 配置notary服务地址与信任锚
nClient, err := client.NewFileCachedNotaryRepository(
"/tmp/notary-cache",
repo,
"https://notary.ipipp.com",
nil,
trustpinning.TrustPinConfig{},
)
if err != nil {
return err
}
// 获取已签名的目标列表
targets, err := nClient.ListTargets()
if err != nil {
return err
}
for _, t := range targets {
fmt.Printf("tag=%s digest=%sn", t.Name, t.Hashes["sha256"])
}
return nil
}
在边缘节点或离线环境中,无法实时访问Notary服务器。此时可以预先把root.json及必要的timestamp、snapshot缓存到本地只读介质,验证时仅依赖本地信任库。只要镜像digest能在本地元数据中找到且签名有效,就能完成离线校验。这种方式常用于工厂车间、车载终端等网络受限场景。
需要注意的是,timestamp角色有较短有效期,离线太久会导致元数据过期而拒绝验证。运维上可通过定期在联网环境刷新timestamp并分发到边缘节点来平衡安全与可用。此外,验证失败时要明确区分是签名不匹配还是网络问题,避免误将篡改镜像当成了过期错误而放行。
密钥管理与生产实践建议
生产环境里,root密钥应离线冷存储,只在轮换其他角色时才启用。targets密钥可由CI专用签名机持有,并结合OIDC短时令牌限制使用范围。snapshot与timestamp密钥适合放在Notary服务端用在线KMS保护,因为它们需要频繁签名但不掌握最终内容信任根。
当团队成员离职或密钥疑似泄露,应通过root密钥签署新targets公钥并发布撤销旧密钥的元数据。Notary的TUF结构保证旧密钥签出的标签在新信任链下立刻失效。建议在Kubernetes准入控制中集成验证Webhook,任何未通过Notary签名的镜像直接拒绝调度,从源头收缩攻击面。
最后,Notary虽强,但不是孤岛。应配合镜像扫描、SBOM生成与最小基础镜像策略使用。签名只证明来源,不证明内容无漏洞。把Notary验证嵌入CI/CD和运行时两道闸门,才能构建完整的软件供应链信任体系。