在比较五种 Vault 接入方式的文章里,分析过 Vault Agent Injector 给集群带来的资源开销:每个业务 Pod 都要挂载 vault-agent-init 和常驻的 vault-agent 容器,即便业务只在启动时读取一次配置文件,闲置的 Sidecar 依然占用大量 CPU requests。

把交付方式换成 External Secrets Operator(ESO)加 Stakater Reloader,方向很明确,但要在运行着几十个微服务的存量集群里落地,除了替换 Kubernetes 资源,还要处理 Vault 服务端 TLS 加固、权限最小化收敛,以及不中断业务的分批切换。

flowchart LR subgraph Old ["改造前:侵入式 Sidecar 模式"] direction TB V1 ["Vault (明文 HTTP / 宽泛权限)"] Inj ["Injector Webhook"] -.->|注入 2 个容器| P1 subgraph P1 ["业务 Pod"] I1 ["vault-agent-init"] S1 ["vault-agent (常驻)"] A1 ["应用容器"] end I1 -->|HTTP| V1 S1 -->|HTTP 续期| V1 end subgraph New ["改造后:声明式原生 Secret 模式"] direction TB V2 ["Vault (内置 PKI / 强制 TLS / 细粒度策略)"] ESO ["External Secrets Operator"] -->|HTTPS 校验 CA| V2 ESO -->|声明式同步| K8sSec ["Kubernetes Secret"] K8sSec -->|只读挂载| P2 Reloader ["Stakater Reloader"] -->|监听变更| Deploy ["Deployment 滚动更新"] subgraph P2 ["业务 Pod (无 Sidecar)"] A2 ["应用容器"] end Deploy --> P2 CEL ["CEL 准入控制护栏"] -.->|拦截违规配置| ESO end

遗留架构的稳定性与安全隐患#

在着手改造前,梳理存量架构可以发现几个在业务平稳时被掩盖、但在集群波动或审计时极易爆发的隐患。

Webhook 的 Fail-Open 困局#

Vault Agent Injector 依赖 Kubernetes 的 MutatingWebhookConfiguration 拦截 Pod 创建请求并注入配置。为了防止 Injector 自身异常导致整个集群无法调度新 Pod,Webhook 默认配置通常采用 failurePolicy: Ignore。

这带来了一个脆弱点:在节点排空、底层机器回收或者 Injector 实例自身滚动更新时,如果有业务 Pod 并发调度,发往 Injector 的 Webhook 请求可能超时或报连接重置。因为策略是忽略失败,API Server 会直接放行未经注入的裸 Pod。

Spring Boot 应用普遍通过环境变量配置加载路径,例如:

SPRING_CONFIG_ADDITIONAL_LOCATION=optional:file:/vault/secrets/config.properties,optional:file:/vault/secrets/common.properties

optional:file: 前缀让容器在缺少密钥文件时不会在启动阶段立即崩溃,而是使用内置默认值启动。这种静默错误往往要到业务请求触及数据库连接或第三方 API 凭据时才会暴露,排查成本极高。如果把 Webhook 改为 failurePolicy: Fail,一旦 Injector 出现短暂不可用,集群内所有工作负载的扩缩容和发布又会被全盘阻断。

发布期提权与环境通配策略#

许多遗留的 Helm Chart 为了简化应用接入,在发布流程中内置了一个名为 vault-auth 的 Helm Hook。这个 Hook 在服务部署时以拥有高度特权的身份在 Vault 内动态创建 Policy 和 Kubernetes Auth Role。

为了让 Hook 能跑通,集群给部署组件绑定了拥有管理员权限的 ServiceAccount。更严峻的是,生成的 Vault 策略往往充斥着跨环境通配符:

# 典型的松散策略模板
path "secret/data/+/$SERVICE_NAME" {
  capabilities = ["read"]
}

中间的 + 通配符意味着只要拿到任意一个非生产环境的 Pod 凭证,就能横向读取其他环境(如预发甚至生产环境)同名服务的敏感配置,完全打破了环境间的安全隔离红线。

监听器明文传输与组件版本滞后#

在内部集群网络里,为了图省事,不少团队将 Vault 服务端的 tlsDisable 设为 true。微服务、Injector 以及平台工具均通过 http://vault...:8200 与 Vault 明文交互。一旦网络边界被突破,敏感凭据在节点之间都是裸奔状态。

与此同时,长时间未升级的 Vault 集群往往累积了大量已知漏洞(例如涉及插件与内部目录权限的安全漏洞)。给在运行的 Vault 升级并开启 TLS,成了推进上层业务改造前必须先拔掉的钉子。

