本文发表已超过一年。较早的文章可能包含过时内容。请检查页面中的信息自发布以来是否已不再正确。

在 CRI 运行时中验证容器镜像签名

Kubernetes 社区自 v1.24 版本以来一直在签署基于容器镜像的制品。随着相关增强特性在 v1.26 版本中从 alpha 升级到 beta,引入了二进制制品的签名,其他项目也纷纷效仿,为其发布提供了镜像签名。这意味着它们要么在其自己的 CI/CD 流水线中创建签名,例如使用 GitHub actions,要么依赖于 Kubernetes 镜像推广流程,通过向 k/k8s.io 仓库提交 pull request 来自动签署镜像。使用此流程的要求是项目属于 kuberneteskubernetes-sigs GitHub 组织,这样他们才能利用社区基础设施将镜像推送到 staging buckets 中。

假设一个项目现在生成了已签名的容器镜像制品,那么如何实际验证签名呢?可以像官方 Kubernetes 文档中概述的那样手动完成。这种方法的问题在于它完全没有自动化,应该仅用于测试目的。在生产环境中,sigstore policy-controller 等工具可以帮助实现自动化。这些工具通过使用自定义资源定义 (CRD) 以及集成的准入控制器和 webhook 提供更高级别的 API 来验证签名。

基于准入控制器的验证通用使用流程如下:

Create an instance of the policy and annotate the namespace to validate the signatures. Then create the pod. The controller evaluates the policy and if it passes, then it does the image pull if necessary. If the policy evaluation fails, then it will not admit the pod.

这种架构的一个关键优势是简单性:集群中的单个实例在节点上的容器运行时发生任何镜像拉取之前验证签名,镜像拉取是由 kubelet 发起的。这个优势也带来了分离的问题:应该拉取容器镜像的节点不一定是执行准入控制的同一个节点。这意味着如果控制器被攻破,集群范围的策略强制执行将不再可能。

解决这个问题的一种方法是在兼容容器运行时接口 (CRI) 的容器运行时内部直接进行策略评估。运行时直接连接到节点上的kubelet,并执行所有任务,例如拉取镜像。CRI-O 就是其中一种可用的运行时,并将在 v1.28 版本中提供对容器镜像签名验证的全面支持。

它是如何工作的?CRI-O 读取一个名为policy.json 的文件,该文件包含为容器镜像定义的所有规则。例如,您可以像这样定义一个策略,该策略仅允许对任何标签或摘要使用已签名的镜像 quay.io/crio/signed

{
  "default": [{ "type": "reject" }],
  "transports": {
    "docker": {
      "quay.io/crio/signed": [
        {
          "type": "sigstoreSigned",
          "signedIdentity": { "type": "matchRepository" },
          "fulcio": {
            "oidcIssuer": "https://github.com/login/oauth",
            "subjectEmail": "sgrunert@redhat.com",
            "caData": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUI5ekNDQVh5Z0F3SUJBZ0lVQUxaTkFQRmR4SFB3amVEbG9Ed3lZQ2hBTy80d0NnWUlLb1pJemowRUF3TXcKS2pFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUkV3RHdZRFZRUURFd2h6YVdkemRHOXlaVEFlRncweQpNVEV3TURjeE16VTJOVGxhRncwek1URXdNRFV4TXpVMk5UaGFNQ294RlRBVEJnTlZCQW9UREhOcFozTjBiM0psCkxtUmxkakVSTUE4R0ExVUVBeE1JYzJsbmMzUnZjbVV3ZGpBUUJnY3Foa2pPUFFJQkJnVXJnUVFBSWdOaUFBVDcKWGVGVDRyYjNQUUd3UzRJYWp0TGszL09sbnBnYW5nYUJjbFlwc1lCcjVpKzR5bkIwN2NlYjNMUDBPSU9aZHhleApYNjljNWlWdXlKUlErSHowNXlpK1VGM3VCV0FsSHBpUzVzaDArSDJHSEU3U1hyazFFQzVtMVRyMTlMOWdnOTJqCll6QmhNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCUlkKd0I1ZmtVV2xacWw2ekpDaGt5TFFLc1hGK2pBZkJnTlZIU01FR0RBV2dCUll3QjVma1VXbFpxbDZ6SkNoa3lMUQpLc1hGK2pBS0JnZ3Foa2pPUFFRREF3TnBBREJtQWpFQWoxbkhlWFpwKzEzTldCTmErRURzRFA4RzFXV2cxdENNCldQL1dIUHFwYVZvMGpoc3dlTkZaZ1NzMGVFN3dZSTRxQWpFQTJXQjlvdDk4c0lrb0YzdlpZZGQzL1Z0V0I1YjkKVE5NZWE3SXgvc3RKNVRmY0xMZUFCTEU0Qk5KT3NRNHZuQkhKCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0="
          },
          "rekorPublicKeyData": "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFMkcyWSsydGFiZFRWNUJjR2lCSXgwYTlmQUZ3cgprQmJtTFNHdGtzNEwzcVg2eVlZMHp1ZkJuaEM4VXIvaXk1NUdoV1AvOUEvYlkyTGhDMzBNOStSWXR3PT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
        }
      ]
    }
  }
}

