深入理解 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 的結構是這樣的 (偽代碼):

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

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 代碼如下:

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 屬性:

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM