本节目标:把 13.1 的故障模型变成几个可开关的拦截器,注入范围可控到「某个租户的某个比例请求」。
前提:注入能力是生产代码的一部分,必须有默认关闭、独立开关和审计日志,不能是测试分支里的临时补丁。
承接的问题
很多团队的故障注入是这样开始的:在业务方法里临时加一句 Thread.sleep(500),测完删掉。这种做法有三个问题:
- 注入代码可能被遗忘,随发布进入生产。
- 没有比例控制,要么完全不断言,要么全部请求都失败,测不出「部分失败」下的行为。
- 注入点和超时、重试、熔断的相对位置不明确,观测到的现象无法归因。
gRPC 的拦截器提供了天然的注入点:它在业务方法之外、传输层之内,可以改元数据、延迟响应、提前关闭调用,而且不需要改业务代码。代价是它只能操作「调用语义」,管不到 TCP 连接本身;半开连接需要另做。
本节的原则是:开关默认关闭,只对显式标记的测试租户生效,比例由请求 ID 决定而不是全局随机,所有注入都写审计日志。
开关模型:三层过滤
一次请求要同时满足三个条件才会被注入,任何一层不满足就原样放行:
| 层 | 键 | 取值 | 作用 |
|---|---|---|---|
| 总开关 | 系统属性 linkd.fault.enabled | true / 缺省 | 进程级总闸,缺省关闭 |
| 租户 | x-tenant-id | 必须是 tenant-drill | 防止误伤真实租户 |
| 指令 | x-fault | delay:250、abort:UNAVAILABLE、drop:2000 | 指定故障类型与参数 |
再加一条比例控制:x-fault-ratio 取 0–100,表示命中的百分比。比例不是每次请求重新掷骰子,而是用 requestId 和固定 seed 做哈希:
hit = floorMod(hash(requestId, seed), 100) < ratio
这样同一个请求在重试时仍然命中(或者仍然不命中),观测到的错误分布可复现;换一个 seed 就得到另一组样本。用全局 Random 的问题是:重试的第三次可能突然不注入了,客户端看到的错误率被稀释,结论不可信。
seed 来自演练编号,例如 drill-20261028-a 的哈希。演练记录里要写 seed,否则别人无法复现同一组命中。
总开关用系统属性而不是配置中心,是为了在配置中心本身故障时仍能关闭注入。系统属性在进程启动时读取,运行期改 jcmd VM.system_properties 不会生效,这一点要在运行手册里写清楚。
ServerInterceptor:延迟、异常与丢弃
服务端拦截器负责真正制造故障。下面是一个完整实现,三种模式共用一个入口:
package com.plume.linkd.v3.chaos;
import io.grpc.ForwardingServerCall;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public final class FaultInjectingServerInterceptor implements ServerInterceptor {
public static final Metadata.Key<String> FAULT =
Metadata.Key.of("x-fault", Metadata.ASCII_STRING_MARSHALLER);
public static final Metadata.Key<String> RATIO =
Metadata.Key.of("x-fault-ratio", Metadata.ASCII_STRING_MARSHALLER);
public static final Metadata.Key<String> TENANT =
Metadata.Key.of("x-tenant-id", Metadata.ASCII_STRING_MARSHALLER);
public static final Metadata.Key<String> REQUEST_ID =
Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER);
private static final String DRILL_TENANT = "tenant-drill";
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "fault-injector");
thread.setDaemon(true);
return thread;
});
private final boolean enabled;
private final long seed;
public FaultInjectingServerInterceptor(boolean enabled, long seed) {
this.enabled = enabled;
this.seed = seed;
}
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
if (!enabled || !DRILL_TENANT.equals(headers.get(TENANT))) {
return next.startCall(call, headers);
}
String spec = headers.get(FAULT);
if (spec == null || spec.isBlank() || !hit(headers, spec)) {
return next.startCall(call, headers);
}
String[] parts = spec.split(":", 2);
String mode = parts[0];
String arg = parts.length > 1 ? parts[1] : "";
return switch (mode) {
case "delay" -> delay(call, headers, next, Long.parseLong(arg));
case "abort" -> abort(call, arg);
case "drop" -> drop(call, Long.parseLong(arg));
default -> next.startCall(call, headers);
};
}
private boolean hit(Metadata headers, String spec) {
String raw = headers.get(RATIO);
int ratio = raw == null ? 100 : Integer.parseInt(raw);
if (ratio <= 0) {
return false;
}
if (ratio >= 100) {
return true;
}
String requestId = headers.get(REQUEST_ID);
long value = 31L * seed + (requestId == null ? 0 : requestId.hashCode()) + spec.hashCode();
return Math.floorMod(value, 100) < ratio;
}
private <ReqT, RespT> ServerCall.Listener<ReqT> delay(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next,
long delayMs) {
// 同一个单线程调度器保证 sendMessage 与 close 的提交顺序不被打乱。
ServerCall<ReqT, RespT> delayed =
new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override
public void sendMessage(RespT message) {
scheduler.schedule(() -> super.sendMessage(message),
delayMs, TimeUnit.MILLISECONDS);
}
@Override
public void close(Status status, Metadata trailers) {
scheduler.schedule(() -> super.close(status, trailers),
delayMs, TimeUnit.MILLISECONDS);
}
};
return next.startCall(delayed, headers);
}
private <ReqT, RespT> ServerCall.Listener<ReqT> abort(
ServerCall<ReqT, RespT> call, String code) {
Status status = Status.fromCode(Status.Code.valueOf(code))
.withDescription("injected fault: " + code);
call.close(status, new Metadata());
return new ServerCall.Listener<ReqT>() {
};
}
private <ReqT, RespT> ServerCall.Listener<ReqT> drop(
ServerCall<ReqT, RespT> call, long closeAfterMs) {
// 不启动 delegate,请求不会被业务处理;到点关闭,客户端通常先撞上自己的 deadline。
scheduler.schedule(() -> call.close(
Status.UNAVAILABLE.withDescription("injected drop"), new Metadata()),
closeAfterMs, TimeUnit.MILLISECONDS);
return new ServerCall.Listener<ReqT>() {
};
}
}
几个细节决定了这段代码能不能用于真实演练:
delay包装的是sendMessage和close,不是startCall。业务照常执行,只是响应晚到;这样测的是「下游变慢」而不是「服务端不处理请求」。drop不调用next.startCall,所以业务方法完全不会执行。如果客户端 deadline 是 800 ms、drop:2000,客户端会在 800 ms 收到DEADLINE_EXCEEDED,服务端在 2000 ms 才关闭调用。这个时间差本身就是要观测的现象。abort立即关闭,返回一个空的 listener。gRPC 允许在interceptCall里直接关闭调用,不需要启动 delegate。- 调度器是单线程且 daemon,避免注入器阻止进程退出;延迟任务之间不会并发,
sendMessage/close的顺序得以保持。
还有一个边界:ForwardingServerCall 的 sendMessage 被延迟后,如果调用已经被取消,再调用 super.sendMessage 会抛 IllegalStateException。真实实现应在调度任务里先判断 call.isCancelled()。本节的示例为了篇幅没有展开,但演练环境的日志里如果出现这个异常,说明客户端取消与延迟关闭发生了竞争,不是注入器本身坏了。
半开连接:拦截器管不到的那一层
「半开」指 TCP 连接的一端关闭了写方向,另一端仍在读。gRPC 的拦截器看不到连接,只能看到调用;要测半开,得绕过 gRPC 直接操作 socket。
package com.plume.linkd.v3.chaos;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
public final class HalfOpenProbe {
/** 连接 9093 后只关写方向,保留读方向,观察服务端多久回收连接。 */
public static void probe(String host, int port, int readTimeoutMs) throws Exception {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 200);
socket.setSoTimeout(readTimeoutMs);
socket.shutdownOutput();
InputStream in = socket.getInputStream();
int first = in.read();
System.out.println("firstByte=" + first);
}
}
public static void main(String[] args) throws Exception {
probe("127.0.0.1", 9093, 300);
}
}
shutdownOutput() 只关写方向,读方向仍然可用,这是「半开」和「完全关闭」的区别。服务端如果在读到 EOF 后立刻把连接当作已关闭,就可能丢掉还没写完的响应;如果一直不回收,就会泄漏 fd。
这个探针不能证明 gRPC 层的正确性,它只验证传输层。gRPC 客户端还有自己的 keepalive 和连接空闲回收,半开连接最终会被其中一层清理。演练时要同时记录 lsof -nP -iTCP:9093 的连接数和服务端日志里的连接关闭事件,才能判断是哪一层生效。
macOS 上没有 ss,连接状态用 netstat -anv -p tcp 和 lsof 观察;抓包用 sudo tcpdump -i lo0 -nn port 9093。半开连接在抓包里表现为一方发了 FIN,另一方还在发数据。
ClientInterceptor:打标与主动取消
客户端拦截器不制造业务故障,它负责两件事:把演练标记写进元数据,以及在需要时主动取消调用。
package com.plume.linkd.v3.chaos;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.ForwardingClientCall;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public final class DrillClientInterceptor implements ClientInterceptor {
private final String faultSpec;
private final int ratio;
private final long cancelAfterMs;
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "drill-cancel");
thread.setDaemon(true);
return thread;
});
public DrillClientInterceptor(String faultSpec, int ratio, long cancelAfterMs) {
this.faultSpec = faultSpec;
this.ratio = ratio;
this.cancelAfterMs = cancelAfterMs;
}
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method, CallOptions options, Channel next) {
ClientCall<ReqT, RespT> call = next.newCall(method, options);
return new ForwardingClientCall.SimpleForwardingClientCall<>(call) {
@Override
public void start(Listener<RespT> listener, Metadata headers) {
headers.put(FaultInjectingServerInterceptor.TENANT, "tenant-drill");
headers.put(FaultInjectingServerInterceptor.FAULT, faultSpec);
headers.put(FaultInjectingServerInterceptor.RATIO, Integer.toString(ratio));
super.start(listener, headers);
if (cancelAfterMs > 0) {
scheduler.schedule(() -> call.cancel("drill cancel", null),
cancelAfterMs, TimeUnit.MILLISECONDS);
}
}
};
}
}
客户端只打标、不解释故障,注入逻辑集中在服务端。这样同一条故障指令在网关、comm-server 和 libraryd 代理层的行为一致,不会出现「客户端以为注入了、服务端根本没收到」的情况。
主动 cancel 测的是取消传播:客户端取消后,服务端的 Context 应该被取消,业务线程如果还在执行,要能通过 Context.current().isCancelled() 提前退出。取消不是错误,客户端的调用状态是 CANCELLED,不要把它统计进 5xx。
在 linkd-v3 里落地
把注入器装到服务端启动流程里,开关从系统属性读,seed 从启动参数读:
package com.plume.linkd.v3.chaos;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.BindableService;
public final class FaultEnabledServer {
public static Server start(BindableService service, int port) throws Exception {
boolean enabled = Boolean.getBoolean("linkd.fault.enabled");
long seed = Long.getLong("linkd.fault.seed", 20261028L);
return ServerBuilder.forPort(port)
.addService(service)
.intercept(new FaultInjectingServerInterceptor(enabled, seed))
.build()
.start();
}
}
启动脚本里显式打开,演练结束立即去掉:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
"$JAVA_HOME/bin/java" \
-Dlinkd.fault.enabled=true \
-Dlinkd.fault.seed=20261028 \
-jar comm-server/target/comm-server-3.0.0-SNAPSHOT.jar
配置项只有四个,写进运行手册:
| 参数 | 默认 | 说明 |
|---|---|---|
linkd.fault.enabled | false | 进程级总开关,启动时读取 |
linkd.fault.seed | 20261028 | 命中比例的哈希种子,演练记录必须写 |
x-fault | 无 | 请求级指令,缺省不注入 |
x-fault-ratio | 100 | 命中百分比,0 表示关闭 |
不要引入混沌平台,也不要把注入开关做成管理端接口。理由不是「平台不好」,而是当前规模下,一个系统属性加一个 header 已经能覆盖全部需求,额外引入的平台会带来新的权限、审计和可用性问题。等到需要跨机器编排、自动回滚、多实验并行时再考虑平台化。
常见错误与边界
错误一:用全局 Random 做比例。 重试的请求可能逃过注入,错误率被稀释。用 requestId 加固定 seed 做哈希,命中结果可复现。
错误二:只判断 header,不判断租户。 任何客户端都能带 x-fault,等于把故障注入能力开放给了全站。租户白名单是硬条件,不能省。
错误三:把 drop 实现成 abort。 立即返回 UNAVAILABLE 测的是快速失败;不启动业务、延迟关闭才测得出 deadline 与连接回收。两者观测到的指标不同。
错误四:在延迟任务里不判断取消。 客户端取消后仍调用 sendMessage 会抛异常,日志被污染,真正的失败被淹没。
错误五:把注入开关放进配置中心。 配置中心本身可能是被演练的依赖;总开关走系统属性,kill switch 才独立。
错误六:演练结束忘记关开关。 注入器有默认关闭还不够,启动脚本、部署模板、CI 环境都要检查;审计日志里记录每次命中的 requestId,便于事后确认影响面。
边界:拦截器只能注入调用语义。 连接层故障(半开、RST、TCP 重传)要用 socket 探针或抓包验证,不能指望 ServerInterceptor。
边界:注入不等于故障。 注入是受控的输入,故障是未预期的结果。演练报告要区分「我们注入了什么」和「系统实际发生了什么」,后者才是结论。
小结
- 三层过滤:进程总开关、测试租户、请求指令;任何一层不满足就放行。
- 比例用
requestId加固定 seed 哈希,保证重试命中一致、结果可复现。 delay包装sendMessage/close,abort立即关闭,drop不启动业务并延迟关闭,三者观测点不同。- 半开连接在 socket 层验证,
shutdownOutput()只关写方向;拦截器看不到连接。 - 客户端拦截器只打标和取消,注入逻辑集中在服务端,行为才一致。
- 开关默认关闭、走系统属性、记录审计日志;当前规模不需要混沌平台。
阅读导航:上一节 13.1 故障模型与爆炸半径 | 下一节 13.3 演练设计与复盘
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。