基於 Java Agent 實現零傾入監控

本文主要由如何保證服務的可用性也就是系統監控問題逐步引出 Java 語言的高級特性,也就是 Java Agent 的使用。系統代碼可以零傾入就能夠引入監控服務。本文的主要討論的有以下幾個議題:

  • 爲什麼系統需要監控
  • Java 語言如何實現監控
  • Java Agent 簡單示例
  • 開源項目使用 Java Agent

1、爲什麼系統需要監控

隨着分佈式服務框架的流行,特別是微服務等設計理念在系統中應用,業務的調用鏈越來越複雜。

在這裏插入圖片描述
可以看到,隨着服務的拆分,系統的模塊變得越來越多,不同的模塊可能由不同的團隊維護。

一個調用請求可能會涉及到幾十個服務協同處理,牽扯到多個團隊的業務系統,那麼如何快速定位到線上故障?如何有效的進行相關的數據分析工作?

對於大型網站系統,如淘寶、京東等大型互聯網公司,這些問題尤其突出。

2、如何對服務埋點

我們如何在代碼中添加監控信息,一般有以下三種情況:

  • 在系統中使用硬編碼的形式來添加監控代碼
  • 使用 AOP 面向切面的形式來添加監控代碼
  • 使用 Java 高級特性 Java Agent 在 JVM 層面來 AOP 添加監控代碼

在系統中使用硬編碼的情況對於單體系統來說來可以用用,但是對於分佈式系統,就不太適合了。同樣的使用 AOP 編程對於每一個系統都必須引入切面以及相應的切面配置,對於小型分佈式系統來說還勉強可以。對於成百上千個服務集羣來說簡直是一個噩夢。所以對於大型系統一般都是採用 Java Agent 這個 JVM 層面的 AOP 來添加監控邏輯。也就是字節碼增強,這樣對於業務代碼可以零傾入。

xxx.java ==> xxx.class ==> jvm 指令碼 ==> 彙編 ==> CPU

如上圖所示, Java 程序需要運行時:首先 Java 源代碼需要編譯成 Class 文件,文件的內容就由若干條 JVM 指令組成的集合(即代碼邏輯)。插樁的過程就是將這些指令,拆開來,然後再插入監控所需指令,最後進行重新組裝生成新的 Class 字節。

3、Java Agent

javaagent 是 java 1.5 之後引進的特性,其主要作用是在 class 被加載之前對其攔截,已插入我們的監聽字節碼。Agent分爲兩種,一種是在 main 程序之前運行的 Agent,一種是在主程序之後運行的 Agent(前者的升級版,1.6以後提供)下面我們分別來舉例說明。

3.1 主程序之前運行代理程序

1、首先編寫一個 agent 程序:

下面我們就使用 Javassist(JAVA programming ASSISTant) 來編寫 agent 程序,實現對類進行增強。 Javassist 是一個開源的分析、編輯和創建 Java 字節碼的類庫。其主要的優點,在於簡單,而且快速。直接使用 Java 編碼的形式,而不需要了解虛擬機指令,就能動態改變類的結構,或者動態生成。

**注:Javassist 也是基於 ASM 實現的,並且 ASM 的功能更加全面。通過 ASM 實現實現類增強需要會操作字符碼指令,學會使用成本高。 **

package cn.carlzone.learn.agent;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class PreMainAgent {

	public static void premain(String agentArgs, Instrumentation instrumentation){
		System.out.println("hello pre main agent ...");
		// 打印出傳入參數
		System.out.println(agentArgs);
		// 添加類加載過濾器
		instrumentation.addTransformer(new ClassFileTransformer(){

			public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
				if(!className.equals("cn/carlzone/learn/kafka/test/PreMainAgentTest")){
					return null;
				}
				// javassist 類池
				ClassPool pool = new ClassPool();
				pool.appendSystemPath();
				try {
					CtClass ctClass = pool.get("cn.carlzone.learn.kafka.test.PreMainAgentTest");
					CtMethod sayHello = ctClass.getDeclaredMethod("sayHello");
					sayHello.insertBefore("System.out.println(\"system current time millis is :\" + System.currentTimeMillis());");
					return ctClass.toBytecode();
				} catch (Exception e) {
					e.printStackTrace();
				}
				return null;
			}
		});

	}

}