必须启动 CRI-O 才能将该策略用作全局事实来源:

> sudo crio --log-level debug --signature-policy ./policy.json

CRI-O 现在可以在验证其签名的同时拉取镜像。例如,可以使用crictl (cri-tools) 来完成:

> sudo crictl -D pull quay.io/crio/signed
DEBU[…] get image connection
DEBU[…] PullImageRequest: &PullImageRequest{Image:&ImageSpec{Image:quay.io/crio/signed,Annotations:map[string]string{},},Auth:nil,SandboxConfig:nil,}
DEBU[…] PullImageResponse: &PullImageResponse{ImageRef:quay.io/crio/signed@sha256:18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a,}
Image is up to date for quay.io/crio/signed@sha256:18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a

CRI-O 调试日志也将表明签名已成功验证:

DEBU[…] IsRunningImageAllowed for image docker:quay.io/crio/signed:latest
DEBU[…]  Using transport "docker" specific policy section quay.io/crio/signed
DEBU[…] Reading /var/lib/containers/sigstore/crio/signed@sha256=18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a/signature-1
DEBU[…] Looking for sigstore attachments in quay.io/crio/signed:sha256-18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a.sig
DEBU[…] GET https://quay.io/v2/crio/signed/manifests/sha256-18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a.sig
DEBU[…] Content-Type from manifest GET is "application/vnd.oci.image.manifest.v1+json"
DEBU[…] Found a sigstore attachment manifest with 1 layers
DEBU[…] Fetching sigstore attachment 1/1: sha256:8276724a208087e73ae5d9d6e8f872f67808c08b0acdfdc73019278807197c45
DEBU[…] Downloading /v2/crio/signed/blobs/sha256:8276724a208087e73ae5d9d6e8f872f67808c08b0acdfdc73019278807197c45
DEBU[…] GET https://quay.io/v2/crio/signed/blobs/sha256:8276724a208087e73ae5d9d6e8f872f67808c08b0acdfdc73019278807197c45
DEBU[…]  Requirement 0: allowed
DEBU[…] Overall: allowed

策略中所有定义的字段(例如 oidcIssuersubjectEmail)都必须匹配,而 fulcio.caDatarekorPublicKeyData 是来自上游 fulcio (OIDC PKI)rekor (透明日志) 实例的公钥。

这意味着如果您现在使策略中的 subjectEmail 失效,例如改为 wrong@mail.com

> jq '.transports.docker."quay.io/crio/signed"[0].fulcio.subjectEmail = "wrong@mail.com"' policy.json > new-policy.json
> mv new-policy.json policy.json

然后删除镜像,因为它已存在于本地:

> sudo crictl rmi quay.io/crio/signed

现在当您拉取镜像时,CRI-O 会提示所需邮箱错误:

> sudo crictl pull quay.io/crio/signed
FATA[…] pulling image: rpc error: code = Unknown desc = Source image rejected: Required email wrong@mail.com not found (got []string{"sgrunert@redhat.com"})

也可以测试未签名的镜像是否符合策略。为此,您需要将键 quay.io/crio/signed 修改为 quay.io/crio/unsigned 之类的内容:

> sed -i 's;quay.io/crio/signed;quay.io/crio/unsigned;' policy.json

如果您现在拉取容器镜像,CRI-O 将提示该镜像不存在签名:

> sudo crictl pull quay.io/crio/unsigned
FATA[…] pulling image: rpc error: code = Unknown desc = SignatureValidationFailed: Source image rejected: A signature was required, but no signature exists

需要强调的是,CRI-O 会将签名中的 .critical.identity.docker-reference 字段与镜像仓库进行匹配。例如,如果您验证镜像 registry.k8s.io/kube-apiserver-amd64:v1.28.0-alpha.3,那么对应的 docker-reference 应该是 registry.k8s.io/kube-apiserver-amd64

> cosign verify registry.k8s.io/kube-apiserver-amd64:v1.28.0-alpha.3 \
    --certificate-identity krel-trust@k8s-releng-prod.iam.gserviceaccount.com \
    --certificate-oidc-issuer https://# \
    | jq -r '.[0].critical.identity."docker-reference"'

registry.k8s.io/kubernetes/kube-apiserver-amd64

Kubernetes 社区引入了 registry.k8s.io 作为各种仓库的代理镜像。在 kpromo v4.0.2 发布之前,镜像的签名是使用实际的镜像仓库而非 registry.k8s.io 进行的:

