本文發表於一年多前。舊文章可能包含過時內容。請檢查頁面中的資訊自發布以來是否已變得不正確。

在 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 郵件列表與我直接聯絡。