作者:来自 Elastic Sylvain Juge

我们做了两次同样的迁移,前后相隔三年,第二次的 贡献者 从三人增加到了一百人。当你重新构建 Java agent 对代码进行插桩的方式,同时又必须确保生产环境中的一切都正常运行时,会有哪些不同?
Elastic 原生支持 OpenTelemetry。通过开放遥测协议将链路追踪、日志和指标直接发送到 Elasticsearch,无须专有 agent。了解它们如何协同工作,在云端免费试用,或在本地运行。
OpenTelemetry Java agent 现在通过 invokedynamic 委托执行插桩逻辑,而不再将字节码内联到你的方法中。这项工作历时三年,涉及 70 多个拉取请求和 100 多个插桩模块。2026 年 7 月,我关闭了自 2023 年起用于跟踪这项工作的问题。
大多数用户不会察觉到任何变化,这正是我们的目标;如果你察觉到了什么异常,那就是缺陷。真正的变化体现在维护者和自行编写插桩逻辑的开发者身上。你可以获得 类 加载器隔离,也可以在集成开发环境中调试插桩代码。自行编写插桩逻辑不再意味着必须维护 agent 的派生版本,或依赖其内部实现。
此前,我曾与一个三人团队在 Elastic 应用性能监控 Java agent 上完成过同样的迁移。从纸面上看,第二次的计划很简单:
- 采用我们已在 Elastic 应用性能监控 Java agent 的生产环境中验证过的方法。
- 说服 OpenTelemetry 维护者采用这一方法。
- 迁移大部分 OpenTelemetry 插桩代码。
- 宣告胜利并庆祝。
刚开始这项工作时,我们写过一篇博客文章。本文讲述的是第二步到第四步之间发生的事情。
invokedynamic 是什么,为什么它对 Java agent 插桩很重要
如果你对技术细节感兴趣,我建议阅读 Felix Barnsteiner 和 Rafael Winterhalter(Byte Buddy 的作者)最初撰写的博客文章拥抱 invokedynamic,驾驭 Java agent 中的类加载器。
如果你还没读过,下面是简要介绍。Elastic 应用性能监控 Java agent 和 OpenTelemetry Java 插桩都基于 Byte Buddy 构建。它是一个字节码操作库,可以让你在运行时修改应用程序的字节码。
默认情况下,Byte Buddy 提供一种_内联_策略,即直接将插桩字节码注入目标方法。这种方式很简单,但牺牲了一些灵活性。注入的字节码所引用的所有类,都必须对目标类的类加载器可见,而且我们无法使用集成开发环境等常用工具调试注入的代码。此外,最初推动这项工作的一个目标,是让最终用户能够提供自己的插桩逻辑,而无须维护插桩 agent 的派生版本,也无须依赖其内部应用程序编程接口。
我们内部称为 "动态调用插桩" 的方案,使用 Java 7 引入的 Java 虚拟机字节码指令 invokedynamic,将插桩钩子的执行委托出去,而不将插桩代码内联到原始方法体中。
下面对比了这两种插桩模式在实际应用中几个重要方面的差异。
| 对比项 | 内联字节码 | invokedynamic 委托 |
|---|---|---|
| 类的可见性 | 引用的类必须对目标类加载器可见 | 插桩代码在隔离的类加载器中运行 |
| 调试 | 无法使用标准集成开发环境工具调试 | 可正常使用标准断点和单步执行 |
| Java 平台模块系统 | 继承被插桩代码的访问权限 | 必须显式授予访问权限 |
| Java 7 之前的字节码 | 无限制 | 通过生成的 invokestatic 辅助方法处理 |
| 类加载器插桩 | 支持 | 不支持,这些模块仍使用内联方式 |
这种方法的一个主要缺点是,需要修改和重构插桩代码。插桩 agent 还面临另一个挑战:它们部署在数以千计的生产环境中,因此必须承受很大的压力,尽量避免功能退化和缺陷,因为这些问题可能导致应用程序严重崩溃或性能下降。
尽管每次发布前都会进行大量测试,但插桩的特性决定了最终结果高度依赖目标应用程序。我们最多只能通过示例应用程序进行验证,但这些示例永远无法覆盖真实应用程序及其部署环境中的所有边界情况,例如专有 框架 和应用服务器、少见的 Java 虚拟机、开放服务网关规范框架,以及复杂的类加载机制。
我们如何以三人团队完成 Elastic 应用性能监控 Java agent 的迁移
当时,只有三名工程师负责 Elastic 应用性能监控 Java agent。虽然这让协调和快速决策更加容易,但也意味着我们处理支持请求的能力十分有限。
由于一次性全面迁移既不可行,风险也太高,我们必须逐步过渡,每次只迁移少量插件。我最早学到的经验之一是,即使资源有限、所在环境又对风险十分谨慎,你仍然可以推进大型复杂变更;你只需要找到逐步实施变更的方法。这样做还会自然形成一道安全保障,因为风险分散到了多个发布版本中。
处理这类任务时,一个实用的工具是我所说的问题"购物清单",它能让你一眼看清整体进度。我们在这里跟踪了第一次迁移:#1337。
虽然少数用户会在每次新版本发布后立即升级,但大多数用户会随着时间推移逐步升级,这就自然形成了新版本的渐进式推广。大多数软件和库都有类似的采用模式。少数情况下,早期采用者会报告问题,但我们会在大部分用户升级之前迅速修复。
Log4Shell 让我们看到了什么
然而,意外还是发生了。Log4Shell 严重漏洞于 2021 年 12 月披露,距离我们发布最初的博客文章还不到三周,并在整个 Java 生态系统中引发了一波紧急升级。不幸的是,Elastic 应用性能监控 Java agent 也受到了影响,因为 agent 本身使用 log4j 记录日志,在非默认配置下可能被利用,详情见安全公告。
如果你当时尝试过升级应用程序中的 log4j,大概还记得那种混乱和压力。在我看来,几乎所有人都在升级到最新版本的 log4j,并发布自己软件和库的新版本。因此,Maven 中央仓库承受了极大的负载,大多数构建和发布都因超时而失败。
经济学中有一个概念叫_自然实验_,即利用现实世界中意外出现的异常情况,检验无法在受控环境中测试的假设。我们没有直接对应的概念,但这次意外发生的大规模 Elastic 应用性能监控 Java agent 新版本迁移,为我们提供了强有力的信号,表明这套实现稳定,没有重大缺陷。
回头来看,这对团队来说是一段压力极大的时期。但查看版本变更日志就能发现,我们并没有遇到需要发布更多修订版本的重大问题。如下图所示,除了更新 log4j 的 1.28.x 系列版本外,修订版本的数量始终很少,发布节奏也保持稳定:

