
质量架构 - 流量录制回放平台
业界实现
方案名称 | 录制原理 | RPC | HTTP | 写接口 MOCK | 支持语言 |
---|---|---|---|---|---|
jvm-sandbox-repeater - 阿里 | JVM-SANDBOX | ✅ | ✅ | ✅ | Java |
DOOM - 阿里 | 字节码增强 AOP | ✅ | ✅ | Java | |
写轮眼 - 滴滴 | 修改 Golang pcap 源码,类似 tcpdump | ❌ | ✅ | ❌ | Go |
Quake - 美团 | RPC 框架改造/Nginx | ✅ | ✅ | ❌ | Java |
ByteCopy - 字节 | 基于 TCPCopy 的自研 | ✅ | ✅ | ❌ | Go |
diffy - Twitter | 网络层代理 | ✅ | 不限 | ||
Moonbox(月光宝盒) -VIVO | JVM-SANDBOX & jvm-sandbox-repeater | ✅ | ✅ | ✅ | Java |
流量录制的实现大致分为以下几类:
- 字节码增强:以 JVM-SANDBOX 为例,通过动态注入 agent 修改字节码,将目标类作为 AOP 切点 实现流量的录制
- 优势:十分灵活 扩展性强;支持动态插拔
- 劣势:需要自实现 agent;需要风险控制
- 网络层处理:流量进入网关时,通过流量复制( ngx_http_mirror_module)的方式导入流量录制回放平台,美团的 HTTP 请求/Diffy 等是通过这样做的
- 优势:技术成熟,操作相对简单,支持流量筛选
- 劣势:仅支持 HTTP,使用场景受限
- 框架源码改造:通过修改 RPC 框架源码,让所有 RPC 请求自带录制功能
- 优势:通常是公司基础服务团队改造,可靠性有保障,接入方便
- 劣势:小组实现成本较高
最终选择 JVM-SANDBOX 作为底层实现方案,原因如下:
- 平台建设初衷之一是支持 RPC 流量的实时抓包以及 MOCK 能力,提升线下阶段的可测性,所以对于流量录制的实时性有较高要求
- 大厂出品,社区也相对活跃,试用了 repeater 功能基本满足需要,可靠性 ok
- 扩展性强,由于在回放写接口时需要对链路的中间件做 mock 处理,JVM-SANDBOX 对这方面的扩展支持性比较好
JVM-SANBOX 源码阅读
整体结构
| sandbox
|----bin
|----sandbox-agent
|----sandbox-common-api
|----sandbox-api
|----sandbox-provider-api
|----sandbox-spy
|----sandbox-core
|----sandbox-module-starter
|----sandbox-mgr-module
|----sandbox-mgr-provider
|----sandbox-debug-module
一、启动注入
从使用层面,这一阶段需在服务器执行命令 ./sandbox.sh -p
完成自定义 mododule 的注入后,在自定义类的事件驱动下会触发对应的代码逻辑。
1.1 启动脚本 bin
sandbox.sh 脚本主函数 main(),他主要功能是解析 shell 命令参数然后执行对应的命令,最核心的是-p JVM_PORT 参数对应的 attch_jvm 方法:
function attach_jvm() {
# got an token
local token
token="$(date | head | cksum | sed 's/ //g')"
# attach target jvm
"${SANDBOX_JAVA_HOME}/bin/java" \
${SANDBOX_JVM_OPS} \
-jar "${SANDBOX_LIB_DIR}/sandbox-core.jar" \
"${TARGET_JVM_PID}" \
"${SANDBOX_LIB_DIR}/sandbox-agent.jar" \
"home=${SANDBOX_HOME_DIR};token=${token};server.ip=${TARGET_SERVER_IP};server.port=${TARGET_SERVER_PORT};namespace=${TARGET_NAMESPACE}" ||
exit_on_err 1 "attach JVM ${TARGET_JVM_PID} fail."
# get network from attach result
SANDBOX_SERVER_NETWORK=$(grep "${token}" "${SANDBOX_TOKEN_FILE}" | grep "${TARGET_NAMESPACE}" | tail -1 | awk -F ";" '{print $3";"$4}')
[[ -z ${SANDBOX_SERVER_NETWORK} ]] &&
exit_on_err 1 "attach JVM ${TARGET_JVM_PID} fail, attach lose response."
}
//上面的代码会输入类似如下格式的命令,即启动sandbox-core.jar,往下看
java -Xms128M -Xmx128M -jar xxx/sandbox-core.jar 10732 xxx/sandbox-agent.jar home=/xxx;token=xxx;server.ip=xxx;service.port=xxx;namespace=xxx
1.2 sandbox-core.CoreLauncher
<!-- sandbox-core的启动命令中定义了执行入口类 -->
<executions>
<execution>
<goals>
<goal>attached</goal>
</goals>
<phase>package</phase>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifest>
<mainClass>com.alibaba.jvm.sandbox.core.CoreLauncher</mainClass>
</manifest>
</archive>
</configuration>
</execution>
</executions>
执行入口 CoreLauncher 类的构造函数完成两件事:
- vmObj = VirtualMachine.attach(targetJvmPid) :使得当前 JVM 与另一个正在运行的 JVM 实例(通常是我们的被测服务)建立了通信通道
- vmObj.loadAgent(agentJarPath, cfg) :加载代理,向目标 JVM 注入一个代理(agent.jar)实现字节码增强等各种功能
//对应1.1的命令,这里targetJvmPid= 10732; agentJarPath = xxx/sandbox-agent.jar ;token = token
public CoreLauncher(final String targetJvmPid,
final String agentJarPath,
final String token) throws Exception {
// 加载agent
attachAgent(targetJvmPid, agentJarPath, token);
}
//构造函数调用加载,主要实现JVM attach以及agent加载
private void attachAgent(final String targetJvmPid,
final String agentJarPath,
final String cfg) throws Exception {
VirtualMachine vmObj = null;
try {
vmObj = VirtualMachine.attach(targetJvmPid);
if (vmObj != null) {
vmObj.loadAgent(agentJarPath, cfg);
}
} finally {
if (null != vmObj) {
vmObj.detach();
}
}
}
重点看下加载代理时都干了什么,继续~
1.3 sandbox-agent.jar
⭐️⭐️⭐️ 这里先插一个知识点,JVM 加载 agent 有两种方式:
-javaagent 方式(静态加载):这种模式是在 JVM 启动时通过命令行参数指定的。这意味着在 JVM 启动之前,agent 已经被加载,并且可以在应用程序的主类加载之前修改字节码。
定义方法
public static void premain(String agentArgs, Instrumentation inst);
使用示例
java -javaagent:path/to/youragent.jar=agentArguments -jar yourapplication.jar
好处
可以在应用程序的主类加载之前修改字节码,不会出现动态加载时的性能突降劣势
灵活性一般,只能在 JVM 启动时加载attach 方式(动态加载):这种模式是在 JVM 已经启动并运行之后,通过 Attach API 动态加载 agent。它可以在运行时将 agent 动态附加到正在运行的 JVM,是我们使用的方式
定义方法:
public static void agentmain(String agentArgs, Instrumentation inst);
使用实例
VirtualMachine vm = VirtualMachine.attach("targetJvmPid"); vm.loadAgent("path/to/youragent.jar", "agentArguments"); vm.detach();
优势:动态加载/卸载,无需重启被测服务 JVM
劣势:被测服务启动时,需要重新进行类加载操作,有额外的性能开销
补完课可以看到,我们是进入的 AgentLauncher.agentmain 方法
<archive>
<manifestEntries>
<Premain-Class>com.alibaba.jvm.sandbox.agent.AgentLauncher</Premain-Class>
<Agent-Class>com.alibaba.jvm.sandbox.agent.AgentLauncher</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
<Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
</manifestEntries>
</archive>
agentmain 的核心代码:
/**
* 在当前JVM安装jvm-sandbox
*
* @param featureMap 启动参数配置
* @param inst inst
* @return 服务器IP:PORT
*/
private static synchronized InetSocketAddress install(final Map<String, String> featureMap,
final Instrumentation inst) {
final String namespace = getNamespace(featureMap);
final String propertiesFilePath = getPropertiesFilePath(featureMap);
final String coreFeatureString = toFeatureString(featureMap);
try {
final String home = getSandboxHome(featureMap);
// 将Spy注入到BootstrapClassLoader
inst.appendToBootstrapClassLoaderSearch(new JarFile(new File(
getSandboxSpyJarPath(home)
// SANDBOX_SPY_JAR_PATH
)));
// 构造自定义的类加载器,尽量减少Sandbox对现有工程的侵蚀
final ClassLoader sandboxClassLoader = loadOrDefineClassLoader(
namespace,
getSandboxCoreJarPath(home)
// SANDBOX_CORE_JAR_PATH
);
// CoreConfigure类定义
final Class<?> classOfConfigure = sandboxClassLoader.loadClass(CLASS_OF_CORE_CONFIGURE);
// 反序列化成CoreConfigure类实例
final Object objectOfCoreConfigure = classOfConfigure.getMethod("toConfigure", String.class, String.class)
.invoke(null, coreFeatureString, propertiesFilePath);
// CoreServer类定义
final Class<?> classOfProxyServer = sandboxClassLoader.loadClass(CLASS_OF_PROXY_CORE_SERVER);
// 获取CoreServer单例
final Object objectOfProxyServer = classOfProxyServer
.getMethod("getInstance")
.invoke(null);
// CoreServer.isBind()
final boolean isBind = (Boolean) classOfProxyServer.getMethod("isBind").invoke(objectOfProxyServer);
// 如果未绑定,则需要绑定一个地址
if (!isBind) {
try {
classOfProxyServer
.getMethod("bind", classOfConfigure, Instrumentation.class)
.invoke(objectOfProxyServer, objectOfCoreConfigure, inst);
} catch (Throwable t) {
classOfProxyServer.getMethod("destroy").invoke(objectOfProxyServer);
throw t;
}
}
// 返回服务器绑定的地址
return (InetSocketAddress) classOfProxyServer
.getMethod("getLocal")
.invoke(objectOfProxyServer);
} catch (Throwable cause) {
throw new RuntimeException("sandbox attach failed.", cause);
}
}
主要完成了下面三件事,都很关键:
- 注入 Spy 类到 BootstrapClassLoader:获取 Spy JAR 文件的路径并将其注入到 BootstrapClassLoader,以便在所有类加载器中共享。
- 构造一个自定义类加载器,加载沙箱核心功能类,完成类加载(Module、CoreConfigure、ProxyCoreServer),尽量减少对现有工程的侵蚀
- 使用自定义类加载器加载代理服务器核心类 JettyCoreServer,这里主要实现了两部分功能:
- 实例化一个 jetty 服务器,使得后面可以使用 HTTP 通信
- 使用 Sandbox 类加载器加载并重置所有 module 模块
截止到这里,启动 sandbox 的功能就完成了,最重要的就是实现了这几个能力:
- 将 spy 类注入到了 BootstrapClassLoader,这关系到后面整个动态编织逻辑
- 外界将和沙箱可以实现 HTTP 通信,这方便我们对沙箱做很多操作以及状态的变更
- 新启了一个 sandbox 类加载器,将 module 完成了类加载
二、⭐️ 目标类字节码动态增强
在上一步启动 module 的时候,我只是一笔带过了。实际上这里非常的关键,因为会将 module 中的自定义类/方法实现字节码动态增强。举个例子,通常 module 中都会有这部分,他的最终效果是当代码执行到配置增强的类名、方法名、参数时,会触发对应 recordListener 的执行逻辑。这一节就梳理这个增强逻辑如何实现的
final EventWatcher watcher = new EventWatchBuilder(moduleEventWatcher)
.onClass(className)
.includeSubClasses()
.includeBootstrap()
.onBehavior(methodName)
.onWatching()
.onWatch(recordListener);
2.1 onWatch()
核心逻辑在 moduleEventWatcher.watch 方法,他会实现修改目标类的字节码
private EventWatcher build(EventListener listener, ModuleEventWatcher.Progress progress, Event.Type... eventTypes) {
final int watchId = this.moduleEventWatcher.watch(this.toEventWatchCondition(), listener, progress, eventTypes);
......
}
其实现类为 DefaultModuleEventWatcher.watch,主要做了这几件事:
生成一个全局的 watchId
给对应的模块追加 ClassFileTransformer
final SandboxClassFileTransformer sandClassFileTransformer = new SandboxClassFileTransformer(
watchId, coreModule.getUniqueId(), matcher, listener, isEnableUnsafe, eventType, namespace);
- 将 SandboxClassFileTransformer 注册到 CoreModule 中
coreModule.getSandboxClassFileTransformers().add(sandClassFileTransformer);
- 这里 addTransformer 后,接下来引起的类加载都会经过 sandClassFileTransformer
inst.addTransformer(sandClassFileTransformer, true);
PS:如果是移除字节码增强,会调用 removeTransformer
收集所有要增强的类 List<Class>
⭐️ 调用
reTransformClasses()
方法,实际调用了 Instrumentation 的retransformClasses()
方法,对 JVM 已经加载的类重新触发类加载reTransformClasses(watchId,waitingReTransformClasses, progress); => inst.retransformClasses(waitingReTransformClass);
激活增强类
EventListenerHandler.getSingleton() .active(listenerId, listener, eventType);
2.2 类增强的是什么样子,如何与 Spy 联系上?
直接引用官方的例子,一目了然
思考总结
上线已经一年了,现在看还有一些需要完善的:
- Diff 断言:目前比较简单粗暴的对 json diff 作对比,但是每次都会展示一些噪音,可以支持配置一些噪音模板自动过滤掉
- 推流:为了保证时序性选择了同步推送数据,其实也可以用 MQ,只要保证同一个 traceId 时序即可
- 链路录制回放:这个暂时没看到太好的解决方案,能想到的是在现有 traceId 之上再包一层 parent-traceId
关于 JVM-SANDBOX 的介绍,也有一些参考文档: