想给业务应用挂 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 三条硬性规则
- HTTP 状态码永远 200 。返回 4xx/5xx 是"调用失败"(走
failurePolicy),不是"拒绝"。 patchType用JSONPatch(MergePatch是历史遗留)。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 到底挂的哪个版本"时能救命。
九、生产化:可用性、性能、幂等、自举
- 它是同步阻塞链路,注入器抖 = 发布抖。 ≥2 副本 +
PodDisruptionBudget+ Readiness 探针覆盖 HTTPS 端口。 - 别做慢 IO。 查 DB / 调远端制品库都要有超时 + 本地缓存兜底。APIServer 对 webhook 有 QPS 限制,大促扩缩容会放大延迟。
- 幂等是硬要求。 触发来源:webhook 超时重试、
reinvocationPolicy、滚动重建、人工删 Pod。判据写在对象内容里。 - 自举(鸡生蛋) :如果注入器自身 Deployment 也归这套系统管,且 HTTPS 端口由环境变量开关,就出现循环。解法:
- 部署完 webhook 配置后,主动 patch 自己的 Deployment 补开关环境变量;
- 应用启动完成(
ApplicationRunner)后再自检重部署一次 ,把最新caBundle写回(自签方案必做,否则重启即失效)。
- 灰度 :先
objectSelector只放行打了标签的对象,验证后再放开。 - 可观测:handler 里记录"收到 / 跳过原因 / patch 条数",并在管理界面提供最近 N 次注入记录------排障的第一现场。
- 与其它 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)
- SAN 不全是头号杀手 :必须含
svc.ns.svc.cluster.local全名;Ignore策略下证书配错的表现是"什么都没发生"。 caBundle= base64(PEM 证书),不是 DER、不是裸 PEM。- Service 端口必须 443 ,
targetPort才是容器端口。 - HTTP 状态码永远 200 ;拒绝用
allowed:false+status.message。 uid必须回抄,多 webhook 并存靠它路由。- 幂等判据用对象内容(容器名/卷名),别只信注解。
- JSON Pointer 转义 :
~→~0、/→~1;且写入方与读取方键名必须一致,否则标记等于没写。 - 整数组
replace前先合并(尤其是 env),否则会覆盖业务配置、重复注入参数、破坏其它 injector 的注入。 namespaceSelector一定要配,排除系统命名空间。sideEffects: None,否则 dry-run 语义错,注册也不通过。- 别做慢 IO ,超时在
Ignore下表现为"静默不生效"。 - 自签证书要自愈,轮换不同步 caBundle 就重启即失效。
- 副作用要克制:webhook 里尽量只读,别写库/调外部服务(本案例读开关表是取舍,用缓存 + 超时兜住)。
- 善用
objectSelector:按标签先过滤,能砍掉大部分 No-op 调用------最划算的性能优化。
注入 agent 特有
- Pod 名字在
CREATE时可能为空 :按object.metadata.name→generateName→request.name取。 - sidecar 容器要跳过,注入环境变量对它没意义还可能崩。
- 平台自身命名空间必须排除,否则会把自己的 Pod 也注入一遍。
- JVM 参数必须合并 :直接覆盖
JAVA_TOOL_OPTIONS会吃掉业务的-Xmx、-Dspring.profiles.active。
十三、小结
"用 Webhook 做 agent 注入"这件事,说到底是三层的组合:
- 准入层 (通用样板):
AdmissionReview收发、JSON Patch 生成、TLS/caBundle、fail-open、幂等、namespaceSelector/objectSelector范围收敛; - 注入层 (按运行时选模板):JVM 用
JAVA_TOOL_OPTIONS+-javaagent,Node 用NODE_OPTIONS,Python 用PYTHONPATH,Native 用LD_PRELOAD,跨语言用 sidecar/eBPF; - 分发层:initContainer 下载 + emptyDir 共享 + 版本动态解析 + 版本写入注解;换 agent 只改这三处,准入逻辑完全复用。
把这三层分别做成平台能力后,你会发现任何"想给业务进程挂点东西"的需求,都变成了填一张配置表------APM 探针、流量录制、故障注入、在线诊断,都是同一套骨架的不同实例。
完整案例源码结构:
WebhookController(入口)/WebhookService(决策 + patch)/WebhookHttpsConfig(自签证书 + 双端口)/WebhookManageService(代码化部署)/WebhookInitializer(启动自愈)/AgentDownloadService(制品动态分发)。