查看原文
其他

字节码增强技术在监控埋点场景的大规模实践落地及其他领域探索

韩楠 ITPUB 2023-04-21

本文根据 SACC 2022 中国系统架构师大会演讲内容整理而成。演讲嘉宾是货拉拉架构师曹伟,他本次的分享侧重于字节码增强技术的原理、落地实践经验,探讨在其他领域的应用,例如应用于微服务治理领域的可行性与收益。


随着互联网技术的高速发展,业务复杂度及规模也在快速增长,微服务成为主流,微服务可观测性建设的重要性和价值日益凸显,而可观测的前提是服务指标数据的采集和上报,如何在短时间内实现大规模微服务快速接入监控实现自动埋点上报,是个棘手的问题。

分享概要:

1、监控埋点演进史

2、什么是字节码增强技术?

3、监控领域落地实践

4、最佳实践

5、其他应用领域的探讨




监控埋点演进史

下图列举了近20年的一些具有代表性的监控产品,看下它们都是用哪些埋点方式。红色部分是基于字节码增强的无侵入式的埋点方式,蓝色是传统的埋点方式。

我们留意到近些年不管是阿里云,还是腾讯云、华为云,其实它们的监控产品都是用字节码增强的形式无侵入的。这种埋点好处是很多公司接入云产品,云监控可以非常快速。相当于一键接入,无需改任何代码。所以从整个趋势来看,无侵入方式已经有大面积的成功落地实践,技术非常成熟,在监控领域更受大家的追捧,是未来主要的趋势之一。


何为字节码增强技术?

1)字节码增强技术的应用 - 热修复Log4j2漏洞

我们一直在讲字节码增强技术,那么到底什么是字节码增强技术呢?因为时间原因这里不会跟大家详细入原理,但希望通过下面这节让大家对这个技术能有个全面的认识。

这里举了一个大家非常熟悉的Log4j2漏洞的例子,正常的业务服务能通升级迭代来简单的修复这个BUG,但是有一些基础服务,比如说一些开源的KafkaESHBase服务并不能通过简单的升级来解决。

首先升级版本可能会存在数据不兼容或者服务端和客户端版本不兼容问题,甚至仅仅是重启也可能对依赖的业务方服务产生不小的抖动。所以要求我们提出不重启服务的热修复方案,这里就可以基于字节码增强技术来实现服务的热更新,实际上我们提供出去的Agent Jar帮助整个货拉拉数千个基础服务节点实现热更新,完美修复这个Bug。

这里的修复其实很简单,只涉及到一行代码的改动,在lookup这个方法进入之初就return null,避免执行下面的可能产生BUG的代码,那这个Bug自然就解决了。

那具体如何做呢?这里我们需要明白下面2个概念:

  • 谁去修改字节码就是字节码修改框架做的事。针对字节码修复框架,后面列了3个主流框架会逐一介绍。

  • 修改后的字节码数据是怎么生效的,主要由Java Agent技术来实现。

2)Java Agent技术

这里我们先看一下Java Agent是个什么东西?我们知道Class数据最终是被加载到JVM内存里,我们如果要在运行是修改Class数据其实是有2个契机:

  • Class文件被加载到JVM之前,对他做修改,将修改后的数据加载到JVM内存中。

  • 针对已经加载到JVM中的数据JAVA 7之后的版本,是支持把JVM内存里面的Class数据捞出来修改增强然后再放回去,覆盖之前老的数据,这样能达到一个替换、增强的效果。

所以简单说,Java Agent技术就是一套支持运行时动态修改JVM内存中的Class数据的技术或者手段。

上图是Java Agen核心原理图,我们主要关注 2 点:

  • Agent.class

JVM启动后会先进入premain方法,方法JVM会注册一个Transformer。
  • Transformer.class

前文提到的2个修改字节码源数据的契机都会触发Tranceformer的tranceform()方法,入参bankTransBytes就是Class源数据,tranceform方法体里会通过字节码框架对源数据进行一系列修改,然后将修改后的字节码数据以返回值的形式交还给JVM。再次new新的Class实例时就会用修改后的Class字节码数据作为模板进行实例创建,从而达到增强的效果。

