用 MutatingWebhook 做一个通用 Agent 注入器:给任意业务 Pod 无侵入挂上探针/Agent(Java 实战)

想给业务应用挂 agent ------ APM 探针(SkyWalking / Pinpoint / OTel)、流量录制回放、Mock、故障注入、Profiling(Arthas / async-profiler)、安全扫描、日志采集 ------ 又不愿意让业务改 Dockerfile、改 CI、重启流程加步骤?

K8s MutatingWebhook 就是这件事的最佳落点 :它是"对象落库前的最后一道手",能在 Pod 被创建的那一刻,往里面塞 initContainer、挂共享卷、改 JAVA_TOOL_OPTIONS、追加 sidecar。业务方零感知,CI/CD 重新部署后依然生效。

本文用一份 Java(Spring Boot + Fabric8)实现,讲清楚通用 Agent 注入器该怎么造:协议、证书、部署、幂等、自愈、排障,以及按运行时该怎么注入。


0. TL;DR

主题 结论
为什么是 Webhook 唯一能"业务零改动 + CI 重部署不丢 + 集群级统一收敛"的注入点
注入什么 initContainer 下载 agent → emptyDir 共享 → 改应用容器的环境变量/参数(JVM 用 JAVA_TOOL_OPTIONS)
协议 APIServer 同步 POST 一个 AdmissionReview JSON,你必须 HTTP 200 返回 AdmissionReview(uid 原样回抄)
怎么改 patchType: JSONPatch + base64 的 RFC 6902 操作数组
证书 caBundle = base64(PEM 的 CA 证书) ;服务端证书 SAN 必须含 Service 全名
服务 in-cluster 用 clientConfig.service,Service 端口必须 443
容错 生产用 failurePolicy: Ignore(fail-open),但返回状态码永远 200 ,拒绝走 allowed:false
幂等 重试/滚动升级/重建都会重复触发,必须靠内容判据保证只注入一次
排障 kubectl apply --dry-run=server -o yaml 直接看注入结果

一、为什么要用 Webhook:四种"挂 Agent"的姿势对比

姿势 业务是否感知 CI 重新部署后是否保留 版本统一管理 结论
改业务 Dockerfile,把 agent 打进镜像 要改业务仓库 保留(但每套镜像各带一份) ❌ 各版本散落 侵入太强
CI 流水线里加注入步骤 要改每套 CI 保留 ⚠️ 要改所有流水线 漏改就丢
运行时 attach(jattach / attach API) 需要人工或定时任务 ❌ 重启即失效 ⚠️ 适合临时排障,不适合常驻
MutatingWebhook 准入期注入 ✅ 零改动 ✅ 每次创建 Pod 都注入 ✅ 平台侧一处收敛 常驻 agent 的最优解

Webhook 注入的本质:在 Pod 落库之前篡改它。而"挂 agent"恰好就是"改 Pod"------加个卷、加个容器、改个环境变量。所以能力完全对齐。

能挂的东西远不止 Mock:

用途 注入物
APM / 链路追踪 -javaagent:skywalking-agent.jar / OTel javaagent / Pinpoint
流量录制回放 自研 javaagent + 平台地址环境变量
故障注入 / Chaos chaos agent,支持按标签开启
在线诊断 Arthas / async-profiler,注入了但不启动,按需 attach
日志 / 指标采集 轻量 collector sidecar 或 LD_PRELOAD 采集库
安全基线 syscall 审计 agent、运行时防护
多语言探针 Node --require、Python sitecustomize、eBPF sidecar

二、Agent 注入能力矩阵(按运行时怎么挂)

不同语言"挂 agent"的姿势完全不同,写通用注入器前先把这张表钉在墙上:

运行时 注入手段 具体落地 说明
JVM -javaagent:/opt/agent.jar(premain) 环境变量 JAVA_TOOL_OPTIONS JVM 启动时自动读取并打印 Picked up JAVA_TOOL_OPTIONS,容器内同样生效。业务已有 JAVA_TOOL_OPTIONS 时必须合并而不是覆盖
JVM(运行中) attach API / jattach 共享 PID namespace + 同 UID 的工具容器 需要给 Pod 开 shareProcessNamespace: true,webhook 可以顺手改这个字段
Node.js --require /opt/agent.js(CJS)/ --import(ESM) NODE_OPTIONS ESM 注入与 Node 版本相关,建议先支持 CJS
Python sitecustomize.py / usercustomize.py PYTHONPATH=/opt/agent 解释器启动时由 site 模块自动导入(python -S 启动的除外)
.NET CORECLR_ENABLE_PROFILING + CORECLR_PROFILER 环境变量 需要 agent 库(兼容 CLR profiling API)
Native(C/C++/Rust) LD_PRELOAD=/opt/agent.so 环境变量 仅对动态链接的程序有效,需自研 .so
Go / 静态二进制 没有等价物 eBPF sidecar / 编译期插桩 只能靠内核层(eBPF)或改编译
通用 / 跨语言 Sidecar 容器 + 网络层 追加 container,共享网络命名空间 Istio/Datadog 走的就是这条路
采集类 轻量 sidecar / DaemonSet 追加 container 或在节点侧 与业务进程解耦

