在线云Debug系统

项目背景

基本背景

复杂的微服务系统Debug或者调试问题的时候非常麻烦,主要有以下几点

  • 线上系统出现一些问题但是没有相关日志记录。
  • 线上线下环境隔离,线上无法回放当时的具体栈帧细节,得全靠日志猜。
  • 交付测试的时候遇到低概率事件,没打日志需要打日志重启再看。
  • 开发联调环境,单独自己Remote Debugger会阻塞其他正常服务流程和开发。
  • 无法分析调用链上的细节数据、虽然有分布式链路追踪系统,但是粒度太粗。
  • 每次调试预发布环境代码,只能修改一份重启环境、浪费时间和开发机资源。无法做到实时热加载最新修改代码

项目一期版本功能

基于JVMTI接口并参考大量以往开源项目Stackdriver Debugger, Zdebugger, libinstrument, Arthas

  • 调试: 断点表达式、断点达成录制快照、快照后期回放、映射source tree展开与包跳转、即时修改并redefineClass
  • 分析: Trace某方法内部调用链路情况(时间、出入参)、Stack某方法被调用的各链路情况(时间、入参)

以上功能解决了基本的项目背景遇到的问题

先做单机版本、后期再尝试做集群版(集群Opentracing分布式链路追踪、集群DebugTrace分布式调试追踪、集群分布式Metric)

1
2
3
4
5
6
7
8
9
10
1: 接入pinpoint丰富span数据

- rpc或db操作将会形成span最后串联成trace,基于以上形成的span,丰富每个span内部各个method调用的链路情况(出入参及时间消耗)
- 根据表达式成立的情况,进行span调用节点数据丰富


2: 热加载class

- 提供外部热加载class入口
- 根据git commit推送hook,自动获取commit信息中含有"[debug]"信息所涉及的class热加载替换

项目愿景

  • 开发一期功能Alpha(宋小菜开发联调、宋小菜测试环境预装)
  • 一期功能修补Bug后进入Beta、并推入开源社区
  • 经过开源社区的公开Issue提供Bug修补进入RC
  • 进行部分性能影响功能点阉割后发布一期功能的1.0Release

项目结构

Agent端:放置应用端,做应用层数据录制搜集和上报服务端,RedefineClasses代码, 接受Server端的远程操作等
Server端:做数据整理功能和数据的查询服务,以及操作Agent端的代码与配置推送等

Agent

设计轮廓

  • Agent技术,无需开发任何介入,对业务是无侵入,即可在部署的时候加入调试追踪、链路追踪等功能

  • Agent插件系统强大,基本符合开发常用的所有技术栈链路监控和调试,也可自定义相关的追踪业务插件

简介

JavaAgent

jdk1.5以后引入了javaAgent技术,javaAgent是运行方法之前的拦截器。

我们利用javaAgent和ASM字节码技术,在JVM加载class二进制文件的时候.

比如利用ASM动态的修改加载的class文件,在监控的方法前后添加计时器功能.用于计算监控方法耗时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public interface Instrumentation {

//增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

//在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
void addTransformer(ClassFileTransformer transformer);

//删除一个类转换器
boolean removeTransformer(ClassFileTransformer transformer);

boolean isRetransformClassesSupported();

//在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

boolean isRedefineClassesSupported();


void redefineClasses(ClassDefinition... definitions)
throws ClassNotFoundException, UnmodifiableClassException;

boolean isModifiableClass(Class<?> theClass);

@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();


@SuppressWarnings("rawtypes")
Class[] getInitiatedClasses(ClassLoader loader);

//获取一个对象的大小
long getObjectSize(Object objectToSize);



void appendToBootstrapClassLoaderSearch(JarFile jarfile);


void appendToSystemClassLoaderSearch(JarFile jarfile);


boolean isNativeMethodPrefixSupported();


void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix);
}
PreMain
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package agent;
import java.lang.instrument.Instrumentation;

