在 GitOps 使用 Mozilla SOPS 加密 Kubernetes Secret

在 Git 保存加密的 Kubernetes Secret 值,部署時由 GitOps controller 解密。

English繁中
在 GitOps 使用 Mozilla SOPS 加密 Kubernetes Secret

SOPS 可以讓加密後的 Kubernetes Secret 值保存在 Git,部署時再由 GitOps controller 解密。它與 Vault 的差別在於資料來源:Vault 提供 runtime 值,SOPS 則以 Git 裡的加密檔為 source of truth。

encrypted Secret in Git → GitOps controller 解密 → Kubernetes Secret → Pod

小型 cluster、不依賴 Vault 的 bootstrap、需要和 manifest 一起審查的設定,以及以 Git 加上受保護金鑰重建 cluster,都是適合考慮 SOPS 的情境。

金鑰與解密資料放在哪裡

Git 保存加密值與 public recipients;cluster 保存私鑰,controller 在處理 manifests 的流程中解密,Pod 最後取得一般的 Kubernetes Secret。

不要將私鑰提交到 Git、讓 CI 輸出解密資料,或把解密後的檔案寫回 repository。Argo CD repo-server 與 Redis 也不能任由其他 workloads 存取。解密後仍需 Kubernetes RBAC、namespace 隔離與 runtime 存取限制;有權讀 Secret 或進入 Pod 的人仍可能取得原值。

產生 age identity

SOPS 支援 age、OpenPGP、AWS KMS、GCP KMS、Azure Key Vault 與 Vault transit。對家用或小型 cluster,我會選擇 age:public recipient 可以放進 .sops.yaml,private identity 則另外保護;多個 recipients 可用於輪替或緊急存取。

安裝並產生 identity:

brew install age sops
age-keygen -o cluster-age.agekey

產生的檔案包含 public recipient 與 private identity。以下只是格式示意,Git 只能保存 public recipient:

# public key: age1h3examplepublicrecipientxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AGE-SECRET-KEY-1EXAMPLEPRIVATEIDENTITYXXXXXXXXXXXXXXXXXXXXXXXXXXXX

將私鑰交給 Flux

Flux 使用 flux-system namespace 裡的 Secret:

kubectl -n flux-system create secret generic sops-age \
  --from-file=age.agekey=./cluster-age.agekey \
  --dry-run=client -o yaml | kubectl apply -f -

移除工作目錄裡的私鑰前,要先在 repository 之外保存受保護的恢復副本。若唯一私鑰也隨 cluster 消失,就無法靠 Git 重建 Secrets。不要將它留在共用目錄或 CI artifacts;刪除檔案也不等於所有儲存系統都已安全抹除。

若環境已有 cloud KMS 或硬體保護的 secret store,可以用身分授權取代把原始私鑰複製到各台管理電腦。

設定加密規則

Repository 根目錄的 .sops.yaml 指定哪些檔案、哪些欄位要加密:

creation_rules:
  - path_regex: apps/.*/secrets/.*\.ya?ml$
    encrypted_regex: '^(data|stringData)$'
    age: age1h3examplepublicrecipientxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

這個規則匹配 apps/ 下 secrets 目錄的 YAML,只加密 data 與 stringData。Metadata、資源名稱與 key 保持可讀,reviewer 仍能判斷哪個 namespace 會收到哪份 Secret。

不同環境可以使用不同 recipients:

creation_rules:
  - path_regex: clusters/prod/.*/secrets/.*\.ya?ml$
    encrypted_regex: '^(data|stringData)$'
    age: age1prodrecipientxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

  - path_regex: clusters/staging/.*/secrets/.*\.ya?ml$
    encrypted_regex: '^(data|stringData)$'
    age: age1stagingrecipientxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Controller 也必須只持有自己環境的私鑰。若 staging 和 production controllers 都拿到兩把金鑰,僅靠這份規則無法隔離資料。

加密一份 dotenv Secret

先建立一般 Secret manifest,以 stringData 保存 dotenv 檔內容:

apiVersion: v1
kind: Secret
metadata:
  name: example-api-env-file
  namespace: example-api
type: Opaque
stringData:
  .env: |
    DATABASE_URL=postgres://example-api:change-me@postgres.example.internal:5432/example_api
    REDIS_URL=redis://:change-me@redis.example.internal:6379/0
    JWT_SECRET=change-me

將它存成 apps/example-api/secrets/env-file.yaml,再加密:

sops --encrypt --in-place apps/example-api/secrets/env-file.yaml

加密後的結構如下:

apiVersion: v1
kind: Secret
metadata:
  name: example-api-env-file
  namespace: example-api
type: Opaque
stringData:
  .env: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
sops:
  age:
    - recipient: age1h3examplepublicrecipientxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        ...
        -----END AGE ENCRYPTED FILE-----
  encrypted_regex: ^(data|stringData)$
  version: 3.x.x

確認值已變成 ENC[...],並搜尋原本應被加密的內容:

rg --files-with-matches "change-me|DATABASE_URL|JWT_SECRET" apps/example-api/secrets

本例的變數名稱都在 .env 字串內,因此這個查詢應沒有結果。

由 Flux 解密並讓 Pod 讀取

Flux 的 kustomize controller 原生支援 SOPS。下面的 Kustomization 引用存有私鑰的 sops-age Secret;platform GitRepository 與 apps/example-api 的 manifests 必須先準備好:

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: example-api
  namespace: flux-system