Raft 存储升级与内置 PKI 启用 TLS#

在改造应用交付链路之前,首要任务是加固密钥源头:在保证服务不中断的前提下,将 Vault 升级至修补好漏洞的稳定版本,并在监听器上强制启用双向或单向 TLS。

Raft 存储快照与升级前约束#

对于采用 Integrated Storage(Raft)模式的高可用 Vault 集群,所有配置和密钥数据均以状态机日志的形式落在本地持久化卷上。升级绝不能直接修改镜像标签就执行。

第一步是在当前的 Active 节点执行 Raft 快照保存并做异地备份:

# 在当前 Active 节点执行快照保存
vault operator raft snapshot save /vault/data/raft-snapshot-pre-upgrade.snap

升级到较新的 Vault 版本(例如 1.20+ 系列)时,需要特别注意配置约束:新版本对使用 Raft 存储的容器环境要求显式声明 disable_mlock = true。如果宿主机没有赋予 IPC_LOCK 权限且缺少该配置,新版本容器启动时会因为内存锁定失败直接陷入崩溃循环。

# server 配置中必须明确显式关闭 mlock
server:
  ha:
    enabled: true
    raft:
      enabled: true
      config: |
        ui = true
        disable_mlock = true
        
        listener "tcp" {
          address = "[::]:8200"
          cluster_address = "[::]:8201"
          tls_cert_file = "/vault/userconfig/vault-tls/vault.crt"
          tls_key_file = "/vault/userconfig/vault-tls/vault.key"
        }
        
        storage "raft" {
          path = "/vault/data"
        }        

通过 Helm 将 StatefulSet 的更新策略设为 OnDelete 或标准滚动更新。升级过程中,依赖云厂商 KMS 或预设的 Shamir 恢复密钥实现自动解封,并依次验证备节点(Standby)与主节点(Active)升级后的 Raft Committed Index 与 Applied Index 保持一致。

零外部依赖的内置 PKI 与单向 TLS 体系#

为了给 Vault 启用 TLS,常见的做法是引入外部的 cert-manager。但在基础架构底层,让密钥管理中心反向依赖外部 Operator 会拉长依赖链路。

更轻量的做法是利用 Vault 自身的 PKI 秘密引擎生成内部 CA,并签发覆盖集群内全套 Service 域名的服务端证书:

  1. 启用 PKI 引擎并生成根 CA(设置 10 年长效 TTL):
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki
vault write -field=certificate pki/root/generate/internal \
    common_name="Vault Internal CA" \
    ttl=87600h > vault-ca.crt
  1. 配置角色并签发包含所有集群 SAN(Subject Alternative Name)的服务端证书:
vault write pki/roles/vault-server \
    allowed_domains="vault,vault.vault,vault.vault.svc.cluster.local,vault-internal,localhost,127.0.0.1" \
    allow_subdomains=true \
    allow_bare_domains=true \
    allow_localhost=true \
    allow_ip_sans=true \
    max_ttl=43800h

vault write -format=json pki/issue/vault-server \
    common_name="vault.vault.svc.cluster.local" \
    alt_names="vault,vault-active,vault-active.vault.svc.cluster.local,vault-0.vault-internal,vault-1.vault-internal,vault-2.vault-internal" \
    ip_sans="127.0.0.1" \
    ttl=43800h > bundle.json
  1. 将生成的证书、私钥和根 CA 存入 vault-tls Secret,通过挂载注入到 Vault Pod 内。

服务端开启 TLS 后,Raft 节点间通信的 retry_join 配置需要补充 leader_ca_cert_file 路径。此外,Vault Helm Chart 默认的就绪探针(Readiness Probe)如果配置了 HTTP 探测,在切到 TLS 后会误报 400 失败。解决办法是移除探针的 path,让 Chart 自动切到基于命令行的 vault status -tls-skip-verify 探针。

验证 TLS 生效的关键是在服务端发起明文请求测试:

# 明文请求必须被直接阻断
curl -s http://127.0.0.1:8200/v1/sys/seal-status
# 返回:Client sent an HTTP request to an HTTPS server.

此时,服务端已完成硬化,集群内所有客户端均须带上 CA 证书通过 HTTPS 进行通信。

权限最小化与 IaC 统一纳管#

彻底废弃原架构中危险的发布期 Helm Hook,核心思想是实现职责分离:业务 Chart 仅负责声明使用密钥,而读取权限的授予完全收拢到底层基础设施代码(Terraform)中。