添加代碼分爲三個部分:

  • 第一個是添加簡單的打印 hello pre main agent ....
  • 第二個是打印出運行 JVM 傳入的參數
  • 第三個是從 PreMainAgentTest 類中獲取到 sayHello 方法並在方法執行之前添加打印當前系統時間

2、接着編寫 MANIFEST.MF 文件

因爲我們的項目是基於 maven 來管理的,所在需要在 resoures 目錄下編寫MANIFEST.MF文件。MANIFEST.MF文件是用來描述 Jar 包的信息,例如指定入口函數等。我們需要在該文件中加入如下配置,指定我們編寫的含有premain方法類的全路徑,然後將 agent 類打成 Jar 包。

MANIFEST.MF

Manifest-Version: 1.0
Premain-Class: cn.carlzone.learn.agent.PreMainAgent

並且在 pom.xml 添加上 maven-jar-plugin 這個 maven 插件:

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>cn.carlzone.learn</groupId>
    <artifactId>agent-test</artifactId>
    <version>1.0-SNAPSHOT</version>

    <name>agent-test</name>

    <dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.25.0-GA</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.0.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
                            <Premain-Class>
                                cn.carlzone.learn.agent.PreMainAgent
                            </Premain-Class>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

3、編寫我們的 main 方法類

這裏的程序就是我們要代理的程序。我們在主程序的VM options添加上啓動參數。

-javaagent: /Users/carl/.m2/repository/cn/carlzone/learn/agent-test/1.0-SNAPSHOT/agent-test-1.0-SNAPSHOT.jar=carlgood

其中 carlgood 爲上文中傳入permain方法的agentArgs參數。
在這裏插入圖片描述

運行我們的主程序:

在這裏插入圖片描述
本來當前 cn.carlzone.learn.kafka.test.PreMainAgentTest#main 通過字節碼增強技術在 main 方法運行之前添加了三段邏輯。

注意:如果運行的主程序的 classpath 下面沒有 Javassist jar 包,就不會打印系統當前時間。

3.2 在主程序運行之後的代理程序

在主程序運行之前的agent模式有一些缺陷,例如需要在主程序運行前就指定 javaagent 參數,premain方法中代碼出現異常會導致主程序啓動失敗等,並且有的邏輯是需要在項目啓動之後不需要重啓項目就能夠對啓動的項目進行修改,比如說熱部署。爲了解決這些問題,JDK1.6以後提供了在程序運行之後改變程序的能力。它的實現步驟和之前的模式類似:

1、編寫agent類

我們複用上面的類,將premain方法修改爲agentmain方法,由於是在主程序運行後再執行,意味着我們可以獲取主程序運行時的信息,這裏我們打印出來主程序中加載的類名。

public class AgentMain {

	public static void agentmain(String args, Instrumentation instrumentation) {
		System.out.println("agent main start ...");
		Class[] classes = instrumentation.getAllLoadedClasses();
		for (Class loadedClass : classes) {
			System.out.println(loadedClass.getName());
		}
		System.out.println("agent main end ...");
	}

}

2、修改MANIFEST.MF文件

Manifest-Version: 1.0
Agent-Class: cn.carlzone.learn.agent.AgentMain

個性 pom.xml 打包插件:

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>cn.carlzone.learn</groupId>
        <artifactId>carl-learn</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <groupId>cn.carlzone.learn</groupId>
    <artifactId>agent-test</artifactId>
    <version>1.0-SNAPSHOT</version>

    <name>agent-test</name>

    <dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.0.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
