字节码增强技术在监控埋点场景的大规模实践落地及其他领域探索
本文根据 SACC 2022 中国系统架构师大会演讲内容整理而成。演讲嘉宾是货拉拉架构师曹伟,他本次的分享侧重于字节码增强技术的原理、落地实践经验,探讨在其他领域的应用,例如应用于微服务治理领域的可行性与收益。
随着互联网技术的高速发展,业务复杂度及规模也在快速增长,微服务成为主流,微服务可观测性建设的重要性和价值日益凸显,而可观测的前提是服务指标数据的采集和上报,如何在短时间内实现大规模微服务快速接入监控实现自动埋点上报,是个棘手的问题。
分享概要:
1、监控埋点演进史
2、什么是字节码增强技术?
3、监控领域落地实践
4、最佳实践
5、其他应用领域的探讨
监控埋点演进史
下图列举了近20年的一些具有代表性的监控产品,看下它们都是用哪些埋点方式。红色部分是基于字节码增强的无侵入式的埋点方式,蓝色是传统的埋点方式。
我们留意到近些年不管是阿里云,还是腾讯云、华为云,其实它们的监控产品都是用字节码增强的形式无侵入的。这种埋点好处是很多公司接入云产品,云监控可以非常快速。相当于一键接入,无需改任何代码。所以从整个趋势来看,无侵入方式已经有大面积的成功落地实践,技术非常成熟,在监控领域更受大家的追捧,是未来主要的趋势之一。
何为字节码增强技术?
1)字节码增强技术的应用 - 热修复Log4j2漏洞
我们一直在讲字节码增强技术,那么到底什么是字节码增强技术呢?因为时间原因这里不会跟大家详细深入原理,但希望通过下面这节让大家对这个技术能有个全面的认识。
这里举了一个大家非常熟悉的Log4j2漏洞的例子,正常的业务服务能通升级迭代来简单的修复这个BUG,但是有一些基础服务,比如说一些开源的Kafka、ES和HBase服务并不能通过简单的升级来解决。
首先升级版本可能会存在数据不兼容或者服务端和客户端版本不兼容问题,甚至仅仅是重启也可能对依赖的业务方服务产生不小的抖动。所以要求我们提出不重启服务的热修复方案,这里就可以基于字节码增强技术来实现服务的热更新,实际上我们提供出去的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 Agen核心原理图,我们主要关注 2 点:
Agent.class
Transformer.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层:支持当前主流的组件、客户端和版本
2)Show me your code - Http Client埋点
在看例子之前,先看下埋点时需要遵循哪些准则,以下是我们实践过程中总结出来的四个准则。以统计HttpClient调用游耗时的埋点为例,首先我们要找它的拦截方法。这个拦截方法怎么找?
满足实际耗时的统计需求
即确实可以统计到真实的调下游的耗时。
满足埋点时需要的一些数据
比如说我们要埋下游的请求,要拿下游的 Host、Path以及调了下游哪些接口,包括调下游的成功与否,Response 数据也要能拿到。
埋点过程中,增强的方法要尽可能少
因方法多,难免维护成本、编码复杂度会提高,此外多少仍会有一定性能损耗,所以要保证方法尽可能少。
需要避开一些异步方法
虽然异步方法我们支持,但是如果处理不得当,会出现一些奇怪的问题,所以也是要尽可能避免,降低复杂度。
所以我们最终就选择了这个类的方法作为拦截点去做增强埋点。增强的时候也是按照既定的套路,先构造一个切面,把 executor 的 execute 方法和我们的HttpRequestExecutorAdvice 这个进行关联。关联之后去专注写 advice 方法。advice 方法入口处一个 start,从此开始计时出口处结束计时,做统计的埋点。这里主要关注下metric方法。
metric其实逻辑很简单,我们获取到Context和Request 然后构造出来我们需要的这些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实现类隔离
采用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 Interface和AdviseFactory,通过这2个类可以避免Biz Code(业务代码)里对AdviseImpl(增强代码)类的直接加载和访问,从而解决类的不可见问题。
1.3 隔离方式对比
上述表格是两种方案的对比,主要围绕其实现难度、优缺点和适用场景进行对比,实践中可以根据具体场景进行选择。
2)父子级线程上下文透传
接下来我们来聊下常见的父子级线程上下文透传问题。
我们以Trace埋点为例,因为Thread本身就有父子级关系,需要把这些上下文信息透传到子线程里以达到Trace在多线程间串联的效果。对此行业上有多种方案。
基于ThreadLocal 做透传
增强Runnable 和 Callable
增强 ThreadPoolExecutor 类
3)不同字节码框架组合问题
实践中也难免会存在一个服务被多个字节码框架增强的情况,稍不注意也会存在一些问题。
比如说 Agent Javassist 在前,Bytebuddy在后,这样没问题,但是若 Javassist 居于后,则存在一个问题,会导致 Bytebuddy 增强的逻辑失效。
下面我们分析下为何会失效?
上图左右分别是Bytebuddy和Javassist的Transform类代码。我们重点看下Class 数据的流转,即可知晓原因。
首先Bytebuddy增强时拿的是JVM传递给他的Java Class元数据来做增强的。这个数据其实有可能是在已被上一个agent 增强了,它是在其他人增强的基础上再增强一次进而保证两个增强效果均得以保留。
而Javassist 不同,Javassist 增强时,是从自己的 ClassPool 里面去拿Class元信息,这个类元信息一定是最原始的未被增强的,在此基础上做一些增强之后就会把之前增强的Class数据覆盖了,导致前面的增强失效。
4)Agent启动方式
接下来我们聊下Agent的3种启动方式。
基于 JVM 启动参数启动。
动态Attach
上文最初提到的 Log4J2 修复的例子其实就是通过动态Attach方式启动的。
以SDK形式内嵌,服务运行时启动
这种方式其实是在业务启动时,选择一个合适的时机主动的去执行premain方法。
其他应用领域的探讨
最后我们来聊下字节码增强技术在其他领域的应用探索。
首先是微服务治理,结合行业的演进历程我们可以看到,最开始微服务的治理能力均集成于 SDK 里,以 SDK 的形式提供这种能力,这存在以下两个痛点。
依赖冲突问题,依赖版本难于管理
SDK升级成本非常高
Sidecar其实就是最近几年比较火热的 service mesh相关技术,它其实是把一些逻辑放在Sidecar Agent里面,因为他们是独立于业务服务部署再节点中,与业务代码做解耦,可以做到动态的无感知升级。
另外随着 JavaAgent 技术的成熟稳定和大规模应用,基于字节码增强形式的服务治理能力,也在慢慢被行业认可并应用到具体实践中。比如阿里云和腾讯云等云厂商都支持JavaAgent形式。
其共同特点都是可以解耦业务应用和治理能力,实现治理能力的下沉、业务无侵入和快速升级。
Sidecar的一大优点是支持多语言,这是JavaAgent 满足不了的,JavaAgent 只适用于 JVM 语义生态体系下的一些服务。但是JavaAgent 也有自身的优点,其性能高于Sidecar,毕竟Sidecar一个独立的进程,其底层原理本质上就是拦截一些数据流做解析,必然会有一些性能损耗,但JavaAgent是魔改代码在同一个进程运行性能更高。
除此之外,我们还可以做些别的尝试,比如服务热部署,这块美团内部已经大面积落地了,用到了一个很核心的技术就是技术字节码增强技术。它可以将之前需要经过打包、发布整个数分钟耗时缩短到秒级别,大幅提升整个开发、测试的效率。
|嘉宾介绍|
曹伟
货拉拉 架构师
16年硕士毕业于上海大学,工作6年主要从事于微服务框架研发、分布式定时调度和全链路监控等领域的工作,成功主导过百万级TPS的全链路监控服务建设、微服务研发框架的设计与落地。目前主要专注于微服务框架研发工作,对微服务框架的设计思想和推广落地有较丰富的经验。