环境级单一角色与模板化策略#

摒弃过去为每个微服务单独创建 Vault Role 和通配 Policy 的碎片化做法,每个 Kubernetes 环境仅保留一个基础 Auth Role,配合细粒度的 Path 模板化策略:

# Terraform: qa 环境策略定义
resource "vault_policy" "qa_service" {
  name = "qa-service"

  policy = <<EOT
# 严格限制只能读取属于自身 ServiceAccount 名称的路径
path "secret/data/qa/{{identity.entity.aliases.auth_kubernetes.metadata.service_account_name}}" {
  capabilities = ["read"]
}

# 允许读取当前环境的公共配置
path "secret/data/qa/common" {
  capabilities = ["read"]
}
EOT
}

resource "vault_kubernetes_auth_backend_role" "qa" {
  backend                          = "kubernetes"
  role_name                        = "qa"
  bound_service_account_names      = ["*"]
  bound_service_account_namespaces = ["qa"]
  token_policies                   = ["qa-service"]
  token_ttl                        = 3600
  audience                         = "vault"
}

通过这一设计,权限实现了多重收敛:

  1. 环境严格隔离:qa 角色只绑定在 qa 命名空间,无法认证其他环境。
  2. 路径级最小化:借助 {{identity.entity.aliases...}} 变量,Pod 只能拿到与自身 ServiceAccountName 一致的密钥路径,不再存在跨服务跨环境读数据的漏洞。
  3. Audience 绑定:强制限制服务账号令牌的 Audience 必须为 vault,避免默认的 Kubernetes 令牌被用作跨系统凭证转发。

使用 Kubernetes 原生 CEL 策略筑牢安全护栏#

