您好,登錄后才能下訂單哦!
這篇文章主要為大家分析了如何分析無侵入的微服務探針原理的相關知識點,內容詳細易懂,操作細節合理,具有一定參考價值。如果感興趣的話,不妨跟著跟隨小編一起來看看,下面跟著小編一起深入學習“如何分析無侵入的微服務探針原理”的知識吧。
隨著微服務架構的興起,應用行為的復雜性顯著提高,為了提高服務的可觀察性,分布式監控系統變得十分重要。
基于 Google 的 Dapper 論文,發展出了很多有名的監控系統:Zipkin、Jaeger、Skywalking 以及想一統江湖的 OpenTelemetry 等。一眾廠家和開源愛好者圍繞著監控數據的采集、收集、存儲以及展示做出了不少出色的設計。
時至今日即使是個人開發者也能依賴開源產品,輕松的搭建一套完備的監控系統。但作為監控服務的提供者,必須要做好與業務的解綁,來降低用戶接入、版本更新、問題修復、業務止損的成本。所以一個可插拔、無侵入的采集器成為一眾廠家必備的殺手锏。
為了獲取服務之間調用鏈信息,采集器通常需要在方法的前后做埋點。在 Java 生態中,常見的埋點方式有兩種:依賴 SDK 手動埋點;利用 Javaagent 技術來做無侵入埋點。下面圍繞著 無侵入埋點的技術與原理為大家做一個全面的介紹。
分布式監控系統中,模塊可以分為:采集器(Instrument)、發送器(TransPort)、收集器(Collector)、存儲(Srotage)、展示(API&UI)。
zipkin 的架構圖示例
采集器將收集的監控信息,從應用端發送給收集器,收集器進行存儲,最終提供給前端查詢。
采集器收集的信息,我們稱之為 Trace (調用鏈)。一條 Trace 擁有唯一的標識 traceId,由自上而下的樹狀 span 組成。每個 span 除了spanId 外,還擁有 traceId 、父 spanId,這樣就可以還原出一條完整的調用鏈關系。
為了生成一條 span , 我們需要在方法調用的前后放入埋點。比如一次 http 調用,我們在 execute() 方法的前后加入埋點,就可以得到完整的調用方法信息,生成一個 span 單元。
在 Java 生態中,常見的埋點方式有兩種:依賴 SDK 手動埋點;利用 Javaagent 技術來做無侵入埋點。不少開發者接觸分布式監控系統,是從 Zipkin 開始的,最經典的是搞懂 X-B3 trace協議,使用 Brave SDK,手動埋點生成 trace。但是 SDK 埋點的方式,無疑和業務邏輯做了深深的依賴,當升級埋點時,必須要做代碼的變更。
那么如何和業務邏輯解綁呢?
Java 還提供了另外一種方式:依賴 Javaagent 技術,修改目標方法的字節碼,做到無侵入的埋點。這種利用 Javaagent 的方式的采集器,也叫做探針。在應用程序啟動時使用 -javaagent ,或者運行時使用 attach( pid) 方式,就可以將探針包導入應用程序,完成埋點的植入。無侵入的方式,可以做到無感的熱升級。用戶不需要理解深層的原理,就可以使用完整的監控服務。目前眾多開源監控產品已經提供了豐富的 java 探針庫,作為監控服務的提供者,進一步降低了開發成本。
想要開發一個無侵入的探針,可以分為三個部分:Javaagent ,字節碼增強工具,trace 生成邏輯。下面會為大家介紹這些內容。
使用 JavaAgent 之前 讓我們先了解一下 Java 相關的知識。
類 c 語言 Java 從1994年被 sun 公司發明以來,依賴著 "一次編譯、到處運行" 特性,迅速的風靡全球。與 C++ 不同的是,Java 將所有的源碼首先編譯成 class (字節碼)文件,再依賴各種不同平臺上的 JVM(虛擬機)來解釋執行字節碼,從而與硬件解綁。class 文件的結構是一個 table 表,由眾多 struct 對象拼接而成。
類型 | 名稱 | 說明 | 長度 |
---|---|---|---|
u4 | magic | 魔數,識別Class文件格式 | 4個字節 |
u2 | minor_version | 副版本號 | 2個字節 |
u2 | major_version | 主版本號 | 2個字節 |
u2 | constant_pool_count | 常量池計算器 | 2個字節 |
cp_info | constant_pool | 常量池 | n個字節 |
u2 | access_flags | 訪問標志 | 2個字節 |
u2 | this_class | 類索引 | 2個字節 |
u2 | super_class | 父類索引 | 2個字節 |
u2 | interfaces_count | 接口計數器 | 2個字節 |
u2 | interfaces | 接口索引集合 | 2個字節 |
u2 | fields_count | 字段個數 | 2個字節 |
field_info | fields | 字段集合 | n個字節 |
u2 | methods_count | 方法計數器 | 2個字節 |
method_info | methods | 方法集合 | n個字節 |
u2 | attributes_count | 附加屬性計數器 | 2個字節 |
attribute_info | attributes | 附加屬性集合 | n個字節 |
字節碼的字段屬性
讓我們編譯一個簡單的類`Demo.java`
package com.httpserver;public class Demo { private int num = 1; public int add() { num = num + 2; return num; }}
用16進制打開 Demo.class 文件,解析后字段也是有很多 struct 字段組成:比如常量池、父類信息、方法信息等。
JDK 自帶的解析工具 javap ,可以以人類可讀的方式打印 class 文件,其結果也和上述一致
JVM(Java Virtual Machine),一種能夠運行 Java bytecode 的虛擬機,是Java 體系的一部分。JVM 有自己完善的硬體架構,如處理器、堆棧、寄存器等,還具有相應的指令系統。JVM 屏蔽了與具體操作系統平臺相關的信息,使得Java 程序只需生成在 JVM 上運行的目標代碼(字節碼),就可以在多種平臺上不加修改地運行, 這便是 "一次編譯,到處運行" 的真正含義 。
作為一種編程語言的虛擬機,實際上不只是專用于 Java 語言,只要生成的編譯文件符合 JVM 對加載編譯文件格式要求,任何語言都可以由JVM編譯運行。
同時 JVM 技術規范未定義使用的垃圾回收算法及優化 Java 虛擬機指令的內部算法等,僅僅是描述了應該具備的功能,這主要是為了不給實現者帶來過多困擾與限制。正是由于恰到好處的描述,這給各廠商留下了施展的空間。
維基百科:已有的 JVM 比較
其中 HotSpot(Orcale) 與性能更好的 OpenJ9(IBM) 被廣大開發者喜愛。
JVM 部署之后,每一個 Java 應用的啟動,都會調用 JVM 的 lib 庫去申請資源創建一個 JVM 實例。JVM 將內存分做了不同區域,如下是 JVM 運行時的內存模型:
方法區:用于存放的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據
堆:所有線程共享,放置 object 對象與數組,也是 GC (垃圾收集器的主要區域)
虛機棧&程序計數器:線程私有的,每一個新的線程都會分配對應的內存對象。每一個方法被調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。
Java 應用程序在啟動和運行時,一個重要的動作是:加載類的定義,并創建實例。這依賴于 JVM 自身的 ClassLoader 機制。
雙親委派
一個類必須由一個 ClassLoader 負責加載,對應的 ClassLoader 還有父 ClassLoader ,尋找一個類的定義會自下而上的查找,這就是雙親委派模型。
為了節省內存,JVM 并不是將所有的類定義都放入內存,而是
啟動時:將必要的類通過 ClassLoader 加載到內存
運行時:創建一個新實例時,優先從內存中尋找,否則加載進內存
執行方法:尋找方法的定義,將局部變量和方法的字節碼放入虛機棧中,最終返回計算結果。當然靜態方法會有所區別。
這樣的設計讓我們聯想到:如果能在加載時或者直接替換已經加載的類定義,就可以完成神奇的增強。
默默無聞的 JVM 屏蔽了底層的復雜,讓開發者專注于業務邏輯。除了啟動時通過 java -jar 帶內存參數之外,其實有一套專門接口提供給開發者,那就是 JVM tool Interface 。
JVM TI 是一個雙向接口。JVM TI Client 也叫 agent ,基于 event 事件機制。它接受事件,并執行對 JVM 的控制,也能對事件進行回應。
它有一個重要的特性 - Callback (回調函數 )機制:JVM 可以產生各種事件,面對各種事件,它提供了一個 Callback 數組。每個事件執行時,都會調用 Callback 函數, 所以編寫 JVM TI Client 的核心就是放置 Callback 函數。
正是有了這個機制能讓我們向 JVM 發送指令,加載新的類定義。
現在我們試著思考下:如何去魔改應用程序中的方法的定義呢?
這有點像大象放入冰箱需要幾步:
按照字節碼的規范生成新的類
使用 JVM TI ,命令 JVM 將類加載到對應的內存去。
替換后,系統將使用我們增強過的方法。
這并不容易,但幸運的是,jdk已經為我們準備好了這樣的上層接口 instructment 包。它使用起來也是十分容易,我們下面通過一個 agent 簡單示例,來講解 instructment 包的關鍵設計。
javaagent 有兩種使用 方式:
啟動時加入參數配置 agent 包路徑 : -javaagent:/${path}/agent.jar;
運行時attach 到JVM 實例的pid ,將 jar 包附著上去 :VirtualMachine.attach(pid);VirtualMachine.loadAgent("/<path>/agent.jar");
使用第一種方式的 demo
public class PreMainTraceAgent { public static void premain(String agentArgs, Instrumentation inst) { inst.addTransformer(new DefineTransformer(), true); } static class DefineTransformer implements ClassFileTransformer{ @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { System.out.println("premain load Class:" + className); return classfileBuffer; } }}
|
然后在 resources 目錄下新建目錄:META-INF,在該目錄下新建文件:MANIFREST.MF:
最后打包成 agent.jar 包
premain() :-javaagent 方式進入的入口。顧名思義他是在 main 函數前執行的,制作 jar 包時需要在 MF 文件中指名入口 Premain-Class: PreMainTraceAgent
Instrumentation:JVM 實例的句柄。無論是 -javaagent 還是 attach 上去,最終都會獲得一個實例相關的 Instrumentation。inst 中比較重要的兩個函數是 redefineClasses(ClassDefinition... definitions) 與 retransformClasses(Class<?>... classes) 通過這兩個函數,我們都可以將增強后字節碼加入到 JVM 中
redefineClasses() 和 retransformClasses() 的區別 ? redefineClasses() 適合將新加入的類做修改,而 retransformClasses() 可以將哪些已經加載到內存中的類定義做替換
ClassFileTransformer:這個接口里面有一個重要的方法 transform() ,使用者需要實現這個類。當這個類被加入 inst 的內的 Transformer 數組時,每一個類的加載或修改,都會調用到該方法。類的定義相關信息,比如類二進制定義 classfileBuffer
addTransformer() :可以將實現了 ClassFileTransformer 的類加入 Instrumentation 中內置的數組。就像一個加工廠,上一個 ClassFileTransformer 處理過的類,會作為下一個 ClassFileTransformer 的參數。
到了這里就會發現,增強字節碼也是如此的簡單。
通過前面的了解,有種修改字節碼也不過如此的感覺 ^_^ !!!但是我們不得不重視另一個問題,字節的如何生成的?
大佬:我熟悉 JVM 規范,明白每一個字節碼的含義,我可以手動改class文件,為此我寫了一個庫 。
高手:我知道客戶的框架,我修改源碼,重新編譯,將二進制替換進去。
小白:字節碼我是看不懂啦,大佬寫的庫我會用就行了。
下面會介紹幾個常見的字節碼生成工具
ASM 是一個純粹的字節碼生成和分析框架。它有完整的語法分析,語義分析,可以被用來動態生成 class 字節碼。但是這個工具還是過于專業,使用者必須十分了解 JVM 規范,必須清楚替換一個函數究竟要在 class 文件做哪些改動。ASM 提供了兩套API:
CoreAPI 基于事件的形式表現類;
TreeAPI 基于對象的方式來表現類
初步掌握字節碼 與JVM 內存模型的知識,可以照著官方文檔進行簡單地類生成。
ASM 十分強大,被應用于 1. OpenJDK的 lambda語法 2. Groovy 和 Koltin 的編譯器 3. 測試覆蓋率統計工具 Cobertura 和 Jacoco 4. 單測 mock 工具,比如 Mockito 和 EasyMock 5. CGLIB ,ByteBuddy 這些動態類生成工具。
ByteBuddy 是一款出眾的運行時字節碼生成工具,基于 ASM 實現,提供更易用的 API。被眾多分布式監控項目比如 Skywalking、Datadog 等使用 作為 Java 應用程序的探針來采集監控信息。
以下是與其他工具的性能比較。
Java Proxy:JDK 自帶的代理機制,可以做到托管用戶的類,以便于擴展。但是必須給定一個接口,作用有限
Cglib:很有名氣,但是開發的太早了,并沒有隨著 JDK 的特性一起更新。雖然它的庫依舊很有用,但是也慢慢被被使用者從項目中移除
Javassit: 這個庫企圖模仿 javac 編譯器,做到運行時轉化源代碼。這非常有雄心,然而這個難度很有挑戰,目前為止和 javac 還有相當大的差距。
在我們實際的使用中,ByteBuddy 的 API 確實比較友好,基本滿足了所有字節碼增強需求:接口、類、方法、靜態方法、構造器方法、注解等的修改。除此之外內置的 Matcher 接口,支持模糊匹配,可以根據名稱匹配修改符合條件的類型。
但也有缺點,官方文檔比較舊,中文文檔少。很多重要的特性,比如切面,并未詳細介紹,往往需要看代碼注釋,和測試用例才弄懂真正的含義。如果對 ByteBuddy 這個工具有興趣的同學,可以關注我們的公眾號,后面的文章會就 ByteBuddy 做專門的分享。
通過字節碼增強,我們可以做到無侵入的埋點,那么和 trace 的生成邏輯的關聯才算是注入靈魂。下面我們通過一個簡單例子,來展示這樣的結合是如何做到的。
Tracer API
這是一個簡單的 API,用來生成 trace 消息。
public class Tracer { public static Tracer newTracer() { return new Tracer(); } public Span newSpan() { return new Span(); } public static class Span { public void start() { System.out.println("start a span"); } public void end() { System.out.println("span finish"); // todo: save span in db } }}
僅有一個方法 sayHello(String name)目標類 Greeting
public class Greeting { public static void sayHello(String name) { System.out.println("Hi! " + name); }}
手動生成 trace 消息,我們需要在方法的前后加入埋點手動埋點
... public static void main(String[] args) { Tracer tracer = Tracer.newTracer(); // 生成新的span Tracer.Span span = tracer.newSpan(); // span 的開始與結束 span.start(); Greeting.sayHello("developer"); span.end();}...
無侵入埋點
字節增強可以讓我們無需修改源代碼。現在我們可以定義一個簡單的切面,將 span 生成邏輯放入切面中,然后利用 Bytebuddy 將埋點植入。
TraceAdvice
將 trace 生成邏輯放入切面中去
public class TraceAdvice { public static Tracer.Span span = null; public static void getCurrentSpan() { if (span == null) { span = Tracer.newTracer().newSpan(); } } /** * @param target 目標類實例 * @param clazz 目標類class * @param method 目標方法 * @param args 目標方法參數 */ @Advice.OnMethodEnter public static void onMethodEnter(@Advice.This(optional = true) Object target, @Advice.Origin Class<?> clazz, @Advice.Origin Method method, @Advice.AllArguments Object[] args) { getCurrentSpan(); span.start(); } /** * @param target 目標類實例 * @param clazz 目標類class * @param method 目標方法 * @param args 目標方法參數 * @param result 返回結果 */ @Advice.OnMethodExit(onThrowable = Throwable.class) public static void onMethodExit(@Advice.This(optional = true) Object target, @Advice.Origin Class<?> clazz, @Advice.Origin Method method, @Advice.AllArguments Object[] args, @Advice.Return(typing = Assigner.Typing.DYNAMIC) Object result) { span.end(); span = null; }}
onMethodEnter:方法進入時調用。Bytebuddy 提供了一系列注解,帶有 @Advice.OnMethodExit 的靜態方法,可以被植入方法開始的節點。我們可以獲取方法的詳細信息,甚至修改傳入參數,跳過目標方法的執行。
OnMethodExit:方法結束時調用。類似 onMethodEnter,但是可以捕獲方法體拋出的異常,修改返回值。
植入 Advice
將Javaagent 獲取的 Instrumentation 句柄 ,傳入給 AgentBuilder (Bytebuddy 的 API)
public class PreMainTraceAgent { public static void premain(String agentArgs, Instrumentation inst) { // Bytebuddy 的 API 用來修改 AgentBuilder agentBuilder = new AgentBuilder.Default() .with(AgentBuilder.PoolStrategy.Default.EXTENDED) .with(AgentBuilder.InitializationStrategy.NoOp.INSTANCE) .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) .with(new WeaveListener()) .disableClassFormatChanges(); agentBuilder = agentBuilder // 匹配目標類的全類名 .type(ElementMatchers.named("baidu.bms.debug.Greeting")) .transform(new AgentBuilder.Transformer() { @Override public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, TypeDescription typeDescription, ClassLoader classLoader, JavaModule module) { return builder.visit( // 織入切面 Advice.to(TraceAdvice.class) // 匹配目標類的方法 .on(ElementMatchers.named("sayHello")) ); } }); agentBuilder.installOn(inst); } // 本地啟動 public static void main(String[] args) throws Exception { ByteBuddyAgent.install(); Instrumentation inst = ByteBuddyAgent.getInstrumentation(); // 增強 premain(null, inst); // 調用 Class greetingType = Greeting.class. getClassLoader().loadClass(Greeting.class.getName()); Method sayHello = greetingType.getDeclaredMethod("sayHello", String.class); sayHello.invoke(null, "developer"); }
本地調試
除了制作 agent.jar 之外,我們本地調試時可以在 main 函數中啟動,如上面提示的那樣。
打印結果
WeaveListener onTransformation : baidu.bms.debug.Greetingstart a spanHi! developerspan finishDisconnected from the target VM, address: '127.0.0.1:61646', transport: 'socket'可以看到,我們已經在目標方法的前后,已經加入 trace 的生成邏輯。
實際的業務中,我們往往只需要對應用程序使用的框做捕獲,比如對 Spring 的 RestTemplate 方法,就可以獲得準確的 Http 方法的調用信息。這種依賴這種字節碼增強的方式,最大程度的做到了和業務解耦。
關于“如何分析無侵入的微服務探針原理”就介紹到這了,更多相關內容可以搜索億速云以前的文章,希望能夠幫助大家答疑解惑,請多多支持億速云網站!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。