spec:
  interval: 5m
  path: ./apps/example-api
  prune: true
  sourceRef:
    kind: GitRepository
    name: platform
  decryption:
    provider: sops
    secretRef:
      name: sops-age

此 Secret 的 .env key 是一整份 dotenv 檔。將它掛載到目錄,並設定應用程式讀取 /etc/example-api/.env。以下是 Deployment 的相關片段:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: example-api
  namespace: example-api
spec:
  template:
    spec:
      containers:
        - name: api
          image: ghcr.io/example/example-api:1.0.0
          volumeMounts:
            - name: app-config
              mountPath: /etc/example-api
              readOnly: true
      volumes:
        - name: app-config
          secret:
            secretName: example-api-env-file

envFrom 將 Secret entries 映射為環境變數,不會解析 .env 字串裡的每一行。這裡改用 volume,讓 .env 成為檔案;參考 Kubernetes Secret 檔案掛載。

使用 Argo CD 時的解密位置

Argo CD 通常透過 config management plugin、Helm Secrets 或 KSOPS 接入。解密發生在 manifest generation 階段,因此要考慮 repo-server 與 cache 的資料存取:

  • 用 NetworkPolicy 隔離 repo-server。
  • 保護 Redis,避免 application namespaces 存取。
  • 固定 plugin image,交由平台維護。
  • 不將解密 manifests 印到 logs。
  • 以 Argo CD RBAC 限制 generated manifests 的讀取。

以 SOPS 為主要流程的小型 cluster,可以利用 Flux 原生支援;已使用 Argo CD 的環境則要比較 plugin 的管理成本與 Vault/External Secrets 的做法。

輪替 secret 值

用 SOPS editor 修改檔案:

sops apps/example-api/secrets/env-file.yaml

儲存後提交加密 diff,確認 plaintext 沒有留下,並等待 Flux 同步更新。get secret 只能確認物件存在,不能證明應用程式已讀到最新值:

rg --files-with-matches "new-plain-value" apps/example-api/secrets
kubectl -n example-api get secret example-api-env-file

環境變數要透過重建 Pod 更新;一般 Secret volume 會逐步更新檔案,但 application 仍要重新讀取。不能只因 Secret 已更新,就假設程式已使用新值。

如果應用程式只在啟動時讀取檔案,等更新後的 Secret 套用完成,再重啟:

kubectl -n example-api rollout restart deploy/example-api

輪替 age key

先把新 recipient 加進 .sops.yaml:

creation_rules:
  - path_regex: apps/.*/secrets/.*\.ya?ml$
    encrypted_regex: '^(data|stringData)$'
    age: >-
      age1oldrecipientxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,
      age1newrecipientxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

更新加密檔,讓新舊 recipients 都能解密:

sops updatekeys apps/example-api/secrets/env-file.yaml

將新私鑰安裝到 cluster,並在只提供新私鑰的受控環境確認檔案能解密;同時裝著新舊 key 時 reconcile 成功,不能證明新 key 可用。

從 .sops.yaml 移除舊 recipient 後,逐一更新受影響的檔案,並輪替其 data key:

sops updatekeys apps/example-api/secrets/env-file.yaml
sops rotate --in-place apps/example-api/secrets/env-file.yaml

updatekeys 改變的是誰能解開既有 data key;rotate 才會產生新的 data key 並重新加密值。若省略後者,舊 identity 的持有人仍可能從 Git 歷史取回相同的 data key,解開之後以它加密的新值。參考 SOPS key rotation 文件。

提交加密變更,確認 Flux 使用新 key 同步成功後,再移除 cluster 與管理端使用中的舊私鑰。舊備份若仍需要解密,應另存受保護的恢復副本。輪替無法收回歷史密文或已取得的明文;若憑證已外洩,還要在來源系統輪替憑證本身。

提交前檢查

.gitignore 排除工作用金鑰與解密檔:

*.agekey
*.dec.yaml
*.decrypted.yaml
.env

本機可搜尋明文與私鑰特徵,只列出匹配的檔名,避免輸出含有 secret 的整行:

rg --files-with-matches "AGE-SECRET-KEY|DATABASE_URL=|JWT_SECRET=|BEGIN OPENSSH PRIVATE KEY" .

rg 找到匹配時回傳 0,沒有匹配時回傳 1;接入 CI 時要讓「找到匹配」使檢查失敗,並另外處理搜尋錯誤。這個查詢遵守 ignore 規則;CI 還應以 secret scanner 檢查已追蹤檔案,因為 .gitignore 不會停止追蹤已提交的檔案。

在有解密權限的受控環境檢查檔案能否解密,輸出直接丟棄:

sops --decrypt apps/example-api/secrets/env-file.yaml >/dev/null

確認 Git 內保存的欄位仍是加密值:

yq '.stringData[".env"]' apps/example-api/secrets/env-file.yaml

Review 不必看到明文,也能檢查 Secret 的 namespace、名稱、使用它的 workloads、recipient 變更與 .sops.yaml 規則。

依資料來源選擇工具

需要動態 secret、集中稽核、跨系統共享或外部輪替時,可使用 Vault;希望加密檔以 Git 為 source of truth 時,可使用 SOPS。ESO 負責將外部 secret manager 的值同步到 Kubernetes;Sealed Secrets 則提供 Kubernetes 專用的非對稱加密流程。

我的 cluster 仍使用 Vault 與 ESO 提供 runtime 值;bootstrap 或較小的 cluster,才考慮用 SOPS 管理 Git 裡的加密設定。