cloud

用 ClusterExternalSecret 將 Registry 登入資訊分發到多個 namespace

以 Vault 保存 credential,讓 namespace label 控制分發範圍,並分開驗證 Secret 同步與真正的 image pull。

English繁中

新增一個 namespace 後,Deployment 看起來沒有問題,Pod 卻停在 ImagePullBackOff。檢查才發現 imagePullSecrets 指向 regcred,但這份 Secret 只存在另一個 namespace。手動複製一次很快;每增加一個 workload、每輪替一次 credential 都要再複製,才是後續的負擔。

我的 GitOps 設定用 Vault 保存 Registry 登入資料,再由 ClusterExternalSecret 在選中的 namespaces 建立 ExternalSecret。各個 ExternalSecret 負責產生當地的 regcred,workload 仍然使用 Kubernetes 原生的 imagePullSecrets

本文集中在分發與輪替。Vault Kubernetes auth、CA 信任與一般 ExternalSecret 的起點,可先看 Vault 與 External Secrets

一份來源,不是一份跨 namespace Secret

Vault KV: registry/home → ClusterSecretStore/vault-registry
ClusterExternalSecret → namespace apps-a / ExternalSecret → Secret/regcred
                      → namespace apps-b / ExternalSecret → Secret/regcred

這裡仍然有多份 Kubernetes Secrets,只是它們有同一個受管理的來源。Kubernetes 的 private registry 文件要求 pull Secret 位於使用它的 Pod 所在 namespace;不能直接引用 default/regcred 來避開這個邊界。

開始前要準備好:

  • 已安裝支援範例 external-secrets.io/v1 資源的 External Secrets Operator(ESO)。
  • Vault KV v2 的 mount 為 secret,Kubernetes auth 已可用。
  • ESO 的 ServiceAccount 為 external-secrets,位於同名 namespace。
  • ESO 能驗證 https://vault.home.arpa 的 certificate,簽發它的 CA certificate 存在 external-secrets/vault-ca ConfigMap;這裡存的是公開憑證,不是 CA private key。
  • Node 能解析並透過 TLS 存取 Registry。Secret 分發不會替 container runtime 安裝 CA。

先保存一份可獨立使用的 Docker config

Registry credential 應有 pull 所需的最小權限,不使用可以管理整個 Registry 的帳號。準備一份受保護的 Docker config JSON,確認其中的 auths 對應實際 image 使用的 hostname 與 port,再存入 Vault:

vault kv put secret/registry/home \
  ".dockerconfigjson=@/secure/registry-pull.json"

這個檔案不能只是指向桌面 credential helper 的 credsStorecredHelpers 設定。Kubernetes 不會呼叫桌面 helper 替 kubelet 找登入資訊;交付給它的 config 必須包含可用於 Registry 的 credential。若從既有 Secret 移轉,需先將 .data[".dockerconfigjson"] 解碼一次,不把外層 base64 當成原始 JSON 再存入 Vault。

此例只給 ESO role 讀取單一 KV v2 path:

path "secret/data/registry/home" {
  capabilities = ["read"]
}

把 policy 存成 registry-pull-read.hcl,由有權管理 Vault 的人套用並綁定既有 Kubernetes auth:

vault policy write registry-pull-read registry-pull-read.hcl
vault write auth/kubernetes/role/external-secrets-registry \
  bound_service_account_names=external-secrets \
  bound_service_account_namespaces=external-secrets \
  audience=https://kubernetes.default.svc.cluster.local \
  policies=registry-pull-read \
  ttl=1h

audience 必須符合 ESO 要求的 ServiceAccount JWT 與該 cluster 的設定;下方 Store 的 serviceAccountRef.audiences 明確使用相同值。這仍是範例,不應略過環境核對,設定位置可參考 ESO Kubernetes authentication。建立 role 也不會代替 Vault Kubernetes auth 所需的 API server、CA 與 TokenReview 設定。

Store 管來源,ClusterExternalSecret 管分發

以下 Store 只提供 Registry credential。範例額外用 conditions 限制哪些 namespace 能使用它;這是存取邊界,不只是分發篩選:

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: vault-registry
spec:
  conditions:
    - namespaceSelector:
        matchLabels:
          registry.example.com/pull-credentials: enabled
  provider:
    vault:
      server: https://vault.home.arpa
      path: secret
      version: v2
      caProvider:
        type: ConfigMap
        namespace: external-secrets
        name: vault-ca
        key: ca.crt
      auth:
        kubernetes:
          mountPath: kubernetes
          role: external-secrets-registry
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets
            audiences:
              - https://kubernetes.default.svc.cluster.local
---
apiVersion: external-secrets.io/v1
kind: ClusterExternalSecret
metadata:
  name: registry-pull-credentials
