GraalVM 静态编译下 OTel Java Agent 的自动增强方案与实现

作者:望陶、铖朴

image.png

随着 OpenTelemetry 在可观测领域影响力的不断提升,其项目以极快的速度不断演进。阿里云作为国内最广泛使用 Java 的厂商之一,深度参与 OTel Java Instrumentation 演进与社区活动,贡献、Review 各类 PR(pull request)合计超过 100 余个,参与 Issues 讨论 58 个,在 OpenTelemetry 项目的贡献榜亚太地区排名第一。

在 2024 OpenTelemetry Community Day [ 1] 会议中,阿里云可观测工程师张乎兴(望陶)和饶子昊(铖朴)为大家带来了《GraalVM 静态编译下 OTel Java Agent 的自动增强方案与实现》 [ 2] 的演讲分享,介绍阿里云在相关领域的探索方案,本文是相关分享对应的中文整理。

01 背景

image.png

随着云原生时代 Serverless 场景应用程序的不断演进,现代的 Java 程序正面临两大前所未有的挑战:第一个是Java 程序启动时间长,第二个是内存消耗大。为什么 Java 程序启动时间会非常长呢?从图中 Lifecycle of Java apps [ 3] 可以看到,一个典型的 Java 应用启动的过程中,需要经历以下几个阶段,虚拟机初始化,类加载,JIT 编译等过程,这些都会显著加大应用程序的启动时间。同时,由于没有预先加载优化,以及额外步骤会额外的占用一些内存来存放加载后的类,这些都是造成内存占用高的原因。

image.png

大约 5 年前 GraalVM 的第一个稳定版本发布了 [ 4] ,相比传统的虚拟机模式,GraalVM Native Image 模式能够带来几个显著的优势:

  1. 去除了虚拟机初始化,JIT 编译,解释执行等过程,从而使得启动时间大幅降低。

  2. 由于运行前完成了对程序的编译优化,虚拟机启动过程中消耗的内存也将大大减少,从图中可以看到,一些典型框架编写的微服务应用,在使用 Native Image 模式后,其内存和虚拟机模式相比降低了 5 倍以上 [ 5]

image.png

那么 GraalVM 的 Native Image 模式是如何工作的呢?简单来说就是将 Java 的字节码,通过 GraalVM 提供的编译器,编译成操作系统相关的可执行二进制文件。如上右图所示 [ 6] ,这里面具体做了以下几件事:

  1. 做可达性分析,GraalVM 编译器将会对 Java 程序从入口(通常为 Java 程序的 main 函数)开始进行可达性分析,因此只有可达的程序片段才会被编译到最终的二进制文件中,这一动作也降低了最终编译后文件的大小。

  2. 做类的初始化,类初始化后可以直接将状态保存起来,这样下次 Native Image 启动的时候就可以直接读取到内存中,大大节约了初始化的时间。

  3. 做对象的快照,初始化的过程中也会初始化一些对象,编译时把这些对象也一并保存下来,以节约启动时间。

最终把上述这些信息,一起打包到二进制文件当中,产生一个包含一些必要的运行时支撑组件以及前面收集的所有信息的最终可执行文件。

image.png

听起来很不错,但是变成 GraalVM Native Image 之后,也会带来一系列问题,例如:

  1. Java 程序的许多动态特性都不直接生效了,例如动态类加载,反射,动态代理等。需要使用 GraalVM 提供的额外配置方式来解决这个问题。

  2. 丧失了 Java 程序多年来引以为傲的平台无关性。

  3. 最重要的是,基于字节码改写实现的 Java Agent 将不再适用,因为没有了字节码概念,所以之前我们通过 Java Agent 做到的各种可观测能力,例如收集 Trace/Metrics 等信号这些能力都不能生效了。

因此,我们希望在提升启动时间和降低内存消耗的同时,让应用同时具备开箱即用的可观测能力,即 Java Agent 所做的增强都能继续保持工作。那么该如何解决这个问题呢?

02 解决方案

image.png

针对上述这一普遍痛点,阿里云可观测团队联合阿里云程序语言与编译器团队一起,在业界首创性地设计实现了一种静态的 Java Agent 插桩增强能力来解决该问题。

在正式介绍具体解决方案之前,回顾一下 Java Agent 的工作原理显得有必要。Java Agent 使用中包含了一些重要过程:preMain 执行、main 函数执行和类加载。当应用程序使用 Java Agent 时,它会为特定类(例如图中的类 C)注册一个转换器 transformer。在 preMain 执行之后,会执行应用的 main 函数,在这个过程中可能会加载各种类,当类加载器遇到类C时,会触发 Java Agent 注册的回调 callback,其中会执行针对类 C 的转换逻辑,将其转换为类 C',最后,类加载器加载转换后的类 C',从而实现基于 Java Agent 对原始应用中的特定类进行字节码改写,增加一些额外逻辑的效果。

然而,在 GraalVM 中,运行过程中的字节码不再存在,因此无法采用类似上述方案在运行时增强应用程序。如果要实现上述类似的能力,我们只能在运行之前去实现上述能力。因此,这个问题就转化为:

