多agent治理在海拍客的应用与实践

2022年01月10日 2,558次浏览

左帅(白鹤)

背景与问题

Java Agent这个技术,对于大多数读者来说都比较陌生,但是多多少少又接触过,实际上,我们平时用的很多工具,都是基于Java Agent实现的,例如常见的热部署JRebel,各种线上诊断工具(btrace, greys),还有阿里开源的arthas。另外我们大伙熟知的apm性能监控工具skywalking,pinpoint等都是agent的实际运用.那我们要怎么简单理解他呢 ,如果熟悉spring的读者应该知道动态代理技术,相对于agent技术,大家可以理解成一种jvm级别的aop技术. 有了它,可以在类加载前后增加相应代码,实现我们要的特性.

随着agent场景的普及,我们公司也在很多方面要用到agent带来的功能. 本文重点举例介绍3个agent场景,也是我们公司大量使用的地方.首先是apm调用链,大家应该对这个比较熟悉,业务迁移到微服务之后,服务之间的调用关系势必要借助apm工具来进行追踪的.其次是测试团队使用的覆盖率测试工具jacoco,另一个场景是我们在进行beta发布,全链路压测等场景需要对流量进行识别和传递,那么标签传递的过程中,会遇到大量的异步场景,调用走到线程池以后,标签会出现传递丢失,那么在这种情况下,目前比较流行的解决方案是接入阿里开源的transmittable-thread-local框架. 以上三个场景apm,jacoco,transmittable-thread-local等 ,都是基于agent(或者推荐使用agent方式)方式接入的,针对这么多agent,甚至以后会出现更多的类似场景,我们要怎么管理,引入了过多的agent以后会不会引入过多的风险?

总结起来,我们面临的问题其实就是一个agent的治理问题,梳理一下,其实主要目标大概是下面几个方面:

  • 能解决jar包依赖冲突
  • 处理类加载隔离问题
  • 尽量保持agent轻量级
  • 保证后续场景可扩展
  • 保证业务方最低的接入集成成本

调研与方案

在介绍实际方案之前,先铺垫下相关agent技术的前置知识.,已经了解的读者建议略过agent原理介绍部分.

简单agent入门

如果你还不了解agent的基本用法和能干什么事情,请先来看2个使用agent技术的 "Hello World"小例子.

例子:如何接入agent

Example 1:
实现 :
在自己真实应用启动的最前面执行一些操作

1.1 新建一个java agent工程(普通maven工程即可) 增加一个类,实现premain方法

import java.lang.instrument.Instrumentation;

/**
 * @since 2020/8/5
 */
public class ExampleAgent {

    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("==============premain1 execute===================");
        System.out.println("agentArgs : " + agentArgs);
    }
}

1.2.maven插件配置premain类为上面创建的类

<build>
        <finalName>my-agent</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <executions>
                    <execution>
                        <phase>package</phase>
                    </execution>
                </executions>
                <configuration>
                    <archive>
                        <manifestEntries>
                            <Premain-Class>ExampleAgent</Premain-Class>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
......略          

1.3.在自己的项目通过javaagent参数启动,比如idea场景示例如下
配置javagent

/**
 * @since 2020/8/5
 *
 */
public class MainClass {

    public static void main(String[] args) {
        System.out.println("============= my main class started ... =================");
    }
}

1.4.执行程序MainClass 可以看到输出如下:

总结: 通过将agent打入jar,然后在正式程序中使用-javaagent:XXXX 参数,实现了程序main函数之前执行了agetn的premain函数

例子:如何使用agent对类拦截

Example2:
实现: 对一个类进行"aop"拦截
上面的例子仅仅实现了先与自身程序执行agent的特性. 那么怎么实现对特定class进行拦截并处理呢 ?

要实现对class进行拦截并进行响应的修改,需要借助字节码增强工具:javassist/asm等,本例就使用javassist,另外一个需要了解的组建是Instrumentation,这个下文在介绍,这里面只要当作一个工具.
要实现通过agent对class进行运行期拦截,我们一般要进行下面几个步骤:
2.1 创建一个普通的java项目作为agent,并引入javassist

<dependency>
  <groupId>org.javassist</groupId>
  <artifactId>javassist</artifactId>
  <version>${javassist.version}</version>