在大规模迁移过程中,如果单纯依赖人工审查 Git 提交,难免会有配置漏配(例如漏了 CA 证书、写错角色、或者仍指向 http:// 明文地址)。

从 Kubernetes 1.28 开始正式引入的 ValidatingAdmissionPolicy 提供了基于 CEL(Common Expression Language)的原生准入控制,无须在集群中常驻第三方 Webhook 控制器,直接在 API Server 层面拦截违规声明。

针对 ESO 的 SecretStore,可以部署如下声明式校验规则:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: eso-secretstore-guardrail
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: ["external-secrets.io"]
        apiVersions: ["v1", "v1beta1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["secretstores"]
  matchConditions:
    - name: target-namespaces
      expression: "request.namespace.startsWith('qa') || request.namespace.startsWith('staging')"
  validations:
    - expression: "has(object.spec.provider) && has(object.spec.provider.vault)"
      message: "SecretStore 提供商必须为 Vault"
    - expression: "object.spec.provider.vault.server == 'https://vault.vault.svc.cluster.local:8200'"
      message: "Vault 服务端地址必须强制使用集群内 HTTPS 端口"
    - expression: "has(object.spec.provider.vault.caBundle) && size(object.spec.provider.vault.caBundle) > 0"
      message: "SecretStore 必须内嵌有效的 caBundle 验证服务端证书"
    - expression: "object.spec.provider.vault.auth.kubernetes.role == request.namespace"
      message: "Vault 认证角色必须与当前命名空间精确对应"
    - expression: "object.spec.provider.vault.auth.kubernetes.serviceAccountRef.audiences == ['vault']"
      message: "ServiceAccount audiences 必须包含 ['vault']"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: eso-secretstore-guardrail-binding
spec:
  policyName: eso-secretstore-guardrail
  validationActions: [Deny, Audit]
  matchResources:
    namespaceSelector:
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: In
          values: ["qa", "staging"]

在迁移初期,可将 validationActions 设置为 [Audit, Warn],用来发现并修正测试配置;在正式批量切流前,果断切换为 [Deny] 模式。任何违规提交的 PR 在 Argo CD 执行同步时都会被 API Server 直接打回,确保不合规的配置无法落库。

双轨比对与分批灰度迁移#

一切准备就绪后,应用侧的切换需要遵循严密的灰度编排,避免因字段渲染差异引发生产故障。

Spike 阶段的字节级双轨比对#

ESO 是利用模版引擎把 Vault 的 JSON 字段还原为应用读取的 .properties 文件。为了验证模板还原是否与旧版 Vault Agent 完全一致,在测试环境挑选典型服务执行双轨测试:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: orders-api-config
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: orders-api-vault
    kind: SecretStore
  target:
    name: orders-api-vault-secret
    template:
      engineVersion: v2
      data:
        config.properties: |
          {{- range $k, $v := (ds "appConfig") }}
          {{ $k }}={{ $v }}
          {{- end }}          
        common.properties: |
          {{- range $k, $v := (ds "commonConfig") }}
          {{ $k }}={{ $v }}
          {{- end }}          
  dataFrom:
    - sourceRef:
        generatorRef:
          apiVersion: external-secrets.io/v1alpha1
          kind: VaultDynamicSecret # 或直接用 extract 从 secret/data/qa/orders-api 取数

把 ExternalSecret 生成的 Secret 打印出来,与旧 Pod 内生成的 /vault/secrets/config.properties 跑 diff:

  • 校验每行末尾的换行符格式(LF vs CRLF);
  • 校验空值处理与特殊符号转义;
  • 校验 Key-Value 的排序规则,避免配置因排序混乱导致 Hash 抖动引发应用反复重启。

试点服务的容灾与自动重启实测#

选择调用链路末端的单一服务(例如缓存代理或定时任务消费端)作为 Pilot 进行上线。在 Pilot 服务上必须实测两大场景:

  1. 离线解耦韧性测试:
    • 将 ESO 控制器的 Deployment 副本数缩容为 0,模拟 Operator 故障或重启;
    • 手动删除业务 Pod,观察 Deployment 重新拉起 Pod 的过程;
    • 结果验证:由于 Kubernetes Secret 已经保存在 etcd 中,新 Pod 挂载本地 Secret 瞬间启动成功,启动过程对 ESO 控制器和 Vault 本身实现了解耦,彻底解决了旧架构中 Webhook 超时导致启动失败的难题。
  2. 变更自动滚动生效测试:
    • 在 Vault 中修改一个非关键的配置项;
    • 等待到达 refreshInterval 轮询周期,ESO 自动更新了 Kubernetes Secret 的数据;
    • Stakater Reloader 检测到 Secret 的 resourceVersion 发生变化,自动触发了 Deployment 的平滑滚动更新(RollingUpdate);
    • 应用新 Pod 正常启动并拉取最新配置,旧 Pod 在就绪探针成功后优雅终止。

批量灰度编排#

经验证无误后,将集群内数十个服务按照拓扑依赖关系分为若干批次推进:

  • 第一批(外围独立服务):后台管理前端、报表消费端、独立的网关边缘代理;
  • 第二批(异步任务与定时调度):数据同步 Job、延时重试队列消费服务;
  • 第三批(核心后端服务):订单处理、认证服务、会话结算等关键链路。

每批变更通过 GitOps PR 合并,并在控制台监控 Prometheus 告警规则:

# 监控 ExternalSecrets 同步状态
- alert: ExternalSecretSyncFailed
  expr: externalsecret_status_condition{condition="Ready", status="False"} > 0
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "ExternalSecret {{ $labels.name }} 同步失败"

彻底下线遗留组件与收尾#

当集群内所有业务命名空间的 Pod 都已迁移至 ESO,且检查集群中不再存在带 vault.hashicorp.com/agent-inject: "true" 注解的实例后,即可执行最后的收尾拆除工作:

  1. 禁用 Injector Webhook:在 Vault Helm values 中明确配置:
    injector:
      enabled: false
    
    卸载 Injector Deployment 及注册在 API Server 上的 MutatingWebhookConfiguration。
  2. 清理历史特权:删除各业务命名空间为了运行 Hook 而创建的 vault ServiceAccount、集群级别的 vault-admin 角色以及过期的松散 Vault 策略。
  3. 确立新服务接入基线:在代码库文档中明确指引:新应用接入 Kubernetes 统一采用 vault.delivery: eso 配置,配置入 Git,密钥入 Vault。

小结#

把集群密钥分发从 Vault Agent Injector 切到 ESO 之后,主要变化在三处:

  • 解耦启动依赖:Pod 直接读原生的 Kubernetes Secret,启动时不再强依赖 Injector Webhook 或当时的 Vault 网络状况。
  • 收敛通信与权限:Vault 通过内置 PKI 开启服务端 TLS 阻断明文传输,配合 ServiceAccount 动态路径与 CEL 准入策略,防止配置越权。
  • 配置联动:由 Reloader 监听 Secret 变化并触发滚动更新,替代了手动重启。

存量集群迁移的关键在前面的防线:先做好 Raft 快照,在测试服务上跑通 properties 双轨 diff,再按依赖关系分批灰度。

相关文章#

参考资料#