A. 如何在运行前转换目标类,得到转换后的类?

B. 如何在运行前让转换后的类替换原始类?

image.png

针对上述两个问题,我们设计的整体方案如上图所示。它包含两个阶段:预运行和静态编译。在预运行阶段,应用程序挂载 OTel Java Agent 和 Native Image agent 两个探针进行预执行。其中,OTel Java Agent 负责在预执行过程中将类从 C 转换为 C'。Native Image agent 负责在该过程中收集转换后的类,例如收集上图中展示的类 C'。从而解决问题 A:如何在运行前转换目标类,得到转换后的类?

接下来,在静态编译阶段,我们将原始应用程序、OTel Agent、转换后的类和配置作为输入,并对其进行编译。在编译过程中,我们将应用程序中类 C 替换为 C',并生成一个仅包含 C' 的可执行程序以供运行。从而解决问题 B:如何在运行前让转换后的类替换原始类?

image.png

了解了整体方案后,有的读者可能对 Native Image agent 是什么以及如何使用它来收集转换后的类感到好奇。

Native Image agent 其实是 GraalVM 已提供的一个工具。它可以扫描我们的应用程序以收集静态编译所需的所有动态配置。以便消除 GraalVM Compiler 的不足影响,允许开发人员在 GraalVM 中继续使用 Java 所提供的一些动态特性,例如反射、动态代理等。

但是,它并不能直接帮助我们收集转换后的类。为了解决这个问题,我们在 Native Image agent 中添加了一个拦截器。此拦截器在转换前后检查类的字节码。如果检测到更改,它会记录并保存它们;否则,它会忽略该类。

事实上,我们发现仅记录转换后的类是不够的。有些类不是原始应用程序的一部分,例如动态生成的类。因此,我们还需要使用 Native Image agent 对其进行收集。此外,由于 PreMain 是 JVM 和 Java Agent 中的概念,在 GraalVM 中没有被原生支持。我们也用它生成了必要的 premain 配置,以便让 GraalVM 知道 OTel Java Agent 的入口点。

除了上述内容,对于一些特殊情况,我们还做了一些额外适配。例如,因为 GraalVM 编译器也是一个 Java 程序,我们无法直接基于 Native Image agent 收集 JDK 中被变换后的类并像一般的非 JDK 类一样直接在编译时进行替换,因为这可能会影响 GraalVM 的编译行为。因此,我们通过在 GraalVM 中实现了一些特殊 API,并在 OTel Java Agent 中基于其对 JDK 类进行重新埋点,以使 GraalVM 静态编译过程中可以识别到相关内容并在不修改自身所依赖的 JDK 前提下,在最终生成的 Native Image 可执行文件中包含相关 JDK 类的转换逻辑。最后,OTel Java Agent 中有多个类加载器,而 GraalVM 中只有一个类加载器,我们对类进行 Shade 处理以实现类似的功能。

image.png

有了转化后的类和一些必要配置,那么如何在静态编译中用 C' 替换 C 呢?我们主要提供两种方法,第一种是 JDK 中的 -classpath 工具,如果一个路径下出现两个同名的类,它具有让第一个类生效的特点,所以只需要在开头声明转化后类的路径即可解决替换问题,--module-path 是 JDK 9 以后提供的能力,也能实现类似效果,其主要是将要替换的类打成 jar 包,然后通过 patch 原始的 module 以实现替换目的。

03 能力演示

0~43s: 视频第 1 部分,主要演示预执行过程,该过程会给该 Spring Boot + MySQL 的程序,挂载 OTel Java Agent 和 Native Image agent 两个探针,以产生增强类,并对增强类进行收集。请注意视频中预执行过程中该程序基于 JVM 进行运行所产生的耗时 18.517s(其中包含 JVM 启动,探针类增强等,单纯的 Spring Boot 应用启动也消耗了 6.152s),预执行后,会生成一个 native-configs 的文件夹,其中就包含了 Native Image agent 在预执行过程中采集的一些动态配置文件和被记录的动态增强类文件。

44s~1min 44s: 视频第 2 部分,主要演示静态编译过程,该过程我们将原始应用程序、OTel Agent、转换后的类和配置作为输入,并对其进行编译。编译过程包含静态分析,静态编译等多个主要步骤,会较为耗时,演示视频中使用的是 32 核 64GB 的机器进行的编译。静态编译完成后,会生成一个跟宿主机强相关的二进制可执行文件,即视频中的 demo 文件,可以使用其直接进行执行。

1min 45s~2min 6s: 视频第 3 部分,主要演示静态编译后的程序运行过程,可以看到无论是程序本地的执行耗时 0.069s 还是 JVM 得整体启动耗时 0.088s,相较于 JVM 场景下,都有非常显著的提升。另外可以看到基于该方案后,静态编译后的程序,指标、调用链等数据都被正常采集。

image.png

