背景#

一个非生产集群把 Vault Agent 的默认 CPU request 从 250m 调到 25m 之后(为什么要调见 K8s 应用从 Vault 取密钥:Agent sidecar、init-only、VSO、ESO 和 CSI 怎么选),已经在跑的 pod 仍然是 250m,要各自重启一次才变。同一个集群里的 vault-agent-injector 原来只有 1 个副本,后来改成 2 个副本并加了 PodDisruptionBudget,原因是它不可用的那段时间里新建的 pod 不会被注入 agent。

两件事来自同一个事实:injector 是一个 mutating admission webhook,在 pod 写进集群之前的准入阶段改写 pod 定义,本身用一个普通的 Deployment 部署。

集群里的 Deployment 和 MutatingWebhookConfiguration#

这个集群里 injector 由两样东西组成,都是 Vault Helm chart 装的:

  • vault namespace 下的 Deployment vault-agent-injector,镜像 hashicorp/vault-k8s:1.4.2(2024-07-03 发布)。
  • 一个 MutatingWebhookConfiguration vault-agent-injector-cfg,告诉 API server 创建 pod 时调用它。

为了看清每一步,用 kind 起了一个 Kubernetes 1.37.0 的三节点集群(1 个 control plane、2 个 worker),装 vault-helm 0.28.1,也就是默认带 vault-k8s 1.4.2 的那一版 chart,只开 injector,global.externalVaultAddr 指向一个不存在的地址。agent 容器连不上 Vault,pod 会停在 Init,但 pod 定义有没有被改写、改成了什么都看得到。chart 渲染出来的 webhook 配置节选如下:

kubectl get mutatingwebhookconfiguration vault-agent-injector-cfg -o yaml
webhooks:
- clientConfig:
    service:
      name: vault-agent-injector-svc
      namespace: vault
      path: /mutate
      port: 443
  failurePolicy: Ignore
  matchPolicy: Exact
  name: vault.hashicorp.com
  namespaceSelector: {}
  objectSelector:
    matchExpressions:
    - key: app.kubernetes.io/name
      operator: NotIn
      values:
      - vault-agent-injector
  reinvocationPolicy: Never
  rules:
  - apiGroups:
    - ""
    apiVersions:
    - v1
    operations:
    - CREATE
    resources:
    - pods
    scope: Namespaced
  sideEffects: None
  timeoutSeconds: 30

这份配置来自 chart 的 injector-mutating-webhook.yaml,从 0.28.1 到 0.34.1(2026-08-13 发布)只改过版权行。后面几节要用到的字段有四个:

  • rules 只有 CREATE 和 pods,pod 的其他操作不会送到 injector。
  • clientConfig 指向 injector 的 Service,API server 走 HTTPS 调 /mutate。证书默认由 injector 自己生成,再把 CA 写回这份配置的 caBundle,Vault 文档管这叫 Auto TLS。
  • failurePolicy: Ignore 和 timeoutSeconds: 30。Kubernetes 的默认值分别是 Fail 和 10 秒,chart 把两个都改了。
  • namespaceSelector 为空,objectSelector 只排除 injector 自己的 pod,所以集群里所有 namespace 创建 pod 都会调用它,kube-system 也不例外。injector 对 kube-system 和 kube-public 的跳过写在它自己的代码里(handler.go,到 1.7.7 没变),前提是请求已经送到 injector。

Deployment 这一侧,chart 把 injector.agentDefaults 渲染成 injector 容器的环境变量 AGENT_INJECT_CPU_REQUEST、AGENT_INJECT_CPU_LIMIT、AGENT_INJECT_MEM_REQUEST 等(injector-deployment.yaml),injector 启动时读进来,改写 pod 时套到注入的容器上。

一次 pod 创建怎么经过 injector#

sequenceDiagram participant RS as ReplicaSet controller participant API as kube-apiserver participant INJ as vault-agent-injector participant KL as kubelet participant AG as vault-agent-init participant V as Vault RS->>API: 创建 pod Note over API: 认证、鉴权 API->>INJ: AdmissionReview,POST /mutate INJ-->>API: JSON Patch,加容器、内存卷和状态注解 Note over API: validating admission,写入 etcd API-->>KL: 调度到节点后由 kubelet 启动 KL->>AG: 先跑 init 容器 AG->>V: 用 pod 的 ServiceAccount 登录并读取密钥 AG->>AG: 渲染到 /vault/secrets