</dependency>

2.1 在agent中编写转换器
可以在转换器中过滤要转换哪些类,并借助asm/javassist进行字节码修改. 我们一般继承jdk提供的ClassFileTransformer来实现转换逻辑.

/**
 * @since 2020/8/5
 */
public class ExampleTransformer implements ClassFileTransformer {

    public ExampleTransformer() {
        System.out.println("start new  ExampleTransformer()");
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined
            , ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        if (className.equals("cn/hipac/Book")) {
            System.out.println("正在加载类:" + className);
            ClassPool classPool = ClassPool.getDefault();
            try {
                CtClass ctClass = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));
                CtMethod ctMethod = ctClass.getDeclaredMethod("test");
                System.out.println("获取方法名称:" + ctMethod.getName());
                ctMethod.insertBefore("System.out.println(\"%%%我是动态插入的打印语句-before%%% \");");
                ctMethod.insertAfter("System.out.println(\"%%%我是动态插入的打印语句-after%%% \");");
                byte[] transformed = ctClass.toBytecode();
                return transformed;
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return classfileBuffer;
    }
}

2.3. 在agent的premain 中加载这个转换器

/**
 * @since 2020/8/5
 */
public class ExampleAgent {

    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("==============premain1 execute===================");
        //add transformer
        inst.addTransformer(new ExampleTransformer(), true);
    }
}

2.4. 剩下步骤和例子1中一样.打包,然后实际项目中使用javaagent:XXX 启动.
唯一需要注意的是,需要在maven插件中增加对retransform的支持

<build>
        <finalName>my-agent</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <executions>
                    <execution>
                        <phase>package</phase>
                    </execution>
                </executions>
                <configuration>
                    <archive>
                        <manifestEntries>
                            <Premain-Class>ExampleAgent</Premain-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
... 略          

2.5.运行项目
通过上面例子transformer可知,该转换器是转换了方法Book#test 方法,下面是未使用agent和使用agent的对比

自己项目中的book类如下

package cn.hipac;

/**
 * @since 2020/8/5
 *
 */
public class Book {

    public void test() {
        System.out.println("====  book: <<maven in action>> ====");
    }
}

本项目的入口类如下

/**
 * @since 2020/8/5
 *
 */
public class MainClass {

    public static void main(String[] args) {
        System.out.println("============= my main class started ... =================");
        Book book = new Book();
        book.test();
        System.out.println("============= my main class over ... =================");
    }
}

未接入agnet:

接入agent之后:

总结: 可以看到我们通过自定义转换器实现类jvm基本的aop.上面的逻辑我们用术语来说叫做字节码"插桩"技术,这个技术中我们重点需要关注的就是ClassFileTransformerInstrumentation接口.
要完成一个Instrument(插桩),基本步骤如下

  • 定义一个代理类并添加premain(也就是在main执行前执行)方法。代理类可以是任何一个普通的Java类。

public static void premain(String args, Instrumentation inst) ​

  • 定义一个实现ClassFileTransformer接口的转换类(通常由代理带实现即可)
  • 将第二步的转换类实例添加进Instrumentation里。

inst.addTransformer(ClassFileTransformer);

下面我们在介绍下agent这块的相关原理.

agent技术介绍

agent原理

所谓Java Agent,其功能都是基于java.lang.instrument中的类去完成。Instrument提供了允许Java编程语言代理检测JVM上运行的程序的功能,而检测的机制就是修改字节码。Instrument位于rt.jar中,java.lang.instrument包下,使用Instrument可以用来检测协助运行在 JVM中的程序;甚至对已加载class进行替换修改,这也就是我们常说的热部署、热加载一句话总结Instrument:检测类的加载行为对其进行干扰(修改替换)

Instrument的实现基于JVMTI(Java Virtual Machine Tool Interface)的,所谓JVMTI就是一套由 Java 虚拟机提供的,为JVM 相关的工具提供的本地编程接口集合。JVMTI基于事件驱动,简单点讲就是在JVM运行层面添加一些钩子可以供开发者去自定义实现相关功能。

