一、背景
在线应用的诊断一直是日常维护中的难点和痛点,2018年下半年,Alibaba 开源了 java 应用诊断工具 arthas ,让 java 应用的诊断能力上了一个台阶。作为基础架构团队,我们自然也对它非常感兴趣。研究后发现,arthas 确实是一个非常优秀的 java 诊断工具,但是也存在一些不足。
-
一是 arthas 更像是一个工具,而不像一个产品。如果要使用它,首先要登录相关机器,然后在机器上下载 arthas,再执行一些命令来运行。这整个流程里,下载可能出现问题,运行 arthas 也需要具有目标进程相应的权限,还需要先看看对应进程id等等...这些确实只是一些小问题,但也可以选择让这些问题不存在,让整个使用过程更加流畅。
-
二是 arthas 缺少 web 界面。命令行界面用起来确实很酷,但不可否认在相当一部分情况下 web 界面更直观更友好,很多需要查文档的情况在 web 界面下都可以直接操作,降低了使用门槛。
-
三是 arthas 所有功能都针对单台机器,实际上很多时候我们需要考虑和观察整个应用的运行情况,需要一个应用级的视角。
-
四是 arthas 是一个独立的工具。对于一个开源工具,这不是一个缺点,但如果能和公司内部的应用中心、发布系统等做一些适配的话,在使用上会更方便,也能够做出一些独立工具做不到的功能。
基于以上的原因,我们决定做一个更强大、更好用的一站式 java 应用诊断解决方案 - Bistoury。Bistoury 集成了 arthas,对上述的几点不足进行了针对性改进,还提供了在线 debug、线程级 cpu 监控等 killer feature,目前也已经在 github 开源。
二、设计与实现
这一节内容首先会对 bistoury 整体设计进行介绍,让读者对 bistoury 有个大体的认识,接下来再对一些设计和功能点进行说明。
2.1 整体设计
Bistoury 涉及到的必要组件有用户系统、agent、proxy、ui、注册中心、负载均衡器、应用中心,其中 agent、proxy 和 ui 直接归属于 bistoury,其它都属于外部系统。
- 用户系统就是待诊断的正在运行的应用。
- agent 和待诊断系统部署在同一台机器上,接收来自 proxy 的命令,并根据其类型直接执行一部分命令,还要负责另一部分命令与用户系统之间的交互。
- proxy 负责维护与 agent 之间的长期 netty 连接,并以 websocket 的方式维护与 ui 在命令执行期间的连接,它将 ui 传来的命令发送给具体的一个或多个 agent,同时对相应的连接进行管理。
- ui 则提供图形化和命令行界面,接收用户请求并发送给 proxy,并将最终结果展示给用户。
- 注册中心负责 proxy 的注册,ui 通过注册中心获取 proxy 地址。
- 负载均衡器负责 agent 到 proxy 的负载均衡,agent 在每次启动时通过负载均衡器获取单个 proxy 地址,并与之建立长期连接。
- 应用中心提供应用和机器的相关信息给 bistoury,用于简化操作,并提供一些特殊功能需要的信息。
- 其它:bistoury 还会访问公司内代码仓库等系统,但这些都是具体功能所需要,并不在 bistoury 整体的系统设计上。
一次请求
一次响应
2.2 字节码插桩
Bistoury 的大量功能都使用了字节码插桩,同时因为 bistoury agent 需要对应用透明,与应用系统作为不同的进程分开运行,使用了 agentmain 方式动态 attach 到正在运行的用户系统上。
不过字节码插桩和运行时 instrumentation 在网上有大量文章,这里不再进行具体说明。
2.3 ClassLoader设计
ClassLoader 的设计是 bistoury 系统中非常重要的一个部分,也是 bistoury 能够透明升级,各项功能能够正常运行的基础。Bistoury 集成了 arthas,ClassLoader 的设计也和 arthas 存在一些一致的地方,但只说明差异反而无法说清楚整个设计,这里会一起说明。
下图所示的是 bistoury agent 动态 attach 后,应用内部的 ClassLoader 结构图,其中 BistouryClassLoader 是一个 bistoury 专有的 ClassLoader。
classloader 结构
2.3.1 BistouryClassLoader
可以从上面图中看到,BistouryClassLoader 加载了 attach jar,这个 jar 包含了 Bistoury 各个功能具体实现以及依赖 jar 包。
为什么要使用一个专有的 BistouryClassLoader 呢,这是因为 attach jar 中包含的各个 jar 包在用户系统中也可能存在,如果版本不一致很可能会出现问题;bistoury agent 会进行升级,它的功能实现代码、依赖的jar包都可能变化,需要对它们的影响范围做一个限制;用户系统可能非常稳定,甚至一年都没有重启过,而 agent 可能在这一年中升级了几十个版本,每个版本都需要在用户系统里面加载一大堆类,这些 jar 包和类都需要进行卸载。
配合从 agent 加载到 BootstrapClassLoader 中的 instrument jar,每次 agent 升级或卸载时,做完清理工作后将 instrument jar 中的 ClassLoader 引用重置,就可以将整个 BistouryClassLoader 和里面的 attach jar 回收。
2.3.2 MagicClassLoader
下面是完整的 bistoury 的 ClassLoader 结构图,可以看到比前面的 classloader 结构图多出来一个 MagicClassLoader,这是 bistoury 中的一个特殊的 ClassLoader,用来解决同名类加载优先级问题。
首先来说一说这个问题的场景。在 bistoury 的开发过程中,为了满足需求,发现需要对 arthas 和 jackson 的源码的进行少量修改。可以选择的解决方案有自己 fork 一个分支,针对 jackson 也可以选择不使用序列化框架自己写一个。但要修改的代码比较少,笔者不想大动干戈也不想长期维护 fork 分支,只想要简单依赖 jar 包就好,于是就有了 MagicClassLoader 的出现。
MagicClassLoader 作用是 bistoury 可以指定一些类,把这些类委托给MagicClassLoader 加载,MagicClassLoader 会优先加载 Bistoury-magic-classes.jar 中的类文件。这样的话,只需要把需要修改源码的少量几个类放入 Bistoury-magic-classes.jar,就可以达到修改 jar 包中源代码的目的。
完整 classloader 结构
2.4 在线 debug
一直以来,调试都是在线应用的痛点。
曾经在微博上流传着这么一个程序员才懂的笑话,NASA 要发射一个新型火箭,火箭发射升空后发现不行,NASA 把火箭拖回来加了两行 log,再次发射,发现又不行,又加了两行 log 发射,发现又不行...
当然这只是一个笑话,但这样的场景在我们的实际开发中却屡见不鲜,多少次我们解决故障的时间就在不断地加 log,发布,加 log,发布的过程中溜走。
Arthas 的 watch 命令让我们可以观察函数的入参、返回值、异常等等,然而似乎每次 watch 都需要看看文档里参数该如何设置,面对函数中的本地变量也是无能为力,特别是行数较多的方法,方法内部的情况还是难以明了,想象一下面对上百行的方法,你需要脑补出其中各个本地变量值的情形,这个时候,我们需要的是 ide 的 debug 功能。
Bistoury 的在线 debug 功能正是针对这个场景而生,它模拟了 ide 的调试体验,在功能上和远程调试,或者说你在 ide 上 debug 本地代码几乎一致。你在代码某一行打一个断点或条件断点,断点触发就能看到本地变量、成员变量、静态变量以及调用栈;与 idea 远程 debug 不同的是,它不需要在系统启动就带上调试相关参数,对应用完全透明,同时在断点触发时不会暂停整个系统,而是只打印断点处快照信息,打印后继续执行代码逻辑,完美符合我们对在线应用的 debug 需求。
2.4.1 对比
这里用一个简单例子在对 arthas 的 watch 和在线 debug 进行对比。假如我们要 debug 如下一段简单的代码。
protected ModelAndView detailView(String viewName, String code) {
Application app = applicationManager.getAppByCode(code, From.master);
applicationManager.checkOwner(app);
returncreateView(viewName).addObject("app", app);
}
下图是使用 arthas 进行 watch 的结果:
arthas watch
从图里可以看到,arthas 能够获取到传入方法的两个参数也就是 viewName 和 code 的信息,但如果我们想查看这里局部变量 app 的属性呢?也许返回值里能找到它,但局部变量可不一定都会出现在返回值里。如果是使用 bistoury,我们可以在下图第71行源代码处打上断点,获取执行信息:
bistoury 在线 debug
断点触发后,在右边的局部变量一栏,bistoury 会将 app、code、viewName 三个局部变量都展示出来,加上调用栈等信息,可以起到类似于本地 ide debug 的效果。
2.4.2 原理
下面两段代码表现了在线 debug 打断点前后的差异。
userSystem.preDo();
userSystem.do();
userSystem.afterDo();
userSystem.preDo();
if (hitBreakPoint()) {
captureSnapshot();
}
userSystem.do;
userSystem.afterDo();`
当用户添加断点后,bistoury 会在断点处添加字节码,判断是否需要触发断点,捕捉断点处上下文信息。
函数的入参、返回值、异常、静态变量等信息我们通过 arthas 也可以获取,bistoury 更进一步的是获取到了本地变量的信息。
这里涉及到两个问题:断点设置在源码处,如何对应字节码里的位置;这个位置有哪些本地变量,它们的名字和值如何获取。
通过查阅 java 虚拟机规范,我们可以发现,java 类字节码里用来表示方法的 method_info 结构有一个 code 属性,code 属性的属性表里有一个叫做 LineNumberTable,这里用 java 代码来近似描述 LineNumberTable 的一部分结构:
class LineNumberTable {
LineNumber[] lineNumbers;
}
class LineNumber {
short start_pc; // 方法body字节码数组的索引
short line_number; // 源文件的行号}
根据 java 虚拟机规范里的说明,每一次源文件行号发生变化都会在 lineNumbers 里添加一条记录。那么我们就可以对字节码文件进行扫描,根据扫描结果可以得出每一个源文件行号所对应的字节码索引范围,也就知道了字节码应该添加在哪里。
同样是在 code 属性的属性表中,我们还可以找到一个名为 LocalVariableTable 的属性,还是用 java 代码来对其中一部分结构进行描述:
可以看到,变量的范围就是[start_pc, start_pc + length),而通过 index 字段我们可以获取到变量的值。
结合前面的 LineNumberTable 信息,就可以获取断点处有哪些本地变量,达到获取断点处本地变量信息的目的。
2.5 线程级cpu监控
在系统的日常运维中,有时会碰到 cpu 使用率突然飙高的情况。这个时候我们会登录机器,top 查看进程 id,top -h 查看消耗 cpu 的线程 id,然后 jstack 看看对应的线程是哪一个,最后再进行具体分析。 暂且不考虑这一系列操作需要的时间,我们收到报警的时候可能正在公司外吃饭,或是正在睡觉,而等我们做好准备登录上机器时问题已经结束了,现场没了,我们还能做的就只是看着机器的 cpu 监控图一脸茫然...
可以看到,传统的机器 cpu 使用率监控给出的信息量太少,它能够帮助发现问题,但在解决问题时作用不大。
Bistoury 的线程级 cpu 监控正是为解决各种 cpu 问题而生,让此类问题的解决难以置信的简单。
线程级 cpu 监控效果
上面两个图是对 bistoury 线程级 cpu 监控效果展示,可以看到,我们可以获取到每一分钟应用 cpu 和线程的完整信息,从使用率到线程栈,从线程名到线程状态,所有信息一目了然。
2.5.1 原理
Cpu 使用率可以在机器 /proc 文件夹下面获取,通过每隔一分钟进行一次检测,我们可以获取到线程从上一分钟到当前的 cpu 使用时间,与 cpu 使用时间做对比,就可以获取到每分钟实际的 cpu 使用率;而瞬时 cpu 使用率可以通过间隔几秒钟进行检测获取。
Linux 线程 id 和 jvm 线程的关联与我们日常运维的操作类似,都是通过 jstack 获取线程号来进行关联,同时在 jstack 中,我们还可以获取到具体的线程栈和锁等各种信息。
最后将每分钟的信息持久化,通过界面将数据展示出来,这也就是 bistoury 的线程级 cpu 监控。
2.6 堆对象统计
有时候我们会发现应用内存的增长不太正常,但使用 jmap dump 整个应用内存分析又感觉有点大费周章,想要简单一点解决问题,这时候可以使用 bistoury 的堆对象统计功能。打开堆对象统计开关后,bistoury 每分钟使用类似 jmap - histo 的命令,获取整个 jvm 堆里不同类型对象的统计信息。通过与存储系统结合,我们就能够将不同时间段的统计信息做对比,找出可能有问题的地方(比如一段时间内增长最快的对象)进行分析处理。
堆对象统计
三、结尾
本文对 bistoury 的开发背景和整体设计做了简单介绍,并对一些设计和功能实现进行了具体描述,希望能给读者在 java 应用的诊断方面带来一定的启发。