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

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

自 v1.24 版本发布以来,Kubernetes 社区一直在对其基于容器镜像的工件进行签名。虽然在 v1.26 中将相应的增强功能alpha 升级到 beta 后引入了二进制工件的签名,但其他项目也纷纷效仿,为其发布版本提供镜像签名。这意味着它们要么在自己的 CI/CD 管道中创建签名,例如通过使用 GitHub 操作,要么依赖 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 发起的。此优势也带来了分离的问题:应该拉取容器镜像的节点不一定是执行准入的同一节点。这意味着如果控制器受到损害,则无法再进行集群范围内的策略强制执行。

解决此问题的一种方法是在与 kubelet 直接连接的容器运行时接口 (CRI) 兼容的容器运行时中直接执行策略评估。运行时执行所有任务,例如拉取镜像。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": "[email protected]",
            "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 无效,例如将其设置为 [email protected]

> jq '.transports.docker."quay.io/crio/signed"[0].fulcio.subjectEmail = "[email protected]"' 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 [email protected] not found (got []string{"[email protected]"})

也可以针对策略测试未签名的镜像。为此,您必须将键 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 [email protected] \
    --certificate-oidc-issuer https://127.0.0.1 \
    | 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 [email protected] \
    --certificate-oidc-issuer https://127.0.0.1 \
    | 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 体验,并且不依赖于安装在集群中的第三方软件。

仍然需要考虑一些极端情况:例如,如果您想以与策略控制器支持的方式相同的方式允许每个命名空间的策略,该怎么办?好吧,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 的小改动可以对此有所帮助。

现在一切都在容器运行时中发生,因此必须有人维护和定义策略,以围绕该功能提供良好的用户体验。策略控制器的 CRD 很棒,同时我们可以想象集群中的守护程序可以为每个命名空间编写 CRI-O 的策略。这将使任何额外的钩子过时,并将验证镜像签名的责任转移到实际拉取镜像的实例上。我评估了在普通 Kubernetes 中实现更好的容器镜像签名验证的其他可能路径,但我找不到原生 API 的完美契合点。这意味着我认为 CRD 是可行的方法,但用户仍然需要一个实际为它服务的实例。

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