你拉下来的镜像,真的是你构建的那个吗
假设你在服务器上这样部署一个服务:
docker pull ghcr.io/someuser/myapp:latest
docker run -d --name app ghcr.io/someuser/myapp:latest然后一切正常运行。但这里藏着一个几乎没人认真想过的问题:你凭什么相信 ghcr.io/someuser/myapp:latest 这个镜像就是你上次 push 上去的那个?
镜像仓库是用「账号密码 / 访问令牌」保护推送的,但保护的是「谁能推」,不是「推上来的东西有没有被动过」。一旦你的仓库凭据泄露、CI 流水线被入侵、或者中间有人做了缓存投毒,攻击者可以推一个「同名 tag」的恶意镜像上去,而你的服务器拉取时不会做任何校验——它只认 tag 名字。这就是典型的供应链攻击:你没有犯错,攻击者攻陷了你依赖的某一环。
Sigstore 和 cosign 就是为了解决这个问题:给镜像签名,部署前验签。签名证明「这个镜像的字节级内容确实是由某个身份构建、且之后没被改过」。这篇文章讲清楚 cosign 的原理、怎么签名验签、怎么在 CI 和服务器上落地,最后列出几个一定要避开的坑。
先理解原理:镜像签名到底签的是什么
很多人以为签名是「把一段签名数据塞进镜像里」,其实不是。cosign 用的是分离签名(detached signature)的变体:
- 计算镜像的内容摘要(digest),比如
sha256:1a2b3c...。这个 digest 由镜像的 manifest 决定,manifest 又由各层的 digest 组成——也就是说,镜像里任何一个字节变了,digest 必然变。 - 用你的私钥对这个 digest 签名,得到一段签名。
- 把签名(以及公钥/证书)作为一个独立的 OCI artifact,推到同一个仓库里、关联到这个镜像的 digest 上。
验证时反过来:拉取镜像 → 重新计算 digest → 用公钥验证签名 → 确认签名对应的 digest 和当前镜像一致。任何环节对不上,验签就失败。
关键点:签名绑定的是 digest,不是 tag。tag(比如 latest)是可变的、可以指向任何 digest,所以永远不要「验证 tag」。部署时必须用 digest(image@sha256:...),或者「验签后立即按 digest 部署」,才能避免验签和运行之间被偷换。
cosign 有两种签名模式:
- 密钥对模式(key pair):自己生成一对公私钥,私钥签名、公钥验证。离线可控,简单直接,适合个人和小团队。
- 无密钥模式(keyless):不做长期密钥,借助 Sigstore 的 Fulcio(签发短期证书)和 Rekor(透明日志)。签名身份绑定到 OIDC(比如 GitHub Actions 的身份),签名事件被记录在公开的透明日志里,任何人都能审计。这是 Sigstore 的招牌能力,也是 GitHub Actions 里最省心的方式。
个人站长如果只是给自己构建的镜像加一道校验,密钥对模式就够了,我们就从这里开始。
生成密钥对并给镜像签名
先装 cosign,单个二进制,放 PATH 就行:
# 从官方 release 拿最新版(示例版本号按需替换)
curl -LO https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
install -m 0755 cosign-linux-amd64 /usr/local/bin/cosign
cosign version生成密钥对。强烈建议给私钥设密码:
cosign generate-key-pair
# 会提示输入私钥密码(回车留空表示不加密,不推荐)
# 生成 cosign.key(私钥)和 cosign.pub(公钥)cosign.key 是私钥,绝对不要进 Git、不要传公共仓库;cosign.pub 是公钥,可以随便分发,部署端要有。
给镜像签名前,先构建并推送镜像,拿到 digest:
docker build -t ghcr.io/someuser/myapp:v1.2.0 .
docker push ghcr.io/someuser/myapp:v1.2.0
# 拿到 digest
docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/someuser/myapp:v1.2.0
# 形如 ghcr.io/someuser/myapp@sha256:1a2b3c...然后用 digest 签名(注意:用 digest 而非 tag):
cosign sign --key cosign.key \
ghcr.io/someuser/myapp@sha256:1a2b3c...
# 会提示输入私钥密码,然后上传签名 artifact 到仓库验证签名:
cosign verify --key cosign.pub \
ghcr.io/someuser/myapp@sha256:1a2b3c...验证通过会输出签名者的身份信息(证书/OIDC issuer 等),失败则报错并返回非零退出码。这个退出码非常有用——可以直接在部署脚本里 if cosign verify ...; then docker run ...; fi。
在 GitHub Actions 里用无密钥签名(最省心)
自己管私钥有个躲不开的麻烦:私钥放哪?放 GitHub Secrets 里,一旦仓库或 CI 被攻陷,私钥就泄露了,攻击者可以用你的身份签任何恶意镜像,验签反而成了帮凶。Sigstore 的无密钥模式就是为了消除这个长期私钥:
- CI 运行时,Sigstore 通过 OIDC 验证「你现在确实是 GitHub Actions 里
someuser/myapp这个仓库的 workflow」。 - 验证通过后,Fulcio 签发一张有效期只有几分钟的短期证书,绑定这次构建的身份。
- cosign 用这张短期证书签名,并把签名事件写进 Rekor 透明日志(不可篡改、公开可查)。
- 证书很快过期,但签名记录留在日志里,事后仍能验证「是那个时刻、那个身份签的」。
GitHub Actions 里的配置非常短:
name: build-and-sign
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # 无密钥签名必须开,用于 OIDC
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.ref_name }} .
docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}
- uses: sigstore/cosign-installer@v3
- name: Sign the image
run: |
cosign sign --yes \
ghcr.io/${{ github.repository }}@$(docker inspect --format='{{index .RepoDigests 0}}' \
ghcr.io/${{ github.repository }}:${{ github.ref_name }} | cut -d@ -f2)id-token: write 这行权限是灵魂,没有它 OIDC 拿不到,无密钥签名会失败。签名完成后,任何人都可以用下面的命令验证,不需要你提供任何公钥:
cosign verify \
--certificate-identity-regexp 'https://github.com/someuser/myapp/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/someuser/myapp@sha256:1a2b3c...这条命令的含义是:「验证这个镜像的签名,且签名者必须是 GitHub Actions 里 someuser/myapp 仓库的身份、颁发者必须是 GitHub 的 OIDC。」如果攻击者用别的身份签了个镜像,这里会直接拒绝——这正是无密钥模式比密钥对更安全的地方:你验的不是「谁有这把钥匙」,而是「谁在什么条件下构建的」。
把验签接进部署流程
签了名不验,等于没签。真正有意义的是部署前强制验签。写一个部署脚本,把验签作为前置门禁:
#!/usr/bin/env bash
set -euo pipefail
IMAGE_DIGEST="$1" # 形如 ghcr.io/someuser/myapp@sha256:...
# 1. 验签,失败直接退出
cosign verify \
--key /etc/cosign/cosign.pub \
"$IMAGE_DIGEST"
# 2. 验签通过,按 digest(不是 tag)部署
docker pull "$IMAGE_DIGEST"
docker stop myapp 2>/dev/null || true
docker rm myapp 2>/dev/null || true
docker run -d --name myapp --restart unless-stopped "$IMAGE_DIGEST"
echo "deployed: $IMAGE_DIGEST"两个细节要盯死:
- 部署用 digest。即使前面验的是 tag 对应的 digest,
docker run时也要用同一个 digest,否则 tag 在这几秒内被重指向,你运行的就是另一个未验证的镜像。 - 公钥要放对地方。公钥文件在部署机上,权限设成只读,不要跟镜像放一起(放一起等于让攻击者能一起改)。
如果你用 Kubernetes,更省事的做法是用 policy-controller(Sigstore 的策略控制器)或者 Kyverno/OPA Gatekeeper 配一条 admission policy:集群里所有 Pod 只能运行通过验签的镜像,没签名的直接拒绝调度。相当于把「验签」从「你记得做」变成「集群强制做」。
五个必踩的坑
坑一:验 tag 而不是验 digest。 最常见的错误。tag 可变、可被重指向,验签通过后按 tag 拉取,中间有任何改动你都发现不了。永远验 digest、永远按 digest 部署。
坑二:私钥进 Git 或明文放 CI 变量。 密钥对模式下,私钥泄露等于签名体系崩盘。私钥要么本地加密保存、要么放专门的密钥管理(Vault/SOPS),CI 里通过安全注入使用。能用无密钥模式就别用长期私钥。
坑三:签了 latest 就到处用 latest。 latest 每次构建都指向新 digest,昨天的签名对今天的 latest 无效。生产上给镜像打不可变 tag(版本号或 commit sha),签名也对那个具体 digest 签,别用浮动 tag。
坑四:只签不验,或者验了不阻断。 有些流程「验签」只是打印一行日志,失败也照常部署。那签名就是个装饰。验签失败必须让部署硬失败(set -e + 非零退出),否则等于没做。
坑五:以为签名能防住一切。 签名只保证「镜像没被篡改、来源可信」,防不住「你签的那个镜像本身就是带漏洞的」。签名要配合镜像扫描(Trivy、Grype 扫 CVE)、SBOM(cosign attach sbom)、最小基础镜像一起用。签名是供应链安全的一环,不是全部。
给个人站的一条务实路径
如果你一个人维护几个服务,不需要一上来就搞全套 Sigstore,可以按这个顺序推进:
- 先固定 digest。哪怕不签名,部署时也用
image@sha256:...,别用latest。这一步零成本,就能挡住 tag 被偷换。 - 加签名。GitHub Actions 构建的话直接上无密钥模式;本地构建就用密钥对,私钥加密保存。
- 部署脚本强制验签,失败即中止。
- 加镜像扫描,把高危 CVE 挡在上线之前。
- 有余力再上 admission policy,把验签变成集群级强制。
供应链安全听起来离个人站长很远,但你每天都在 docker pull 别人的镜像——基础镜像、中间件、开源工具,全是从公网拉下来的。cosign 给你的不只是一段签名,而是一种习惯:部署之前,先确认你拿到的确实是你要的那个东西。在一个镜像层层叠叠、依赖动辄上百个的世界里,这个习惯的价值只会越来越高。