Bistoury原理解析

2019-12-12 16:24:15 浏览数 (1)

今天想和大家聊聊Java中的APM,简单介绍Java中的Instrumentation技术,然后重点分析bistoury的实现原理

Instrumentation

即Java探针技术,通过Instrumentation,开发者可以构建一个独立于应用程序的代理程序(Agent),用来监测和协助运行在JVM上的程序,甚至能够替换和修改某些类的定义而对业务代码没有侵入,主要场景如APM,常见的框架有:SkyWalkingPinpointZipkinCATarthasbistoury其实也算吧。

推荐一篇博客:Instrumentation

静态Instrumentation

从JDK1.5开始支持

Agent逻辑在main方法之后执行,两个关键方法:

代码语言:javascript复制
// 优先级高
public static void premain(String agentOps, Instrumentation instrumentation);
public static void premain(String agentOps);

通常agent的包里面MATE-INF目录下的MANIFEST.MF中会有这样一段声明

代码语言:javascript复制
Premain-Class: Agent全类名

在启动应用的时候,添加Agent参数触,Agent逻辑在main方法之后执行

代码语言:javascript复制
java -javaagent:agentJar.jar="Hello World"  -jar agent-demo.jar

动态Instrumentation

从JDK1.6开始支持

Agent逻辑在main方法之后执行,两个关键方法:

代码语言:javascript复制
// 优先级高
public static void agentmain(String agentArgs, Instrumentation inst); 
public static void agentmain(String agentArgs);  

可以动态触发,通过VirtualMachine这个类attach到对应的JVM,然后执行VirtualMachine#loadAgent方法

代码语言:javascript复制
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent(agentJar路径);

在程序运行的过程中,可以通过 Instrumentation API 动态添加自己实现的 ClassFileTransformer

代码语言:javascript复制
Instrumentation#addTransformer(ClassFileTransformer)

ClassFileTransformer

An agent provides an implementation of this interface in order to transform class files. The transformation occurs before the class is defined by the JVM

代理程序(即自己的Agent)提供实现类,用于修改class文件,该操作发生在 JVM 加载 class 之前。它只有一个transform方法,实现该方法可以修改 class字节码,并返回修改后的 class字节码,有两点要注意:

  1. 如果此方法返回null, 表示我们不对类进行处理直接返回。否则,会用我们返回的byte[]来代替原来的类
  2. ClassFileTransformer必须添加进Instrumentation才能生效 Instrumentation#addTransformer(ClassFileTransformer)
  3. 当存在多个Transformer时,一个Transformer调用返回的byte数组将成为下一个Transformer调用的输入
代码语言:javascript复制
byte[] transform(ClassLoader         loader,
                String              className,
                Class<?>            classBeingRedefined,
                ProtectionDomain    protectionDomain,
                byte[]              classfileBuffer) throws IllegalClassFormatException;

例如

代码语言:javascript复制
// 定义一个 ClassFileTransformer
public abstract class Transformer implements ClassFileTransformer {
    private static final Logger logger = BistouryLoggger.getLogger();

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            if(className.equals(xxxx)){
                通过ASM修改字节码,并返回修改后的字节码
            }
            return null;
        } catch (Throwable e) {
            logger.error("", "transform failed", "Classs: {}, ClassLoader: {} transform failed.", className, loader, e);
        }
    }
}

// 添加一个Agent  JDK1.5
public static void premain(String agentArgs, Instrumentation inst) {
    inst.addTransformer(new Transformer());
}
// 触发Agent JDK1.5
java -javaagent:/agent.jar="传递的参数" -jar test.jar


// 添加一个Agent  JDK1.6
public static void agentmain (String agentArgs, Instrumentation inst) {
    inst.addTransformer(new Transformer());
}
// 触发Agent JDK1.6
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent("agent.jar");

ASM

