云布道师
经过去年的 Log4j-core 的治理工作,我们通过 Maven 的依赖仲裁机制,在蚂蚁集团静态代码扫描平台-STC 和资产威胁透视-哈勃 2 款产品的联动合作下,很好地完成了直接依赖和间接依赖场景下的治理工作。但路还很远,新的场景层出不穷,故事还远远没有结束,我们要做的事情还非常多。
新的故事
在今年的某一天,一位好朋友找到了我,给我讲了一个有意思的故事。"他说他发现他负责的 Bu 的生产服务器上,有危险版本 log4j-core 的 pom 文件和相关的字节码文件。"听到这里我下意识地说:"在主 pom 的依赖和依赖管理的位置引入安全版本,并且子 pom 不要写任何版本号就可以了"。已经不知道今年看到多少这种问题了,不自觉的产生了肌肉反应。"不不不不",他急促地打断了我,我开始意识到事情应该没那么简单了。"是业务引入了一个不是很流行的三方 Jar 包,这个三方 Jar 包又把 log4j 的源代码给打进去了(但是没有修改包路径),并且业务同学由于安全团队给他发了工单,业务同学也按照我们说的方式,重新引入了安全版本的 Log4j。这个时候,应用上就同时拥有了安全版本的 Log4j 和危险版本的。"讲到这里,我已经听明白了。一个应用同时引入了安全版本的 Log4j 和 经过重打包之后的危险版本的 Log4j。由于重打包的 Jar 包并不是很流行,其作者也不会第一时间重新打一个安全版本。这样就造成了短时间重打包版本不能升级的情况下,这个应用到底是安全的,还是不安全的,还是说这个结果是不可预测的?最后我也是答应我的好朋友尝试研究一下这个问题,争取给他一个满意的答复! 散落一地的细节
挂完电话,我把已知的一些细节尝试梳理了一下。此时线上服务的依赖情况应该是下图这个样子。 猜想
业务代码在依赖 JDK,安全版本的 Log4j 和重打包的危险版本 log4j 时,此时此刻无非就 4 个猜想的答案。毕竟我都引入了安全版本,毕竟重打包是少数。但是这个听起来没什么说服力。第三种带着一点量子力学的味道。此时应用应该是既危险又安全,我们无法直接说是安全还是危险,只能是代码真正运行时的那一刻通过观察得到一个具体的结果。当调用到某不知名三方 Jar 的时候,由于他重打包了危险版本,这个时候他会用他重打包的代码。所以是危险的。当业务的代码调用是在不知名的三方 Jar 之外的范围,这个时候,应用会智能的选择安全版本,这个时候反而安全了。感觉还是有点道理的。4. 此时应用一定是安全或者危险的,但我们缺少一些关键的信息导致我们无法进行预测。正所谓上帝不掷骰子,背后肯定会有一些其他的关键信息被隐藏了起来。我们现在无法预测结果,本质是因为缺失了这部分信息。经过了一段时间的调研,笔者把目光定位到了 JVM 的类加载器(ClassLoader)和 Maven 的打包机制上。认为故事的本质在于 JVM 是按照什么样的偏好对类进行加载以及 Maven 是如何进行打包的这 2 个核心命题。 ClassLoader 类仲裁机制
从我们开发代码的视角,我们写代码这件事,好像是 JVM 在按照约定给我们干活。但其实反过来,从 JVM 的视角,我们写代码本质上是在取悦 JVM。那既然是取悦,那么 JVM 的偏好就显得尤为重要!JVM 中进行类的加载这件事情是由类加载器进行工作的。类加载器进行加载类的时候,除了满足双亲委派机制的条件下,每一个 ClassLoader 加载的时候都是严格按照每个 ClassLoader 的 classpath 的顺序进行加载的,一旦在某个 classpath 完成了加载,那么后面的 classpath 便不会再次进行寻找对应的字节码文件。ClassPath 就是由一群文件目录或者 Jar 包组成的列表。ClassLoader 就是按照图中的列表顺序加载,比如当类加载器在加载 log4j的 org.apache.logging.log4j.core.lookup.JndiLookup 字节码时,按顺序找到图中的黄色 Jar 包,发现自己找到了,并且经过验证这个 classs 是合法的字节码时,此时便终止继续向下进行遍历。 黑盒验证
笔者对此模型也进行了一个实验,选择对象是 fastjson,因为这个依赖比较轻,打包比较快。分别手动打包了一个重打包版本 fastjson (dobulepackage)和一个安全版本的 fastjson (fastjson-1.2.68-noneautotype)。并且设定好他们的 classpath 顺序。故意让重打包的版本靠前。笔者这里写了一段代码去加载 ParserConfig 类,结果输出的是重打包版本的。看到这里,笔者认为,ClassLoader 进行类加载时,无论从性能角度还是稳定性角度考虑,按顺序加载这么做都是非常正确的。但是这个时候就会存在当重打包版本万一排名在安全版本之前,就会造成业务同学认为我的代码已经修复了的假象!于是,故事的核心矛盾就变成了每一个应用的 classpath 的顺序到底是谁决定的,是怎么决定的?最后我把视线从 ClassLoader 转移到了在角落里的 Maven 上。直觉告诉我,这个秘密藏在 Maven 中。 Maven 打包机制
Maven 本身是一个开放的基础平台,他是允许其他公司或者某个个体来实现自己的插件。当然,Maven 官方也是同样提供了很多官方插件的。常规的 Jar 包很多都是通过 Maven 的打包插件生成的。Jar 包的产生要归功于各式各样的打包插件。Springboot 打包插件是 Spring 官方在 Maven 这个平台上自研的,并且实现了很多魔法。我们今天说的 ClassPath 的生成信息其实就是藏匿在每一个打包插件中的。也就是说,Maven 的打包机制决定了 ClassPath 的顺序,进而决定了相同类的加载顺序。接下来我们可以按照 Springboot,以及其他可执行的 Fatjar 这 2 个类别分别进行阐述。因为这 2 种类型的应用的使用的打包插件是不一样的,所以生成 ClassPath 的逻辑也是各有千秋,百花齐放。所以接下来我就按照这 2 种类型分门别类的进行阐述和解释。 SpringBoot 打包
Springboot 在真正运行之前,是会打包成一个 Fatjar 的。所谓 Fatjar 就是包含了程序运行期间需要的所有依赖,会把所有的依赖都打入到 BOOT-INF/lib 目录下。但是这个 Fatjar 是在基本 Jar 包格式下扩展出来的,只能适用于 Springboot 的自定义ClassLoader,普通类加载器是没办法使用的。(下图为 Springboot 包的作为 Jar 包方式运行的结构图)Springboot 在以 Jar 包方式运行起来的时候,本质上是使用的自定义 ClassLoader。由于 Springboot 在 Jar 包内做了一些黑魔法,导致 ClassLoader 可以正常加载 BOOT-INF/lib 目录下的 Jar 包,并且会按照 Jar 文件的 entry 的顺序生成好 ClassPath 顺序,加载到 ClassLoader 的 ucp 属性中。这个 ClassPath 的顺序其实跟 classpath.idx 文件中的顺序其实就是一致的 (注意,Springboot 启动其实并没有直接读取 classpath.idx 文件,而是读取 Jar 文件的 entry 的顺序。但效果其实是一样的)。Springboot 的 Fatjar 里的 ClassPath 是存放在 BOOT-INF 下的 classpath.idx 文件内的,打开文件我们可以一览无余。这个文件的顺序是直接消费了 Springboot bundle 下的依赖树的深度遍历结果。因为在 Springboot 中,我们打包插件是通常是放在 bootstrap(每个 springboot 应用看哪个 module 打包,主要是看 spring-boot-maven-plugin 插件的声明所在 module)的 pom.xml 文件内的。假设我们的 bootstrap 的 pom.xml 的依赖树如下,那么经过深度遍历之后的顺序为 1,4,7,5,2,6,3。最后将这个顺序写入到 classpath.idx 文件中去。经过上图我们发现,这种深度遍历对安全来说是致命的,是非常不友好的。因为这种遍历导致很多间接依赖都排在了很多直接依赖之前,倘若 1 号 Jar 包下的 7 号包 是一个重打包版本,我们发现 7 号包在最终的 classpath 排名第三,排在了直接依赖 2 和 3 之前。这是非常危险的。也就是说,如果业务同学的 2 或 3 是安全的修复版本,同样也被一个不知名的间接依赖的重打包的危险版本进行管控了。看到这里,有些同学会自己主动检查一下自己的 Jar 包,但是发现自己的 Springboot jar 包里并没有上文提到的 classpath.idx 文件。这是因为上面的 classpath.idx 是在 Springboot 的打包插件 2.3 版本之后才加入的。那么如果我的 Springboot 版本低于 2.3 之前,我的 Fatjar 的 classpath 顺序是怎样的呢?答案其实还是一样的,就是上文我们提到的 entry 的顺序。下图你会发现其实 lib 目录跟存在 classpath.idx 文件的内容的顺序其实是一致的。都是 pom 的深度遍历结果。(下图左边就是 zip 文件的文件 entry 排序,springboot 在启动创建类加载器之前会过滤掉不是 classes 目录和不是 BOOT-INF/lib 目录) 普通 Fatjar
这里专门把普通 FatJar 单独拿出来进行阐述,原因在于普通 Fatjar 和 Springboot 这种 Jar 包格式不一样,Springboot 把所有的 Jar 包都放在 lib 目录下的。这个前面也提到过了,这是一种“不符合规范”的 Jar 包格式。真正符合规范的,同时 Maven 官方也提供了相关打包插件。是将所有依赖的字节码文件的全路径名都以平铺的方式打入到 Jar 包内的根目录中,如下图。比较流行的插件有 maven-shade-plugin 和 maven-assembly-plugin。这 2 个都是 maven 官方的。
在这种平铺的打包方式下,相同全类路径名的 class 文件,由于文件系统的限制(不允许出现同目录下的相同名字的文件),天然地保证了在 Jar 包打完之后就已经实现了竞争。在 maven-shade-plugin 和 maven-assembly-plugin 插件默认配置中,二者都不约而同的展现出了先打包先优先的原则。相同文件第二次打包时,会检查当前文件目录下是否已存在该文件,如果存在,便舍弃第二次准备打包的文件。整个顺序同样是按照打包插件所在的 module 的依赖树的深度遍历顺序为基础的。以下图为例,打包的顺序为 1,4,7,5,2,6,3
由于 Jar 包 7 是安全版本的,但是在依赖树的深度遍历排序中在 4 之后,导致 7 的代码没有被打进最终的 Fatjar。所以此时 Fatjar 运行的时候,执行的是危险版本的 Jar 包 4 里的字节码文件。看到这里我们会发现,平铺式的 Fatjar 和 Springboot 的顺序生成逻辑是一样的,确实如此。但我们不得不停下来思考下,我既然分开写,那定然存在一些区别,二者的区别是什么呢?我这里抛传引玉一下,Springboot 的方式会把危险的 class 字节码和安全的 class 字节码依旧会打进去,但是平铺式 Fatjar 这种压根只会选择 1 个。本质上他们是在竞争的时机不一样,Springboot 竞争是在运行时,平铺式 Fatjar 是在打包时。讲到这里,故事该有个结局了。故事里的应用是一个 SpringBoot 应用,我们可以看下 Jar 包内的 classpath.idx 文件,就知道这个应用到底受不受影响了,进而知道这个应用到底是安全的还是危险的。确实,在调研之初,从体感上我会觉得只有调用到 Fatjar 内部的代码才会使用重打包版本的字节码,但是事实往往是有惊喜,好故事会打破人的认知。类加载机制的设计推翻了这一切。从源码层面看待结论
从源码上我们也可以看出 Maven 和 ClassLoader 的机制为什么是这样,而不是单单的他的机制是什么样。因为笔者相信,任何机制都无法保证与时俱进下的先进性,所以笔者认为上文中提到的所有的仲裁机制有一天可能会发生变化,这些结论并非最重要,而是如何调研这些结论更为重要!前面我们分别介绍了 ClassLoader 的加载类时的仲裁机制和 Maven 在打包时对 ClassPath 的处理。接下来我们寻找下相关的源码进行理解。 ClassLoader 类仲裁机制的实现
在 JDK8 中,我们最常用的其实就是应用类加载器。在应用类加载器中,当 Java 去加载一个类时,首先会根据 ucp 的顺序去尝试加载。在 JDK9 中,类加载器机制出现了一定的变化,这里我们不详细阐述。一旦加载不到,便会顺着 ucp 的顺序进行获取下一个 jar 包。一旦加载的结果 var6 不为空时,便会直接返回,不会再继续加载了。 Maven 打包插件的源码实现
在 maven-shade-plugin 插件中,实现拷贝的能力是使用的 IOUtils。如果第二次发生了相同文件的拷贝,这里会提示已经存在一个相同的文件。"We have a duplicate in jar"思考
这个重打包 Log4j 场景下的故事给我带来了一些额外的思考。我觉得一个好的故事是应该可以打破一个人的部分认知的。在我调研这个故事之前,我其实心里更多的认为是重打包的危险版本并不会生效,只会在调用重打包范围内代码时才可能生效。现在想想虽然有些可笑,但却不乏收获。 尝试去对看似合理的事物保持猎奇
很多事情在现有的技术体系下看似是合理的,就好比 00 后出生的小孩,他可能觉得移动支付天然就应该是这个样子。在没有接触到蚂蚁 STC 之前,我也一样认为在 IDEA 中,点一下函数的调用就可以跳转到函数的实现是一件很普通不过的事情。后来在做蚂蚁 STC 这个产品之后,我发现这背后要处理的工作非常之大,要考虑到各种函数情况下的函数关系,变量关系的构建,可以说非常的复杂。
用技术去反向牵引思考
我一直很反对为了思考而思考。我觉得好的思考应该是主动的,而非被迫的。我觉得技术本身是可以牵引一个人去思考的,当然不仅技术可以做到牵引思考。只有技术慢慢深耕,才会看到一些之前没有看到的视角和盲区,才会自然而然的引发对这片盲区的思考,以及会站在之前的视角尝试去思考现在已经发现的盲区,试问自己该如何能更早的发现这片盲区。 去成为领域专家而不是岗位专家
可以给自己的岗位下一个角色的定义,去用边界来描述自己的岗位。但不要给自己设定边界,更不要给自己的领域设定边界。人们认识这个世界,发现这个世界不过几千年而已。在这几千年的沉淀下,我们人类诞生了语言,数学,相对论等等无数优秀之作。但我们所说的数学家也好,物理学家也好,依旧是按照人的想法去描述这个世界。但这个世界并不是按照人类所说的这些分门别类的学科进行运转的,我们设定这些学科仅仅是方便我们自己去理解。方便我们理解的初心不要成为现实中的绊脚石。故不要给自己轻易定下条条框框。附录
1. Maven 的源码地址
https://archive.apache.org/dist/maven/maven-3/
2. SpringBoot 的打包插件源码地址(托管在 Springboot 源码中)
https://github.com/spring-projects/spring-boothttps://docs.spring.io/spring-boot/docs/current/reference/html/executable-jar.html4. maven-shade-plugin 插件源码地址
https://github.com/apache/maven-shade-plugin5. maven-assembly-plugin 插件源码地址
https://github.com/apache/maven-assembly-plugin