> cosign verify registry.k8s.io/kube-apiserver-amd64:v1.28.0-alpha.2 \
    --certificate-identity krel-trust@k8s-releng-prod.iam.gserviceaccount.com \
    --certificate-oidc-issuer https://# \
    | jq -r '.[0].critical.identity."docker-reference"'

asia-northeast2-docker.pkg.dev/k8s-artifacts-prod/images/kubernetes/kube-apiserver-amd64

docker-reference 更改为 registry.k8s.io 使最终用户更容易验证签名,因为他们无法了解所使用的底层基础设施的任何信息。在镜像签名时设置身份的功能也已通过标志 sign --sign-container-identity 添加到 cosign 中,并将包含在其即将发布的版本中。

Kubernetes 镜像拉取错误码 SignatureValidationFailed 最近被添加到 Kubernetes 中,并将从 v1.28 版本开始可用。这个错误码允许最终用户直接从 kubectl CLI 理解镜像拉取失败的原因。例如,如果您使用要求 quay.io/crio/unsigned 必须已签名的策略,将 CRI-O 与 Kubernetes 一起运行,那么像这样的 Pod 定义:

apiVersion: v1
kind: Pod
metadata:
  name: pod
spec:
  containers:
    - name: container
      image: quay.io/crio/unsigned

在应用 Pod manifest 时将导致 SignatureValidationFailed 错误:

> kubectl apply -f pod.yaml
pod/pod created
> kubectl get pods
NAME   READY   STATUS                      RESTARTS   AGE
pod    0/1     SignatureValidationFailed   0          4s
> kubectl describe pod pod | tail -n8
  Type     Reason     Age                From               Message
  ----     ------     ----               ----               -------
  Normal   Scheduled  58s                default-scheduler  Successfully assigned default/pod to 127.0.0.1
  Normal   BackOff    22s (x2 over 55s)  kubelet            Back-off pulling image "quay.io/crio/unsigned"
  Warning  Failed     22s (x2 over 55s)  kubelet            Error: ImagePullBackOff
  Normal   Pulling    9s (x3 over 58s)   kubelet            Pulling image "quay.io/crio/unsigned"
  Warning  Failed     6s (x3 over 55s)   kubelet            Failed to pull image "quay.io/crio/unsigned": SignatureValidationFailed: Source image rejected: A signature was required, but no signature exists
  Warning  Failed     6s (x3 over 55s)   kubelet            Error: SignatureValidationFailed

这种整体行为提供了更原生的 Kubernetes 体验,并且不依赖于在集群中安装第三方软件。

仍然有一些特殊情况需要考虑:例如,如果您想像 policy-controller 支持的那样允许按 Namespace 设置策略怎么办?在 v1.28 中有一个即将推出的 CRI-O 功能可以解决这个问题!CRI-O 将支持 --signature-policy-dir / signature_policy_dir 选项,该选项定义了 Pod Namespace 分隔的签名策略的根路径。这意味着 CRI-O 将查找该路径并组装一个策略,例如 <SIGNATURE_POLICY_DIR>/<NAMESPACE>.json,如果存在,将在拉取镜像时使用该策略。如果在拉取镜像时未提供 Pod Namespace(通过 sandbox config),或者拼接后的路径不存在,则将使用 CRI-O 的全局策略作为回退。

另一个需要考虑的特殊情况对于容器运行时内的正确签名验证至关重要:kubelet 仅在镜像在磁盘上不存在时才会触发容器镜像拉取。这意味着来自 Kubernetes Namespace A 的无限制策略可以允许拉取镜像,而 Namespace B 则无法强制执行策略,因为该镜像已经存在于节点上。最后,CRI-O 不仅要在镜像拉取时验证策略,还要在容器创建时验证。这个事实使得事情更加复杂,因为 CRI 在容器创建时并不会真正传递用户指定的镜像引用,而是传递一个已解析的镜像 ID 或摘要。对 CRI 进行一个小小的修改可以帮助解决这个问题。

现在一切都在容器运行时内发生,需要有人维护和定义策略,以便围绕该功能提供良好的用户体验。policy-controller 的 CRD 很棒,我们可以设想集群中的一个守护进程可以按 Namespace 为 CRI-O 编写策略。这将使任何额外的 hook 过时,并将验证镜像签名的责任转移到实际拉取镜像的实例上。我评估了在纯 Kubernetes 中实现更好容器镜像签名验证的其他可能路径,但我找不到适合原生 API 的最佳方案。这意味着我认为 CRD 是可行的方法,但用户仍然需要一个实际提供该功能的实例。

感谢阅读这篇博文!如果您想了解更多、提供反馈或寻求帮助,请随时通过 Slack (#crio)SIG Node 邮件列表与我直接联系。