通用注入器的可复用骨架(本案例就是这么抽象出来的):

ini 复制代码
initContainer(下载 agent 到 emptyDir /opt/agent)
        │  共享 emptyDir
        ▼
业务容器
  ├─ volumeMount: emptyDir → /opt/agent
  ├─ env(按语言选择模板):
  │     JVM    → JAVA_TOOL_OPTIONS=-javaagent:/opt/agent/agent.jar
  │     Node   → NODE_OPTIONS=--require /opt/agent/agent.js
  │     Python → PYTHONPATH=/opt/agent
  │     Native → LD_PRELOAD=/opt/agent/agent.so
  └─ 平台侧的 agent 配置(服务地址、开关、采样率)

关键认知:注入器本身与 agent 无关。 换 agent = 换"下载地址 + 挂载路径 + 参数模板"三件套,准入逻辑完全不用改。后面第八节的案例就是把这个抽象落到 mock agent 上。


三、通用注入器的六个部件

一个能上生产的注入器,拆开看只有六块:

部件 职责 本案例实现
① 准入入口 收 AdmissionReview、回 AdmissionReview,fail-open WebhookController
② 决策器 该不该注入?(命名空间/开关/幂等/可解析) WebhookService.handleAdmissionReview
③ Patch 生成器 生成 JSON Patch:卷 + initContainer + env + 注解 buildPatch / JsonPatchBuilder
④ 制品分发 agent 从哪来?版本怎么选? AgentDownloadService(制品库 API + 缓存)
⑤ 证书管理 HTTPS 链路 + caBundle 同步 WebhookHttpsConfig / cert-manager
⑥ 部署与自愈 把 webhook 配置部署/更新/移除,并解决自举 WebhookManageService / WebhookInitializer

③ 是业务逻辑,①②⑤⑥ 是任何注入器都一样的样板------这也是为什么值得把它做成平台能力,而不是每个 agent 写一套。


四、协议:AdmissionReview 与 JSON Patch

4.1 一次注入请求

scss 复制代码
Deployment 创建 Pod
        │
        ▼
   kube-apiserver ──(HTTPS POST /mutate)──► 你的 Webhook 服务
        │              AdmissionReview(JSON)
        │  ◄──── AdmissionReview + patch(base64) ────┘
        ▼
   应用 patch → Schema 校验 → Validating → etcd

请求体(关键字段):

json 复制代码
{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "request": {
    "uid": "705ab4f5-6393-11e8-b7cc-42010a800002",
    "kind":        { "group": "", "version": "v1", "kind": "Pod" },
    "resource":    { "group": "", "version": "v1", "resource": "pods" },
    "name": "myapp-7b9c8f4d5-abc12",
    "namespace": "default",
    "operation": "CREATE",
    "object":    { "kind": "Pod", "metadata": { "...": "..." }, "spec": { "...": "..." } },
    "dryRun": false
  }
}
字段 要点
request.uid 必须原样回抄,APIServer 靠它对账
request.object 要注入的目标资源(Pod 会被存成的样子)
request.name CREATE 阶段经常是空的 ,真实名字在 object.metadata.name / generateName
request.dryRun --dry-run=server 时为 true,你的 handler 不能有真实副作用

4.2 注入响应

json 复制代码
{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "response": {
    "uid": "705ab4f5-6393-11e8-b7cc-42010a800002",
    "allowed": true,
    "patchType": "JSONPatch",
    "patch": "<patch JSON 的 base64>"
  }
}

patch 明文(解码后):

json 复制代码
[
  { "op": "add", "path": "/spec/volumes/-", "value": { "name": "agent-volume", "emptyDir": {} } },
  { "op": "add", "path": "/spec/initContainers/-", "value": { "name": "agent-init", "...": "..." } },
  { "op": "replace", "path": "/spec/containers/0/env",
    "value": [ { "name": "JAVA_TOOL_OPTIONS", "value": "-javaagent:/opt/agent/agent.jar" } ] }
]

