GTLC全球技术领导力峰会·上海站,首批讲师正式上线! 了解详情
写点什么

最全面!一文让你看懂无侵入的微服务探针原理

2020 年 12 月 30 日

最全面!一文让你看懂无侵入的微服务探针原理

前言


随着微服务架构的兴起,应用行为的复杂性显著提高,为了提高服务的可观察性,分布式监控系统变得十分重要。


基于 Google 的 Dapper 论文,发展出了很多有名的监控系统:Zipkin、Jaeger、Skywalking 以及想一统江湖的 OpenTelemetry 等。一众厂家和开源爱好者围绕着监控数据的采集、收集、存储以及展示做出了不少出色的设计。 


时至今日即使是个人开发者也能依赖开源产品,轻松的搭建一套完备的监控系统。但作为监控服务的提供者,必须要做好与业务的解绑,来降低用户接入、版本更新、问题修复、业务止损的成本。所以一个可插拔、无侵入的采集器成为一众厂家必备的杀手锏。


 为了获取服务之间调用链信息,采集器通常需要在方法的前后做埋点。在 Java 生态中,常见的埋点方式有两种:依赖 SDK 手动埋点;利用 Javaagent 技术来做无侵入埋点。下面围绕着 无侵入埋点的技术与原理为大家做一个全面的介绍。


无侵入的采集器(探针)


分布式监控系统中,模块可以分为:采集器(Instrument)、发送器(TransPort)、收集器(Collector)、存储(Srotage)、展示(API&UI)。



 zipkin 的架构图示例


采集器将收集的监控信息,从应用端发送给收集器,收集器进行存储,最终提供给前端查询。


采集器收集的信息,我们称之为 Trace (调用链)。一条 Trace 拥有唯一的标识 traceId,由自上而下的树状 span 组成。每个 span 除了 spanId 外,还拥有 traceId 、父 spanId,这样就可以还原出一条完整的调用链关系。



为了生成一条 span , 我们需要在方法调用的前后放入埋点。比如一次 http 调用,我们在 execute() 方法的前后加入埋点,就可以得到完整的调用方法信息,生成一个 span 单元。



在 Java 生态中,常见的埋点方式有两种:依赖 SDK 手动埋点;利用 Javaagent 技术来做无侵入埋点。不少开发者接触分布式监控系统,是从 Zipkin 开始的,最经典的是搞懂 X-B3 trace 协议,使用 Brave SDK,手动埋点生成 trace。但是 SDK 埋点的方式,无疑和业务逻辑做了深深的依赖,当升级埋点时,必须要做代码的变更。 


那么如何和业务逻辑解绑呢?


Java 还提供了另外一种方式:依赖  Javaagent 技术,修改目标方法的字节码,做到无侵入的埋点。这种利用 Javaagent 的方式的采集器,也叫做探针。在应用程序启动时使用 -javaagent ,或者运行时使用 attach( pid) 方式,就可以将探针包导入应用程序,完成埋点的植入。无侵入的方式,可以做到无感的热升级。用户不需要理解深层的原理,就可以使用完整的监控服务。目前众多开源监控产品已经提供了丰富的 java 探针库,作为监控服务的提供者,进一步降低了开发成本。


想要开发一个无侵入的探针,可以分为三个部分:Javaagent ,字节码增强工具,trace 生成逻辑。下面会为大家介绍这些内容。


基础概念


使用 JavaAgent 之前 让我们先了解一下 Java 相关的知识。


什么是字节码?


类 c 语言 Java 从 1994 年被 sun 公司发明以来,依赖着 "一次编译、到处运行" 特性,迅速的风靡全球。与 C++ 不同的是,Java 将所有的源码首先编译成  class (字节码)文件,再依赖各种不同平台上的 JVM(虚拟机)来解释执行字节码,从而与硬件解绑。class 文件的结构是一个 table 表,由众多 struct 对象拼接而成。


类型

名称

说明

长度

u4magic魔数,识别Class文件格式4个字节
u2minor_version副版本号2个字节
u2major_version主版本号2个字节
u2constant_pool_count常量池计算器2个字节
cp_infoconstant_pool常量池n个字节
u2access_flags访问标志2个字节
u2this_class类索引2个字节
u2super_class父类索引2个字节
u2interfaces_count接口计数器2个字节
u2interfaces接口索引集合2个字节
u2fields_count字段个数2个字节
field_infofields字段集合n个字节
u2methods_count方法计数器2个字节
method_infomethods方法集合n个字节
u2attributes_count附加属性计数器2个字节
attribute_infoattributes附加属性集合n个字节


                                                  字节码的字段属性