有关于ASM的简单介绍,推荐两篇博客:ASM访问者模式、ASM使用

  1. ClassReader: 数据结构。将字节数组或者class文件读入到内存当中,并以树的数据结构表示,树中的一个节点代表着class文件中的某个区域
  2. ClassVisitor: 操作。调用ClassReader#accept方法,传入一个ClassVisitor对象,在ClassReader中遍历树结构的不同节点时会调用不同ClassVisitor对象中的visit方法,从而实现对字节码的修改
  3. ClassWriter: 操作。ClassWriterClassVisitor的实现类,它是生成字节码的工具类, 将字节 输出为 byte[],在责任链的末端,调用ClassWriter#visitor 进行修改后的字节码输出工作

JMX

JMX(Java Management Extensions)是一个为应用程序植入管理功能的框架。JMX是一套标准的代理和服务,实际上,用户可以在任何Java应用程序中使用这些代理和服务实现管理

说的有点抽象,推荐一篇博客 JMX

我自己的理解,JMX分为ServerClient, MBean是它的核心概念

  1. Server: MBean的容器,负责管理所有的MBean,同时我认为它就是一个Agent程序,在Java应用启动的时候自己启动。让我不太明白的是,为什么通过jps命令不能看到这个进程呢?
  2. Client: 即客户端,可以和Server建立连接,常见的客户端有:jvisualvm、jconsole、自己小工具
  3. MBean: JMX里面的一个概念,可以通过自定义MBean做一些事情,动态改改属性值啥的,也就是说,JMX只认识MBean,不认识别的。基于内置的一些MBean,可以获取内存、线程、系统等指标信息。所以如果想做一些监控上的事情,可以基于它内置的MBean

Bistoury

去哪儿网开源的一个对应用透明无侵入的Java应用诊断工具,可以让开发人员无需登录机器或修改系统,就可以从日志、内存、线程、类信息、调试、机器和系统属性等各个方面对应用进行诊断,提升开发人员诊断问题的效率和能力。内部集成了arthas,所以它是arthas的超集。其中两个比较有特色的功能:在线DEBUG、动态监控,就是基于 Instrumentation ASM 做的。

在开始分析这个框架之前,可以先看看它的整体架构 Bistoury设计文档

启动流程

Agent

前置处理

bistoury-instrument-agent模块,这就是Agent,里面有一个核心类 AgentBootstrap2,该类同时持有 premain 和 agentmain 方法,并在pom.xml文件中配置了 Premain-ClassAgent-Class

代码语言:javascript复制
public static void premain(String args, Instrumentation inst) {
    main(args, inst);
}

public static void agentmain(String args, Instrumentation inst) {
    main(args, inst);
}

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <showDeprecation>true</showDeprecation>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <version>2.4</version>
            <configuration>
                <archive>
                    <manifestEntries>
                        <Premain-Class>qunar.tc.bistoury.instrument.agent.AgentBootstrap2</Premain-Class>
                        <Agent-Class>qunar.tc.bistoury.instrument.agent.AgentBootstrap2</Agent-Class>
                        <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        <Can-Retransform-Classes>true</Can-Retransform-Classes>
                    </manifestEntries>
                </archive>
            </configuration>
        </plugin>
    </plugins>
</build>

从上可以看出,不管是 premain 和 agentmain 方法,里面都调用了 main 方法,而 main 方法主要负责以下功能

  1. 类加载器相关,自定义类加载器
  2. 初始化arthas的java.arthas.Spy类,将AdviceWeaver中的各个方法引用赋值给Spy
  3. 初始化bistoury的qunar.tc.bistoury.instrument.spy.BistourySpys1类,将GlobalDebugContext,SnapshotCapture,AgentMonitor中的各个方法引用赋值给BistourySpys1,这些方法最总通过ASM方式进行调用
  4. 执行 BistouryBootstrap#bind方法,启动一个telnetServer端,所以我们可以通过telnet向其发送命令
GlobalDebugContext

和在线DEBUG相关,涉及到两个方法, isHithasBreakpointSet,这两个方法最终通过字节码的形式进行调用

  1. isHit: 判断是否到达断点
  2. hasBreakpointSet: 判断是否已经存在断点
SnapshotCapture