按顺序走一遍:

  1. Deployment 滚动、节点被腾空后重建 pod,实际发出创建请求的是 ReplicaSet controller。
  2. 请求通过认证和鉴权后进入准入阶段。Kubernetes 文档把准入分成两段,先跑 mutating,再跑 validating,都在对象写入之前。API server 把 pod 定义包在 AdmissionReview 里发给 injector。
  3. injector 看 pod 上有没有 vault.hashicorp.com/agent-inject: "true"。有的话返回一组 JSON Patch:加上 vault-agent-init(init 容器)、vault-agent(sidecar)和共享的内存卷,容器资源取自上面那几个环境变量,最后加一个注解 vault.hashicorp.com/agent-inject-status: injected(源码)。
  4. API server 按改写后的定义做校验、写入 etcd,之后的调度和启动跟普通 pod 一样。

kind 里给一个带注解的 Deployment 起 pod,读回来的 pod 定义:

kubectl -n demo get pods -o custom-columns='POD:.metadata.name,STATUS:.metadata.annotations.vault\.hashicorp\.com/agent-inject-status,INIT:.spec.initContainers[*].name,CONTAINERS:.spec.containers[*].name,INIT_CPU:.spec.initContainers[*].resources.requests.cpu,CPU:.spec.containers[*].resources.requests.cpu'
POD                           STATUS     INIT               CONTAINERS        INIT_CPU   CPU
orders-api-745c5bfc79-65ndb   injected   vault-agent-init   app,vault-agent   250m       100m,250m

Deployment 里只写了一个 app 容器,申请 100m。init 容器、sidecar 和它们各自的 250m 都是 injector 加的,另外还多了 home-init、home-sidecar、vault-secrets 三个 medium: Memory 的 emptyDir。

