深入理解 Skywalking Agent

  1. 概述
  2. Agent 功能介绍 + 整体结构 + 设计
  3. 插件机制详解
  4. Trace Segment Span 详解
  5. 异步 Trace 详解
  6. 如何正确地编写插件并防止内存泄漏
  7. 扩展:如何基于 Skywalking 打造全链路压测
  8. 总结与参考

概述

在 APM 和全链路监控领域,Skywalking 是非常有名的项目,我司使用的就是该方案来进行应用性能监控和分布式链路跟踪。而我本人最近的工作和 Skywalking 也高度相关,因此,lz想以本文来作为这段时间,对关于 Skywalking 的知识点进行总结和分享,包括插件机制的原理,核心领域模型的分析,异步 trace 可能存在的问题,编写复杂插件时如何避免采坑,如何基于 Skywalking 打造全链路压测等等。如果不当,还请指出,不吝赐教。另外,本文只关注 Skywalking Java Agent,关于 Skywalking 其他的组件,不在本文探讨之列。

Agent 功能介绍 + 整体结构 + 设计

Skywalking Java Agen 使用 Java premain 作为 Agent 的技术方案,关于 Java Agent,其实有 2 种,一种是以 premain 作为挂载方式(启动时挂载),另外一种是以 agentmain 作为挂载方式,在程序运行期间随时挂载,例如著名的 arthas 就是使用的该方案;agentmain 会更加灵活,但局限会比 premain 多,例如不能增减父类,不能增加接口,新增的方法只能是 private static/final 的,不能修改字段,类访问符不能变化。而 premian 则没有这些限制。

另外,agentmain 的挂载方式,对性能是有影响的,他的工作原理是启动一个新的进程,触发ClassFileLoadHook 事件,然后修改正在运行的字节码,那如果这个类正在运行怎么办呢?JVM 会在安全点暂停所有线程,然后触发我们编写的 Agent 钩子,并重新转换字节码。而在暂停所有现场的过程中,程序就会产生可能不可控的延迟。

另外说一个题外话,关于 Redefine 和 Reransform 的区别,前者会覆盖掉被修改的内容,后者会保留被修改的内容。Redefine 是 Java 1.5 引入的,Reransform 是 Java 1.6 引入的。Redefine 有很多缺陷,例如 Redefine 后的类不能恢复,不能修改删除 field 和 method,包括方法参数,名称和返回值。Jdk 1.6 的 Reransform 则解决了这些问题。关于 Reransform 和 Redefine,可以参考 arthas 作者的一些文章介绍。

回到 Skywalking 上面,Skywalking 是在 premian 方法中类加载时修改字节码的。使用 ByteBuddy 类库(基于 ASM)实现字节码插桩修改。入口类 SkyWalkingAgent#premain

Skywalking Agent 整体结构基于微内核的方式,即插件化,apm-agent-core 是核心代码,负责启动,加载配置,加载插件,修改字节码,记录调用数据,发送到后端等等。而 apm-sdk-plugin 模块则是各个中间件的插装插件,比如 Jedis,Dubbo,RocketMQ,Kafka 等各种客户端。

如果想要实现一个中间件的监控,只需要遵守 Skywalking 的插件规范,编写一个 Maven 模块就可以。Skywalking 内核会自动化的加载插件,并插桩字节码。

Skywalking 的作者曾说:不管是 Linux,Istio 还是 SkyWalking ,都有一个很大的特点:当项目被「高度模块化」之后,贡献者就会开始急剧的提高。

而模块化,插件化,也是一个软件不容易腐烂的重要特性。Skywalking 的就是遵循这个理念设计。

插件机制详解