4.3 三条硬性规则

  1. HTTP 状态码永远 200 。返回 4xx/5xx 是"调用失败"(走 failurePolicy),不是"拒绝"。
  2. patchType 用 JSONPatch (MergePatch 是历史遗留)。
  3. apiVersion 建议回抄请求版本(1.22+ 只会发 v1,固定返回 v1 也能跑)。

五、Java 最小骨架(不依赖 K8s SDK)

Webhook 本质就是"收 JSON、回 JSON",不需要 K8s 客户端也能写;真正需要 K8s SDK 的是"部署 webhook 自身配置"那部分。

java 复制代码
public interface AdmissionHandler {
    /** 返回 RFC 6902 patch 操作;空数组表示不修改 */
    ArrayNode mutate(JsonNode admissionRequest);
}
java 复制代码
public final class JsonPatchBuilder {

    private final ArrayNode ops = JsonNodeFactory.instance.arrayNode();

    public JsonPatchBuilder add(String path, JsonNode value)     { return op("add", path, value); }
    public JsonPatchBuilder replace(String path, JsonNode value) { return op("replace", path, value); }
    public JsonPatchBuilder remove(String path)                  { return op("remove", path, null); }

    /** 追加到数组末尾:RFC 6902 里 path 末端的 "-" 表示 append */
    public JsonPatchBuilder append(String arrayPath, JsonNode value) { return add(arrayPath + "/-", value); }

    public boolean isEmpty() { return ops.isEmpty(); }
    public ArrayNode build() { return ops; }

    /** JSON Pointer 转义:先 ~ 再 /,顺序不能反 */
    public static String ptr(String key) { return key.replace("~", "~0").replace("/", "~1"); }

    private JsonPatchBuilder op(String op, String path, JsonNode value) {
        ObjectNode node = JsonNodeFactory.instance.objectNode();
        node.put("op", op);
        node.put("path", path);
        if (value != null) node.set("value", value);
        ops.add(node);
        return this;
    }
}

Controller:把 fail-open 做成默认行为。

java 复制代码
@PostMapping(value = "/mutate",
             consumes = MediaType.APPLICATION_JSON_VALUE,
             produces = MediaType.APPLICATION_JSON_VALUE)
public JsonNode mutate(@RequestBody(required = false) String rawBody) {
    if (rawBody == null || rawBody.isBlank()) return allow("admission.k8s.io/v1", "");

    JsonNode review;
    try { review = mapper.readTree(rawBody); }
    catch (Exception e) { return allow("admission.k8s.io/v1", ""); }

    String apiVersion = review.path("apiVersion").asText("admission.k8s.io/v1");
    String uid        = review.path("request").path("uid").asText("");

    try {
        ArrayNode patch = handler.mutate(review.path("request"));   // ← 决策 + 生成 patch
        return respond(apiVersion, uid, patch);
    } catch (Exception e) {
        log.error("处理失败,放行 uid={}", uid, e);                  // ← 绝不让注入器故障阻塞集群
        return respond(apiVersion, uid, null);
    }
}
java 复制代码
private JsonNode respond(String apiVersion, String uid, ArrayNode patch) {
    ObjectNode response = mapper.createObjectNode();
    response.put("uid", uid);
    response.put("allowed", true);
    if (patch != null && !patch.isEmpty()) {
        response.put("patchType", "JSONPatch");
        response.put("patch", Base64.getEncoder().encodeToString(mapper.writeValueAsBytes(patch)));
    }
    ObjectNode review = mapper.createObjectNode();
    review.put("apiVersion", apiVersion);
    review.put("kind", "AdmissionReview");
    review.set("response", response);
    return review;
}

再加一层 Spring 兜底,把漏网异常也变成放行:

java 复制代码
@RestControllerAdvice(basePackages = "com.example.webhook")
public class WebhookExceptionHandler {
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.OK)                       // 注意:200,不是 500
    public JsonNode handle(Exception e) {
        log.error("全局兜底,放行", e);
        return mapper.createObjectNode().set("response",
                mapper.createObjectNode().put("allowed", true));
    }
}

六、TLS 与证书:三种方案

APIServer 只接受 HTTPS,并用 caBundle 校验你的服务端证书。

方案 适用 说明
cert-manager 生产首选 Issuer + Certificate + 注解 cert-manager.io/inject-ca-from: <ns>/<cert> 自动注入 caBundle,自动轮换
openssl 脚本签发 有运维流程 生成 CA + Server 证书 → kubernetes.io/tls Secret → 手工把 base64(ca.crt) 填进 caBundle,轮换要人工
应用内自签(BouncyCastle) 产品化平台 启动时生成密钥对 + 自签证书,起 HTTPS 端口,并自动把 caBundle 写回 webhook 配置。适合"平台自带部署能力"