3)字节码增强框架

首先是ASM,它是一个比较底层的框架,是字节码增强框架的鼻祖。从上图代码中我们可以看到你需要了解JVM的指令集、Class文件规范,才能很好的进行增强编码。整体学习成本非常高,操作性也不强,除此之外我们没法针对某行增强代码进行断点Debug,开发效率大打折扣。

第二个是Javassist,它一个日本人基于ASM进行二次开发的更高级的框架,它对用户屏蔽JVM指令集和Class文件等这些晦涩难记的概念,我们可以遵循Java语法进行开发,但是从图中我们也不难看到,所有的增强逻辑都是采用硬编码(转义字符串)的形式开发,同时也一样不支持断点Debug,整体开发效率还是受到很大的限制。

最后是ByteBuddy框架,它比Javassist更高级,也更符文Java开发习惯,首先它采用切面编程思想,整体结构更清晰,这里我们只关注切面代码,会发现我们可以像日常写Java代码一样写我们的增强逻辑,并且可以随意对增强部分的代码进行断点调试,易上手、调试,整体编码效率有质的飞跃。

显而易见,ByteBuddy是最优的选择,它不需要太多的学习成本,又支持随意的断点调试,是个非常友好的字节码增强框架。


监控领域落地实践



1货拉拉现状

上图是我们监控落地现状,总体而言,字节码增强技术比较稳定,已经大面积成功落地的。


上图是我们的JAVA 埋点SDK图谱,整体看各基础组件都覆盖到了非常完善。

  • 客户端:支持自研的SOA Client,支持市面上主流的同步、异步Http Client

  • 服务端:同样支持自研的SOA,主流的Tomcat、Undertow以及Webflux异步埋点都支持得非常好

  • 基础服务层面:支持Job、全链路灰度和Log等

  • 数据DB层:支持当前主流的组件、客户端和版本

可以看到整体的场景覆盖率能达到100%,完全能满足当前货拉拉复杂的场景。我们所有的埋点完全基于字节码增强技术实现代码“零侵入”。实现业务“零代码”改造“一键快速接入”,这是我们实现“弯道超车”的基础。

2Show me your code - Http Client埋点

在看例子之前,先看下埋点时需要遵循哪些准则,以下是我们实践过程中总结出来的四个准则。统计HttpClient调用游耗时的埋点为例首先我们要找它的拦截方法。这个拦截方法怎么找?

  • 满足实际耗时的统计需求

    即确实可以统计到真实的调下游的耗时。

  • 满足埋点时需要的一些数据

    比如说我们要埋下游的请求,要拿下游的 HostPath以及调了下游哪些接口,包括调下游的成功与否,Response 数据拿到。

  • 埋点过程中,增强的方法要尽可能少

    因方法多,难免维护成本、编码复杂度会提高,此外多少仍会有一定性能损耗,所以要保证方法尽可能少。

  • 需要避开一些异步方法

    虽然异步方法我们支持,但是如果处理不得当,会出现一些奇怪的问题,所以也是要尽可能避免降低复杂度

基于以上四个准则,我们就找出来 HttpClient 的依赖里面有一个类 HttpRequestExecutor的execute 方法是所有上游接口不管调 get、post 或其他一些方法,它都会走到 execute,并能够从Connection 里面拿到 IP 等埋点必备信息。

所以我们最终就选择了个类的方法作为拦截点去做增强点。增强的时候也是按照既定的套路,先构造一个切面,把 executor 的 execute 方法和我们的HttpRequestExecutorAdvice 这个进行关联。关联之后去专注写 advice 方法。advice 方法入口处一个 start从此开始计时出口处结束计时,做统计的埋点。这里主要关注下metric方法。

metric其实逻辑很简单,我们获取到ContextRequest 然后构造出来我们需要的这些Metric Tag。我们是基于 ByteBuddy 来做的经过这一套操作下来比较简单。如果用的是Javassist或者ASM开发量都非常大,这还不考虑开发期间的调试成本。