Skywalking 如何加载插件的呢? Skywalking 的插件在 maven 打包完成后,会自动放在 plugins 目录下,Skywalking 在启动时,会使用自定义的 AgentClassLoader 进行插件加载,该 ClassLoader 重写了findclass 方法(并没有破坏双亲委派模型)。启动时,Skywalking 就会查找所有的 skywalking-plugin.def 文件,并使用默认的 AgentClassLoader 加载这些文件里定义的插件元数据类,来映射目标 class 和拦截 class 的关系(代码位置 PluginBootstrap#loadPlugins )。此时真正的拦截插件并不会加载,这些映射规则,则是插件开发者自己定义的。

在 Skywalking 中,每个业务 classLoader 实例,都会对应一个新的 AgentClassLoader。哪些是业务ClassLoader呢?比如 sun.misc.Launcher$AppClassLoaderorg.springframework.boot.loader.LaunchedURLClassLoader ,sun.misc.Launcher$ExtClassLoader, sun.reflect.DelegatingClassLoader ,业务自己创建的 ClassLoader 等等。

而 AgentClassLoader 的路径则是 plugins 和 activations 目录,AgentClassLoader 可以在这 2个路径下查找 Class。

当加载一个类时,比如 Jedis,那么就会触发 javaAgent 的 Instrumentation 钩子,Instrumentation 内部则实现了一整套逻辑。Skywalking 会检查是否有 Jedis 的插件(这个规则是Jedis 插件里的 skywalking-plugin.def 定义,此文件在启动时就加载了),如果有,就使用一个新的 AgentClassLoader (parent 是目标类加载器)来加载拦截器,并将拦截器插入到调用方法的前面和后面(代码位置 ClassEnhancePluginDefine#enhance)。

为什么要用一个新的 AgentClassLoader 呢?假设不用 AgentClassLoader,用默认的 AgentClassLoader,这个 AgentClassLoader 的 parent 是 JDK AppClassLoader,而如果 Jedis 是 一个自定义类加载器加载的,且插件里又访问 Jedis 这个类,因为 AgentClassLoader 是无法访问到 Jedis 这个类文件的,因此只能向上查找,向上查找到 AppClassLoader,肯定是查不到的,因为 Jedis 是自定义类加载器加载的。

如下图:

而如果我们使用一个新的 AgentClassLoader,并将其 parent 设置为 Jedis 的 ClassLoader,则可以解决这个问题,如下图:

插件分为 3 种:构造器插件,静态方法插件,实例方法插件。分别是 InstanceConstructorInterceptor 接口,StaticMethodsAroundInterceptor 接口, InstanceMethodsAroundInterceptor 接口。

我们随便点开一个插件,例如 HttpClient 插件:

该插件在拦截器代码里访问 apache http 的类。我们可以在其执行execute方法时,拦截到请求参数,并进行解析。根据 Skywalking 的规范,设置各种标签和 Span。关于 Span ,下面会单独详解。

Trace Segment Span 详解

Skywalking 是全链路追踪和 APM 插件,我们这里先讨论全链路跟踪,暂时不讨论 APM。自从 google 2010 发布 Dapper 论文以来,各种全链路跟踪插件如雨后春笋般的出现。Skywalking是其中优秀的代表。

全链路跟踪一般有几个概念 Trace,Span。Trace 代表了一次调用所产生的链路,并且会有一个全局唯一的 ID,在 google的论文中,他是一组 span 的集合,Span 表示一个组件的调用信息,是整个 Trace 中的一个节点,他的 ID 在 trace 中是唯一的。

一般 Span 的结构是这样的 (伪代码):

1
2
3
4
5
6
7
8
class Span {
int id; // 自身 Span 的 ID
int parentId; // 父 Span 的 ID
String name; // Span 的名称
String traceId; // 全局 traceID
Date startTime; // span 的启动时间
Date endTime; // span 的执行结束时间
}

Segment 是 Skywalking 代码里的独有概念,他表示的是一个 JVM 里一个线程里的一次调用链路,通常会有多个 Span。SKywalking Agent 代码中是没有 Trace 实体的,Trace 其实就是多个 Segment 连接成的一个东西。

一个 Segment 由多个 Span 组成,当一个线程一次调用运行结束了,那么这个 Segment 就结束了(非异步场景),SKywalking 就会把这个调用信息返回到后端统计服务 OAP 中,此时,就可以通过 web 页面进行搜索查看了。

我们来看下代码是怎么写的,首先看 Segment,该类全称是 TraceSegment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TraceSegment {

private String traceSegmentId;
private List<TraceSegmentRef> refs;
private List<AbstractTracingSpan> spans;
private DistributedTraceIds relatedGlobalTraces;
private final long createTime;

public TraceSegment() {
this.traceSegmentId = GlobalIdGenerator.generate();
this.spans = new LinkedList<>();
this.relatedGlobalTraces = new DistributedTraceIds();
this.relatedGlobalTraces.append(new NewDistributedTraceId());
this.createTime = System.currentTimeMillis();
}
}

traceSegmentId: 表示自身作为 Segment 的全局唯一 ID;
refs:每次有新的流量进入 JVM,都会创建一个新的 Segment,如果他的前面还是有一个 JVM 的话,那么就将前面这个 JVM 的 Segment 保存到 refs 链表中(新版本已经不是链表了,只是一个单对象,链表可能会导致内存泄漏),这样就将 Segment 串联起来了。
spans:在 JVM 中运行 Span 节点,都会保存到 spans 中。
relatedGlobalTraces:第一个节点生成的唯一 ID,也就是 TraceID;注意,虽然构造方法这里赋值了,但是后面会调用其 Set 方法,将其覆盖。

Span 结构是怎么样的呢?Span 种类比较多,分为入口 Span(例如 Tomcat 入口,SpringMVC 入口),出口 Span(DB 客户端,Jedis 客户端,Http 客户端),本地方法 Span(本地函数);

SKywalking 抽象的 Span 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract class AbstractTracingSpan implements AbstractSpan {
protected int spanId; // 自身 ID,从0开始
protected int parentSpanId; // 父 span ID
protected List<TagValuePair> tags; // 执行过程中,记录的数据
protected String operationName; // 名字
protected volatile boolean isInAsyncMode = false; // 是否为异步模式
private volatile boolean isAsyncStopped = false; // 异步是否停止
protected final TracingContext owner; // 持有该 Span 的上下文
protected long startTime; // 开始时间
protected long endTime; // 结束时间
protected boolean errorOccurred = false; // 是否发生了错误
protected int componentId = 0; // Span 组件 ID
protected List<LogDataEntity> logs; // 日志
protected List<TraceSegmentRef> refs; // 父 Segment
}

可以看到,SKywalking 的 Span 设计和大部分设计是差不多的。我们注意到有个 TracingContext,这是一个关键对象,用来维护一次调用过程中,所有 Span 的生命周期。

TracingContext 属性:

1
2
3
4
5
6
7
8
9
10
11
public class TracingContext implements AbstractTracerContext {
private TraceSegment segment; // 当前调用的 Segment
// 当前调用的所有 Span,使用链表维护,模拟栈的进出
private LinkedList<AbstractSpan> activeSpanStack = new LinkedList<>();
private int spanIdGenerator; // id 生成器
private volatile int asyncSpanCounter; 异步计数器
private volatile boolean isRunningInAsyncMode; 是否为异步模式
private volatile ReentrantLock asyncFinishLock; 异步执行锁
private volatile boolean running; 是否结束
private final long createTime; 创建时间
}

此类的关键就是 activeSpanStack,其使用链表模拟了栈的进出,为什么使用栈的结构呢?使用栈结构能够更方便的管理 Span 的生命周期。在 SKywalking 中,一个 Span 创建成功,就是入栈操作,该 Span 执行结束,则是出栈操作。当这个栈空了,表示这个 Segment 执行结束了。

具体如下图所示:

上图中,显示了 SKywalking 中如何管理 Span 的生命周期:当第一个 Span 创建时,例如 Tomcat Span,则会放到栈底,当 Jedis Span 对外访问时(例如执行 get 命令),则放在栈顶。当 Jedis 操作执行结束时,则会出栈,当 ThreadSpan 执行 Run 方法结束时,也会出栈,当访问 Tomcat 的请求执行结束时,则也会出栈,直至栈为空。当栈为空,则会将这些 Span 发送到后端 OAP server 进行保存。

然后我们总结下 trace Segment span 的关系:

大体上,就是这样的一个关系。

异步 Trace 详解

前面我们了解了 Span 和 Segment 的原理,其实还有一点,SKywalking Agent 用来存储 Span 的容器是 ThreadLocal,便于在单个线程中,随时取出 Span 对象。当栈为空时,则会删除 ThreadLocal 对象,防止内存泄漏。

那如果是异步 Trace,该怎么办呢?SKywalking 提供了 capture 和 continued(snapshot),前者表示将当前栈顶的 Span 复制并返回一个快照,continued 表示将快照恢复为当前栈顶 Span 的父 Span,以此来完成 Span 和 Span 之间的链接。

例如,当我们使用异步线程执行任务时,SKywalking 在默认情况下,是无法链接当前线程的 Span 和异步线程的 Span 的,除非我们在 Runnable 实现类使用 TraceCrossThread 类似的注解,表示这个 Runnable 需要跨线程追踪,那么,SKywalking 就会做出 capture 和 continued(snapshot) 操作,将主线程的 Span+Segment 复制到 Runnable 中,并将这 2 个 Span 进行链接。如下图

上图中,主线程复制当前线程 Segment 和 Span 的基本信息,包括 Segment ID,Span ID,Name 等信息。然后在子线程中,进行回放,回放的操作,就是将这个 快照 的信息,保存到 Span 的父 Span 中,标记子线程的父 Span 就是这个 Span。

还有一种场景的异步 Span,比如在 A 线程开启,在 B 线程关闭,我们需要记录这个 Span 的耗时。比方说,异步 HttpClient,我们在主线程开启了访问,在异步线程得到结果,就复合刚刚我们说的场景。

SKywalking 为我们提供了 prepareForAsyncasyncFinish 这两个方法,当我们在 A 线程创建了一个 Span,我们可以执行 span.prepareForAsync 方法,表示这个 span 开始了访问,即将进入异步线程。当在 B 线程得到结果后,执行 span.asyncFinish 则表示,这个 span 执行结束了,那么, A 线程就可以将整个 Segment 标记结束,并返回到 OAP server 中进行统计。那么如何在 B 线程里得到这个 Span 的实例,然后调用 asyncFinish 方法呢?实际上,是需要插件开发者自己想办法传递的,比如在被拦截对象的参数里、构造函数里传递。

那么这 2 种异步模式的区别是什么呢?说实话,我在刚刚看到这两个的时候,脑子也有点迷糊,经过总结,发现两者虽然看起来相似,当谁也代替不了谁。

简单来说,prepareForAsync 和 asyncFinish 只是为了统计一个 Span 跨越 2 个线程的场景,例如上面的提到 HttpAsyncClient 场景。在 A 线程创建,在 B 线程结束,我们需要在 B 线程拿到返回值和耗时。

而 capture 和 continued(snapshot) 的使用场景是为了连接 2 个线程的不同 Span。我们将主线程的最后一个 Span 和子线程的第一个 Span 相连接。

而两者也是可以结合使用。如下图:

以上,表示了一次 HttpAsyncClient 请求中,如何将 Span 进行跨线程连接,并记录返回值。

最终的效果如上。

如何正确地编写插件防止内存泄漏

在使用 SKywalking 的过程中,我也写过一些公司内部的插件,如果是同步调用的话,就比较简单,例如,在 before 方法中创建一个 span(就是向栈中推入一个 Span),在 after 方法中,执行 stop span(就是从栈中弹出一个 Span)。

当编写异步插件时,需要考虑的情况就比较复杂。有几个点需要注意:

  1. 当我们执行 capturecontinued 时,栈顶一定要有 Span。这样才能将这两个 Span 进行链接。

  2. 当我们执行 prepareForAsync 异步时,一定要在其他线程执行 asyncFinish,否则这个 Segment 就会断开,因为如果不执行 asyncFinish,这个 Segment 就不会 finish,也就不会发送到后端 OAP。另外,对一个 Span 执行完 prepareForAsync 后,一定不要忘记执行这个 span 的 stop 方法。

  3. 一定要正确的调用 ContextManager.stopSpan(),否则,一定会出现内存泄漏。假设,Tomcat Span 是入口,在 Tomcat 插件的 after 方法里,执行了 stopSpan,但是栈却没有清空,那么 ThreadLocal 里的对象就不会清除,当下次在这个线程里调用 continued 时,continued 会将其他线程的对象继续添加到这个线程里的 Segment 列表里。导致内存无限增大(新版本限制了链表的大小,但没有从根本解决问题)。

扩展:如何基于 Skywalking 打造全链路压测

SKywalking 是基于 java agent 技术打造的,而 java agent 又非常的适合开发全链路压测产品,那么,是否可以借助 SKywalking 的现有能力开发出全链路压测呢?答案是可以的。

全链路压测的核心问题是压测的过程中不能有脏数据,当影子流量进入容器,这些流量不能进入正式的数据库。通常的做法是,例如在执行 SQL 的时候,判断是否是影子流量,如果是,则更换 SQL 数据源,即不能在正式库中执行影子 SQL。

基于 SKywalking 的目前的实现,我们只需要对一个类实现多个插件即可,并将这些插件进行包装,基于过滤器模式进行串联,实现对一个类的 压测增强 和 全链路Trace增强。

总结与参考

以上,就是本人这段时间,对 SKywalking(8.1.0) 学习和使用的总结。SKywalking 版本升级的很快,现在已经是 8.9.0 版本了,又有了很多功能的更新,大家可以参考的看看。

参考:

  1. opentracing 规范 https://github.com/opentracing/specification/blob/master/specification.md#the-opentracing-data-model

  2. Instrumentation API https://www.matools.com/api/java8

  3. Arthas源码分析–jad反编译原理 https://hengyun.tech/arthas-jad/

  4. Skywalking原理分析 http://www.bewindoweb.com/306.html

  5. JVM 源码分析之 javaagent 原理完全解读 https://www.infoq.cn/article/javaagent-illustrated/

  6. SkyWalking源码分析https://www.processon.com/view/link/611fc4c85653bb6788db4039#map

  7. dapper 论文 https://static.googleusercontent.com/media/research.google.com/zh-CN//archive/papers/dapper-2010-1.pdf

  8. SKywalking Java Agent 源码地址 https://github.com/apache/skywalking-java

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2017-2022 莫那·鲁道

请我喝杯咖啡吧~

支付宝