6.1 胜负手是 SAN(不是 CN)

in-cluster 通过 Service 访问,SAN 必须覆盖四级 DNS:

xml 复制代码
<service>
<service>.<namespace>
<service>.<namespace>.svc
<service>.<namespace>.svc.cluster.local

自签实现:

java 复制代码
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048, new SecureRandom());
KeyPair keyPair = keyGen.generateKeyPair();

X500Name subject = new X500Name("CN=my-webhook,O=my-org");
X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
        subject, BigInteger.valueOf(System.currentTimeMillis()),
        notBefore, notAfter, subject, keyPair.getPublic());

builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
builder.addExtension(Extension.keyUsage, true,
        new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment));
builder.addExtension(Extension.extendedKeyUsage, false,
        new ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth));
builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(sans));

ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSAEncryption").build(keyPair.getPrivate());
X509Certificate cert = new JcaX509CertificateConverter().getCertificate(builder.build(signer));

String certPem = "-----BEGIN CERTIFICATE-----\n" +
        Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(cert.getEncoded()) +
        "\n-----END CERTIFICATE-----\n";
String caBundle = Base64.getEncoder().encodeToString(certPem.getBytes());   // base64(PEM)

6.2 一个进程两个端口(Tomcat 额外 Connector)

不用为 webhook 单独起服务,主端口之外追加一个 HTTPS Connector:

java 复制代码
@Bean
WebServerFactoryCustomizer<TomcatServletWebServerFactory> webhookConnectorCustomizer() {
    return factory -> {
        Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
        connector.setPort(8443);
        connector.setSecure(true);
        connector.setScheme("https");
        connector.setProperty("SSLEnabled", "true");

        SSLHostConfig host = new SSLHostConfig();
        host.setHostName("_default_");
        SSLHostConfigCertificate cert =
                new SSLHostConfigCertificate(host, SSLHostConfigCertificate.Type.RSA);
        cert.setCertificateFile("/etc/webhook/certs/tls.crt");
        cert.setCertificateKeyFile("/etc/webhook/certs/tls.key");
        host.addCertificate(cert);
        connector.addSslHostConfig(host);

        factory.addAdditionalTomcatConnectors(connector);
    };
}

配合 @ConditionalOnProperty(name = "webhook.enabled", havingValue = "true") 做开关------这一点后面会牵出"自举"问题。


七、部署:MutatingWebhookConfiguration 字段全解

yaml 复制代码
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: my-agent-injector
  annotations:
    cert-manager.io/inject-ca-from: middleware/my-injector-cert   # 自动填 caBundle