3埋点方式对比

如图所示。原生的方式与字节码增强方式的差异,主要从灵活度、维护成本、侵入性、接入便捷性四个维度加以考量。

灵活度方面,原生的方式埋点无非就是基于Filter 和 Intercepter这些扩展机制来操作但有些情况下它是不满足条件的。比如Redis为例想获取某次请求IP在这些机制下并不好操作那么可能就需要SDK 做二次封装。如果再不满足,就需要修改它的源码,这样成本就大了,需要去维护多个主流版本、多个 SDK,还需要去打包、去维护,成本非常高,而且信用性也很高。因为业务每次接入升级还需要做对应的版本变更以及调试测试,因此整体成本是非常高的。

但是如果用字节码增强就很简单了,基本上是无侵入的,只要能看到源代码,比如 Redis 客户端的源代码,即可找到合适的拦截点对其做增强。我们只需维护几个主流的工具类、客户端、版本即可,对业务基本上无感知,维护成本非常低,没有嵌入性,实现业务零代码改动”接入。


最佳实践



接下来看看在基于字节码增强实践中会遇到了哪些问题?

1类隔离

隔离是个比较抽象的概念可以结合下图的双亲委派模型下的ClassLoader类加载流程图加以理解。

首先JavaAgent.jar被Application 加载到的,且Application ClassPath下。那么久存在一个问题,比如业务代码用了netty3但是我们要做数据上报的相关代码用的是netty 4,此时就会出现 netty 相关类的版本冲突想要业务和埋点都能正常运行的话就必须先解决这个冲突问题

关于版本冲突,行业上有两种解决方案:

  • maven-shade-plugin 打一个shade包基于包名差异化进行类隔离

  • 自定义ClassLoader实现类隔离

1.1 maven-shade-plugin

采用maven-shade-plugin来达到类隔离的手段简单和粗暴,只要梳理出潜在的冲突类修改包路径后打进shade包中即可,困难在于梳理出完整的冲突类。

1.2 自定义ClassLoader实现类隔离

上图是自定义ClassLoader的类加载逻辑,通过打破双亲委派模型实现Agent依赖的jar和业务依赖jar的隔离,但实际落地中其实会产生一些问题,比如下面的NoClassDefFoundError的问题。

我们来分析下为什么会产生NoClassDefFoundError的问题,我们看Base类的process方法被增强后多了一段StringUtils.isBlank(“abc”)StringUtils是lang3依赖,lang3是被Agent.jar引入的由AgentClassLoader加载的,对于ApplicationClassLoader来说不可见,所以运行时才会报NoClassDefFoundError异常。

解决这个问题根本在于解决不同ClassLoader对同一个类的可见性问题,解决思路有2个方向,一种是让大家都可见,另外一种是避免Application ClassLoader和不可见类的相遇,这里我们选择了后者。

如上图ClassLoader的加载模型,我们引入Advise相关类,Bootstrap ClassLoader拥有上帝视角,可以看到所有的类,并且他负责加载Advise InterfaceAdviseFactory,通过这2个类可以避免Biz Code(业务代码)里对AdviseImpl增强代码类的直接加载和访问,从而解决类的不可见问题。

1.3 隔离方式对比

上述表格是两种方案的对比主要围绕其实现难度、优缺点和适用场景进行对比,实践中可以根据具体场景进行选择

2父子级线程上下文透传

接下来我们来聊下常见的父子级线程上下文透传问题。

我们以Trace埋点为例,因为Thread本身就有父子级关系,需要把这些上下文信息透传到子线程里以达到Trace在多线程间串联的效果。对此行业上有多种方案。

  • 基于ThreadLocal 做透传

JDK原生就有 ThreadLocal,它能够做到父子级线程之间的透传。但是比如在多线程复用的复杂场景下,会有一定的困难,实用性不强。当然可以用阿里的 Transmittable ThreadLocal 来解决该问题。
  • 增强Runnable 和 Callable 

我们在类上加一个变量Context保存上下文信息在真正执行Run方法时再将Context加载到当前上下文实现透传。不过缺点是它对Lambda表达式的支持不是很友好。
  • 增强 ThreadPoolExecutor 类

