一个跑 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 同步#

flowchart TB Q1["应用运行中要续期动态凭据
或运行中会重读密钥文件"] -->|"是"| 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 更省事。

相关文章#

参考资料#