injector 自己不去 Vault 取密钥。vault-k8s 的 go.mod 里没有 Vault 的 API 客户端,Vault 地址只是作为环境变量写进注入的容器。向 Vault 认证、渲染 /vault/secrets/*.properties 的是每个 pod 里那两个 agent 容器,用的是 pod 自己的 ServiceAccount(Vault 文档)。

只处理 CREATE:改了默认值要重启 pod#

Vault 文档的概述写的是 injector 拦截 pod 的 CREATE 和 UPDATE 事件,但 chart 注册给 API server 的规则只有 CREATE。就算收到 UPDATE,Kubernetes 也只允许在运行中的 pod 上改镜像、tolerations 等少数字段(Pod update and replacement),加不了容器。vault-k8s 的 ShouldInject 遇到已经不是 Pending 的 pod 直接跳过,注释写的是「If injection didn’t happen on pod creation, then it’s too late now.」这段判断到 vault-k8s 1.7.7(2026-10-27 发布)还是原样(agent.go)。

所以 agentDefaults.cpuRequest 改成 25m,改动的只是 injector Deployment 的环境变量。injector 滚动重启之后,新创建的 pod 才按 25m 注入,已经在跑的 pod 一直是 250m。

kind 里还多看到一个细节:injector 自己的滚动和业务 pod 的重启有先后。改完 agentDefaults 后马上重启业务 Deployment,新 pod 是由还没退出的旧 injector 副本处理的,拿到的仍是 250m(下面第二行);等 injector 滚动完再重启一次,才是 25m(第一行)。

POD                           INIT_CPU   CPU
orders-api-77b6c5757d-7jfzz   25m        100m,25m
orders-api-785cbfd87d-m2qtr   250m       100m,250m

改完默认值,先等 injector 滚动完成,再重启业务:

kubectl -n vault rollout status deploy/vault-agent-injector
kubectl -n <namespace> rollout restart deploy/<name>

重启完可以把还没换过来的 pod 列出来。下面这组输出是在单节点 kind 里重跑的:kind 里没有真的 Vault,新 pod 停在 Init,滚动停在第一个新 pod 上,两个旧的 250m pod 都还在,新 pod 是 25m,被过滤掉了。

kubectl get pods -A -o custom-columns='NAMESPACE:.metadata.namespace,POD:.metadata.name,AGENT_INIT_CPU:.spec.initContainers[?(@.name=="vault-agent-init")].resources.requests.cpu' | awk 'NR==1 || ($3 != "<none>" && $3 != "25m")'
NAMESPACE            POD                                             AGENT_INIT_CPU
demo                 orders-api-d79959f89-2g9dk                      250m
demo                 orders-api-d79959f89-7xxfg                      250m

failurePolicy: Ignore 的代价#

按 Kubernetes 文档,Ignore 是指调用 webhook 出错时忽略这个错误、让请求继续,出错包括网络错误、超时、连不上和非 2xx 响应;超时的结果同样按 failurePolicy 处理。放到 injector 上,就是它挂了、在重启、或者 API server 连不上它的时候,pod 照常创建,只是没有 agent,也就没有 /vault/secrets 下的文件,创建请求本身不报任何错。

kind 里把 injector 缩到 0 个副本,再扩业务 Deployment。新 pod 起来了,没有 init 容器,也没有 agent-inject-status 注解:

POD                           STATUS   INIT     CONTAINERS   CREATED
orders-api-77b6c5757d-hn7jg   <none>   <none>   app          2026-11-09T16:02:38Z

这个 pod 等了 30 秒才创建出来。kube-apiserver 的日志:

W1109 16:02:38.503764       1 dispatcher.go:210] Failed calling webhook, failing open vault.hashicorp.com: failed calling webhook "vault.hashicorp.com": failed to call webhook: Post "https://vault-agent-injector-svc.vault.svc:443/mutate?timeout=30s": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

紧接着再创建几个不带任何注解的 pod,default 和 kube-system 下都有,每个不到 1 秒就创建出来,日志里的错误换成了 dial tcp 10.96.177.29:443: connect: connection refused。重新扩缩一次 injector 再试,结果一样:injector 的 pod 刚删掉后的第一次调用等满 30 秒,之后几次 connection refused,很快放行。等满 30 秒的那一次是 default 下一个普通 pod。webhook 对所有 namespace 生效,injector 不可用时,跟 Vault 无关的 pod 创建一样要等,最长等到 timeoutSeconds。

这种失败要从 API server 这一侧看。kube-apiserver 有一个按 webhook 名字计数的指标,能抓到 kube-apiserver 指标的集群可以给它的增量设告警:

kubectl get --raw /metrics | grep '^apiserver_admission_webhook_fail_open_count'
apiserver_admission_webhook_fail_open_count{name="vault.hashicorp.com",type="admit"} 1

这个指标在 metrics 参考里还是 ALPHA,不保证稳定。另一个办法是直接找要求注入、却没有 injected 注解的 pod:

kubectl get pods -A -o json | jq -r '.items[] | select(.metadata.annotations["vault.hashicorp.com/agent-inject"] == "true" and .metadata.annotations["vault.hashicorp.com/agent-inject-status"] != "injected") | "\(.metadata.namespace)/\(.metadata.name)"'
demo/orders-api-745c5bfc79-jpffs

把 failurePolicy 改成 Fail 能让这类 pod 创建失败,但影响面跟着 webhook 的范围走:injector 不可用期间,集群里所有 namespace 的 pod 都建不出来,kube-system 的跳过逻辑在 injector 代码里,这时帮不上忙。Kubernetes 的 Admission Webhook Good Practices 对 mutating webhook 的建议正好相反:设成 Ignore,让它 fail open,再用一个 validating 环节检查请求是否符合要求。同一页还建议用小的超时;chart 暴露了 injector.webhook.timeoutSeconds,可以对照 apiserver_admission_webhook_admission_duration_seconds 看到的实际耗时往下调。

用 ValidatingAdmissionPolicy 拦下没注入的 pod#

ValidatingAdmissionPolicy 在 Kubernetes 1.30 GA(2024-04-24),用 CEL 写规则,由 API server 自己执行,不需要另外部署服务。validating 阶段在所有 mutation 之后,正好能看到 injector 改写后的结果。下面这条策略只匹配要求注入的 pod,要求它们带着 injected 注解;kube-system 和 kube-public 排除在外,因为 injector 本来就不处理这两个 namespace:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: require-vault-agent-injected
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE"]
        resources: ["pods"]
    namespaceSelector:
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: NotIn
          values: ["kube-system", "kube-public"]
  matchConditions:
    - name: requests-vault-agent
      expression: >-
        has(object.metadata.annotations) &&
        'vault.hashicorp.com/agent-inject' in object.metadata.annotations &&
        object.metadata.annotations['vault.hashicorp.com/agent-inject'] == 'true'        
  validations:
    - expression: >-
        'vault.hashicorp.com/agent-inject-status' in object.metadata.annotations &&
        object.metadata.annotations['vault.hashicorp.com/agent-inject-status'] == 'injected'        
      message: "pod requests Vault Agent injection but was not mutated by the injector"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: require-vault-agent-injected
spec:
  policyName: require-vault-agent-injected
  validationActions: ["Deny"]

matchConditions 只认字符串 "true"。injector 解析这个注解时也接受 1、True 这类写法,团队里如果混用,要把条件放宽。

kind 里 injector 保持 0 个副本,删掉业务 pod 让 ReplicaSet 重建,创建请求被拒,ReplicaSet 记下 FailedCreate 事件:

Error creating: pods "orders-api-77b6c5757d-rpwz7" is forbidden: ValidatingAdmissionPolicy 'require-vault-agent-injected' with binding 'require-vault-agent-injected' denied request: pod requests Vault Agent injection but was not mutated by the injector

不带注解的 pod 不受影响,照常创建。injector 扩回 1 个副本后,ReplicaSet 按自己的退避节奏重试,这次大约 15 秒后建出了带 vault-agent-init 和 vault-agent、注解为 injected 的 pod。业务 Deployment 的滚动在 injector 恢复前停住,没有密钥的 pod 起不来,滚动期间老 pod 留着多少由 maxUnavailable 决定。

这条策略只把「没注入」变成「建不出来」,injector 不可用时 API server 仍然要先等 webhook 失败,等待时间还是由 timeoutSeconds 决定。在单节点 kind 里把 injector 缩到 0,再直接 kubectl apply 一个要求注入的 pod,请求等了 30 秒才被拒。

拒绝也能从 API server 的指标上看到。apiserver_validating_admission_policy_check_total 按策略、绑定和处理结果计数,在 metrics 参考里是 BETA。injector 缩到 0 以后,先建一个要求注入的 pod(被拒),再建一个不带注解的 pod(照常创建),两个计数器是这样的:

kubectl get --raw /metrics | grep -E '^apiserver_(admission_webhook_fail_open_count|validating_admission_policy_check_total)' | grep -E 'vault|require-vault'
apiserver_admission_webhook_fail_open_count{name="vault.hashicorp.com",type="admit"} 2
apiserver_validating_admission_policy_check_total{enforcement_action="deny",error_type="invalid_error",policy="require-vault-agent-injected",policy_binding="require-vault-agent-injected"} 1

两个 pod 都先经过了一次 webhook 放行,所以 fail_open_count 是 2;策略只匹配要求注入的那个,拒绝计数是 1。这两条序列在第一次发生之前都不存在,策略刚生效、还没拒绝过请求时,用同样的命令 grep 不到它们。

缩小影响面的另一种做法是给 MutatingWebhookConfiguration 加 matchConditions,只把带注解的 pod 送给 injector,但到 chart 0.34.1,模板里都没有这个字段,加上去要在 chart 之外单独维护一份 patch。请求加这个字段的 vault-helm#1079(2024-12-11 提出)还没关。

双副本、PDB 和反亲和#

只有 1 个副本时,这个 pod 不在的每一段时间都是上面那个窗口:节点被 drain、Cluster Autoscaler 缩容、虚拟机被云厂商回收、injector 自己崩溃。按 Kubernetes 的 disruption 文档,drain 和为缩容腾空节点属于 voluntary disruption,PDB 能限制同时被驱逐的数量,Cluster Autoscaler 缩容时也遵守 PDB;VM 消失、硬件故障属于 involuntary disruption,PDB 拦不住,只能靠副本分散在不同节点上。所以 PDB 要和多副本一起用:1 个副本配 maxUnavailable: 1 等于没有限制,配 minAvailable: 1 又会让 drain 一直卡住。

chart 的 values:

injector:
  replicas: 2
  podDisruptionBudget:
    maxUnavailable: 1
  strategy:
    rollingUpdate:
      maxSurge: 0
      maxUnavailable: 1

podDisruptionBudget 默认为空,不建 PDB,maxUnavailable: 1 是 chart 注释里给的例子。反亲和不用另外写:chart 的 injector.affinity 默认就是按 kubernetes.io/hostname 的 requiredDuringSchedulingIgnoredDuringExecution podAntiAffinity,两个副本一定落在不同节点上(values.yaml)。

strategy 那几行是因为 kind 里的滚动卡住过一次。Deployment 的 maxSurge 和 maxUnavailable 默认都是 25%,换算成个数时 maxSurge 向上取整、maxUnavailable 向下取整,2 个副本就是先多起 1 个、一个都不停。kind 里只有 2 个 worker,两个旧副本各占一个节点,多出来的新副本因为反亲和哪里都放不下,一直 Pending,滚动卡住不动。改成 maxSurge: 0、maxUnavailable: 1 后先停一个再起一个,滚动期间有一个副本在服务。可调度节点多于副本数的集群碰不到这个问题。

副本数大于 1 时,chart 会给 injector 加上环境变量 AGENT_INJECT_USE_LEADER_ELECTOR=true。Auto TLS 下只有 leader 生成证书、更新 webhook 的 caBundle,再通过一个 Secret 把证书分给其他副本;leader 是谁看 vault namespace 里 ConfigMap vault-k8s-leader 的 owner(Vault 安装文档)。kind 里升到 2 个副本后,这个 ConfigMap 的 owner 是其中一个 injector pod。

最后在 kind 里依次 drain 两个 worker。第一个 worker 上的 injector 被驱逐,替补副本因为反亲和只能 Pending;轮到第二个 worker 时,驱逐被 PDB 拒绝,drain 一直重试:

evicting pod vault/vault-agent-injector-6bb459568d-f7pf5
error when evicting pods/"vault-agent-injector-6bb459568d-f7pf5" -n "vault" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.

和 operator、CSI driver 的区别#

三类组件都能把 Vault 里的值交给 pod,差别在起作用的时间点。injector 只在 pod 创建请求经过准入时改写一次 pod 定义。operator(VSO、ESO)按 Kubernetes 文档的定义是「clients of the Kubernetes API that act as controllers for a Custom Resource」,用 Deployment 部署,持续 watch 自己的 CRD(如 ExternalSecret)并对账,把 Vault 里的值同步成 K8s Secret,不碰 pod 定义。Secrets Store CSI driver 每个节点一个 DaemonSet pod,kubelet 给 pod 挂 CSI 卷时,本节点的 driver 通过 gRPC 找 provider 取内容再挂成 tmpfs,Vault 文档写明 pod 会一直停在启动前,直到从 Vault 读到密钥。起作用的时间点不同,组件不可用时新 pod 的表现也就不同,三种方式各自的表现和怎么选,见 K8s 应用从 Vault 取密钥:Agent sidecar、init-only、VSO、ESO 和 CSI 怎么选。

小结#

injector 只在 pod 创建时改写定义,取密钥的是注入进去的 agent。所以改 agentDefaults 后要等 injector 滚动完再重启业务;chart 默认的 Ignore 让 injector 不可用时,漏注入的 pod 也照常创建。对应的做法:保留 Ignore,用 apiserver_admission_webhook_fail_open_count 或注解检查发现漏注入,用 ValidatingAdmissionPolicy 拦下没注入的 pod;injector 跑 2 个副本加 PDB,节点少时把滚动改成 maxSurge: 0。

参考资料#