从 Vault Agent 迁移到 ESO:TLS 加固、权限收敛与业务平滑切换
目录
在比较五种 Vault 接入方式的文章里,分析过 Vault Agent Injector 给集群带来的资源开销:每个业务 Pod 都要挂载 vault-agent-init 和常驻的 vault-agent 容器,即便业务只在启动时读取一次配置文件,闲置的 Sidecar 依然占用大量 CPU requests。
把交付方式换成 External Secrets Operator(ESO)加 Stakater Reloader,方向很明确,但要在运行着几十个微服务的存量集群里落地,除了替换 Kubernetes 资源,还要处理 Vault 服务端 TLS 加固、权限最小化收敛,以及不中断业务的分批切换。
遗留架构的稳定性与安全隐患#
在着手改造前,梳理存量架构可以发现几个在业务平稳时被掩盖、但在集群波动或审计时极易爆发的隐患。
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 域名的服务端证书:
- 启用 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
- 配置角色并签发包含所有集群 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
- 将生成的证书、私钥和根 CA 存入
vault-tlsSecret,通过挂载注入到 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"
}
通过这一设计,权限实现了多重收敛:
- 环境严格隔离:
qa角色只绑定在qa命名空间,无法认证其他环境。 - 路径级最小化:借助
{{identity.entity.aliases...}}变量,Pod 只能拿到与自身ServiceAccountName一致的密钥路径,不再存在跨服务跨环境读数据的漏洞。 - 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 服务上必须实测两大场景:
- 离线解耦韧性测试:
- 将 ESO 控制器的 Deployment 副本数缩容为 0,模拟 Operator 故障或重启;
- 手动删除业务 Pod,观察 Deployment 重新拉起 Pod 的过程;
- 结果验证:由于 Kubernetes Secret 已经保存在 etcd 中,新 Pod 挂载本地 Secret 瞬间启动成功,启动过程对 ESO 控制器和 Vault 本身实现了解耦,彻底解决了旧架构中 Webhook 超时导致启动失败的难题。
- 变更自动滚动生效测试:
- 在 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" 注解的实例后,即可执行最后的收尾拆除工作:
- 禁用 Injector Webhook:在 Vault Helm values 中明确配置:
卸载 Injector Deployment 及注册在 API Server 上的 MutatingWebhookConfiguration。
injector: enabled: false - 清理历史特权:删除各业务命名空间为了运行 Hook 而创建的
vaultServiceAccount、集群级别的vault-admin角色以及过期的松散 Vault 策略。 - 确立新服务接入基线:在代码库文档中明确指引:新应用接入 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,再按依赖关系分批灰度。