K8s 应用从 Vault 取密钥:Agent sidecar、init-only、VSO、ESO 和 CSI 怎么选
目录
一个跑 dev、QA、STG 的非生产 Kubernetes 集群,worker 是 4 vCPU 的 spot 节点,由 Cluster Autoscaler 按 pod 的 requests 扩缩。一次批量重启四十来个 Deployment 时,滚动更新多出来的 pod 放不下,Autoscaler 一口气加了 4 台节点。
查 requests 的构成时,最大的一块来自 Vault Agent:大约 70 个 pod 走 Vault Agent Injector 取密钥,每个 pod 被注入一个 vault-agent-init 和一个常驻的 vault-agent,两者都申请 250m CPU。业务 namespace 的 CPU requests 一共 26.9 vCPU,其中 16.5 vCPU 是 agent 的;整个集群申请了 31 vCPU,Prometheus 里过去 7 天的实际用量 p95 是 3.4 vCPU,最忙的那个 vault-agent 容器 p95 是 0.0012 vCPU。
调低 requests 能解决眼前的问题,但它引出了另一个问题:这些服务为什么需要一个常驻的 sidecar。
Agent 容器的 250m 从哪来,为什么 init 也要算#
250m 是 Vault Helm chart 的默认值。vault-helm 0.34.1(2026-08-13 发布)里 injector.agentDefaults 写的是 cpuRequest: "250m"、cpuLimit: "500m"、memRequest: "64Mi"、memLimit: "128Mi",注解文档里 agent-requests-cpu 的默认值也是 250m。chart 不覆盖、pod 上也不写注解,每个注入的容器就按这个值申请。
init 容器同样要算。Kubernetes 文档对 pod 实际申请量的定义是取两者中较大的一个:所有 init 容器里最大的那个 request,和所有普通容器 request 的和。一个业务容器申请 100m、filebeat 申请 25m 的 pod,把 sidecar 降到 25m 后普通容器合计 150m,init 容器如果还是 250m,调度器仍按 250m 预留。好在 injector 给两种容器用的是同一份资源配置,vault-k8s 1.7.6(2026-08-07 发布)里 init 容器调用的也是 parseResources(),改 agentDefaults 两边一起生效。
这些预留会直接推高节点数量。Cluster Autoscaler 的 FAQ 写明扩容只看 pod 能不能放下,不看 CPU 负载;缩容的条件是节点上 pod 的 CPU 和内存 requests 之和低于可分配量的 50%。requests 虚高的集群,节点数跟着虚高。
应用只在启动时读一次配置,sidecar 没有活干#
这批服务都是 Spring Boot,部署时用 SPRING_CONFIG_ADDITIONAL_LOCATION 指向 /vault/secrets/config.properties 和 /vault/secrets/common.properties,配置在启动时加载一次。代码里没有 @RefreshScope、spring-cloud-vault,也没有监听文件变化的逻辑。常驻的 vault-agent 在后台续期 token、重新渲染文件,应用不会再读这些文件;反过来,在 Vault 里改了一个值,服务要等下次重启才拿得到。
这种情况下,sidecar 只在启动前那一刻有用,而那一刻 init 容器已经做完了。Vault Agent Injector 有现成的开关:vault.hashicorp.com/agent-pre-populate-only 设成 true 时只注入 init 容器,不注入 sidecar。文档里推荐 Job 和 CronJob 这么用,只在启动时读配置的长期服务同样适用。
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "qa-orders-api"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-inject-secret-config.properties: "secret/data/qa/orders-api"
vault.hashicorp.com/agent-inject-template-config.properties: |
{{- with secret "secret/data/qa/orders-api" -}}
{{- range $k, $v := .Data.data }}{{ $k }}={{ $v }}
{{ end }}{{- end }}
再把 injector 的默认值调到接近实际用量,init 容器也一起降下来:
injector:
agentDefaults:
cpuRequest: "25m"
cpuLimit: "500m"
memRequest: "64Mi"
memLimit: "128Mi"
这一步不改应用行为,代价是所有注入了 agent 的 Deployment 要各重启一次。它没解决的是两件事:Vault 里改了值仍然要人去重启,pod 启动仍然依赖 injector 的 webhook。
换接法之前要回答的几个 DevSecOps 问题#
要不要继续往前走,取决于对密钥管理的要求,这里按六个问题逐一过:
- 密钥落在哪。 只在 pod 的内存卷里,还是会进 etcd 变成 K8s Secret。进 etcd 就要看加密和 RBAC:能读这个 namespace 里 Secret 的人都能读到密钥。
- 以谁的身份去取。 每个应用一个 ServiceAccount、一个 Vault role,还是一个全局身份替所有应用取。前者在 Vault 审计日志里分得清是谁读的,后者只看得到 operator。
- 谁来写 Vault 的 role 和 policy。 见过一种写法:应用 chart 里的 Helm hook 用一个能增删改任意
sys/policies/acl/*和auth/kubernetes/role/*的 Vault 角色,部署时给自己注册 policy。能在那个 namespace 用这个 ServiceAccount 起 pod 的人,就等于拿到了 Vault 的管理权。 - 值变了怎么生效。 自动滚动重启、等下次部署,还是应用在运行中自己重读。
- 依赖的组件挂了会怎样。 新 pod 起不来、起来了但没有密钥,还是不受影响。
- 每个 pod 多出多少东西,以及这套组件由谁维护、多久出一个版本。
五种接法逐项对照#
下表按这些核心维度逐项对照(其中 Vault role 与 policy 的平台治理见后文统一基线)。operator 两列的身份一栏都取决于配置:VSO 的 VaultAuth 里写哪个 ServiceAccount,就以谁的身份认证,文档建议每个应用单独一个。
| Agent sidecar | Injector init-only | VSO | ESO | Vault CSI Provider | |
|---|---|---|---|---|---|
| 密钥落在哪 | pod 内的 tmpfs 卷 | 同左 | K8s Secret(etcd) | K8s Secret(etcd) | 节点上的临时卷,可选同步成 K8s Secret |
| 向 Vault 证明身份 | pod 自己的 ServiceAccount | 同左 | VaultAuth 指定的 ServiceAccount |
SecretStore 的 serviceAccountRef |
pod 自己的 ServiceAccount,只支持 Kubernetes auth |
| 值变了 | agent 重新渲染文件,应用得自己重读 | 重启后才更新 | 按 refreshAfter 同步,rolloutRestartTargets 自动滚动重启 |
按 refreshInterval 同步,CRD 里没有重启工作负载的字段 |
Vault 文档的对比里列为不做续期和轮换 |
| 组件不可用时 | webhook 默认 failurePolicy: Ignore,新 pod 不注入就启动,拿不到密钥 |
同左 | 已同步的 Secret 还在,新 pod 照常启动 | 同左 | 新 pod 卡在挂卷,起不来 |
| 每个 pod 额外的容器 | init 和 sidecar 各一个 | 一个 init | 无,集群里一个 operator | 无,集群里一个 operator | 无,每个节点一个 DaemonSet |
| 后端 | 只有 Vault | 只有 Vault | Vault | 多种:云厂商的 Secrets Manager、Vault 等 | Secrets Store CSI driver 的各家 provider |
这五种接法在几个关键维度的取舍上有明显差异:
operator 方案把密钥放进了 etcd,这是它和 Agent 方案在安全上的主要差别。EKS 从 2025-03-05 起对 1.28 及以上版本的集群默认做 Kubernetes API 数据的信封加密,用的是 AWS 托管的密钥;要自己掌控密钥、在 CloudTrail 里看到解密记录,得换成客户托管的 KMS key。加密之外,能读 Secret 的 RBAC 要收紧。在一个开发者能 exec 进 pod 的集群里,这一条和现状的差别没有表面上大:Agent 方案下,密钥文件同样能在 pod 里直接读到。
VSO 和 ESO 都能把多个字段渲染成一个文件。VSO 用 destination.transformation.templates,ESO 用 target.template。生成的 Secret 挂到 /vault/secrets/ 下,SPRING_CONFIG_ADDITIONAL_LOCATION 和应用代码都不用改。两者在「值变了」这一格上不一样:VSO 1.6.2(2026-10-21 发布)的 VaultStaticSecret 自带 rolloutRestartTargets,支持 Deployment、DaemonSet、StatefulSet 和 Argo Rollout;ESO v2.14.0(2026-10-23 发布)的 CRD 里没有重启工作负载的字段,要另外装 Reloader 这类控制器。
维护节奏也要算进去。ESO 在 2025-08-13 通过 README 的更新宣布因维护者不足暂停发布,v1.0.0 在 2025-11-07 发布,之后 2.x 保持大约每周一个版本。VSO 由 HashiCorp 维护,Vault 本身从 1.15 起改用 BSL 许可,有许可顾虑的团队会看 OpenBao 这类分支。
CSI 方案在这批服务上没有优势:它不经过 etcd,但多了一个每节点的 DaemonSet 和节点临时卷;虽然 CSI 驱动本身支持静态 Secret 轮换,但 Vault CSI Provider 不支持动态密钥的续期,也没有 Agent 的模板渲染能力。应用自己用 Vault SDK 或 spring-cloud-vault 取密钥也能去掉 sidecar,代价是每个服务都要改代码,前端和非 Java 服务各写一套。
推荐:先去掉 sidecar,再换成 ESO 同步#
或运行中会重读密钥文件"] -->|"是"| A1["保留 Agent sidecar"] Q1 -->|"否"| Q2["密钥不允许进 etcd"] Q2 -->|"是"| A2["Injector init-only
长期看 CSI"] Q2 -->|"否"| Q3["生产也接云厂商
Secrets Manager"] Q3 -->|"是"| A3["ESO 加 Reloader"] Q3 -->|"否,只有 Vault"| A4["VSO"]
对这类只在启动时读配置的服务,落地分两步。
第一步是 init-only 加调低 agentDefaults,前面已经给了配置。它不改应用、不改密钥的存放位置,只要一次滚动重启,一次性收回 sidecar 占的 requests 和内存。
第二步换成 ESO 同步,解决「改了值要人去重启」和「webhook 挂了 pod 拿不到密钥」这两个遗留问题。选 ESO 是因为生产不打算自建 Vault:每个区域用自己账号里的云厂商 Secrets Manager,非生产继续用 Vault。一个 ESO 同时接这两种后端,chart 里只有一种 ExternalSecret 写法,生产和非生产的差别收进各自的 SecretStore。ESO 不自带重启,用 Reloader 补;如果生产也只用 Vault,VSO 自带 rolloutRestartTargets,少装一个控制器,选它更省事。
每个应用一个 SecretStore,serviceAccountRef 指向这个应用自己的 ServiceAccount,Vault 审计里才分得清是哪个应用在读。ESO 的 Vault provider 文档提到,Vault 建议在 Kubernetes auth role 里显式配置 audience 避免告警和未来认证失败,ESO 这边在 serviceAccountRef.audiences 里写同一个值:
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: orders-api-vault
namespace: qa
spec:
provider:
vault:
server: "https://vault.vault.svc:8200"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "qa-orders-api"
serviceAccountRef:
name: "orders-api"
audiences:
- "vault"
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: orders-api-config
namespace: qa
spec:
refreshInterval: "1m"
secretStoreRef:
kind: SecretStore
name: orders-api-vault
target:
name: orders-api-config
template:
engineVersion: v2
data:
config.properties: |
spring.datasource.username={{ .dbUser }}
spring.datasource.password={{ .dbPassword }}
data:
- secretKey: dbUser
remoteRef:
key: qa/orders-api
property: spring.datasource.username
- secretKey: dbPassword
remoteRef:
key: qa/orders-api
property: spring.datasource.password
Deployment 把生成的 Secret 挂回原来的路径,再加上 Reloader 的注解,Secret 一变就滚动重启:
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
namespace: qa
annotations:
secret.reloader.stakater.com/reload: "orders-api-config"
spec:
template:
spec:
serviceAccountName: orders-api
volumes:
- name: app-config
secret:
secretName: orders-api-config
containers:
- name: app
env:
- name: SPRING_CONFIG_ADDITIONAL_LOCATION
value: "optional:file:/vault/secrets/config.properties"
volumeMounts:
- name: app-config
mountPath: /vault/secrets
readOnly: true
换哪种接法,有几件事都要一起做。Vault 的 role 和 policy 由平台用代码统一定义(Terraform 的 Vault provider),应用 chart 里不放任何能写 policy 的身份。路径和 role 按环境拆开,policy 写成 secret/data/qa/orders-api,不写 secret/data/+/orders-api:后者让 QA 的 pod 也能读 STG 的密钥。Vault 开 audit device 并把日志送出集群,每个应用用自己的 ServiceAccount 认证,审计里才能分清是谁读的。到 Vault 的连接开 TLS。
小结#
Vault Agent Injector 的默认资源对 init 和 sidecar 一视同仁,各 250m CPU;pod 实际申请量又取 init 和普通容器之和中较大的一个,Autoscaler 按 requests 加节点,几十个 pod 下来就是好几台节点的预留。应用只在启动时读配置时,常驻 sidecar 只带来开销,agent-pre-populate-only 加调低 agentDefaults 是改动最小的第一步。往后换成 ESO,换来的是密钥变更自动滚动、不再依赖 webhook,以及生产的云厂商 Secrets Manager 和非生产的 Vault 共用一套 ExternalSecret 写法;代价是密钥进 etcd,要用加密和 RBAC 补上,重启要靠 Reloader。生产也只用 Vault 的团队,用 VSO 更省事。
相关文章#
- 重启即 sealed:homelab Vault 自愈链路的三个坑
- 在 K3s 上串起一条信息管道:Miniflux → KaraKeep → Gotify → Telegram(ESO 从 Vault 同步密钥的实际用法)
参考资料#
- Vault Agent Injector annotations —
agent-pre-populate-only、agent-requests-cpu等注解和默认值 - vault-helm v0.34.1 values.yaml —
injector.agentDefaults和 webhookfailurePolicy的默认值 - vault-k8s v1.7.6 container_init_sidecar.go — init 容器的资源设置来源
- Kubernetes: Init Containers — effective request 的计算规则
- Cluster Autoscaler FAQ — 扩缩容按 requests 判断
- Vault: Agent Injector vs. Vault CSI Provider — 两种方式的对比
- Vault Secrets Operator: Vault auth、secret transformation、API reference
- External Secrets Operator: templating、HashiCorp Vault provider
- stakater/Reloader —
secret.reloader.stakater.com/reload注解 - external-secrets PR #5137 — 2025-08-13 暂停发布的 README 更新
- Amazon EKS now envelope encrypts all Kubernetes API data by default