最后,我们也对一些常用的框架,如 Spring Boot、Kafka、Redis 和 MySQL 分别基于该方案静态编译后,与原本的 JVM 方案进行可观测数据采集运行做了一下对比。可以看到,在 GraalVM 下,基于该方案为应用提供可观测能力的情况下,相比于原 JVM 方案,在应用启动速度和运行时内存占用方面都有显著较低。

04 未来计划

image.png

未来,我们主要会在两个方面入手对当前工作进行进一步的优化:

  1. 我们会进行更加全面的测试,包括 Metrics/Trace/Logs 的多种信号进行完整的测试。

  2. 我们考虑将预执行阶段合并到编译阶段进行,这样可能会更加完整地采集到转换后的类。目前方案的局限主要是,在预执行阶段,只有被执行过的类的字节码才会被收集,当单元测试不够的情况下,可能会有遗漏。

05 未来开源规划

阿里云长期关注与参与可观测,静态编译领域的开源项目建设:

在可观测领域,阿里云深度参与 OpenTelemetry 的 Java Instrumentation 项目的发展演进,贡献、Review 各类 PR 合计超过 100 余个,参与 Issues 讨论 58 个,在 OpenTelemetry 项目的贡献榜亚太地区排名第一,@steverao [ 7] 成为了 OpenTelemetry Java Instrumentation 的 Triager,未来还将继续持续拥抱 OpenTelemetry 社区,目前自研的 Go 探针即将全面开源,并且考虑贡献给 OpenTelemetry Go Instrumentation 社区,目前关于捐献社区已经有一个 Discusstion 正在讨论 [ 8] ,未来还有更多的探针会陆续开源。

在静态编译领域,阿里云作为 GraalVM 社区中国唯一的全球顾问委员会成员,积极参与相关技术演进与推动其在国内的落地。

当前,我们已经向上述两个社区贡献了对应方案的实现 PRs [ 9] [ 10] 。未来,我们计划跟两个社区一起推进相关方案的完善与落地。

最后,在今年 8 月,于中国香港即将举办的 KubeCon + CloudNativeCon + Open Source Summit + AI_dev China 2024 [ 11] 大会上,阿里云将携手业界企业或高校带来 4 个 OpenTelemetry 相关生态工作分享。其中,将有一个专门的 Talk,介绍阿里云是如何全面拥抱 OpenTelemetry [ 12] ,欢迎大家到时来到现场讨论交流,也期待未来更多的公司一起加入,协手共同推进相关领域发展!

相关链接:

[1] 2024 OpenTelemetry Community Day

otelcommunitydayna24.sched.com/?iframe=no

[2] GraalVM 静态编译下 OTel Java Agent 的自动增强方案与实现*otelcommunitydayna24.sched.com/event/1d0AC...*

[3] Lifecycle of Java apps

shipilev.net/talks/j1-Oc...

[4] GraalVM 的第一个稳定版本发布了

en.wikipedia.org/wiki/GraalV...

[5] 其内存和虚拟机模式相比降低了 5 倍以上

medium.com/graalvm/lig...

[6] GraalVM 静态编译过程

www.infoq.com/articles/na...

[7] 开发者 @steverao

github.com/steverao

[8] OpenTelemetry Go Instrumentation 捐献讨论

github.com/open-teleme...

[9] OTel Java Agent 中的静态编译适配

github.com/open-teleme...

[10] GraalVM 中的 Java Agent 适配

github.com/oracle/graa...

[11] KubeCon + CloudNativeCon + Open Source Summit + AI_dev China 2024

kccncossaidevchn2024.sched.com/

[12] 如何全面拥抱 OpenTelemetry

sched.co/1eYcJ

相关推荐
A ?Charis17 小时前
我来讲一下-Service Mesh.
云原生·service_mesh
严格要求自己19 小时前
nacos-operator在k8s集群上部署nacos-server2.4.3版本踩坑实录
云原生·容器·kubernetes
少吃一口就会少吃一口19 小时前
k8s笔记
云原生·容器·kubernetes
星海幻影20 小时前
云原生-docker安装与基础操作
网络·安全·docker·云原生·容器
2301_8061313621 小时前
Kubernetes 核心组件调度器(Scheduler)
云原生·容器·kubernetes
运维&陈同学1 天前
【HAProxy08】企业级反向代理HAProxy高级功能之自定义日志格式与IP透传
linux·运维·nginx·云原生·负载均衡·lvs·haproxy·反向代理
涔溪1 天前
云原生后端深度解析
后端·云原生
运维&陈同学1 天前
【HAProxy05】企业级反向代理HAProxy调度算法之静态算法与动态算法
linux·运维·算法·nginx·云原生·负载均衡·lvs·haproxy
颜淡慕潇2 天前
【K8S问题系列 | 9】如何监控集群CPU使用率并设置告警?
后端·云原生·容器·kubernetes·问题解决
运维&陈同学2 天前
【模块一】kubernetes容器编排进阶实战之k8s基础概念
运维·docker·云原生·容器·kubernetes·云计算