作者介绍
王鹏,2017年加入去哪儿机票事业部,主要从事后端研发工作,目前在机票事业部负责行程单和故障演练平台以及公共服务ES、数据同步中间件等相关的研发工作。
去哪儿网2005年成立至今,随着系统规模的逐步扩大,已经有成百上千个应用系统,这些系统之间的耦合度和链路的复杂度不断加强,对于我们构建分布式高可用的系统架构具有极大挑战。我们需要一个平台在运行期自动注入故障,检验故障预案是否起效——故障演练平台。
这是某事业部的系统拓扑图:
系统之间的依赖非常复杂、调用链路很深、服务之间没有分层。在这种复杂的依赖下,系统发生了几起故障:
三个故障原因:
各种各样的问题,在这种复杂的依赖结构下被放大,一个依赖30个SOA服务的系统,每个服务99.99%可用。99.99%的30次方≈99.7%。0.3%意味着一亿次请求会有3,000,00次失败,换算成时间大约每月有2个小时服务不稳定。随着服务依赖数量的变多,服务不稳定的概率会呈指数 性提高,这些问题最后都会转化为故障表现出来。
如何构建一个高可用的系统呢?首先要分析一下不可用的因素都有哪些:
高可用系统典型实践
理论上来说,当图中所有的事情都做完,我们就可以认为系统是一个真正的高可用系统。但真是这样吗?
那么故障演练平台就隆重登场了。当上述的高可用实践都做完,利用故障演练平台做一次真正的故障演练,在系统运行期动态地注入一些故障,从而来验证下系统是否按照故障预案去执行相应的降级或者熔断策略。
故障演练平台:检验故障预案是否真正的起作用的平台。
故障类型:主要包括运行期异常、超时等等。通过对系统某些服务动态地注入运行期异常来达到模拟故障的目的,系统按照预案执行相应的策略验证系统是否是真正的高可用。
故障演练平台架构主要分为四部分:
-
前台展示系统(WEB):展示系统之间的拓扑关系以及每个AppCode对应的集群和方法,可以选择具体的方法进行故障的注入和解除;
-
发布系统(Deploy):这个系统主要用于将故障演练平台的Agent和Binder包发布到目标APP的机器上并且启动执行。前台展示系统会传递给发布平台要进行故障注入的AppCode以及目标APP的IP地址,通过这两个参数发布系统可以找到相应的机器进行Jar包的下载和启动;
-
服务和命令分发系统(Server):这个系统主要是用于命令的分发、注入故障的状态记录、故障注入和解除操作的逻辑、权限校验以及相关的Agent的返回信息接收功能。前台页面已经接入QSSO会对当前人可以操作的IP列表做故障注入,防范风险。后端命令分发的模块会和部署在目标APP上的Agent进行通信,将命令推送到Agent上执行字节码编织,Agent执行命令后返回的内容通过Server和Agent的长连接传回Server端;
-
Agent和Binder程序:Agent负责对目标APP做代理并且做字节码增强,具体代理的方法可以通过传输的命令来控制,代理方法后对方法做动态的字节码增强,这种字节码增强具有无侵入、实时生效、动态可插拔的特点。Binder程序主要是通过发布系统传递过来的AppCode和启动端口(ServerPort)找到目标APP的JVM进程,之后执行动态绑定,完成运行期代码增强的功能。
目前AOP的实现有两种方式:
静态编织的问题是如果想改变字节码必须重启,这给开发和测试过程造成了很大的不便。动态的方式虽然可以在运行期注入字节码实现动态增强,但没有统一的API很容易操作错误。基于此,我们采用动态编织的方式、规范的API来规范字节码的生成——Agent组件。
Agent组件:通过JDK所提供的Instrumentation-API实现了利用HotSwap技术在不重启JVM的情况下实现对任意方法的增强,无论我们是做故障演练、调用链追踪(QTrace)、流量录制平台(Ares)以及动态增加日志输出BTrace,都需要一个具有无侵入、实时生效、动态可插拔的字节码增强组件。
Agent的事件模型
如图所示,事件模型主要可分为三类事件:
BEFORE在方法执行前事件、THROWS抛出异常事件、RETURN返回事件。这三类事件可以在方法执行前、返回和抛出异常这三种情况做字节码编织。
如下代码:
// BEFORE
try {
/*
* do something...
*/
foo();
// RETURN
return;
} catch (Throwable e) {
// THROWS
}
事件模型可以完成三个功能:
-
在方法体执行之前直接返回自定义结果对象,原有方法代码将不会被执行;
-
在方法体返回之前重新构造新的结果对象,甚至可以改变为抛出异常;
-
在方法体抛出异常之后重新抛出新的异常,甚至可以改变为正常返回。
Agent如何防止“类污染”
在开发Agent的时候,第一个应用是故障演练平台,那么这个时候其实我们并不需要Agent执行的过程中有自定义结果对象的返回,所以第一个版本的Agent采用硬编码的方式进行动态织入:
故障类加载模型
首先介绍下几个类加载器:
-
BootstrapClassLoader引导类加载器加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分;
-
ExtClassLoader它负责加载<JAVA_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库;
-
AppClassLoader它负责加载系统类路径java-classpath或-D java.class.path指定路径下的类库,也就是我们经常用到的classpath路径;
-
CommonClassLoader以及下边的都是Tomcat定义的ClassLoader。
Agent和相关的lib会放到AppClassLoader这一层去加载,利用Javasist做字节码的织入,所以Javasist的加载器就是AppClassLoader。
但是想改变的是Tomcat WebClassLoader所加载的com.xxx.InvocationHandler这个类的Invoke方法,不同的ClassLoader之间的类是不能相互访问的,做字节码的变换并不需要这个类的实例,也不需要返回结果,所以可以通过Instrument API拿到这个类加载器,并且可以根据类名称获取到这个类的字节码进行字节码变换。故障类Drill.class和变形后的com.xxx.InvocationHandler.class重新load到JVM中,完成了插桩操作。
以Dubbo为例说明下如何注入故障和解除故障:
Dubbo调用的注入过程
-
服务A调用服务B在Client端的Proxy层做AOP;
-
启动Agent并且生成一个Drill类invoke方法,抛出一个运行期异常;
-
字节码变形:在代码第一行之前增加Drill.invoke();
-
如果想变换异常类型,改变Drill类即可,换成Sleep 3s ClassRedifine之后会重新load到JVM完成故障类型的转化或者清除。
遇到的问题
上边的方式貌似很完美的解决了问题,但是随着平台的使用业务线要对很多接口和方法同时进行故障演练,那么我们生成的Drill类里面就会有各种:
if method==业务线定义方法
do xxx
而且很容易拼接出错并且难以调试,只能把生成的类输出为文件,查看自己写的字节码编译成class文件是否正确,简直太痛苦了!
怎么解决?
新的架构需要解决三个问题:
-
类隔离的问题:不要污染原生APP;
-
事件的实现是可编译的;
-
支持返回自定义的结果。
下一版本的Agent实现就产生了,把所有Agent的类和实现的功能抽象出来,放到一个自定义的AgentClassLoader里面,字节码注入到目标APP后可以通过反射的方式来调用具体的事件实现。
类加载模型
-
在BootstrapClassLoader里面注入Drill类作为通信类;
-
Agent会接受命令,根据事件类型对InvocationHandler做字节码变形,注入到目标APP;
-
在目标APP调用的时候,调用Drill.invoke(targetJavaClass,targetJavaMethod, targetThis, args)传递过来几个参数(目标类、方法、实例、本身参数等);
-
Drill类通过反射的方式调用AppClassLoader里面的具体事件实现,比如BEFORE事件的执行代码,来完成注入后的逻辑执行。
Agent的整体架构
Agent的整体架构如图所示:
使用的好处是很明显的:
-
零成本接入,无需申请任何资源;
-
故障注入解除,无需重启服务;
-
可以提供所有集群的拓扑结构。
但是如何才能正确使用呢?如下图所示:
使用方法
步骤一、输入AppCode;
步骤二、选择故障方法;
步骤三、指定机器;
步骤四、注入故障。
故障演练平台最核心的就是Agent组件——字节码编织框架,这个框架是纯Java的基于Instrumentation-API的AOP解决方案。它可以方便研发人员对于字节码插桩拆桩操作,可以很容易的实现故障演练、流量录制以及其他的应用模块。
作者:王鹏
来源:Qunar技术沙龙订阅号(ID:QunarTL)