让我们编译一个简单的类`Demo.java`




      用 16 进制打开 Demo.class 文件,解析后字段也是有很多 struct 字段组成:比如常量池、父类信息、方法信息等。


      JDK 自带的解析工具 javap  ,可以以人类可读的方式打印 class 文件,其结果也和上述一致


      什么是 JVM? 


      JVM(Java Virtual Machine),一种能够运行 Java bytecode 的虚拟机,是 Java 体系的一部分。JVM 有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM 屏蔽了与具体操作系统平台相关的信息,使得 Java 程序只需生成在 JVM 上运行的目标代码(字节码),就可以在多种平台上不加修改地运行, 这便是 "一次编译,到处运行" 的真正含义 。


      作为一种编程语言的虚拟机,实际上不只是专用于 Java 语言,只要生成的编译文件符合 JVM 对加载编译文件格式要求,任何语言都可以由 JVM 编译运行。


      同时 JVM 技术规范未定义使用的垃圾回收算法及优化 Java 虚拟机指令的内部算法等,仅仅是描述了应该具备的功能,这主要是为了不给实现者带来过多困扰与限制。正是由于恰到好处的描述,这给各厂商留下了施展的空间。



                                              维基百科:已有的 JVM 比较 


      其中 HotSpot(Orcale) 与性能更好的 OpenJ9(IBM) 被广大开发者喜爱。


      JVM 的内存模型


      JVM 部署之后,每一个 Java 应用的启动,都会调用 JVM 的 lib 库去申请资源创建一个 JVM 实例。JVM 将内存分做了不同区域,如下是 JVM 运行时的内存模型:




      • 方法区:用于存放的类信息、常量、静态变量、即时编译器编译后的代码等数据

      • :所有线程共享,放置 object 对象与数组,也是 GC (垃圾收集器的主要区域)

      • 虚机栈 &程序计数器:线程私有的,每一个新的线程都会分配对应的内存对象。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。


      双亲委派加载机制


      Java 应用程序在启动和运行时,一个重要的动作是:加载类的定义,并创建实例。这依赖于 JVM 自身的 ClassLoader 机制。



                                                                 双亲委派 


      一个类必须由一个 ClassLoader 负责加载,对应的 ClassLoader 还有父 ClassLoader ,寻找一个类的定义会自下而上的查找,这就是双亲委派模型。


      为了节省内存,JVM 并不是将所有的类定义都放入内存,而是


      • 启动时:将必要的类通过 ClassLoader 加载到内存

      • 运行时:创建一个新实例时,优先从内存中寻找,否则加载进内存

      • 执行方法:寻找方法的定义,将局部变量和方法的字节码放入虚机栈中,最终返回计算结果。当然静态方法会有所区别。


      这样的设计让我们联想到:如果能在加载时或者直接替换已经加载的类定义,就可以完成神奇的增强。


      JVM tool Interface


      默默无闻的 JVM 屏蔽了底层的复杂,让开发者专注于业务逻辑。除了启动时通过 java -jar 带内存参数之外,其实有一套专门接口提供给开发者,那就是 JVM tool Interface 。


      JVM TI 是一个双向接口。JVM TI Client 也叫 agent ,基于 event 事件机制。它接受事件,并执行对 JVM 的控制,也能对事件进行回应。


      它有一个重要的特性 - Callback (回调函数 )机制:JVM 可以产生各种事件,面对各种事件,它提供了一个 Callback 数组。每个事件执行时,都会调用 Callback 函数, 所以编写 JVM TI Client 的核心就是放置 Callback 函数。


      正是有了这个机制能让我们向 JVM 发送指令,加载新的类定义。 


      JavaAgent 


      现在我们试着思考下:如何去魔改应用程序中的方法的定义呢?


      这有点像大象放入冰箱需要几步:


      1. 按照字节码的规范生成新的类

      2. 使用 JVM TI ,命令 JVM 将类加载到对应的内存去。


      替换后,系统将使用我们增强过的方法。


      这并不容易,但幸运的是,jdk 已经为我们准备好了这样的上层接口 instructment 包。它使用起来也是十分容易,我们下面通过一个 agent 简单示例,来讲解 instructment 包的关键设计。


      Javaagent 简单示例


      javaagent 有两种使用 方式:


      • 启动时加入参数配置 agent 包路径 : -javaagent:/${path}/agent.jar;

      • 运行时 attach 到 JVM 实例的 pid ,将 jar 包附着上去 :VirtualMachine.attach(pid);VirtualMachine.loadAgent("/<path>/agent.jar");


      使用第一种方式的 demo


        public class PreMainTraceAgent {

        public static void premain(String agentArgs, Instrumentation inst) {

        inst.addTransformer(new DefineTransformer(), true);

        }

        static class DefineTransformer implements ClassFileTransformer{

        @Override

        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {

        System.out.println("premain load Class:" + className);

        return classfileBuffer;

        }

        }

        }


        Manifest-Version: 1.0

        Can-Redefine-Classes: true

        Can-Retransform-Classes: true

        Premain-Class: PreMainTraceAgent


        然后在 resources 目录下新建目录:META-INF,在该目录下新建文件:MANIFREST.MF: 


        最后打包成 agent.jar 包


        •  premain() :-javaagent  方式进入的入口。顾名思义他是在 main 函数前执行的,制作 jar 包时需要在 MF 文件中指名入口 Premain-Class: PreMainTraceAgent

        • Instrumentation:JVM 实例的句柄。无论是 -javaagent 还是 attach 上去,最终都会获得一个实例相关的 Instrumentation。inst 中比较重要的两个函数是 redefineClasses(ClassDefinition... definitions) 与 retransformClasses(Class<?>... classes) 通过这两个函数,我们都可以将增强后字节码加入到 JVM 中 

        • redefineClasses() 和 retransformClasses() 的区别 ?

          redefineClasses() 适合将新加入的类做修改,而 retransformClasses() 可以将哪些已经加载到内存中的类定义做替换

        • ClassFileTransformer:这个接口里面有一个重要的方法 transform() ,使用者需要实现这个类。当这个类被加入 inst  的内的 Transformer 数组时,每一个类的加载或修改,都会调用到该方法。类的定义相关信息,比如类二进制定义 classfileBuffer 

        • addTransformer() :可以将实现了 ClassFileTransformer 的类加入 Instrumentation 中内置的数组。就像一个加工厂,上一个 ClassFileTransformer 处理过的类,会作为下一个 ClassFileTransformer 的参数。


        到了这里就会发现,增强字节码也是如此的简单。

        字节码生成工具


        通过前面的了解,有种修改字节码也不过如此的感觉 ^_^ !!!但是我们不得不重视另一个问题,字节的如何生成的?


        1.  大佬:我熟悉 JVM 规范,明白每一个字节码的含义,我可以手动改 class 文件,为此我写了一个库。

        2.  高手:我知道客户的框架,我修改源码,重新编译,将二进制替换进去。

        3.  小白:字节码我是看不懂啦,大佬写的库我会用就行了。


        下面会介绍几个常见的字节码生成工具


        ASM 


        ASM 是一个纯粹的字节码生成和分析框架。它有完整的语法分析,语义分析,可以被用来动态生成 class 字节码。但是这个工具还是过于专业,使用者必须十分了解 JVM 规范,必须清楚替换一个函数究竟要在 class 文件做哪些改动。ASM 提供了两套 API:


        • CoreAPI 基于事件的形式表现类;

        • TreeAPI 基于对象的方式来表现类


        初步掌握字节码 与 JVM 内存模型的知识,可以照着官方文档进行简单地类生成。 


        ASM 十分强大,被应用于


        1. OpenJDK 的 lambda 语法

        2. Groovy 和 Koltin 的编译器

        3. 测试覆盖率统计工具 Cobertura 和 Jacoco

        4. 单测 mock 工具,比如 Mockito 和 EasyMock

        5. CGLIB ,ByteBuddy 这些动态类生成工具。

        BYTEBUDDY


        ByteBuddy 是一款出众的运行时字节码生成工具,基于 ASM 实现,提供更易用的 API。被众多分布式监控项目比如 Skywalking、Datadog 等使用 作为 Java 应用程序的探针来采集监控信息。


        以下是与其他工具的性能比较。



        • Java Proxy:JDK 自带的代理机制,可以做到托管用户的类,以便于扩展。但是必须给定一个接口,作用有限

        • Cglib:很有名气,但是开发的太早了,并没有随着 JDK 的特性一起更新。虽然它的库依旧很有用,但是也慢慢被被使用者从项目中移除

        • Javassit: 这个库企图模仿 javac 编译器,做到运行时转化源代码。这非常有雄心,然而这个难度很有挑战,目前为止和 javac 还有相当大的差距。


        在我们实际的使用中,ByteBuddy 的 API 确实比较友好,基本满足了所有字节码增强需求:接口、类、方法、静态方法、构造器方法、注解等的修改。除此之外内置的 Matcher 接口,支持模糊匹配,可以根据名称匹配修改符合条件的类型。


        但也有缺点,官方文档比较旧,中文文档少。很多重要的特性,比如切面,并未详细介绍,往往需要看代码注释,和测试用例才弄懂真正的含义。如果对 ByteBuddy 这个工具有兴趣的同学,可以关注我们的公众号,后面的文章会就 ByteBuddy 做专门的分享。 

        Trace 数据的生成


        通过字节码增强,我们可以做到无侵入的埋点,那么和 trace 的生成逻辑的关联才算是注入灵魂。下面我们通过一个简单例子,来展示这样的结合是如何做到的。


        Tracer API


        这是一个简单的 API,用来生成 trace 消息。


        public class Tracer {public static Tracer newTracer() {return new Tracer();}
        public Span newSpan() { return new Span();}

        public static class Span { public void start() { System.out.println("start a span"); }
        public void end() { System.out.println("span finish"); // todo: save span in db }}
        复制代码


        仅有一个方法 sayHello(String name)目标类 Greeting



        手动生成 trace 消息,我们需要在方法的前后加入埋点手动埋点


        ...
        public static void main(String[] args) {Tracer tracer = Tracer.newTracer();// 生成新的spanTracer.Span span = tracer.newSpan();
        // span 的开始与结束span.start();Greeting.sayHello("developer");span.end();
        }...
        复制代码


        无侵入埋点


        字节增强可以让我们无需修改源代码。现在我们可以定义一个简单的切面,将 span 生成逻辑放入切面中,然后利用 Bytebuddy 将埋点植入。



        TraceAdvice 


        将 trace 生成逻辑放入切面中去


        public class TraceAdvice {
        public static Tracer.Span span = null;
        public static void getCurrentSpan() { if (span == null) { span = Tracer.newTracer().newSpan(); }}
        /** * @param target 目标类实例 * @param clazz 目标类class * @param method 目标方法 * @param args 目标方法参数 */@Advice.OnMethodEnterpublic static void onMethodEnter(@Advice.This(optional = true) Object target, @Advice.Origin Class<?> clazz, @Advice.Origin Method method, @Advice.AllArguments Object[] args) { getCurrentSpan(); span.start();
        }
        /** * @param target 目标类实例 * @param clazz 目标类class * @param method 目标方法 * @param args 目标方法参数 * @param result 返回结果 */@Advice.OnMethodExit(onThrowable = Throwable.class)public static void onMethodExit(@Advice.This(optional = true) Object target, @Advice.Origin Class<?> clazz, @Advice.Origin Method method, @Advice.AllArguments Object[] args, @Advice.Return(typing = Assigner.Typing.DYNAMIC) Object result) { span.end(); span = null;
        }
        复制代码


        1. onMethodEnter:方法进入时调用。Bytebuddy 提供了一系列注解,带有 @Advice.OnMethodExit 的静态方法,可以被植入方法开始的节点。我们可以获取方法的详细信息,甚至修改传入参数,跳过目标方法的执行。

        2. OnMethodExit:方法结束时调用。类似 onMethodEnter,但是可以捕获方法体抛出的异常,修改返回值。


        植入 Advice


        将 Javaagent 获取的 Instrumentation 句柄 ,传入给 AgentBuilder (Bytebuddy 的 API)


        public class PreMainTraceAgent {
        public static void premain(String agentArgs, Instrumentation inst) {
        // Bytebuddy 的 API 用来修改 AgentBuilder agentBuilder = new AgentBuilder.Default() .with(AgentBuilder.PoolStrategy.Default.EXTENDED) .with(AgentBuilder.InitializationStrategy.NoOp.INSTANCE) .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) .with(new WeaveListener()) .disableClassFormatChanges();
        agentBuilder = agentBuilder // 匹配目标类的全类名 .type(ElementMatchers.named("baidu.bms.debug.Greeting")) .transform(new AgentBuilder.Transformer() { @Override public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, TypeDescription typeDescription, ClassLoader classLoader, JavaModule module) {
        return builder.visit( // 织入切面 Advice.to(TraceAdvice.class) // 匹配目标类的方法 .on(ElementMatchers.named("sayHello")) ); } }); agentBuilder.installOn(inst);}
        // 本地启动public static void main(String[] args) throws Exception { ByteBuddyAgent.install(); Instrumentation inst = ByteBuddyAgent.getInstrumentation();
        // 增强 premain(null, inst); // 调用 Class greetingType = Greeting.class. getClassLoader().loadClass(Greeting.class.getName()); Method sayHello = greetingType.getDeclaredMethod("sayHello", String.class); sayHello.invoke(null, "developer");}
        复制代码


        本地调试


        除了制作 agent.jar 之外,我们本地调试时可以在 main 函数中启动,如上面提示的那样。


        打印结果


        WeaveListener onTransformation : baidu.bms.debug.Greeting

        start a span

        Hi! developer

        span finish

        Disconnected from the target VM, address: '127.0.0.1:61646', transport: 'socket'


        可以看到,我们已经在目标方法的前后,已经加入 trace 的生成逻辑。


        实际的业务中,我们往往只需要对应用程序使用的框做捕获,比如对 Spring 的 RestTemplate 方法,就可以获得准确的 Http 方法的调用信息。这种依赖这种字节码增强的方式,最大程度的做到了和业务解耦。


        What`s more ?


        在实际的业务中,百度基础架构部云原生团队也积累不少踩坑经验 :


            1. 有没有一个好的探针框架,让我 "哼哧哼哧" 写业务就行 ?


            2. 如何做到无感的热升级,让用户在产品上轻松设置埋点 ?


            3. ByteBuddy 到底该怎么用,切面的注解都是什么意思?


           4. Javaagent + Istio 如何让 Dubbo 微服务治理框无感的迁移到 ServiceMesh 上 ?


        感兴趣的同学,可以关注我们的公众号,后面我们会为大家带来更精彩的分享!


        作者简介

        孙启元,百度研发工程师,现就职于百度基础架构部云原生团队,对云原生微服务监控、Kubernetes 等方向有深入的研究和实践经验。


        本文授权转载自公众号“云原生计算”。

        原文链接

        https://mp.weixin.qq.com/s/lfIGjddkFVlhDZH8uHYdWA


        2020 年 12 月 30 日 16:111424

        评论

        发布
        暂无评论
        发现更多内容

        LeetCode 3. Longest Substring Without Repeating Characters

        liu_liu

        算法 Leetc

        第5周:作业一

        远方

        Week 05 总结

        鱼_XueTr

        缓存 分布式数据库 消息队列

        week5. 课后作业

        dj_cd

        第5周-总结

        Dawn

        架构师训练营Week5总结

        平淡人生

        week5 学习总结

        任小龙

        架构师训练营第五周总结

        James-Pang

        极客大学架构师训练营

        区块链大规模应用“补位”开始了

        CECBC区块链专委会

        RxJS学习总结

        shinji

        RXJS

        致那些高考结束的同学们

        小天同学

        读书 读书感悟 高考

        架构师第五周作业

        suke

        极客大学架构师训练营

        一致性哈希算法简单实现

        Jerry Tse

        源码 极客大学架构师训练营 作业 一致性哈希

        架构师训练营 Week5作业

        平淡人生

        架构师培训 -05 缓存、消息和负载均衡

        刘敏

        架构师训练营 第五周 个人感想

        且听且吟

        再谈大型网站技术应用——上篇

        Jerry Tse

        网站架构 分布式系统 极客大学架构师训练营 作业

        一致性哈希算法&Java实现

        Lane

        极客大学架构师训练营

        架构0期Week5Work1

        Nan Jiang

        架构师训练营」第 5 周作业

        edd

        极客大学架构师训练营

        第五周作业 - 一致性 hash 实现

        netbanner

        极客大学架构师训练营

        架构师训练营第五周作业

        James-Pang

        极客大学架构师训练营

        架构师第五周总结

        suke

        极客大学架构师训练营

        第5周总结

        远方

        第5周总结

        andy

        架构师训练营第 05周—— 练习

        李伟

        LeetCode 12. Integer to Roman

        liu_liu

        算法 LeetCode

        技术选型:如何构建技术选型方法论

        WANDEFOUR

        极客大学架构师训练营 技术选型

        第五周学习总结

        李白

        第5周作业

        andy

        一致性哈希算法分析与go语言实现

        superman

        golang 极客大学架构师训练营 一致性Hash算法

        DNSPod与开源应用专场

        DNSPod与开源应用专场

        最全面!一文让你看懂无侵入的微服务探针原理-InfoQ