webhooks:
  - name: agent-injector.example.com          # 必须 FQDN 形式且全局唯一
    admissionReviewVersions: ["v1"]
    sideEffects: None                         # 无副作用才能配合 dry-run
    failurePolicy: Ignore                     # 调不通就放行(生存优先)
    matchPolicy: Equivalent
    reinvocationPolicy: IfNeeded              # 被其他 webhook 改过后允许再调
    timeoutSeconds: 5                         # 默认 10,最大 30
    rules:
      - operations: ["CREATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
        scope: Namespaced
    namespaceSelector:                        # 第一道范围保险
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: NotIn
          values: ["kube-system", "kube-public", "kube-node-lease", "middleware"]
    objectSelector:                           # 第二道保险(按标签)
      matchExpressions:
        - key: agent-inject
          operator: In
          values: ["enabled"]
    clientConfig:
      service:
        name: my-injector-svc
        namespace: middleware
        path: /mutate
        port: 443                             # in-cluster 必须 443
      caBundle: <base64(PEM)>
字段 建议 理由
failurePolicy Ignore 起步 注入器挂掉不该让全集群无法建 Pod
timeoutSeconds 3~5 准入是同步链路,超时即失败
namespaceSelector 必配 排除系统命名空间,最省心的隔离
objectSelector 强烈推荐 按标签灰度,前期只放行打了标记的对象
reinvocationPolicy IfNeeded 与 Istio 等其它 injector 共存时保证最终状态正确
sideEffects None dry-run 语义正确,且是注册要求

配套 Service:

yaml 复制代码
apiVersion: v1
kind: Service
metadata: { name: my-injector-svc, namespace: middleware }
spec:
  selector: { app: my-platform }
  ports:
    - { name: webhook, port: 443, targetPort: 8443 }

八、把注入器做成"通用":声明式开关 + 插件化

8.1 开关放哪:优先 label,其次 annotation

K8s 原生只能按 label 过滤(namespaceSelector / objectSelector 都是 matchLabels/matchExpressions),annotation 只能你在 handler 里自己判断。所以设计原则:

粒度 载体 说明
全局 ConfigMap / CRD 默认开关、agent 版本、平台地址
命名空间级 namespace 的 label 直接喂给 namespaceSelector,零代码过滤
工作负载级 Deployment 的 label / annotation label → objectSelector;annotation → handler 内判断,适合放参数(版本、采样率)
应用级(动态) 平台 DB / CRD 支持"面板上点开关",业务无感知

实战组合拳:objectSelector 先按 agent-inject: enabled 过滤掉 90% 的 No-op 请求(省掉绝大部分 webhook 调用,这是最重要的性能优化),handler 内再读平台开关做二次决策。

8.2 想注入多种 agent?插件化

java 复制代码
public interface AgentInjector {
    /** 这个容器要不要注入本 agent */
    boolean matches(JsonNode pod, JsonNode container);
    /** 本 agent 的 patch 片段(卷/挂载/环境变量/参数模板) */
    void contribute(PatchContext ctx, JsonPatchBuilder patch);
    /** 唯一名字,用于幂等判据(容器名/卷名/注解键) */
    String name();
}

WebhookService 遍历所有 AgentInjector,把各自的 patch 片段合并成一个数组返回。APM 探针、录制 agent、诊断 agent 各写一个实现类即可,准入样板完全复用。

两个必守约束:

  • 最小改动:只加自己的卷/容器/env 键,别整数组删别人的东西(你删掉的可能是 Istio 注入的);
  • 幂等判据用内容:用自己特有的容器名/卷名判断,别信"只会调一次"。

8.3 制品分发:agent 从哪来

方式 优点 缺点
initContainer 下载 + emptyDir 共享(本案例) agent 与业务镜像解耦,版本随时更新 依赖制品源可达;多一次下载耗时
agent 打进基础镜像 启动快、无网络依赖 要改镜像构建,更新要重建
共享 PVC / hostPath 适合大文件、可缓存 权限与节点耦合、HostPath 有安全风险
CSI 卷挂载 优雅 复杂度高

选"下载"这条路时,版本解析要做动态 + 缓存 + fallback(制品库 API 查最新版本,带 TTL 缓存,失败回落固定地址)------这样 agent 发包后无需改配置。

另外强烈建议:把注入的 agent 版本写进 Pod annotation (如 agent-inject/version: 1.2.3),线上排查"这个 Pod 到底挂的哪个版本"时能救命。


九、生产化:可用性、性能、幂等、自举

  1. 它是同步阻塞链路,注入器抖 = 发布抖。 ≥2 副本 + PodDisruptionBudget + Readiness 探针覆盖 HTTPS 端口。
  2. 别做慢 IO。 查 DB / 调远端制品库都要有超时 + 本地缓存兜底。APIServer 对 webhook 有 QPS 限制,大促扩缩容会放大延迟。
  3. 幂等是硬要求。 触发来源:webhook 超时重试、reinvocationPolicy、滚动重建、人工删 Pod。判据写在对象内容里。
  4. 自举(鸡生蛋) :如果注入器自身 Deployment 也归这套系统管,且 HTTPS 端口由环境变量开关,就出现循环。解法:
    • 部署完 webhook 配置后,主动 patch 自己的 Deployment 补开关环境变量;
    • 应用启动完成(ApplicationRunner)后再自检重部署一次 ,把最新 caBundle 写回(自签方案必做,否则重启即失效)。
  5. 灰度 :先 objectSelector 只放行打了标签的对象,验证后再放开。
  6. 可观测:handler 里记录"收到 / 跳过原因 / patch 条数",并在管理界面提供最近 N 次注入记录------排障的第一现场。
  7. 与其它 injector 共存 :Istio、Datadog 等也在改同一个 Pod,执行顺序不保证(受 reinvocationPolicy 影响)。所以务必"最小改动 + 合并语义 + 幂等"。

十、排障手册

bash 复制代码
# 1) 手工造一个请求打本地(不看集群,先证明服务本身是对的)
curl -k -X POST https://127.0.0.1:8443/mutate \
  -H 'Content-Type: application/json' --data-binary @admission-review.json | jq .

# 想看清 patch 内容
curl -s -k -X POST https://127.0.0.1:8443/mutate -H 'Content-Type: application/json' \
  --data-binary @admission-review.json | jq -r '.response.patch' | base64 -d | jq .

# 2) 端到端验证(dry-run=server 会真实调用 webhook,并打印"将要落库的对象")
kubectl apply --dry-run=server -f pod.yaml -o yaml

# 3) 看注册情况
kubectl get mutatingwebhookconfigurations
kubectl get mutatingwebhookconfiguration my-agent-injector -o yaml
现象 原因
tls: failed to verify certificate SAN 不匹配 / caBundle 类型错(放了 DER 或裸 PEM)/ 换证书没更新
context deadline exceeded 超过 timeoutSeconds;handler 里有慢 IO
connect: connection refused Service selector 选不到 Pod / 端口不是 443 / 容器 HTTPS 端口没起
no endpoints available 副本为 0 或 Pod 未 Ready
webhook 压根没被调用 rules 不匹配,或 namespaceSelector / objectSelector 过滤掉了
Pod 建了但没注入 Ignore 策略吞掉了异常 → 去 handler 日志看"跳过/失败"记录

排查顺序 :先看 webhook 配置(caBundle / service / rules / selector)→ 再进 Pod curl -k https://localhost:8443/mutate 证明服务活着 → 最后看 handler 日志的跳过原因。


十一、实战案例:把上面这套跑成"Mock Agent 注入器"

场景:内网 Mock 平台要把 mock-agent.jar 以 -javaagent 注入业务 JVM 做 Dubbo Mock。要求:业务零改动、CI 重部署后注入不丢、按应用开关。

技术栈:Spring Boot 3.2 + Java 17 + Fabric8 K8s Client 6.10 + BouncyCastle 1.78。

11.1 注入内容

ini 复制代码
Pod
├─ volumes:      mock-agent-volume (emptyDir)          ← 共享下载目录
├─ initContainers:
│    └─ mock-agent-init (nginx:alpine + wget)
│         下载 mock-agent.jar → /tmp/mock-agent/
├─ containers[业务容器 i]:
│    ├─ volumeMounts: mock-agent-volume → /tmp/mock-agent
│    └─ env:
│         JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8 -javaagent:/tmp/mock-agent/mock-agent.jar -Dmock.agent.mandatory=false"
│         MOCK_SERVER_URL / ZK_ADDRESS / MOCK_AGENT_LOG_ENABLED
└─ annotations: status=injected

11.2 决策九道闸门

markdown 复制代码
1. request 缺失?                → 放行
2. 系统命名空间(kube-system/kube-public/kube-node-lease/平台自身 middleware)?→ 跳过
3. pod.spec 为空?               → 跳过
4. 已注入过?(注解 + initContainer 名双重检查)→ 跳过
5. 解析不出 Deployment 名?       → 跳过
6. 平台开关表里没有这个应用?      → 跳过(表 deployment_agent_config)
7. ConfigMap 缺失/为空?          → 跳过
8. patch 为空?                   → 跳过
9. 通过 → base64(JSONPatch) + 记一条注入日志

11.3 幂等

java 复制代码
private boolean isAlreadyInjected(JsonNode podObject) {
    // 快路径:状态注解
    if ("injected".equals(podObject.path("metadata").path("annotations")
            .path("mock-webhook-v2.eptok.com/status").asText(""))) return true;

    // 实锤:initContainer 名
    for (JsonNode c : podObject.path("spec").path("initContainers")) {
        if ("mock-agent-init".equals(c.path("name").asText())) return true;
    }
    return false;
}

11.4 从 ReplicaSet 反推 Deployment(决定"注入哪一个应用的开关")

java 复制代码
for (JsonNode ref : podObject.path("metadata").path("ownerReferences")) {
    if ("ReplicaSet".equals(ref.path("kind").asText(""))) {
        String rsName = ref.path("name").asText("");
        int lastDash = rsName.lastIndexOf('-');            // {deployment}-{hash}
        return lastDash > 0 ? rsName.substring(0, lastDash) : rsName;
    }
}
return null;   // 裸 Pod / 其它控制器 → 不注入

11.5 配置分层:平台开关 > 全局默认

ini 复制代码
ConfigMap mock-agent-config(全局默认,namespace=middleware)
  ├─ mock-server-url / zk-address
  ├─ mock-fallback (1=降级模式, 0=强制模式)
  └─ agent-download-url

MySQL deployment_agent_config(按 namespace + deployment 精确匹配,命中才注入)
  └─ mockServerUrl / zkAddress / mockFallback / logEnabled

Agent 下载地址由 AgentDownloadService 从 JFrog API 动态解析最新 SNAPSHOT,带 5 分钟缓存 ------ agent 发包后不用改配置。

11.6 Patch 生成的关键细节

① emptyDir 共享卷(注意"追加"语义)

java 复制代码
if (podSpec.has("volumes") && podSpec.get("volumes").isArray()) {
    patch.add(createOp("add", "/spec/volumes/-", volume));   // 已有数组 → 末尾追加
} else {
    patch.add(createOp("add", "/spec/volumes", volumes));    // 没有 → 新建
}

② initContainer 下载(失败即失败)

java 复制代码
initContainer.put("name", "mock-agent-init");
initContainer.put("image", "rancher/mirrored-library-nginx:1.19.9-alpine");
// sh -c "... wget -q -O /tmp/mock-agent/mock-agent.jar '<url>' ... || { echo FAILED; exit 1; }"

③ 环境变量:合并,而不是覆盖(这是通用注入器的核心正确性)

java 复制代码
Set<String> MANAGED = Set.of("MOCK_SERVER_URL", "ZK_ADDRESS", "MOCK_AGENT_LOG_ENABLED");
// 遍历原有 env:
//   JAVA_TOOL_OPTIONS → extractNonMockOptions() 剔除 mock 相关片段后再拼接(保留 -Xmx 等业务参数)
//   MANAGED 中的变量   → 丢弃(防止残留脏值)
//   其它              → 原样保留
// 组装出完整 env 数组后,整数组 replace
patch.add(createOp("replace", "/spec/containers/" + i + "/env", envArray));

extractNonMockOptions 按空白切分,丢掉 -javaagent:*mock*、-Dmock.agent.*、-Dmock.platform.*,其余原样保留。保证重复执行不会叠加出两个 -javaagent,也不会吃掉业务自己的 JVM 参数。

④ sidecar 跳过 :istio-proxy / envoy / linkerd / datadog / fluent-bit / prometheus-* / otel-collector / kube-rbac-proxy / cloudsql-proxy 等按名称黑名单过滤,-proxy/-sidecar/-mesh/-init 结尾的一并跳过。

11.7 用 Fabric8 代码化部署(拒绝手写 YAML)

java 复制代码
MutatingWebhookConfiguration config = new MutatingWebhookConfigurationBuilder()
    .withNewMetadata().withName("mock-webhook-v2").endMetadata()
    .withWebhooks(new MutatingWebhookBuilder()
        .withName("mock-webhook-v2.eptok.com")
        .withAdmissionReviewVersions("v1", "v1beta1")
        .withSideEffects("None")
        .withTimeoutSeconds(10)
        .withFailurePolicy("Ignore")
        .withClientConfig(new WebhookClientConfigBuilder()
            .withService(new ServiceReferenceBuilder()
                .withName("mock-platform-webhook-svc")
                .withNamespace("middleware")
                .withPath("/webhook/mutate")
                .withPort(443).build())
            .withCaBundle(caBundle)
            .build())
        .withRules(new RuleWithOperationsBuilder()
            .withOperations("CREATE").withApiGroups("").withApiVersions("v1")
            .withResources("pods").build())
        .withNamespaceSelector(new LabelSelectorBuilder().addToMatchExpressions(
            new LabelSelectorRequirementBuilder()
                .withKey("kubernetes.io/metadata.name").withOperator("NotIn")
                .withValues("kube-system", "kube-public", "kube-node-lease", "middleware")
                .build()).build())
        .build())
    .build();

client.admissionRegistration().v1().mutatingWebhookConfigurations()
      .resource(config).createOrReplace();       // 幂等

Service(443 → 8443 )、ConfigMap、Secret 同样 createOrReplace(),"部署 / 更新 / 移除"全变成 REST 调用,管理面板点按钮即可。

11.8 自举:启动时自动重部署

自签证书每次重启都会变,caBundle 必须同步刷新:

java 复制代码
@Component
public class WebhookInitializer implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        if (!webhookEnabled) return;
        webhookManageService.generateAndDeployCerts();   // 刷新 ConfigMap + caBundle + webhook 配置
        patchDeploymentForWebhook();                     // 保证 WEBHOOK_ENABLED=true
    }
}