<!--                            <Premain-Class>-->
<!--                                cn.carlzone.learn.agent.PreMainAgent-->
<!--                            </Premain-Class>-->
                            <Agent-Class>
                                cn.carlzone.learn.agent.AgentMain
                            </Agent-Class>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

3、啓動主程序,編寫加載agent類的程序

在程序運行後加載,我們不可能在主程序中編寫加載的代碼,只能另寫程序,那麼另寫程序如何與主程序進行通信?這裏用到的機制就是attach機制,它可以將JVM A連接至JVM B,併發送指令給JVM B執行,JDK自帶常用工具如jstack,jps等就是使用該機制來實現的。在這裏我們運行一個 Spring boot Web 項目。

@RestController
@SpringBootApplication
public class Bootstrap {
	
	@GetMapping("/index")
	public String index(){
		return "index";
	}

	public static void main(String[] args) {
		SpringApplication.run(Bootstrap.class, args);
	}
	
}

通過 jps 命令查到 Spring boot 項目運行的 PID.

在這裏插入圖片描述
編寫 A 程序代碼:

public class AgentMainTest {

	public static void main(String[] args) {
		try {
			VirtualMachine vm = VirtualMachine.attach("34437");
			vm.loadAgent("/Users/carl/.m2/repository/cn/carlzone/learn/agent-test/1.0-SNAPSHOT/agent-test-1.0-SNAPSHOT.jar");
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

}

我們使用VirtualMachine attach到目標進程,其中 34437 爲Spring boot Web 進程的 PID,可以使用 jps 命令獲得,也可以使用VirtualMachine.list方法獲取本機上所有的Java進程,再來判斷tomcat進程,loadAgent方法第一個參數爲Jar包在本機中的路徑,第二個參數爲傳入 agentmain 的 args 參數,此處爲null,運行程序:
在這裏插入圖片描述
然而什麼都沒有打印啊!是不是什麼地方寫錯了呢?仔細想想就會發現,我們是將進程 attach到了Spring boot web 項目的進程上,agent其實是在主程序 B 中運行的,所以程序 A 中自然就不會進行打印,我們跳回Spring boot web 程序的控制檯,查看結果。
在這裏插入圖片描述

4、總結

以上就是Java Agent的倆個簡單小例子,Java Agent 十分強大,它能做到的不僅僅是打印幾個監控數值而已,還包括使用Transformer(推薦觀看)等高級功能進行類替換,方法修改等,要使用Instrumentation的相關API則需要對字節碼等技術有較深的認識。

在開源框架中,skywalkingpinpoint 這兩款 APM 框架都使用到了 premain 這個主程序之前運行代理程序來收集系統監控信息。而阿里巴巴開源的另一款 Java診斷工具Arthas:也使用到了 premain 這個主程序之前運行代理程序以及 agentmain 這個在主程序運行之後的代理程序來實現不重啓項目對線上項目進行 Java 診斷。
對於系統監控我就不做過多介紹了,下面簡單的介紹一下 Arthas:

Arthas 是Alibaba開源的Java診斷工具,深受開發者喜愛。

  • 當你遇到以下類似問題而束手無策時,Arthas可以幫助你解決:
  • 這個類從哪個 jar 包加載的?爲什麼會報各種類相關的 Exception?
  • 我改的代碼爲什麼沒有執行到?難道是我沒 commit?分支搞錯了?
  • 遇到問題無法在線上 debug,難道只能通過加日誌再重新發布嗎?
  • 線上遇到某個用戶的數據處理有問題,但線上同樣無法 debug,線下無法重現!
  • 是否有一個全局視角來查看系統的運行狀況?
  • 有什麼辦法可以監控到JVM的實時運行狀態?
  • 怎麼快速定位應用的熱點,生成火焰圖?

Arthas支持JDK 6+,支持Linux/Mac/Winodws,採用命令行交互模式,同時提供豐富的 Tab 自動補全功能,進一步方便進行問題的定位和診斷。

參考文章:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章