迁移 OpenTelemetry Java agent:规模扩大后有哪些变化
作为 Elastic 为 OpenTelemetry 所做贡献的一部分,我们发现,invokedynamic 能够为 OpenTelemetry Java 插桩带来好处。
这个 项目 的贡献者和维护者比 Elastic 应用性能监控 Java agent 更多,因此,协调工作以及说服其他人相信,为 100 多种受支持技术迁移插桩代码值得投入精力,自然也更具挑战性。此外,所有采用 OpenTelemetry 的可观测性厂商都会分发和使用 OpenTelemetry Java 插桩,因此,任何功能退化造成的影响都会大得多。
讨论始于 2023 年 7 月,我们创建了 #8999 问题,用于跟踪迁移计划和进度。接下来的几周里,我的同事 Jonas Kunz 编写了最初的概念验证实现,并完成了大部分底层工作,让 OpenTelemetry Java 插桩能够支持 invokedynamic 插桩和类加载器隔离。
迁移过程中,我们遇到了几个关键挑战,其中一些与迁移 Elastic 应用性能监控插桩时遇到的类似:
迁移 100 多个插桩模块
首先,要处理覆盖范围广泛的迁移。100 多个插桩模块需要重构,和之前一样,如果没有渐进式迁移和测试路径,这项工作就无法完成。
为此,我们采用了多种策略和技术,包括:
- 添加运行时开关,让用户可以主动启用
invokedynamic插桩。这样,我们就能在不影响现有用户的情况下,进行本地测试,并在持续集成环境中开展长达数月的持续测试。 - 使用 Byte Buddy 转换插桩代码,例如,在
@Advice注解上设置inline = false,或通过修改字节码,让代码兼容两种插桩模式。这些操作全部在运行时完成。 - 在项目最后阶段,使用大语言模型辅助完成机械性的代码转换。
这些措施让我们能够在数月内尽可能安全地测试和修改代码。主要瓶颈仍然是测试和审核变更。
为什么类加载器插桩仍采用内联方式
从更具体的技术层面来看,一些插桩模块与 invokedynamic 不兼容,需要继续采用内联方式,例如类加载器插桩。解析 invokedynamic 调用点的过程会触发类加载,进而可能导致递归解析,而这种情况是不受支持的。
插桩代码移至其他模块后的 Java 平台模块系统 访问权限
另一个有意思的挑战是与 Java 9 引入的 Java 平台模块系统兼容。采用内联插桩时,插桩代码会继承被插桩代码的访问权限。如果改为将插桩逻辑委托给位于不同模块和类加载器中的另一个类,就需要授予相应的访问权限。幸运的是,解决这个问题很容易,因为我们已经在 Elastic 应用性能监控 Java agent 中解决过它。
向应用程序类加载器注入代理类
对于少数插桩模块,当我们需要让应用程序代码能够访问某个类时,就必须将该类直接注入目标类加载器,例如对 Spring 框架进行插桩时。当插桩代码与应用程序代码不再处于同一个类加载器中时,就很难让两者共享同一个类,因为没有共同的类加载器来加载它。为此,我们引入了代理类:将代理类注入应用程序类加载器,再由它将调用委托给插桩类加载器中的代码,类似于 Java 开发工具包中的代理机制。
对使用 Java 7 之前版本编译的库进行插桩
最后一个同样重要、也是我最喜欢的挑战,是让这套机制适用于 Java 7 之前的版本。一些库和框架使用较早的 Java 版本编译,远早于 invokedynamic 指令的引入。这在长期未发布新版本或已无人维护的库中相当常见。Java 虚拟机在加载这些字节码之前进行验证时,会拒绝任何与其版本不匹配的指令,即使虚拟机本身支持该指令。你可以在插桩前升级应用程序字节码的版本来解决这个问题;换句话说,我们升级字节码,使其看起来像是使用 Java 7 编译的。
幸运的是,唯一棘手且开销较大的操作是重新计算栈映射帧,因为较新的 Java 虚拟机要求知道栈中存储的所有内容的类型。Elastic 应用性能监控 Java agent 仍在使用这项技术,但 OpenTelemetry Java 插桩后来改用了另一种方法:当类的版本不支持 invokedynamic 时,生成一个包含 invokedynamic 指令的辅助类,再通过 invokestatic 调用它,从而完全避免提升类版本或重新计算栈映射帧(拉取请求 #15258)。
OpenTelemetry Java agent 中 invokedynamic 的下一步计划
OpenTelemetry Java 插桩距离 3.0 版本的发布越来越近,届时将为所有受支持的模块默认启用 invokedynamic 插桩。总有一些方面可以继续改进,但大部分工作已经完成,我们可以认为这次迁移已经结束。
例如,在当前实现中,agent 包含的部分 OpenTelemetry 应用程序编程接口仍在进行_包重定位_,也就是在构建时重命名,以避免与应用程序类路径中同一个库的另一份副本发生冲突。借助类加载器隔离,我们应该能够像在 Elastic 应用性能监控 Java agent 中那样,为 invokedynamic 插桩移除这一步骤,但要同时保持与内联插桩的兼容性并不容易。一如既往,欢迎大家贡献代码,我也很乐意帮助任何尝试解决这个问题的人。
就我个人而言,我参与过的项目中,很少有项目能同时涉及如此深入的技术和如此大量的协调工作:类加载器隔离、Java 平台模块系统的访问规则、字节码版本处理技巧,以及数十名项目参与者,这些贯穿了前后相隔三年的两次独立迁移。我要感谢 OpenTelemetry 的维护者和贡献者,以及我在 Elastic 的同事。没有他们,这一切工作都不可能完成。
原文:OpenTelemetry Java agent: classloader isolation | Elastic Observability Labs