Instrument工作流程总结如下

  • 在 JVM 启动时,通过 JVM 参数 -javaagent,传入 agent jar,Instrument Agent 被加载;
  • 在 Instrument Agent 初始化时,注册了 JVMTI 初始化函数 eventHandlerVMinit;
  • 在 JVM 启动时,会调用初始化函数 eventHandlerVMinit,启动了 Instrument Agent,用 sun.instrument.instrumentationImpl 类里的方法 loadClassAndCallPremain 方法去初始化 Premain-Class 指定类的 premain 方法;
  • 初始化函数 eventHandlerVMinit,注册了 class 解析的 ClassFileLoadHook 函数;
  • 在解析 Class 之前,JVM 调用 JVMTI 的 ClassFileLoadHook 函数,钩子函数调用 sun.instrument.instrumentationImpl 类里的 transform 方法,通过 TransformerManager 的 transformer 方法最终调用我们自定义的 Transformer 类的 transform 方法;
  • 因为字节码在解析 Class 之前改的,直接使用修改后的字节码的数据流替代,最后进入 Class 解析,对整个 Class 解析无影响

总结:

agent使用场景主要有以下几个方面

  • apm:(Application Performance Management)应用性能管理。pinpoint、cat、skywalking等都基于Instrumentation实现

  • idea的HotSwap、Jrebel等热部署工具

  • 应用级故障演练(比如chaosblade,jvmsandbox等)

  • Java诊断工具Arthas、Btrace等

    读者可以从 javaagent 原理 详细了解agent原理.

Instrument核心API介绍


public interface Instrumentation {
    /**
         * 加入一个转换器Transformer,之后的所有的类加载都会被Transformer拦截。
         * ClassFileTransformer类是一个接口,使用时需要实现它,该类只有一个方法,该方法传递类的信息,返回值是转换后的类的字节码文件。
         */
    void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

    /**
        * 对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
        * 该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
        */
    void retransformClasses(Class<?>... classes)
        throws UnmodifiableClassException;

    /**
       *此方法用于替换类的定义,而不引用现有的类文件字节,就像从源代码重新编译以进行修复和继续调试时所做的那样。
       *在要转换现有类文件字节的地方(例如在字节码插装中),应该使用retransformClasses。
       *该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
       */
    void redefineClasses(ClassDefinition... definitions)
        throws ClassNotFoundException, UnmodifiableClassException;

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

    /**
     * 将一个jar加入到bootstrap classloader的 classpath里
     */
    void appendToBootstrapClassLoaderSearch(JarFile jarfile);

    /**
     * 获取当前被JVM加载的所有类对象
     */
    Class[] getAllLoadedClasses();
}

上面api中redefine 和retransform区别

  • 二者的区别:都是替换已经存在的class文件,redefineClasses是自己提供字节码文件替换掉已存在的class文件,retransformClasses是在已存在的字节码文件上修改后再替换之。
  • 相互依赖的类加载: 允许传类集合,以满足类之间相互依赖的情况,加载顺序为集合顺序
  • 替换后生效时机:如果一个被修改的方法已经在栈桢中存在,则栈桢中的会使用旧字节码定义的方法继续运行,新字节码会在新栈桢中执行
  • 不修改变量值:该方法不会导致类的一些初始化方法执行、不会修改静态变量的值
  • **只改变方法体:**该方法可以改变类的方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
  • 字节码有问题时不加载:在类转化前该方法不会check字节码文件,如果结果字节码出错了,该方法将抛出异常。如果该方法抛出异常,则不会重新定义任何类

读者可以参考 Difference between redefine and retransform in javaagent 来深入了解redefineretransform 的区别.

转换器

用来让用户来实现代码增强逻辑的接口。他只有一个方法,方法的参数是原来的类的字节码以及这个类的classloader对象,返回值则是被增强之后的类的字节码。所以你的代码是通过已有的类信息,植入代码之后,形成新的代码(类的字节码)。

 transform(ClassLoader,ClassName,classBeingRedefined,protectionDomain,classfileBuffer)

在以下三种情形下 ClassFileTransformer.transform() 会被执行:

  • 新的 class 被加载。
  • Instrumentation.redefineClasses 显式调用。
  • addTransformer 第二个参数为 true 时,Instrumentation.retransformClasses 显式调用

相关agent组件介绍

基于上面的agent技术,我们在简单对我们所用的agent组件进行简单介绍,主要介绍启动阶段的插桩机制,内部原理请感兴趣的读者自行了解.