部署完成后主动 patch 平台自己的 Deployment 补 WEBHOOK_ENABLED=true(8443 Connector 由它开关)------会触发一次滚动重启,属于设计内的"自举代价"。

11.9 换一个 agent 要改什么?

只有三处,准入逻辑零改动:

要改的 mock agent 的值 换成 APM 探针时的值
① 制品地址 JFrog 上 mock-agent 的 SNAPSHOT SkyWalking skywalking-agent.tar.gz
② 挂载路径 / initContainer /tmp/mock-agent/mock-agent.jar /opt/skywalking/agent.jar
③ 参数模板与环境变量 -javaagent:...-Dmock.agent.mandatory=false + MOCK_SERVER_URL/ZK_ADDRESS -javaagent:/opt/skywalking/agent.jar -Dskywalking.agent.service_name=$APP + SW_AGENT_COLLECTOR_BACKEND_SERVICES

11.10 效果

业务方在面板上打开 Mock 开关 → 平台写开关表并滚动重启 Deployment → 新 Pod 创建时自动注入 agent → Dubbo 调用被 Mock。业务零改动,CI/CD 重新部署后注入依然在。


十二、踩坑清单

通用(任何 MutatingWebhook)

  1. SAN 不全是头号杀手 :必须含 svc.ns.svc.cluster.local 全名;Ignore 策略下证书配错的表现是"什么都没发生"。
  2. caBundle = base64(PEM 证书),不是 DER、不是裸 PEM。
  3. Service 端口必须 443 ,targetPort 才是容器端口。
  4. HTTP 状态码永远 200 ;拒绝用 allowed:false + status.message。
  5. uid 必须回抄,多 webhook 并存靠它路由。
  6. 幂等判据用对象内容(容器名/卷名),别只信注解。
  7. JSON Pointer 转义 :~→~0、/→~1;且写入方与读取方键名必须一致,否则标记等于没写。
  8. 整数组 replace 前先合并(尤其是 env),否则会覆盖业务配置、重复注入参数、破坏其它 injector 的注入。
  9. namespaceSelector 一定要配,排除系统命名空间。
  10. sideEffects: None,否则 dry-run 语义错,注册也不通过。
  11. 别做慢 IO ,超时在 Ignore 下表现为"静默不生效"。
  12. 自签证书要自愈,轮换不同步 caBundle 就重启即失效。
  13. 副作用要克制:webhook 里尽量只读,别写库/调外部服务(本案例读开关表是取舍,用缓存 + 超时兜住)。
  14. 善用 objectSelector:按标签先过滤,能砍掉大部分 No-op 调用------最划算的性能优化。