增强ThreadPoolExecutor  execute() 方法。在execute() 方法进来的时候,我们入参 Runnable 做包装,这个包装类里面可以附着Context在支持run()方法的时候,先去加载Context它对 Lambda 表达式的支持也是比较友好的。货拉拉用的也是此种方案加以解决的。

3不同字节码框架组合问题

实践中也难免会存在一个服务被多个字节码框架增强的情况,稍不注意也会存在一些问题。

比如说 Agent Javassist 在前,Bytebuddy在后,这样没问题但是若 Javassist 居于后,则存在一个问题,会导致 Bytebuddy 增强的逻辑失效。

下面我们分析下为何会失效

图左右分别是BytebuddyJavassistTransform类代码。我们重点看下Class 数据的流转,即可知晓原因。

首先Bytebuddy增强时拿的是JVM传递给他的Java Class元数据来做增强。这个数据其实有可能是在已被上一个agent 增强了,它是在其他人增强的基础上再增强一次进而保证两个增强效果均得以保留

Javassist 不同Javassist 增强时,是从自己的 ClassPool 里面去拿Class元信息,这个类元信息一定是最原始的未被增强的在此基础上做一些增强之后就会把之前增强的Class数据覆盖了,导致前面的增强失效

4Agent启动方式

接下来我们聊下Agent的3种启动方式。

  • 基于 JVM 启动参数启动

  • 动态Attach

文最初提到的 Log4J2 修复的例子其实就是通过动态Attach方式启动的

  • SDK形式内嵌,服务运行时启动

这种方式其实是在业务启动时,选择一个合适的时机主动的去执行premain方法


其他应用领域的探讨

最后我们来聊下字节码增强技术在其他领域的应用探索。

首先是微服务治理,结合行业的演进历程我们可以看到,最开始微服务的治理能力均集成于 SDK 里,以 SDK 的形式提供这种能力存在以下两个痛点。

  • 依赖冲突问题依赖版本难于管理

  • SDK升级成本非常高

如果要大面积推上千个服务升级到某个特定的版本,可能要持续长达几个月的时间,成本非常高。因此行业上开始探索是否将这些治理能力、变化频率比较高的一些功能点下沉到Sidecar里。

Sidecar其实就是最近几年比较火热的 service mesh相关技术,它其实是把一些逻辑放在Sidecar Agent里面因为他们是独立于业务服务部署再节点中,与业务代码做解耦可以做到动态的感知升级。

另外随着 JavaAgent 技术的成熟稳定和大规模应用,基于字节码增强形式的服务治理能力,也在慢慢被行业认可应用到具体实践中。比如阿里云腾讯云等云厂商都支持JavaAgent形式

其共同特点都是可以解耦业务应用和治理能力,实现治理能力的下沉业务无侵入快速升级。

Sidecar的一大优点支持多语言这是JavaAgent 满足不了的,JavaAgent 只适用于 JVM 语义生态体系下的一些服务。但是JavaAgent 有自身的优点,其性能高于Sidecar,毕竟Sidecar一个独立的进程其底层原理本质上就是拦截一些数据流做解析必然会有一些性能损耗JavaAgent是魔改代码在一个进程运行性能更高。

除此之外,我们还可以做些别的尝试,比如服务热部署,这块美团内部已经大面积落地了,用到一个很核心的技术就是技术字节码增强技术。它可以将之前需要经过打包发布整个数分钟耗时缩短到秒级别,大幅提升整个开发、测试的效率。


|嘉宾介绍|



曹伟 

货拉拉 架构师

16年硕士毕业于上海大学,工作6年主要从事于微服务框架研发、分布式定时调度和全链路监控等领域的工作,成功主导过百万级TPS的全链路监控服务建设、微服务研发框架的设计与落地。目前主要专注于微服务框架研发工作,对微服务框架的设计思想和推广落地有较丰富的经验。




如果您想投稿或想要讨论更多,欢迎在后台输入关键词“投稿”,联系我们。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存