pinpoint

项目地址:
https://github.com/pinpoint-apm/pinpoint

pinpoint组件图如下所示

从架构图最上面部分可以看出,业务应用通过agent拉起,然后吧指标通过agent传递给collector收集器,最终形成调用链.
agent的启动入口就是上面所讲的premain方式


public static void premain(String agentArgs, Instrumentation instrumentation) {
        ......
        Map<String, String> agentArgsMap = argsToMap(agentArgs);

        final ClassPathResolver classPathResolver = new AgentDirBaseClassPathResolver();
        //....
		//加载核心jar到bootstrap路径给所有类加载器使用
        BootstrapJarFile bootstrapJarFile = classPathResolver.getBootstrapJarFile();
        appendToBootstrapClassLoader(instrumentation, bootstrapJarFile);
		//启动agent
        PinpointStarter bootStrap = new PinpointStarter(agentArgsMap, bootstrapJarFile, classPathResolver, instrumentation);
        if (!bootStrap.start()) {
            logPinpointAgentLoadFail();
        }
    }

总结: 从上面的代码可知,会吧一些重要的jar加载到bootstrap 类加载器的搜索路径中,然后通过PinpointStarter 的start拉起客户端的链路采集服务.

jacoco

项目地址:
https://github.com/jacoco/jacoco