注入 agent 特有

  1. Pod 名字在 CREATE 时可能为空 :按 object.metadata.name → generateName → request.name 取。
  2. sidecar 容器要跳过,注入环境变量对它没意义还可能崩。
  3. 平台自身命名空间必须排除,否则会把自己的 Pod 也注入一遍。
  4. JVM 参数必须合并 :直接覆盖 JAVA_TOOL_OPTIONS 会吃掉业务的 -Xmx、-Dspring.profiles.active。

十三、小结

"用 Webhook 做 agent 注入"这件事,说到底是三层的组合:

  1. 准入层 (通用样板):AdmissionReview 收发、JSON Patch 生成、TLS/caBundle、fail-open、幂等、namespaceSelector/objectSelector 范围收敛;
  2. 注入层 (按运行时选模板):JVM 用 JAVA_TOOL_OPTIONS + -javaagent,Node 用 NODE_OPTIONS,Python 用 PYTHONPATH,Native 用 LD_PRELOAD,跨语言用 sidecar/eBPF;
  3. 分发层:initContainer 下载 + emptyDir 共享 + 版本动态解析 + 版本写入注解;换 agent 只改这三处,准入逻辑完全复用。

把这三层分别做成平台能力后,你会发现任何"想给业务进程挂点东西"的需求,都变成了填一张配置表------APM 探针、流量录制、故障注入、在线诊断,都是同一套骨架的不同实例。

