本文发布已超过一年。较旧的文章可能包含过时内容。请检查页面信息自发布以来是否已发生变化。

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

Kubernetes 社区自 v1.24 版本起开始对基于容器镜像的制品进行签名。在 v1.26 版本中,相应的增强提案alpha 升级到 beta,引入了对二进制制品的签名。其他项目也纷纷效仿,为其发布版本提供镜像签名。这意味着它们可以在自己的 CI/CD 流水线中创建签名,例如使用 GitHub Actions;或者依赖 Kubernetes 镜像推广流程,通过向 k/k8s.io 仓库提交拉取请求来自动签名镜像。使用此流程的要求是该项目属于 kuberneteskubernetes-sigs GitHub 组织,以便它们可以利用社区基础设施将镜像推送到临时存储桶。

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

基于准入控制器的验证的一般使用流程是

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 命名空间(通过 sandbox config),或者拼接后的路径不存在,那么将使用 CRI-O 的全局策略作为回退。

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

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

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