保存实 属性静态属性局部变量方法调用堆栈 等信息,最后返回将这些信息返回给前端 , 涉及的方法列表如下:

  1. putLocalVariable: 保存局部变量信息
  2. putField: 保存属性信息
  3. putStaticField: 保存静态属性信息
  4. fillStacktrace: 保存方法调用堆栈信息
  5. dump: 将上面这些信息存到DefaultSnapshotStore中
  6. endReceive: 有点不太懂?
AgentMonitor

AgentMonitor和动态监控相关,动态监控可以监控方法的调用次数、异常次数和执行时间,同时也保留最近几天的监控数据。而动态监控的实现原理也很简单,就是在方法执行前后记录调用次数和响应时间,而这部分逻辑就是通过ASM动态插入字节码来实现的

  1. start: 记录开始时间
  2. stop: 计算调用次数和耗时
  3. exception : 计算异常数
BistouryBootstrap

上面已经说过,在main方法中会调用BistouryBootstrap2#bind 方法,该方法用于启动一个ShellServer,这里指的是TelnetServer。 这个类参考了ArthasBootstrap, ArthasBootstrap#bind方法中,主要启动了两个ShellServer,即: TelnetServerHttpServer, 所以我们在使用arthas的时候可以通过web和telnet方式访问。

BistouryBootstrapArthasBootstrap有些不同

  1. BistouryBootstrap只创建了TelnetServer, 并没有创建HttpServer
  2. BistouryBootstraparthas的基础上实现了一个自己的CommandResolver, 即 QBuiltinCommandPack, 该类负责管理所有的Command,也就是说,从功能上来讲, bistouryarthas的超集;

核心bind方法如下,源码感兴趣的自己看一下

代码语言:javascript复制
public void bind(Configure configure) throws Throwable {
    long start = System.currentTimeMillis();
    if (!isBindRef.compareAndSet(false, true)) {
        throw new IllegalStateException("already bind");
    }

    try {
        /**
            * 涉及到各个 Client 的初始化, 将参数 instrumentation 传到各个 client 中
            *
            * JarDebugClient
            * AppConfigClient
            * QDebugClient
            * QMonitorClient
            * JarInfoClient
            */
        InstrumentClientStore.init(instrumentation);

        ShellServerOptions options = new ShellServerOptions()
                .setInstrumentation(instrumentation)
                .setPid(pid)
                .setWelcomeMessage(BistouryConstants.BISTOURY_VERSION_LINE_PREFIX   BistouryConstants.CURRENT_VERSION);
        shellServer = new ShellServerImpl(options, this);
        QBuiltinCommandPack builtinCommands = new QBuiltinCommandPack();
        List<CommandResolver> resolvers = new ArrayList<CommandResolver>();
        resolvers.add(builtinCommands);
        shellServer.registerTermServer(new TelnetTermServer(
                configure.getIp(), configure.getTelnetPort(), options.getConnectionTimeout()));

        for (CommandResolver resolver : resolvers) {
            shellServer.registerCommandResolver(resolver);
        }
        shellServer.listen(new BindHandler(isBindRef));
    } catch (Throwable e) {
        if (shellServer != null) {
            shellServer.close();
        }
        InstrumentClientStore.destroy();
        isBindRef.compareAndSet(true, false);
        throw e;
    }
}
Main

上面提到的只是前置处理会触发的逻辑,即Java Instrumentation 会触发的逻辑,而Agent模块的main方法,其实在是qunar.tc.bistoury.indpendent.agent.Main中,执行这个方法会触发以下逻辑

  1. 根据启动入参bistoury.proxy.host获取Proxy地址
  2. Proxy发送一个Http请求,请求地址为proxyIp:9090/proxy/config/
  3. Proxy返回与Agent建立连接的Ip和端口
  4. 执行AgentClient#initNettyClient方法与Agent建立TCP连接
  5. 根据SPI加载所有的AgentGlobalTaskFactory实现类,然后调用他们的start方法
  6. 开启一个失败重试的定时任务,每分钟执行一次
