作者:陈承
背景
在 2018 年的 2 月,ARMS Java Agent 的第一个版本正式发布,为用户提供无侵入的的可观测数据采集服务。6 年后的今天,随着软件技术的迅猛发展、业务场景的逐渐丰富、用户规模的快速增长,我们逐渐发现过去的功能以及架构的设计逐渐难以合理、优雅的满足今天的需求,重构越来越多的被提及,但总是缺少一个合理的契机。
适时,OTel 项目异军突起,其社区经过短短四年的发展,活跃度位列 CNCF 第二,逐渐成为可观测领域的开源标准。OTel(OpenTelemetry)是一个位于云原生计算基金会(CNCF)的开放源代码项目,旨在标准化遥测数据的收集、处理和导出的方式。其贡献者由来自不同公司和组织的成员组成,他们共同协作创建和维护用于分布式追踪、指标和日志的 API、SDK 和工具。他们的目标是使可观测性更加易于访问并整合到云原生软件开发中,从而使用户能够更有效地监控、调试和优化他们的应用程序。
这些现象也极大的引起了我们的兴趣, 促使我们对 OTel Java Agent 进行了一次深入的调研。
关于 OTel Java Agent
首先我们对比了 OTel Java Agent 和 ARMS 现有探针在 Tracing、Metrics、Logs、Profiling 以及其他五个方面的功能差异,如下表所示。可以看到,简单的从功能层面来说,OTel Java Agent 依托社区广大的贡献者,在插件数量上远远领先。此外,基于一些领先的埋点技术,对于各种异步场景支持较好;ARMS 探针则依托广大的商业化用户场景和多年服务集团内外客户的经验,在采样、多协议支持、指标丰富度以及各种三方功能集成方面比较领先。
功能大类 |
功能子类 |
ARMS (3.1.4) | OTel Java Agent(1.28.0) |
Tracing |
插件支持 |
≈60 |
≈128 |
采样能力 |
头采样、尾采样 |
头采样 |
|
TraceId 透传协议 |
支持 eagleeye,w3c,zipkin,skywalking,Jaeger,自适应切换,自定义配置 | w3c、zipkin、Jaeger |
|
异步透传 | 手动配置 | 自动 | |
兼容 OTel SDK | 部分支持 |
全支持 |
|
动态新增 Span | 支持 |
不支持 |
|
Metrics |
RED 指标 |
支持 | 支持 |
线程相关指标 | 支持 | 不支持 | |
连接池指标 | 支持 | 支持 | |
异常指标 | 支持 | 不支持 | |
系统/JVM指标 | 支持 |
支持 |
|
JMX 指标 | 不支持 | 支持 | |
动态新增指标 | 不支持 | 不支持 | |
logs |
MDC |
支持 |
支持 |
自动采集 |
不支持 |
支持 | |
profiling |
支持 Profiling 事件类型数量 |
5 类CPU 耗时内存样本数内存分配大小应用级墙钟代码热点 | 不支持 |
关联 traceId | 支持 | 不支持 |
|
event |
event 类型数量 | 简单 |
不支持 |
other |
应用安全 | 支持 | 不支持 |
Arthas | 支持 | 不支持 | |
微服务治理 | 支持 | 不支持 | |
内存 dump | 支持 | 不支持 |
除了上面列举的功能对比,在对 OTel Java Agent 调研中,我们也发现他有很多领先的设计在解决埋点生效判断、异步、类隔离等问题时十分方便。这里简单介绍一下他的几个比较领先的设计。
muzzle-check 机制
编译时收集我们埋点代码中访问了被增强类的哪些方法、字段。在运行时,如果待增强类没有相应的方法和字段,则不执行增强动作,避免增强代码报错;在平时,可以对待增强类的所有版本执行静态检测,获得支持版本列表。
VirtualField 机制
JVM 的字节码增强机制有一些限制,对于已加载类的增强,只能修改方法体,不能给类新增字段。这个限制对于我们影响较大,因为在 APM 的场景下,往往有较多的场景需要给类增加字段来作一些变量传递。opentelemetry-java-instrumentation 提供了 VirtualField 机制,如下图所示,通过统一的编程接口,可以给类 T 添加一个类型为 F 的字段。
- 当类 T 当前尚未加载时,此时的实现就是给类 T 增加了类型为 F 的字段。当访问类 T 的 F 字段时和访问普通的类字段一致。
- 当类 T 已经加载时,此时的实现是有一个全局的 ConCurrentWeakhashMap,map 的 key 类型为 T,value 类型为 F。当访问类 T 的 F 字段时实际在底层为 map 的 get 操作。
public static <U extends T, V extends F, T, F> VirtualField<U, V> find(Class<T> type, Class<F>fieldType) { return RuntimeVirtualFieldSupplier.get().find(type, fieldType); }
异步上下文透传
除了原生的 JDK 线程池,对市面上常见的异步框架 akka、netty event loop 等均做了异步埋点。异步埋点思路整体上包含两个步骤:
- 实现了 Runnable 接口的实现类,利用上述的 VirtualField 机制,给实现类增加一个记录 Trace 上下文的字段,同时埋点其 run 方法,run 方法执行时获取增加字段中的 Trace 上下文,并设置到当前线程的 ThreadLocal 中。
- 埋点 Exectuor 的 execute 方法,在 execute 方法执行时从 ThreadLocal 获取当前 Trace 上下文,并设置给对应的 Runnable 实现类。
新埋点思路
最大程度利用框架的拓展能力进行埋点,比如利用 Dubbo 的 Filter 机制、grpc 的 Intercepter 机制、实现 lettuce 的 Tracing 接口等等。而不是一味的对框架的方法进行增强。
除了上面提到的这些,opentelemetry-java-instrumentation 还有很多亮点设计,比如类加载器隔离,opentelemetry-java-sdk 兼容,多 JDK 版本兼容等等,这里不再一一赘述,后续会推出系列文章专门介绍 OTel Java Agent 的一些亮点设计和技巧。
调研结论
当完成 OTel Java Agent 各方面的调研之后,我们会发现他的很多设计都是领先的,一章节提到的那些代码设计和技巧、埋点方式等帮助我们打开了新的思路,可以解决很多困扰许久的问题。OTel Java Agent 的蓬勃发展成为了一个促使我们进行一次大规模重构最合理的契机,再考虑到拥抱开源、拥抱标准的基本原则,于是我们在 2023 年的夏天做了一个重大的决定,在 ARMS Java Agent 的下一个大版本 4.x 版本中,基于 OTel Java Agent 做一次升级重构,将现有 ARMS 3.X 版本探针的商业化能力迁移过来,并做到能 100% 兼容 3.x 探针的功能。
围绕 OTel Java Agent 做了哪些增强
在接下来将近一年的时间里,围绕 openTelemetry-java-instrumentation,首先,我们对其现有的功能进行了升级重构。包括新插件支持,基础的 Tracing 能力增强,指标类型增加、指标维度增加等等;其次,迁移了很多过往几年沉淀的商业化能力。包括 Arthas 诊断,应用安全、内存 Dump,微服务治理(全链路灰度、无损上下线,限流降级等等);最后,围绕探针构建了完善的稳定性保障措施。升级后的探针整体架构图如下图所示:
下面分别展开说明。
新插件支持
OTel 探针对国内一些被广泛使用的框架、中间件支持较少,比如 druid、xxl-job、hsf、influxdb、mybatis、xxlJob、motan、shenyu 等,我们此次增加了对这些框架的支持,并且部分已经贡献给开源。
Tracing 增强
Tracing 能力是 APM 探针的核心能力,OTel 探针原生的 Tracing 能力在企业内部复杂场景下往往会遇到不少挑战,包括多协议场景下断链、极端场景下 Span 数量爆炸、采样难以命中高价值数据等等。针对这些问题,我们对 OTel 探针做了以下增强来解决:
多协议支持
原理:默认情况下会自动按照 EagleEye、W3C、Skywalking、Zipkin、Jaeger、Skywalking 的顺序识别并恢复上游透传的 Trace 上下文。同样也支持按照用户需求配置优先或者强制使用某种协议。
优点:在客户多语言、内部不同部门使用多套 Tracing 系统、外部流量携带 Trace 上下文、上云迁移等场景下能尽量保证不断链。
调用链压缩
原理:ARMS 探针会将一些同一层级的重复 Span 压缩成一个,比如业务代码在一个 for 循环中,调用数据库应用 10000 次,那么在调用链中会生成 10000 个 Span,而经过调用链压缩后,仅会记录一个 Span,并在这个 Span 中记录重复 Span 的次数、最大耗时、最小耗时、总耗时等信息,效果如下图所示。
同时,为了避免尽可能保留重要信息。会将耗时 top3 和最开始报错的三个 Span 转换为 SpanEvent 保留在压缩后 Span 的 SpanEvent 中。
优点:一方面可以避免极端场景下产生大量数据,客户 overhead 过高;另一方面避免 Span 过多场景下,后端查询缓慢、前端渲染卡顿、展示臃肿、客户排查问题难以抓住重点等问题。
缺点:因为仅保留了部分样本,无法看到全部的信息,可能导致丢失用户真正关注的数据。
采样
相比于其他产品单一的采样策略,ARMS 探针提供相对较为丰富的采样策略,且大多不需要用户进行复杂配置,每个采样策略保证特定场景下高价值 Tracing 数据被采样,低价值 Tracing 数据少采样,分别如下所示:
固定比例采样
即现有的默认采样,按照百分比采样链路。
自适应采样
自适应采样会按照 LFU 的策略选取当前调用量 top-1000 的接口,每个接口的采样彼此隔离,可设置两种采样策略,两种策略两种采样分别如下所示。
- 每秒固定条数(默认):一秒采样 10 条。
- 自适应比例:默认 10%,会根据该接口上一分钟请求量动态调整,避免大流量接口采样太多无效数据。
另外对于调用量 top-1000 以外的接口,可以认为是一个 other 接口。处理逻辑和前面介绍的 top-1000 中任 1 接口一致。
小流量采样
无需用户配置,自动保证每一个接口每一分钟至少有一个 Span。原理是用一个布隆过滤器存储一分钟内已经被采样过的接口。并每一分钟定期重置该布隆过滤器。这样可以保证无论用户接口有多发散,内存开销都是确定的。
错慢异常采样
无需用户配置,当一次调用满足下面三个条件时,则上报该次调用相关 Span。
- 接口报错:http 类接口响应码非 2xx、3xx 或者本次调用的 localRootSpan 埋点方法处抛出异常。
- 接口内部有异常:一次调用的非 LocalRootSpan 的 Span 记录到异常信息。
- 接口调用耗时长的定义:接口耗时大于过去一段时间该接口的 p99 耗时。
该采样对于问题排查十分重要,但是因为时机问题,无法保证链路完整。比如接口 A 调用接口 B,A 命中错慢采样,并不能保证 B 接口的 Span 一定上报。
自定义采样
即用户自己配置 100% 采样接口、接口前缀、接口后缀等等。满足用户配置要求的调用会一定采样。
总结
上述各个采样策略会在一次调用中都生效,一个 Span 是否上报由上述采样策略综合决定,详细流程如下图所示:
其中不同颜色的采样策略区别在于:
- 紫色:标准的头采样,只会在链路的 RootSpan 处触发,采样后可以保证后续链路完整。
- 蓝色:只要当前的采样结果是不采样,可以在链路的任何一个 LocalRootSpan 处触发,采样后可以保证后续链路完整。
- 绿色:只要当前的采样结果不采样,可以在链路的任何节点触发,采样后无法保证后续链路完整。
以一个常见的链路 A->B->C 为例说明,在不同节点命中不同采样规则时,对应会链路哪些 Span 会上报,哪些 Span 不上报。
Metrics 增强
更丰富的指标
1)线程池监控指标:针对常见 JDK 线程池,Jetty、Undertow 线程池监控,支持核心线程、最大线程、活跃线程、当前线程、历史最大线程、调度任务、完成任务、拒绝任务以及队列大小 9 类指标。便于排查线程池打满类问题。
2)线程监控指标:将当前 JVM 种所有线程归类后,统计不同类别线程的耗时以及处于不同状态线程的数量,并定时抓取线程栈,便于排查线程阻塞,线程耗时高等问题。
3)MQ 消费延迟指标:针对 MQ 类组件,出了常见的 RED 指标,增加消费延迟指标,便于排查消费延迟类问题。
4)数据库响应大小:针对 DB 类操作,增加请求、响应大小指标,便于排查大查询类问题。
5)新增异常类指标:指标主要为异常次数,维度记录了当前接口,便于排错异常类问题。
更多样的维度
1)接口类 RED 指标记录上游接口:方便查看接口到接口的调用拓扑、调用关系,方便接口异常时快速定位上下游。
2)数据库类调用指标。额外记录当前接口、数据库语句维度,方便接口出问题时,快速定位是否 db 的问题。db 出问题时,快速查询影响的接口,具体出问题的 sql 语句。
Profiling 能力支持
和阿里云 Dragonwell 团队合作,底层基于 async-profiler,提供 CP(Continuous Profiling)的能力。阿里云 Java Agent 提供的 CP 支持多种剖析类型,比如 CPU 热点剖析、堆内存热点剖析,墙钟热点剖析等。
特色能力介绍
除了常见的 CPU 热点剖析、内存热点剖析,ARMS 还针对慢调用链诊断场景,提供了代码热点产品能力,其是在开源 Async Profiler 墙钟能力的基础上,通过关联调用链中的 TraceId & SpanId 信息提供了调用链级别的 On & Off-CPU 火焰图,可有效对 Tracing 的监控盲区细节进行还原,帮助用户诊断各类常见的慢调用链问题,详情可参见文档[1]。
那么,和客户自己用开源的 Async Profiler 生成火焰图相比有什么优势呢?
首先,支持常态化开启。开源的 Async Profiler 未提供支持常态化开启的数据存储与处理能力,难以在生产环境常态化开启,对于一些线上偶现的问题,难以使用其进行问题排查。
其次,运行环境覆盖面更广。开源的 Async Profiler 一些剖析类型对应用运行环境有一定要求,比如 Alpine Linux 基础镜像为了控制体积而去除了 JDK 调试符号(debug symbols)导致无法使用内存热点剖析功能,但是 ARMS 在其基础上通过针对特定版本的 Alpine Linux 基础镜像对应的 JDK 调试符内容做了预适配,对相关类型的环境,在不安装调试符的情况下,也可以使用内存热点。
最后,更好的稳定性。开源的 Async Profiler 常态化开启过程中可能会容易出现 Crash 问题比如 #694[2]或者多个剖析引擎(CPU 热点、内存热点等)同时启动,一个外部条件不满足引发的单引擎失败会导致整体失败,ARMS 在开源 Async Profiler 基础上做了一些 bugfix 和剖析引擎隔离优化,稳定性更好。
性能优化
在分析 OTel Java Agent 的过程中,我们发现它在创建 Span、记录指标等地方,对于 Attributes 有大量的重复 copy 以及排序操作,这些部分是占用整个探针 CPU 开销的大头,我们对这些操作进行了大量的优化,结果表明在 TPS4000 流量的测试场景下,aliyun-java-agent 探针相较开源版本 OTel Java 探针 CPU 性能表现更好,整体容器 CPU 开销水位大约降低 2%;内存性能表现上,在进行 2h 压测后容器申请的 RSS 内存,aliyun-java-agent 探针相较开源版本 OTel 探针的内存占用降低约 10MB。
问题诊断场景的增强
- 集成代码级问题诊断利器 Arthas。无需依赖 JDK,一键开启、关闭。常见命令白屏化操作。且支持企业级鉴权、审计能力。避免任意用户随意执行 Arthas 命令。详情见 Arthas 诊断[3]。
- 内存 Dump。一键对指定机器执行内存 Dump,并配套白屏化分析能力。
云产品集成
- 微服务治理。在同一个 Java Agent 中集成了阿里云 MSE 微服务治理能力,包括全链路灰度、限流降级、无损上下线,系统防护、消息灰度等。
- 集成云安全中心应用安全 RASP。一键开启后拥有危险组件检测、25+ 种攻击行为[4]的监控,阻断的能力。
探针稳定性建设
由于 Java 探针和用户代码运行在一个进程中,且会对用户代码进行增强修改,Java 探针的稳定性建设尤为重要,多年的公有云用户服务经验告诉我们,对于一款可观测产品而言,我们的底线是不能影响业务行为,比如导致用户进程启动失败,用户进程 crash,用户接口报错,占用大量用户机器资源等等。为了最大程度的避免这类问题,并在出现这类问题时能够及时止血,我们在 OT 的基础上增加了下述能力。
- 探针 CPU/内存占用上限控制能力:在探针 CPU 开销,内存占用超过指定阈值时,自动降级探针的 Tracing 或者 Metrics 数据采集能力。
- 探针启动预检能力;因为探针本身有运行的环境要求,为了避免在非预期环境中出现异常行为,探针启动有若干检测项,比如 JVM 类型、JVM 版本、最大堆内存等等,最大程度的避免影响用户业务。
- 探针功能可动态插拔能力:大部分探针功能,特别是可能影响用户业务的能力,都具备动态控制开关,可以在出现问题时快速关闭。
阿里云 Java Agent 4.0 探针带来了哪些好处
从功能层面上来说,这次升级,完全吸纳了 OTel 的优秀设计,对我们现有的很多功能做了升级或者增强。
- 遵循 JDBC 规范的数据库埋点从 JDBC 接口层面埋点,理论上支持所有遵循 JDBC 规范的数据库埋点(3.x 探针仅支持固定的 9 种)。
- 异步埋点无需用户配置,不会断链。
- vertx、webflux、lettuce、Rabbitmq、kafka、RocketMq、ONS 等插件相比老版本,因为埋点的位置优化,指标统计更准确,支持版本范围更广。
- 支持容器场景的系统指标采集。
- 线程池监控支持用户自定义线程池的监控。
从工程质量上来看来说,这次升级重构是对 3.x 代码的一次取其精华、去其糟粕的过程、是重新树立更合理科学的开发规范的过程,通过这次升级重构:4.0 探针的内存占用下降了 20%、线程数降低了 60%,探针包大小降低了 30%。
最后,从长远发展来看,我们制定了每三个月合并一次开源最新稳定代码的计划,可以快速的享受到社区快速迭代的红利。
我们为社区做了什么
在基于 OTel Java Agent 二次开发的过程中,我们也积极的反哺开源,在过去 6 个月中,我们累计向社区贡献并合并各类 PR 40+,其中包含新增在国内广泛使用的 XXL-JOB、InfluxDB、MyBatis 等插件,参与社区日常 PR Review 100+,steverao[5]和 123liuziming[6]两位同事成为社区 member,其中 steverao 受邀作为该项目的 Triager 并且负责该社区的日常维护、代码 CR,且个人贡献长期位列社区前四名,贡献度积分位列社区 Top 20,亚太地区第一。
此外,我们也积极参与社区相关各类会议活动,今年 6 月,受社区邀请,在北美举行的 2024 OpenTelemetry Community Day 活动中,我们同事望陶和铖朴,为社区带来了《GraalVM 静态编译下 OTel Java Agent 的自动增强方案与实现》[7]主题分享,对相关问题的原创性解决方案得到了社区开发者的广泛关注。今年 8 月,在中国香港举行的 KubeCon China 2024 大会上,望陶和铖朴与社区其他开发者一起,在社区 Governance Committee 团队的支持下,代表社区在大会上做了《社区最新进展以及阿里云拥抱 OTel 社区实践》相关分享。
此外,为了促进亚太地区与社区的交流,在团队相关同学向社区提议,在与社区 Governance Committee Member 成员沟通后,OTel 社区也在多个领域,设立了亚太地区友好的周会交流时间。其中包含 Java: SDK + Instrumentation、Semantic Conventions: LLM、Contributor Experience 和 Developer Experience,相关时间可以参考社区周会安排[8],相关会议中有社区最资深的开发者一起参与,欢迎有兴趣的朋友加入。
目前,我们也正在将由阿里云开源的 Go Instrumentation[9]贡献到 OTel 社区,相关内容正在与社区相关 Governance Committee 和 Technical Committee 团队讨论中 #1961[10]。
写在最后
我们用了接近一年的时间完成了基于 OTel Java Agent 的升级重构,并于今年 5 月份发布了 4.x 探针的第一个版本 4.1.0,经过接近半年时间的验证、回归、优化,目前最新的稳定版本 4.1.12[11]已经正式发布,欢迎大家了解使用。
接下来的时间,一方面我们将持续的 Follow OTel Java Agent 的发版节奏,定期合并开源稳定代码,保障用户可以持续的享受社区最新的 feature;另一方面,我们也将重点打造阿里云 Java Agent 相比 OTel Java Agent 的差异化能力,补齐其不足与短板,帮助用户获得更全面、更透彻的应用可观测体验。
对 ARMS 有任何疑问,可搜索钉钉群号:31123480 加入 ARMS 沟通交流群。
相关链接:
[1] 文档
https://help.aliyun.com/zh/arms/application-monitoring/user-guide/enable-continuous-profiling
[2] #694
https://github.com/async-profiler/async-profiler/issues/694
[3] Arthas 诊断
https://help.aliyun.com/zh/arms/application-monitoring/user-guide/arthas-diagnosis
[4] 25+ 种攻击行为
https://help.aliyun.com/zh/arms/application-security/attacks-and-solutions
[5] steverao
[6] 123liuziming
https://github.com/123liuziming
[7] 《GraalVM 静态编译下 OTel Java Agent 的自动增强方案与实现》
[8] 社区周会安排
https://github.com/open-telemetry/community?tab=readme-ov-file#special-interest-groups
[9] Go Instrumentation
https://github.com/alibaba/opentelemetry-go-auto-instrumentation
[10] #1961
https://github.com/open-telemetry/community/issues/1961
[11] 4.1.12
https://help.aliyun.com/zh/arms/application-monitoring/user-guide/versions-of-arms-agent-for-java