本文发表于一年多前。旧文章可能包含过时内容。请检查页面中的信息自发布以来是否已变得不正确。

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

自 v1.24 版本以来,Kubernetes 社区一直在对其基于容器镜像的构件进行签名。v1.26 版本中相应增强alpha 升级到 beta,为二进制构件引入了签名,其他项目也纷纷效仿,为其发布版本提供镜像签名。这意味着它们或者在自己的 CI/CD 管道中创建签名,例如使用 GitHub Actions,或者依赖 Kubernetes 的镜像提升流程,通过向 k/k8s.io 仓库提出拉取请求来自动签名镜像。使用此流程的一个要求是,项目必须是 kuberneteskubernetes-sigs GitHub 组织的一部分,这样才能利用社区基础设施将镜像推送到暂存桶。

假设一个项目现在生成了已签名的容器镜像构件,那么如何实际验证这些签名呢?可以像 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 理解镜像拉取失败的原因。例如,如果你将 CRI-O 与 Kubernetes 一起运行,并使用要求 quay.io/crio/unsigned 签名的策略,那么像这样的 Pod 定义:

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

在应用 Pod 清单时将导致 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 支持的相同方式允许每个命名空间的策略怎么办?嗯,v1.28 中有一个即将推出的 CRI-O 功能可以解决这个问题!CRI-O 将支持 --signature-policy-dir / signature_policy_dir 选项,该选项定义了按 Pod 命名空间分离的签名策略的根路径。这意味着 CRI-O 将查找该路径并组装一个类似 <SIGNATURE_POLICY_DIR>/<NAMESPACE>.json 的策略,该策略将在镜像拉取时(如果存在)使用。如果在镜像拉取时没有提供 Pod 命名空间(通过沙箱配置),或者拼接的路径不存在,则将使用 CRI-O 的全局策略作为后备。

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

既然一切都在容器运行时内发生,就需要有人来维护和定义策略,以便围绕该功能提供良好的用户体验。policy-controller 的 CRD 非常棒,但我们可以想象集群内的一个守护进程可以为 CRI-O 按命名空间写入策略。这将使任何额外的钩子变得多余,并将验证镜像签名的责任转移到实际拉取镜像的实例上。我评估了在纯 Kubernetes 中实现更好的容器镜像签名验证的其他可能路径,但我没有找到一个适合原生 API 的方案。这意味着我认为 CRD 是可行的方案,但用户仍然需要一个实际提供服务的实例。

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