2017年我们一直在做分布式服务跟踪系统的升级改造,为了更好的服务于广大的开发和运维人员,能够在数以千万计的微服务系统中快速的发现问题、定位问题。一年下来,接入了上千个系统,快的几分钟,慢的不到10分钟,最多就是处理一下jar包依赖冲突问题,所以能这么迅速的推广。不过前几天遇到一个客户,他们的系统采用的是OSGI的框架。OSGI这个词相信大部分人是听过没用过的。
当我们的监控系统遇到了OSGI架构系统也是碰撞出了激烈的火花,足足用了两天的时间,才搞定了各种水土不服。我把期间的各种酸甜苦辣记录下来,希望为奋斗在APM一线的同行们提供一点点帮助。
在整个过程中,我们遇到了四个技术关键点:
- 把一个普通的jar包Bundle化。
- OSGI的类加载机制。
- 引入第三方非Bundle化的jar包。
- 通过Activator在Bundle的启动和停止实现回调。
项目背景
首先我简单介绍下两个项目的背景:
监控系统:这是一个利用动态字节码技术,自动的无侵入的对目标系统实行秒级实时监控,监控内容包括但不限于容量、性能、成功率、调用链、应用拓扑等,详细内容请参考:http://os.51cto.com/art/201709/552641.htm。
目标系统:分为两部分,第一部分以war包形式发布,部署到Web容器内,接收http请求;第二部分采用OSGI架构,每一个Bundle是一个业务服务(可以理解为Bundle就是微服务)。这两部分通过servlet的桥接方式连接,其中一些公用Bundle(如slf4j)会放在war包的指定目录中,每个业务Bundle不需要再单独引入。这个架构是我在解决了所有问题之后才总结出来的,在这里提前抛出是为了方便理解后边的内容。
刚开始的时候,我们就把它当做一个普通的系统来接入,把我们的jar包放到了目标系统war包的WEB-INF/lib下,加入启动脚本,再启动系统,可以看到我们的jar打印出的启动日志,然后就没有然后了,除了那一行日志,没有任何效果。这个时候,我就意识到了OSGI系统不是这么玩的,于是开始OSGI的漫漫探索之路......
当看到OSGI的每一个Bundle包都是用单独ClassLoader负责加载时,我仿佛找到了解决方案。
我们把监控jar包打到了每一个业务的Bundle里边,然后重启,结果监控成功了,问题解决了。
从纯技术角度来看貌似是没什么问题,然而噩梦才刚刚开始。由于监控jar包打入了业务Bundle内,带来了两个比较棘手的问题:第一,技术上每个业务Bundle都需要这么去引入,加大了开发工作量,这个和我们极致的设计理念是不符的;第二,业务上每一个业务Bundle就成为了一个单独应用,客户方表示我们这个是一个应用,而不是多个应用。
所以还是要解决之前的问题,把整个目标系统所有的Bundle当做一个应用去接入监控。
我不熟悉目标系统和OSGI架构,客户开发方又不了解监控系统的原理和实现过程,经过了长时间的各种尝试和交流,仍没有丝毫进展。于是我们决定采用一个最原始,最简单,最笨,也是最有效的方法,从“hello world”开始。
当一个系统出现问题,你又不知道问题出在哪部分的时候,要么把这些“部分”一个一个去掉,直到发现没有“问题”为止;要么从“零”开始,然后一个一个的加入,直到出现“问题”为止,我们采取了后者。
首先,针对我们的监控jar包去掉基于动态字节码的自动监控,采用编程式的直接API调用,并且将API接口做了Mock,去掉了所有的第三方依赖。修改目标系统的代码,在希望被监控的方法中直接调用监控API。打包,部署,启动,被监控Bundle不能启动,报错......
- Missing Constraint: Import-Package: com..................sgm......
经过和对方开发人员的沟通和网上查阅OSGI相关资料,了解到这是一个OSGI打包的规范问题。
之前我们的监控jar包是没有按照OSGI的规范去打包的,打的只是一个普通的jar包,所以在OSGI框架下,其他的Bundle是无法访问到我们的jar包中的类,造成启动报错的问题。这里遇到了我们第一个要解决的主要问题:
1. 普通jar包Bundle化
先看下边的两张图:
上边的两张图是jar包中META-INF/MANIFEST.MF文件,第一张图是普通jar包,第二图是具有OSGI规范的jar包。既然知道了,那这样的OSGI的包怎么打?我们用maven来编译,顺理成章的就找到了maven-Bundle-plugin的这个插件,使用很简单,代码如下:
- <plugin>
- <groupId>org.apache.felix</groupId>
- <artifactId>maven-Bundle-plugin</artifactId>
- <version>3.3.0</version>
- <extensions>true</extensions>
- <executions>
- <execution>
- <id>Bundle-manifest</id>
- <phase>process-classes</phase>
- <goals>
- <goal>manifest</goal>
- </goals>
- </execution>
- </executions>
- <configuration>
- <instructions>
- <Import-Package>org.slf4j</Import-Package>
- <Export-Package>com.wangyin.sgm.client</Export-Package>
- <Bundle-RequiredExecutionEnvironment>JavaSE-1.6</Bundle-RequiredExecutionEnvironment>
- </instructions>
- </configuration>
- </plugin>
在这么多属性中, Import-Package和Export-Package尤其重要,一个jar包就是一个Bundle,Bundle和Bundle之间的访问全靠这两个属性来控制。
Import-Package:说明了这个Bundle jar需要调用外部其他Bundle的哪些包(package)。
Export-Package: 说明了这个Bundle jar可以提供哪些包(package)供其他Bundle去调用。
当一个Bundle启动的时候,会分为Resolved(解析),Installed(安装),Started(激活)等几个步骤,解析就是检查jar是不是正常,安装的时候会把Export-Package中的指定的包进行注册,这就知道哪些包由哪些Bundle来提供,激活的时候会检查当前这个Bundle的Import-Package依赖的包是不是都有对应的Bundle来提供,否则激活失败。
修改监控客户端,把父项目,子项目,子子项目全部按照OSGI规范打包,又是一遍部署重启,这次被监控的业务Bundle没有报错,而是监控客户端启动报错:
- Missing Constraint: Import-Package: org.slf4j
有了刚才的经验,一看就知道是没有slf4j,可是对方开发人员反馈slf4j是有的,他们自己也在用,这就奇怪了。
这时我发现还有在监控客户端里MANIFEST.MF的一句话org.slf4j;version="[1.7,2)",version这个词引起了我的注意。
原来在目标系统里的slf4j是1.5版本,而监控客户端要求1.7版本,slf4j降级再试,这次目标系统成功调用到了监控客户端的API,打印出了日志,万里长征终于迈出了第一步。但这只是一个Mock的版本,真正的监控程序还没有加入。
先小结一下:首先在OSGI的框架下,所有jar必须按照OSGI的规范去打包,否则不能使用;其次要对Import-Package和Export-Package配置好,需要依赖外部哪些包,自己又可以提供哪些包供外部使用,同时注意版本,依赖时一般都是要高于哪个版本。
接下来,就要加入真正的代码跑一跑,根据前面的经验还是要慢慢来,我们分两步加入代码,第一次先加入监控代码,待成功后加入网络传输部分的代码,后边遇到的坑让我们发现这个决定是正确的。
打包部署启动调用这个流程已经很熟练了,报错还是如期出现了,这次报错的是com.lmax的disruptor这个第三方开源jar包:
- Missing Constraint: Import-Package: sun.misc
纳尼?sun.misc没有?这不是jdk里的吗,为什么会没有呢?于是,又是一番资料查找,终于明白了其中的道理。
这里就要讲到OSGI的类加载机制了,它并不是我们常说的双亲委托机制。关于这个问题,网上已经有很多文章介绍了,但这里我还是结合这个例子简单的说一下。
2. OSGI的类加载机制
首先每一个Bundle(jar)都会被一个单独的ClassLoader去加载,当一个Bundle的ClassLoader尝试去加载一个类的时候:
- 如果这个类的包名是java.*开头的,那么直接交给bootstrap ClassLoader去加载。
- 查看这个类是否在OSGI的配置文件中有org.osgi.framework.bootdelegation属性定义,如果定义了,交给bootstrap ClassLoader去加载这个类。
- 查看这个类是否在OSGI的配置文件中有org.osgi.framework.system.packages属性定义,如果定义了,交给父类加载器去加载,一般就是AppClassLoader。
- 查看这个类是否在本Bundle的MANIFEST.MF文件的Import-Package中定义,如果定义了,交给Export-Package这个类的Bundle去加载。
- 在上边条件都不满足的时候,那这个类就是自己的Bundle的内部类,由自己的ClassLoader去加载。
现在我们来看一下,下边这张图是disruptor的MANIFEST.MF文件:
在disruptor里使用了sun.misc.Unsafe类,在启动disruptor的时候,需要加载sun.misc.Safe,那么1,2,3点都不满足,命中了第4点,就开始寻找带有Export-Package的Bundle,那肯定找不到。
遵循上边的原则,在配置文件中加入了org.osgi.framework.bootdelegation=javax.*,sun.*,同时又把disruptor中的Import-Package删掉了,问题成功解决了,又向胜利迈进了一步。
接下来,就要把网络传输这部分加入进来了,系统就可以监控了,数据需要发送出来才可以真正的使用。有了之前的经验,感觉应该问题不大,然而现实情况并不是这样……
当把这部分代码和依赖加入后,各种的Missing Constraint: Import-Package: xxx。我发现所有的依赖的第三方jar都在里边,为什么还会报错,我开始怀疑是这些第三方jar本身的问题。
打开这些jar文件的MANIFEST.MF文件查看,果然如此,我们依赖的这些jar有多一半都不是Bundle化的jar包,这里就涉及到了本文第三个要解决的核心问题。
3. OSGI如何加载第三方非Bundle化的jar包
OSGI如何加载第三方非Bundle化的jar包,有如下几种方式:
- 通过父类加载器加载,也就是配置org.osgi.framework.system.packages。
- 将jar转换成Bundle,然后Export-Package。
- 把jar打包进引用方的Bundle。
第一种方式需要目标系统配置,同样不符合我们的设计理念,显然不合适,于是我们首先尝试了第二种方式,重新打包那些非Bundle的第三方jar。
在这个过程中,我们发现这绝对是个苦逼的活,需要找到源码,下载源码,修改pom,有的找源码很费劲,有的还是ant编译的,有的虽然是maven管理,但又依赖了父项目……
总之想顺利的重新打包是个很费劲的事。看来只剩下第三条路了,这又要退回到之前第一个问题,如何打一个Bundle jar。
之前我们是把maven工程的每一个子项目分别Bundle化,如果要把第三方jar打入Bundle,那就有可能一个第三方jar被多次打入不同的子项目Bundle,造成浪费。
所以决定放弃对原有项目的每个子项目单独Bundle化的方案,而是新建一个子项目,由这个子项目引入所有的其他子项目和第三方依赖jar,把他们所有打成一个大的Bundle jar。
- <Import-Package>org.osgi.framework,org.slf4j</Import-Package>
- <Export-Package>com.wangyin.sgm.client</Export-Package>
- <Private-Package>com.wangyin.*,com.lmax.disruptor.*,……
- </Private-Package>
看一下上边的配置,比之前多了一个Private-Package,就是说哪些Package是这个Bundle的内部包,也就是要打入最后Bundle jar的东西。
现在通过编程式API直接调用的方式已经可以监控到目标系统了,最后要做的就是引入运行时字节码增强技术。
还是按照常规的方式,把我们的Agent通过javaagent方式启动,有了之前的经验,我知道这个是被bootstrap ClassLoader加载的,于是就直接在org.osgi.framework.bootdelegation中加入了监控Agent的包名。
结果启动正常,但是无法自动监控,看了日志后发现是监控客户端没有启动。监控客户端的启动是通过在每个类加载的时候,尝试性的使用它的类加载去加载监控客户端的启动类。
如果可以加载上,那么就启动成功了,因为启动程序是放在了启动类的static块中,且启动类是一个单例模式,记录着被监控应用的信息。
从这个启动日志来分析,被监控系统有200多个Bundle,每个Bundle启动都去加载监控客户端,然后我们希望监控客户端被它自己的类加载器去加载。这里就是本文第四个核心问题。
4. 如何在Bundle启动的时候去做一些初始化操作
在OSGI的规范里提供了一个叫BundleActivator的接口,里边有start和stop两个方法,顾名思义,在Bundle启动和停止的时候会回调这两个方法,这就好办了,我们可以在start方法中实现启动监控客户端的代码。
- <instructions>
- <Bundle-Activator>com.wangyin.sgm.client.Activator</Bundle-Activator>
- <Import-Package>org.osgi.framework,org.slf4j</Import-Package>
- <Export-Package>com.wangyin.sgm.client</Export-Package>
- <Private-Package>com.wangyin.*,com.lmax.disruptor.*,org.apache.flume.*
- ,org.apache.avro.*,com.thoughtworks.*,
- ,org.apache.commons.compress.*,org.apache.commons.lang.*
- ,org.codehaus.jackson.*,org.jboss.netty.*,org.apache.velocity.*
- ,org.xerial.snappy,org.tukaani.xz.*
- </Private-Package>
- <Bundle-RequiredExecutionEnvironment>JavaSE-1.6</Bundle-RequiredExecutionEnvironment>
- </instructions>
上边的Bundle-Activator这个标签,指定这个Bundle的Activator是哪个类。
至此所有的问题都得到了解决,这次OSGI应用接入APM监控足足花掉了两天的时间。
由于负责监控系统的人员并没有使用过OSGI,被监控目标系统的开发人员也不知道监控的原理是什么,一开始我们都以两个产品整体去接入,做了许多的无用功,耽误了很多时间。
从这个案例可以看出,当你所做的东西需要应用到一个你不熟悉的技术领域的时候,又不可能有足够的时间去学习这个领域的知识,有一个最好的办法就是改造你所做的系统,从零开始,逐渐加码,去适应那个不熟悉的领域。
千万不要想着能整体一下解决所有问题,因为可能要解决所有问题有100个技术点需要去修改,这100个问题同时暴露,你只有把它们同时都修改了,才能看到你的成果,这是根本不可能的事情。
而这100个问题一个个暴露出来,把一个不可能完成的大任务拆分成若干个可完成的小任务,修改一个,看到一步成功的效果,问题就得到了解决。
作者简介:
张晨, 资深研发工程师,目前任职京东金融,曾任职于搜狐等互联网公司,擅长Java底层技术的研发及疑难问题的定位。从2015年开始从事智能运维监控平台的研发与实践,参与并主导了APM等产品的研发与应用,经历了多次618和双11的千万级TPS的运维保障,支撑了京东金融的大量业务应用。
沈建林,曾在多家知名第三方支付公司任职系统架构师,致力于基础中间件与支付核心平台的研发,主导过 RPC 服务框架、数据库分库分表、统一日志平台,分布式服务跟踪、流程编排等一系列中间件的设计与研发,参与过多家支付公司支付核心系统的建设。现任京东金融集团资深架构师,负责基础开发部基础中间件的设计和研发工作。擅长基础中间件设计与开发,关注大型分布式系统、JVM 原理及调优、服务治理与监控等领域。