spec:
  externalSecretName: regcred
  namespaceSelectors:
    - matchLabels:
        registry.example.com/pull-credentials: enabled
  refreshTime: 1h
  externalSecretSpec:
    refreshPolicy: Periodic
    refreshInterval: 2m
    secretStoreRef:
      name: vault-registry
      kind: ClusterSecretStore
    target:
      name: regcred
      creationPolicy: Owner
      template:
        engineVersion: v2
        type: kubernetes.io/dockerconfigjson
    data:
      - secretKey: .dockerconfigjson
        remoteRef:
          key: registry/home
          property: .dockerconfigjson

兩個 refresh 欄位做不同的事:refreshTime 讓 ClusterExternalSecret 定期檢查 namespace 裡的 ExternalSecrets;refreshInterval 決定各個 ExternalSecret 定期讀取 Vault 的間隔。前者不是 credential 的更新週期,後者也不是保證兩分鐘內一定完成的 SLA。Controller、Vault 或 network 故障都可能延後同步。ClusterExternalSecret 文件有完整定義。

Namespace label 是授權流程的一部分

需要這份 pull credential 的 namespace 才加入 label:

apiVersion: v1
kind: Namespace
metadata:
  name: apps-a
  labels:
    registry.example.com/pull-credentials: enabled

ClusterExternalSecret 的 selector 只決定它在哪裡建立 ExternalSecrets,不能單獨阻止其他人自行建立 ExternalSecret 引用同一個 Store。因此還需要上面的 Store conditions、限制 namespace label 的修改權,以及限制誰能建立 ExternalSecrets。Store conditions 的語意見 ClusterSecretStore 文件

任何可以在已授權 namespace 建立 Pod 的人,都可能透過掛載 Secret 取得這份 credential,即使沒有直接 get secrets 權限。共用 credential 適合相同信任範圍的 workloads;不同租戶或不同 repository 權限應拆成不同 credential、Store 與 selector,不為了方便而共用一個權限過大的帳號。

Pod 仍要明確引用 regcred

Secret 出現不會自動修改 Deployment。可以在 Pod template 或受控的 ServiceAccount 設定 imagePullSecrets。以下是用來驗證的短生命週期 Pod,image 必須換成 Registry 中確實存在、且允許以 true 結束的測試 image:

apiVersion: v1
kind: Pod
metadata:
  name: registry-pull-check
  namespace: apps-a
spec:
  restartPolicy: Never
  imagePullSecrets:
    - name: regcred
  containers:
    - name: check
      image: registry.home.arpa:5000/examples/pull-check:tested
      imagePullPolicy: Always
      command: ["/bin/sh", "-c", "true"]

先確認兩層 reconcile 都完成,再建立測試 Pod:

kubectl get clustersecretstore vault-registry
kubectl get clusterexternalsecret registry-pull-credentials
kubectl -n apps-a wait --for=condition=Ready externalsecret/regcred --timeout=120s
kubectl -n apps-a get secret regcred \
  -o jsonpath='{.type}{"\n"}'
kubectl apply -f registry-pull-check.yaml
kubectl -n apps-a describe pod registry-pull-check

Secret type 應為 kubernetes.io/dockerconfigjson;Pod Events 要確認 image pull 成功。Always 會要求 runtime 檢查 image,但 layer 仍可能重用 cache。若 node 另有 Registry credential,成功也不一定證明它用了 regcred;嚴格驗證時要排除其他 credential source,或比對 Registry 端登入身分紀錄。完成後刪除測試 Pod。

輪替時保留重疊的有效期間

正常輪替可以依序做:

  1. 在 Registry 建立新的、同樣受限的 pull credential,暫時保留舊的。
  2. 更新 Vault 裡同一個 .dockerconfigjson property。
  3. 確認每個目標 ExternalSecret 的 Ready、refresh time 與錯誤狀態,而非只看 ClusterExternalSecret 是否存在。
  4. 使用新 Pod 驗證實際 pull,確認所有目標 namespace 都已完成更新。
  5. 再到 Registry 撤銷舊 credential。

已經執行的 container 不會因 pull Secret 更新就 restart;這也不是用環境變數載入 application secret 的 reload 流程。若是外洩事件,撤銷舊 credential 可能必須優先執行,並接受新 Pod 暫時無法拉取 image 的可用性代價。

移除 namespace label 或刪除 ClusterExternalSecret 可能連帶清理它擁有的 ExternalSecrets 與 Secrets。creationPolicy: Owner 會建立 ownership,不能把移除 label 當成只停止更新、保留資料的開關。既有同名 ExternalSecret 衝突時,也應先確認 ownership 再遷移,不直接覆寫。

這套流程把「到每個 namespace 手動複製」變成可審查的宣告,但沒有消除分發的成本:每個 ExternalSecret 仍各自向 Vault refresh。Namespace 數量增加後要觀察 controller 與 Vault 負載。最重要的驗收仍是三件事:只有預定 namespace 收到 credential、Secret 確實更新、新的 image pull 真的使用了有效授權。