完整案例源码结构:WebhookController(入口)/ WebhookService(决策 + patch)/ WebhookHttpsConfig(自签证书 + 双端口)/ WebhookManageService(代码化部署)/ WebhookInitializer(启动自愈)/ AgentDownloadService(制品动态分发)。

相关推荐
FullStackGeek1 小时前
微信生态接入与微信支付 APIv3 实战:从公众号授权到代理商/直连双模式支付
后端
suhan1 小时前
【数据结构】三、栈和队列:2.顺序栈&共享栈(顺序栈的初始化,判空,进栈,出栈,读取栈顶,顺序栈实例)
数据结构·后端·算法
neoooo1 小时前
🚀Claude Code 小技巧(一):一键进入危险模式
前端·人工智能·后端
中趴菜1 小时前
工具调用 3|工具拿到参数以后,程序还必须检查什么?
后端
AI语宙漫游指南1 小时前
RAG 向量检索(上):从 FLAT 到 IVF,查得快为何可能漏?
后端·算法
java资料站2 小时前
七、Spring AI Alibaba · Skills
java·后端·spring
殷紫川2 小时前
Arthas 4 深度实战:从字节码原理到生产故障秒解
后端
JuiceFS2 小时前
地平线 EB 级存储工程实践
人工智能·后端·自动驾驶
霸道流氓气质3 小时前
LLM API密钥管理与轮换策略:Spring Vault动态密钥、熔断限流协同与多Provider路由的 Java 生产级实现
java·后端·spring