JaCoCo是一个开源的覆盖率工具(官网地址:http://www.eclemma.org/JaCoCo/),它针对的开发语言是java,其使用方法很灵活,可以嵌入到Ant、Maven中;可以作为Eclipse插件,可以使用其JavaAgent技术监控Java程序等等。
jacoco功能不做详细介绍, 简单总结下拿到覆盖率有哪些意义

  • 可以查看代码的分支执行情况,可以查看代码是否存在因为bug 而产生分支不执行的问题
  • 针对无用代码,进行清理
  • 提升代码质量,覆盖率低的代码基本上质量不会好,可能因为设计的原因,造成代码过于松散,可以看下是否有重构的必要性

该工具提供了两种接入方式都可以获取覆盖率

  • On-The-Fly代理模式
  • Offine模式

一般通过代理模式获得覆盖率是比较推荐的方式.通过on-the-fly 其实就是走agent代理,通过premain会拉起tcpserver服务,并完成字节码插桩.
一般通过agent启用jacoco服务参数大致如: java -javaagent:D:/developSoft/jacoco-0.8.5/lib/jacocoagent.jar=includes=*,output=tcpserver,append=**true**,address=127.0.0.1,port=6301 -jar application.jar


public static void premain(final String options, final Instrumentation inst)
			throws Exception {

		final AgentOptions agentOptions = new AgentOptions(options);
		//创建agent实例,拉起tcpserver服务,用来响应外界拉取覆盖率请求
		final Agent agent = Agent.getInstance(agentOptions);
		//启动运行时,代码插桩
		final IRuntime runtime = createRuntime(inst);
		runtime.startup(agent.getData());
		inst.addTransformer(new CoverageTransformer(runtime, agentOptions,
				IExceptionLogger.SYSTEM_ERR));
}

transmittable-thread-local

项目地址:
https://github.com/alibaba/transmittable-thread-local

为什么我们需要使用ttl,请参看ttl解决什么问题

根据我们对该框架的验证,他对threadlocal变量的传递支持更强大,下面的实验背景是:设置一个线程局部变量,然后在异步线程中获取该变量.

场景ThreadLocalInheritableThreadLocalTransmittableThreadLocal
场景1–-new thread不支持支持支持
场景2--ThreadPoolExecutor不支持不支持支持
场景3--ScheduledThreadPoolExecutor不支持不支持支持
场景4--ForkJoinPool不支持不支持支持
场景5--TimerTask不支持不支持支持
场景6--CompletableFuture同ForkJoinPool或者ThreadPoolExecutor场景
场景7--Reactor同ScheduledThreadPoolExecutor场景

ttl也同样推荐使用agent的方式接入,那么让我们看下他是怎么通过agnet拉起的


public static void premain(String agentArgs, @NonNull Instrumentation inst) {
        kvs = splitCommaColonStringToKV(agentArgs);

        Logger.setLoggerImplType(getLogImplTypeFromAgentArgs(kvs));
        final Logger logger = Logger.getLogger(TtlAgent.class);

        try {
            logger.info("[TtlAgent.premain] begin, agentArgs: " + agentArgs + ", Instrumentation: " + inst);
            final boolean disableInheritableForThreadPool = isDisableInheritableForThreadPool();
			//创建对应的转换器实例对象,按顺序添加到集合中
            final List<JavassistTransformlet> transformletList = new ArrayList<JavassistTransformlet>();
            transformletList.add(new TtlExecutorTransformlet(disableInheritableForThreadPool));
            transformletList.add(new TtlForkJoinTransformlet(disableInheritableForThreadPool));
            if (isEnableTimerTask()) transformletList.add(new TtlTimerTaskTransformlet());
			//创建ClassFileTransformer对象,并通过inst添加到类加载回调机制中去
            final ClassFileTransformer transformer = new TtlTransformer(transformletList);
            inst.addTransformer(transformer, true);
            logger.info("[TtlAgent.premain] addTransformer " + transformer.getClass() + " success");
            logger.info("[TtlAgent.premain] end");
            ttlAgentLoaded = true;
        } catch (Exception e) {
            String msg = "Fail to load TtlAgent , cause: " + e.toString();
            logger.log(Level.SEVERE, msg, e);
            throw new IllegalStateException(msg, e);
        }
    }

总结: 从上面的入口类可知,ttl其实和其他agent类似,创建类转换器,然后通过inst添加之后,等待类加载被触发,进而修改 class来影响类行为.

总结

上面几个组件的拉起方式我们做了简单介绍,可以看到其实和上文的agent技术部分是契合的.一般是创建对应的类转换器,在转换器中定义要转换的类的具体行为.然后经等待被触发. 这样的话,我们做agnet治理,是不是只要把三个agent的拉起类合并到一起然后新建一个通用agent工程就万事大吉了呢?其实没有这么简单,在多个agent同时启用的情况下,我们需要关注的问题其实也比较多的.

问题和对应方案

基于上面的知识,我们看下在开发统一agent的过程中遇到哪些问题,应该如何解决?

三个agent如何统一?

根据上文的介绍,这几个agent的入口类其实都很类似,但是他们自己本身还是有区别的. pinpoint项目本身体量很大,工程很重,而且是我们改造最频繁的工程. jacoco工程的agent相对来说比较轻量,而且集成到一起之后一般也不太可能修改. ttl体量最小,只有一个jar,但是可能会经常根据我们的业务特性对他进行修改. 所以综合来看,我们有两种组合方案

  • 三个agent合并到一起,新建一个项目,分别拉起三者

  • 将jacoco的agent和ttl agent合并到pinpoint项目中去,借助pinpoint平台来一起进行多个agent的维护

第一种集成方式好处在于可以独立维护,模块比较清晰.抽取3个组件的agent部分,其实整体比较轻量,pinpoint的依赖jar虽然多,但是都是通过maven依赖的方式来引入,项目本身非常轻量容易维护.缺点是每个项目进行响应的适配和抽取 ,有一定的改造成本.

第二种集成方式好处是能够快速实现并上线,最重的pinpoint部分几乎没有改造成本,只需要吧另外两个组件加入即可.缺点是可扩展性不好,如果走了这个方式,后续没办法做比较好的治理,基本都是在修改pinpoint项目了.本身不符合多agent管理的目标.

所以在集成方式这部分我们使用新增一个集成多个agnet的统一agent来管理.

类隔离机制如何确保?

总结来说,agent设计我们需要遵守: 如果agent本身没有依赖三方库,仅仅作为一个独立的二方包存在,则可以和项目存在于相同类加载起中. 如果与第三方依赖,比如agent和项目都可能使用 log4j,guava等组件 ,则agent必须做出独立加载机制.
正常的类委托机制如下:

首先,pinpoint的agent来说,它包含的jar相对比较多,大致可以分为agent自身所需要的jar(可能和业务方依赖的jar冲突) ,每个插件对应的jar(开启了多少插件,就会加载多少个jar), 项目和agent都需要的jar ,这三个部分.那么比较好的方式是前面两个部分使用独立的类加载起进行加载.第三部分jar有特殊需求,使用bootstrap去加载.整体如下图所示:可以看到他有两个独立的classloader,然后boot目录相关的jar通过bootstrap去加载(怎么样让某些jar交给bootstrap加载呢? 下文有说明)

更详细的pinpoint类加载器设计思路,感兴趣的读者可以查看作者关于这部分的解释[pinpoint类加载器设计思路] 主要是为了更优雅并且不污染系统类加载器.

其次,jacoco来说,本身就需要对业务类进行增强,所以它自身相关的jar可以和项目放在相同classloader也不会有问题.

最后,ttl的agent需要增强的是jdk自身的class(比如线程池,Runnable等class),所以这个agent需要加载到bootstrap,否则会出现类找不到的问题.

总结:
通过观察3个agent的类加载,jacoco主要是为了增强业务类class,对其他组件或者sdk没有修改, ttl主要为了增强线程池相关类,对其他jar没有修改需求. pinpoint虽然引用的jar比较多,但是都是在自身设计的独立的类加载中运行.他所修改的class基本都是第三方组件,比如dubbo,redis,mybatis等,对业务class的增强没有需求.所以总的来说,这三类agent比较有代表性: 要增强的哪些部分class,和是否使用独立classloader隔离之间的关系.这个关系是我们需要关心的. 所以针对我们的场景,我们所设计的统一agent在一期并没有增加独立类加载器,因为可以确保几个agent之间是没有互相影响的.

如何处理jar冲突?

关于如何处理jar冲突,这个问题是必须要处理的,比如pinpoint的agent自身要完成链路的采集所必须依赖的有grpc,commons-lang等等jar, ttl/jacoco他们对增强class也要依赖javassist等三方jar,这个时候如果业务项目引入了这个jar,很有可能出现版本不一致,导致业务项目启动过程出现类找不到或者方法找不到等各种问题. 关于这个场景一般的处理方法有 2个 :

  • 通过独立的类加载器进行隔离

pinpoint使用的这个方案,具体参看上文的类加载器设计部分. 比如agent实现增强所依赖的asm包就放在plugin/lib目录下,他的加载和业务类加载器是隔离的,所以不用担心版本不同出现冲突.

  • 将所以来的三方包进行修改包名,然后重新打包到当前agent中

ttl和jacoco使用的是该方案. 比如jacoco的agent依赖asm,他在打包的时候,通过maven的maven-shade-plugin插件把asm的jar进行重命名, ttl依赖的是javassist组件,他在打包的时候把javassist的jar进行包名修改并重新打包到自己的agent中.

ttl的集成三方库方式 请关注relocations部分


<plugin>
	<artifactId>maven-shade-plugin</artifactId>
	<version>3.2.3</version>
	<executions>
		<execution>
			<id>shade-when-package</id>
			<phase>package</phase>
			<goals>
				<goal>shade</goal>
			</goals>
			<configuration>
				<relocations>
					<relocation>
						<pattern>javassist</pattern>
						<shadedPattern>com.alibaba.ttl.internal.javassist</shadedPattern>
					</relocation>
				</relocations>
				<artifactSet>
					<includes>
						<include>org.javassist:javassist</include>
					</includes>
				</artifactSet>
				<shadeSourcesContent>true</shadeSourcesContent>
			</configuration>
		</execution>
	</executions>
</plugin>

效果如下

jacoco的集成三方库方式,请关注relocations部分


<plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <executions>
          <execution>
            <phase>package</phase>
            <goals>
              <goal>shade</goal>
            </goals>
            <configuration>
              <shadedArtifactAttached>true</shadedArtifactAttached>
              <shadedClassifierName>all</shadedClassifierName>
              <minimizeJar>true</minimizeJar>
              <relocations>
                ....省略
                <relocation>
                  <pattern>org.objectweb.asm</pattern>
                  <shadedPattern>${jacoco.runtime.package.name}.asm</shadedPattern>
                </relocation>

效果如下

  • 借助第三方实现该需求的框架来实现

常见的这种框架比如sofa-ark,是阿里开源的一款轻量级类隔离容器. 感兴趣的读者可以自行了解 sofa-ark介绍

总结:
jar冲突这部分也有各种方案,总体来说,如果依赖的jar比较少,可以直接通过修改包名的方式打入agent中,这样就不存在类冲突的了. 如果依赖的jar比较多,则通过修改包名就不够优雅了.最好是独立的类加载器进行隔离.就我们统一agent来说,如果集成当前的几个场景,则不需要另外设计,可以直接使用. 考虑后续扩展性 ,比较友好的方式是每个agent提供相应的类加载器进行隔离,形成一种规范,则后续更多agent扩展接入的时候可以直接参考.

如何处理多个agent的顺序问题?

根据我们上文所讲的类转换器的知识,可知,如果类在添加转换器之前已经加载,则不会再次触发该转换动作.也就是说,如果某个agent需要增强某个class,但是这个class已经被别的agent启动增强过,则这个agnet可能会失效. 为了处理这类问题. 一般的解决方案如下:

  • 去分析每个agent所加载的是哪些类,这些agent所增强的类有没有冲突

根据上文我们的分析,pinpoint增强的类都是三方jar包,比如redis,mybaits,dubbo等, ttl增强的类是jdk的线程池相关的几个class,jacoco所增强的是业务代码,所以根据分析结果可知,三者并没有冲突.但是pinpint启动的时候会加载线程池相关的class,所以需要放在最后启动.

  • 如果有重复增强的某个class的转换器,则需要抽出该agent中的转换器,通过instrument一起加载,这样类在加载的时候会按照链表的顺序一次调用相关转换器.

如果可以通过调整agent启动顺序来避免agent失效问题,则应当简单解决. 如果无法通过拉起顺序来解决问题,则需要抽离相关agent的ClassFileTransformer 组合程list交给inst在启动入口一并加载

总结:

根据实际场景和现实情况考虑,我们所使用的agent所增强的class基本没有重合,但是pinpoint在启动过程中依赖的线程池,所以需要放在最后拉起, ttl和jacoco可以提前拉起. 这样确保了每个agent都能生效. 这个维度也是我们在治理agent的时候所需要考虑的部分.

怎么最大程度方便升级?

大家应该都了解中间件组件推动升级的成本是相当高的.如果能在业务方没有感知的情况下完成升级(前提是没有问题)那是最理想的. 但是实际上我们不得不在推广新功能或者sdk升级的时候执行比较谨慎的策略,如果部分应用升级之后出现问题则需要立刻回滚.新功能推广的前期需要密切观察运行状态,然后缓慢加速推广的力度.
我们一般在升级的时候,会执行以下策略:

  • 兼容旧功能/场景

我们发布系统老版本已经增加了pinpoint的支持,但是缺少其他agent支持,新功能发布之后,需要保留和老功能的兼容. 一般策略是: 如果没有开启新功能开关,则默认执行老逻辑.

  • 新功能增加开关,可以打开或者关闭

统一agent设置了三个开关,针对每个agent可以独立设置,如果出现问题,可以关闭之后重新拉起应用(暂时不支持动态).

总结:

要实现一个整体比较轻量的方案,同时也考虑安全性可控性,在必要的地方需要增加相应的开关.这样在业务方升级的时候只需要打开开关即可.将功能集成到发布系统后,升级agent做到了比较方便的接入.

如何预留可扩展性?

主要考虑后续更多场景接入的时候,如何能快速评估风险和快速接入.要实现这个要求, 我们要关注以下几个方面:

  • 新agent要做哪些事情 ?
  • 新agent是否有增强class的需求,如果有,增强的是哪些class,使用的是哪些转换器?
    (比如采集jvm指标,本身不需要增强class)
  • 新agent依赖的jar都有哪些? 是如何加载的? 具体加载到哪些classlaoder中去?
  • 新agent对应用的性能影响具体数据是多少?
  • 新增agent针对启动顺序有什么要求?

针对以上几个方面,我们需要设计相应的策略,这样就能快速的接入新的或者维护当前的已接入agent.

应用实践

设计&效果

上文分享了我们在设计统一agent的时候遇到的问题和相应的解决方案. 我们依照方案对几个agnet进行了统一的管理,重点主要是在以下几个方面

  • 新增统一agnet项目来承接多agent治理的功能
  • 统一agent入口类premain通过对接收参数的分析来确定加载哪些agnet和解析对应的参数从而拉起agent
  • 根据上文的分析,启动顺序首先拉起ttl,然后在拉起pinpoint和jacoco
  • 针对依赖jar有的需要加载到app类加载器,有的需要加载到bootstrapClassloader我们使用如下方式

java编码方式(比如拉起jacoco需要添加该agent所需要的jar到app的类加载器,当然也可以通过这个api加到bootstrap中)


if (agentTypes.contains(HiAgentType.JACOCO)) {
    //如果开关开启了jacoco,则检查jacoco自身参数是否存在
    if (hiAgentMap.containsKey(HiAgentType.JACOCO)) {
        String file = HipacBootStrap.class.getProtectionDomain().getCodeSource().getLocation().getFile();
        String agentRtJar = new File(file).getParent()
            .concat("/lib/org.jacoco.agent.rt-{ver}-all.jar");
        instrumentation.appendToSystemClassLoaderSearch(new JarFile(agentRtJar));
        PreMain.premain(hiAgentMap.get(HiAgentType.JACOCO), instrumentation);
        logger.info("hi-agent start jacoco agent success");
    } else {
        logger.info("hi-agent start jacoco agent failed , not get jacoco params");
    }
}

maven-jar-plugin配置的方式(这种主要是针对要加到bootstrap搜索路径中的场景)
请关注下面配置的Boot-Class-Path属性

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-jar-plugin</artifactId>
  <executions>
    <execution>
      <phase>package</phase>
    </execution>
  </executions>
  <configuration>
    <archive>应用实践
      <manifestEntries>
        <Premain-Class>xx.xx.HipacBootStrap</Premain-Class>
        <Boot-Class-Path>lib/transmittable-thread-local-${ttl.version}.jar</Boot-Class-Path>
        <Can-Redefine-Classes>true</Can-Redefine-Classes>
        <Can-Retransform-Classes>true</Can-Retransform-Classes>
        <Pinpoint-Version>${project.version}</Pinpoint-Version>
      </manifestEntries>
    </archive>
  </configuration>
</plugin>
  • 集成到发布系统(效果)

压测数据

  • 我们实际压测效果,符合预期

说明:pinpoint本身开启了比较多插件,当时的测试场景下,我们使用统一agnet并没有比之前单独使用agent增加额外的损耗
下面的报告是我们针对自定义接口的测试项目进行极限压测数据,可能受机器性能,代码预热等多因素影响.
实际上我们在agent上线之后又进行了真实流量的压测对比,结果是:开启agent的pinpoint和ttl之后,性能影响基本在10%以内(5%~10%之间)

后续规划

多场景接入

目前我们统一agent除了使用agent的场景之外,在这里还可以做很多其他的事情

  • jvm指标收集 (已完成, 并且支持业务方自定义指标,统一agent可以提供exporter进而采集到指标平台)
  • 故障诊断 (已完成, 主要是通过统一agent拉起/关闭arthas工具并响应外部系统诊断命令)

动态性

上文可以看到,我们设计的统一agent都是基于启动阶段生效的.这个受限于agent的premain 方案,但是大家应该了解,像类似arthas等开源工具,这类agent可以在运行时进行类增强,.这里面涉及到agnet的agentmain 方面的功能.既然做agent的治理,那么我们的agent后续需要支持静态和动态的多种agent的管理.下面是我们在动态治理这方面的规划


引用和推荐

本文所涉及的相关知识点,可以参考下面文章或者博客,推荐有兴趣的读者深入学习

classloader委托机制学习

https://github.com/oldratlee/land#1-classloader%E5%A7%94%E6%89%98%E5%85%B3%E7%B3%BB%E7%9A%84%E5%AE%8C%E5%A4%87%E9%85%8D%E7%BD%AE

java agent原理深入学习

https://www.infoq.cn/article/javaagent-illustrated

几个agent的小例子

https://github.com/dahuoyzs/javaagent-demo
https://zhuanlan.zhihu.com/p/74255330

jvm attach 机制学习

http://lovestblog.cn/blog/2014/06/18/jvm-attach/

字节码类库实战学习

https://bugstack.cn/itstack/itstack-demo-bytecode.html

深入理解Instrument

https://zhuanlan.zhihu.com/p/361089729

Java Instrument常见问题

https://juejin.cn/post/6844903592319516686