代码语言:javascript复制
public static void main(String[] args) throws Exception {
    log();
    AgentClient instance = AgentClient.getInstance();
    instance.start();
    System.in.read();
}

至此,Agent的启动完成。

Proxy

Proxy的启动逻辑在qunar.tc.bistoury.proxy.container.Bootstrap#main方法中,默认Tomcat端口9090

  1. 获取配置文件目录地址,我们可以在启动的时候添加一个参数-Dbistoury.conf=/Workspace/ZTO/forensic/bistoury-proxy/conf
  2. 启动内置的Tomcat,整合了Spring
  3. NettyServerManagerBean初始化的之前,执行一些初始化操作
  4. 获取ZKClient,Proxy的地址会注册到ZK
  5. 执行NettyServerManager#startAgentServer方法启动针对Agent的Server端, 处理来自Agent的请求,默认端口为9880
  6. 执行NettyServerManager#startUiServer方法启动针对UI的Server端, 处理来自UI的Websocket连接,默认端口为9881

UI

UI的启动逻辑在qunar.tc.bistoury.ui.container.Bootstrap#main方法中,默认Tomcat端口9091

  1. 获取配置文件目录地址,我们可以在启动的时候添加一个参数-Dbistoury.conf=/Workspace/ZTO/forensic/bistoury-ui/conf
  2. 启动内置的Tomcat,整合了Spring

交互流程

命令的请求过程

代码语言:javascript复制
UI -> Prosy -> Agent -> Proxy -> UI
  1. Proxy 与 UI 维持了一个Websocket连接
  2. Proxy 和 Agent 维持了一个TCP连接
  3. 一般我们在前端操作的时候是:前端界面请求UI后台接口返回 Proxy 的Websocket地址,然后浏览器与 Proxy 建立一个Websocket连接

UI发送请求

在界面点击查看主机信息为例

  1. 界面点击查看主机信息,请求UI后端的ConfigController#getProxyWebSocketUrl接口,入参=agentIp
  2. 从注册中心(ZK)获取所有的Proxy
  3. agentIp为入参,请求proxyIP:9090/proxy/agent/get,此步骤用于判断agentIp对应的那个Agent是否可用
  4. Proxy返回Agent信息
  5. UI后后端接口返回前端一个Websocket地址,浏览器和Proxy通过Websocket连接 ws://10.10.134.174:9881/ws
  6. UI通过Websocket连接向Proxy发送命令
  7. Proxy将命令转发请求到Agent
  8. Agent收到命令进行逻辑处理,将结果回给Proxy
  9. Proxy将结果返回给UI

UI与Proxy交互

Proxy接收请求经过 解码 -> 主机有效性校验,最终请求来到UiRequestHandler#channelRead方法,UiRequestHandler构造函数包含4个关键入参

  1. CommunicateCommandStore: 默认实现类DefaultCommunicateCommandStore, 构造函数会注入所有的UiRequestCommand
  2. UiConnectionStore: 默认实现类DefaultUiConnectionStore, 维护ChannelUiConnection之间的关系,UiConnection#write方法返回的ListenableFuture可以添加回调
  3. AgentConnectionStore : 默认实现类DefaultAgentConnectionStore, 维护agentIpAgentConnection之间的关系,AgentConnection#write方法返回的ListenableFuture可以添加回调
  4. SessionManager : 默认实现类DefaultSessionManager, 维护请求IdSession的关系,Session中持有RequestData AgentConnection UiConnection 属性,这是实现请求转发的关键

有关于Session,下次再重点介绍,它是是实现请求转发的关键

请求流程

  1. 根据code(code可以看作是命令的唯一标识)找到对应的CommunicateCommand, 然后获取CommunicateCommandCommunicateCommandProcessor属性
  2. 执行CommunicateCommandProcessor#preprocessor方法
  3. 根据AgentServerInfo找到对应的AgentConnection, 执行sendMessage方法,即执行Session#writeToAgent方法,该方法用于向Agent发送命令
  4. 在回调中执行UiConnection#write方法,用于向UI返回结果