public class pre_MyProgram {
/**
* 该方法在main方法之前运行,与main方法运行在同一个JVM中
* 并被同一个System ClassLoader装载
* 被统一的安全策略(security policy)和上下文(context)管理
*
* @param agentOps
* @param inst
* @author SHANHY
* @create 2016年3月30日
*/
public static void premain(String agentOps,Instrumentation inst){

System.out.println("====premain 方法执行");
System.out.println(agentOps);

// 添加Transformer
inst.addTransformer(new MyTransformer());
}

/**
* 如果不存在 premain(String agentOps, Instrumentation inst)
* 则会执行 premain(String agentOps)
*
* @param agentOps
* @author SHANHY
* @create 2016年3月30日
*/
public static void premain(String agentOps){

System.out.println("====premain方法执行2====");
System.out.println(agentOps);
}
public static void main(String[] args) {
// TODO Auto-generated method stub

}
}
Transformer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class MyTransformer implements ClassFileTransformer {

final static String prefix = "\nlong startTime = System.currentTimeMillis();\n";
final static String postfix = "\nlong endTime = System.currentTimeMillis();\n";

// 被处理的方法列表
final static Map<String, List<String>> methodMap = new HashMap<String, List<String>>();

public MyTransformer() {
add("com.shanhy.demo.TimeTest.sayHello");
add("com.shanhy.demo.TimeTest.sayHello2");
}

private void add(String methodString) {
String className = methodString.substring(0, methodString.lastIndexOf("."));
String methodName = methodString.substring(methodString.lastIndexOf(".") + 1);
List<String> list = methodMap.get(className);
if (list == null) {
list = new ArrayList<String>();
methodMap.put(className, list);
}
list.add(methodName);
}

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
className = className.replace("/", ".");
if (methodMap.containsKey(className)) {// 判断加载的class的包路径是不是需要监控的类
CtClass ctclass = null;
try {
ctclass = ClassPool.getDefault().get(className);// 使用全称,用于取得字节码类<使用javassist>
for (String methodName : methodMap.get(className)) {
String outputStr = "\nSystem.out.println(\"this method " + methodName
+ " cost:\" +(endTime - startTime) +\"ms.\");";

CtMethod ctmethod = ctclass.getDeclaredMethod(methodName);// 得到这方法实例
String newMethodName = methodName + "$old";// 新定义一个方法叫做比如sayHello$old
ctmethod.setName(newMethodName);// 将原来的方法名字修改

// 创建新的方法,复制原来的方法,名字为原来的名字
CtMethod newMethod = CtNewMethod.copy(ctmethod, methodName, ctclass, null);

// 构建新的方法体
StringBuilder bodyStr = new StringBuilder();
bodyStr.append("{");
bodyStr.append(prefix);
bodyStr.append(newMethodName + "($$);\n");// 调用原有代码,类似于method();($$)表示所有的参数
bodyStr.append(postfix);
bodyStr.append(outputStr);
bodyStr.append("}");

newMethod.setBody(bodyStr.toString());// 替换新方法
ctclass.addMethod(newMethod);// 增加新方法
}
return ctclass.toBytecode();
} catch (Exception e) {
System.out.println(e.getMessage());
e.printStackTrace();
}
}
return null;
}
}
Demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class TimeTest {

public static void main(String[] args) {
sayHello();
sayHello2("hello world222222222");
}

public static void sayHello() {
try {
Thread.sleep(2000);
System.out.println("hello world!!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static void sayHello2(String hello) {
try {
Thread.sleep(1000);
System.out.println(hello);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

====premain 方法执行
Hello1
====premain 方法执行
Hello2

hello world!!
this method sayHello cost:2000ms.
hello world222222222
this method sayHello2 cost:1000ms.




JVMTIAgent

JVMTI的定义及原理

在介绍JVMTI之前,需要先了解下Java平台调试体系JPDAJava Platform Debugger Architecture

它是Java虚拟机为调试和监控虚拟机专门提供的一套接口。

JPDA按照抽象层次,又分为三层,分别是:

  • JVM TI(Java VM Tool Interface):虚拟机对外暴露的接口,包括debug和profile。
  • JDWP(Java Debug Wire Protocol):调试器和应用之间通信的协议。
  • JDI(Java Debug Interface):Java库接口,实现了JDWP协议的客户端,调试器可以用来和远程被调试应用通信。

如下图所示JPDA被抽象为三层实现。其中JVMTI就是JVM对外暴露的接口。JDI是实现了JDWP通信协议的客户端,调试器通过它和JVM中被调试程序通信。

JVMTI 本质上是在JVM内部的许多事件进行了埋点。

通过这些埋点可以给外部提供当前上下文的一些信息。甚至可以接受外部的命令来改变下一步的动作。

外部程序一般利用C/C++实现一个JVMTIAgent,在Agent里面注册一些JVM事件的回调。

当事件发生时JVMTI调用这些回调方法。Agent可以在回调方法里面实现自己的逻辑。

JVMTIAgent是以动态链接库的形式被虚拟机加载的。

JVMTI的历史

JVMTI 的前身是JVMDIJava Virtual Machine Profiler Interface 和 JVMPIJava Virtual Machine Debug Interface,它们原来分别被用于提供调试 Java 程序以及 Java 程序调节性能的功能。

在 J2SE 5.0 之后 JDK 取代了JVMDI 和 JVMPI 这两套接口,JVMDI 在最新的 Java SE 6 中已经不提供支持,而 JVMPI 也计划在 Java SE 7 后被彻底取代。

JVMTI的功能

JVMTI处于整个JPDA 体系的最底层,所有调试功能本质上都需要通过 JVMTI 来提供。

从大的方面来说,JVMTI 提供了可用于 debug 和profiler 的接口;同时在 Java 5/6 中虚拟机接口也增加了监听Monitoring,

线程分析Thread analysis以及覆盖率分析Coverage Analysis等功能。

从小的方面来说包含了虚拟机中线程、内存、堆、栈、类、方法、变量,事件、定时器处理等等诸多功能。

具体可以参考oracle 的文档:https://docs.oracle.com/javase/1.5.0/docs/guide/jvmti/jvmti.html。

通过这些接口开发人员不仅可以调试在该虚拟机上运行的 Java 程序,还能查看它们运行的状态,设置回调函数,控制某些环境变量从而优化程序性能。

JVMTI的实现

JVMTI 并不一定在所有的 Java 虚拟机上都有实现,不同的虚拟机的实现也不尽相同。

不过在一些主流的虚拟机中比如 Sun 和 IBM,以及一些开源的如Apache Harmony DRLVM 中,都提供了标准 JVMTI 实现。

资料参考

https://docs.oracle.com/javase/1.5.0/docs/guide/jvmti/jvmti.html

https://www.ibm.com/developerworks/cn/java/j-lo-jpda2/

http://blog.caoxudong.info/blog/2017/12/07/jvmti_reference

http://lovestblog.cn/blog/2015/09/14/javaagent/

https://www.jianshu.com/p/e59c4eed44a2

功能



DebugAgent

本工程借鉴了SkyWalking, Apollo, Arthas等结构和逻辑代码

上图经过更改优化成下图结构形式
逻辑全在JavaAgent端上,JVMTIAgent只做相关的非libstrument标准之外的Jvm接口操作


RPC Service

Grpc通信通道,主要将Leveldb搜集上来的数据进行上报Server以及接受Server上的操作

Server端操作

  • 各个插件的启动和关闭
  • 插件的各个单独配置
    • 如StackDebugPlugin临时表达式的建立,取消等操作

Server端数据汇报

  • 上报各个插件的获取的业务数据

Plugin Service

插件服务,主要的逻辑点,搜集怎样的数据主要由Plugin来提供支持,后期根据业务可以制定各类搜集插件

  • PluginBoostrap 插件启动器

  • PluginFinder 插件检索器

  • PluginImpl 各类插件的业务逻辑实现

Cache Service

缓存服务,主要用于汇报数据的时候防止产生大量数据后推送占用服务器带宽、所以需要有缓存来慢慢推数据

Storage Service

存储服务,将用于防止缓存数据占用过大、导致服务器内存出现oom-kill

以一定量内存数据在没有Rpc推送的情况下将会把插件上报数据慢慢推入本地磁盘

JVMTI

基于jvmti接口,封装部分逻辑操作或直接使用透操作接口、提供上层业务Plugin插件服务支持

UI Service

当前Agent搜集情况以及插件启动与配置情况



Server

借鉴SkyWalking,多Module多Provier

不同Module负责不同的功能领域, 一个Module下不同的Provider提供不同的能力

Rpc Module

  • Grpc通信接收Agent端的插件数据

Cache Module

基于Opentracing数据模型

单个Trace中,Span间的时间关系

1
2
3
4
5
6
7
8

––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time

[Span A···················································]
[Span B··············································]
[Span D··········································]
[Span C········································]
[Span E···] [Span F··] [Span G··] [Span H··]

堆栈信息将切割具体的一次方法调用Span,然后Agent将汇报以Span为维度的数据

由于网络不稳定,先后顺序形成的Stack Debug Trace调用链数据需要靠Server来构建

所以将会有以下结构图

UI Module

以堆栈数据格式输出相关WebApi,前端结合Gitlab项目相关接口进行Debug回显

Storage Module

存储服务,将用于防止缓存数据占用过大、导致服务器内存出现oom-kill

Meta Module & Cluster Module

后期用搜集分布式Stack Debug Opentracing数据及高可用




OpenTracing

OpenTracing通过提供平台无关、厂商无关的API,使得开发人员能够方便的添加(或更换)追踪系统的实现

不过OpenTracing并不是标准。因为 CNCF 不是官方标准机构,但是它的目标是致力为分布式追踪创建更标准的 API 和工具

名词解释

Trace

一个trace代表了一个事务或者流程在(分布式)系统中的执行过程

Span

一个span代表在分布式系统中完成的单个工作单元。也包含其他span的 “引用”,这允许将多个Spans组合成一个完整的Trace

每个span根据OpenTracing规范封装以下内容:

  • 操作名称
  • 开始时间和结束时间
  • key:value span Tags
  • key:value span Logs
  • SpanContext

Tags

Span tags(跨度标签)可以理解为用户自定义的Span注释。便于查询、过滤和理解跟踪数据

Logs

Span logs(跨度日志)可以记录Span内特定时间或事件的日志信息。主要用于捕获特定Span的日志信息以及应用程序本身的其他调试或信息输出

SpanContext

SpanContext代表跨越进程边界,传递到子级Span的状态。常在追踪示意图中创建上下文时使用

Baggage Items

Baggage Items可以理解为trace全局运行中额外传输的数据集合

Debug Opentracing