浏览器Proxy建立Websocket连接的时候,基于Channel创建一个UiConnection,然后基于UiConnectionAgentConnection创建一个DefaultSessionAgentConnection从哪里来? AgentConnectionAgentProxy维持心跳时创建的,核心类AgentMessageHandlerProxyHeartbeatProcessor,创建之后缓存到DefaultAgentConnectionStore, key就是agentIp

Proxy与Agent交互

Proxy
  1. NettyServerManager#startAgentServer
  2. NettyServerForAgent#start 启动Server端
  3. AgentMessageHandler#channelRead0 消息处理,有3种消息 ProxyHeartbeatProcessor : 心跳消息, 在收到心跳的时候,以 agentIp 和 Channel 创建 AgentConnection ,然后 回应 Agent 的心跳 AgentResponseProcessor : Agent返回的数据,根据请求ID从SessionManager中获取,然后执行 session#writeToUi 方法,将结果返回浏览器 AgentInfoRefreshProcessor : 从DB和配置中获取最新的Agent信息,然后返回给 Agent
Agent
  1. AgentClient#initNettyClient
  2. AgentNettyClient#start 启动Client端
  3. RequestHandler#channelRead 消息处理,从RemotingHeader获取code和id,id作为Channel的唯一标识,根据code获取 Processor
  4. 执行Processor#process方法,有以下几种 CancelProcessor : 取消 TaskProcessor 中开启的任务 HeartbeatProcessor : 心跳 MetaRefreshProcessor : 更新MetaStore里面的属性,干嘛的? MetaRefreshTipProcessor : 更新Agent信息 TaskProcessor : 处理任务

动态监控功能

  1. web界面点击添加动态监控按钮
  2. 浏览器与Proxy建立了Websocket连接,浏览器向Proxy发送一个指令qmonitoradd
  3. ProxyAgent通过Netty建立了TCP连接,Proxy将命令转发给Agent
  4. Agent收到消息,解析指令,通过TelnetClientShellServer建立telnet连接
  5. ShellServer收到指令,找到对应的Command, 这里指QMonitorAddCommand
  6. 执行QMonitorAddCommand#process方法,然后执行QMonitorClient#addMonitor方法,最后执行DefaultMonitor#doAddMonitor方法
  7. 然后执行DefaultMonitor#instrument方法,这里面涉及到Java的Instrumentation技术和ASM技术
  8. 创建MonitorClassFileTransformer对象,它实现了ClassFileTransformer接口,织入代理逻辑,就是通过这个对象完成的
  9. 而有关于逻辑的具体织入,是通过MonitorClassVisitor完成。涉及到的知识点:ClassReaderClassWriterClassVisitor
  10. 代理逻辑里面涉及到AgentMonitor相关方法的调用,而AgentMonitor的相关方法会将 调用次数响应时间异常数 存入Metrics
  11. qunar.tc.bistoury.agent.task.monitor.TaskRunner启动时,调用顺序如下:QMonitorClient#reportMonitor -> QMonitorMetricsReportor#report -> 获取Metric

在线调试功能

原理和动态监控一样,也是通过 Instrumentation ASM 实现

  1. 对应的指令为qdebugadd
  2. 对应的Command为QDebugAddCommand
  3. 调用链路:QDebugClient#registerBreakpoint -> DefaultDebugger#doRegisterBreakpoint -> DefaultDebugger#instrument
  4. ASM涉及到DebuggerClassFileTransformerDebuggerClassVisitorDebuggerMethodVisitor
  5. 在执行到对应断点代码的时候,通过执行ASM插入的逻辑,将 本地局部变量实例属性静态变量方法调用堆栈信息 保存到SnapshotCapture
  6. 执行DebuggerMethodVisitor#processForBreakpoint方法,将所有相关的信息存到DefaultSnapshotStore的缓存中
  7. 在前端点击添加断点按钮之后,即发送qdebugadd指令之后,前端会开启一个定时任务,每3s向服务端发送一个qdebugsearch指令,直到服务端返回数据。服务端收到指令,从DefaultSnapshotStore中获取数据返回前端

其它功能下次补充

0 人点赞