프로젝트/APM prototype 개발

[Agent] Agent 동작 구성 및 이해

블랑v 2024. 4. 3. 22:28

Agent 개발

 

Instrumentation 인터페이스

API reference for Java Platform, Instrumentation

 

Instrumentation (Java SE 17 & JDK 17)

public interface Instrumentation This class provides services needed to instrument Java programming language code. Instrumentation is the addition of byte-codes to methods for the purpose of gathering data to be utilized by tools. Since the changes are pur

docs.oracle.com

 

이 인터페이스는 Java 가상 머신이 제공하는 다양한 서비스를 Agent 프로그램에 제공한다.

 

가장 중요한 기능 중 하나는 클래스의 바이트코드를 변형할 수 있는 기능이다.

즉, Java 어플리케이션 클래스 로딩  실행 과정에 개입하여 모니터링이 가능하게 할 수 있다.

 

Agent의 동작

Agent는 Java의 기본 Instrumentation를 사용하여 제어할 것이다.

그렇다면 내가 원하는 Agent가 Java Application이 시작될 때 같이 로딩되어 모니터링을 가능하게 하려면 어떻게 해야 할까?

 

원래 Java 파일의 main이 호출되기 전, 더 쉽게 말하자면 원래 실행할 Java application의 main()을 실행하기 전,

(즉, JVM이 시작되기 전) Agent의 코드를 먼저 실행해야 한다! (정적 로딩)

 

premain은 JVM이 시작될 때 호출되며, agentmain은 JVM이 이미 실행 중일 때 호출된다.

그러므로 이 premain 메소드를 이용할 것이다.

 

premain

premain 메서드의 시그니처는 정확히 public static void premain(String agentArgs, Instrumentation inst), 또는 public static void premain(String agentArgs) 형태여야 한다.

 

package org.agent;

import java.lang.instrument.Instrumentation;
import java.time.LocalDateTime;

public class MyAgent {

    //JVM이 시작될때 premain 메소드가 실행되고, Transformar 클래스를 클래스 변환자로 등록한다.
    //클래스 변환자는 ClassFileTransformer 인터페이스를 구현한 객체로, 클래스의 바이트 코드를 수정할 수 있다.
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("[Custom MyAgent Run]");
        System.out.println("nowTime : " + LocalDateTime.now() + " my Agent has been invoked with args: " + agentArgs);
        inst.addTransformer(new SimpleClassTransformer());
    }
}

 

 

SimpleClassTransformer

 ClassFileTransformer 인터페이스는 Java의 Instrumentation API의 일부로,
 Java 가상 머신(JVM)이 클래스를 로드할 때 그 클래스의 바이트코드를 변형할 수 있는 기능을 제공한다.

 1. '바이트코드 변형' 
 더 쉽게 이야기하면, .class파일이 JVM에 실행되기 전에 class 파일의 내용을 수정하거나, 추가하는 것이다.

 2. 오버라이드
 인터페이스를 구현하기 위해 transform 메서드를 오버라이드 해야한다.
 이 과정에서 parameter를 받는데, 각 인수의 의미는 다음과 같다.

 - ClassLoader loader: 클래스를 로드하는 클래스 로더.
 - String className: 현재 변형하고 있는 클래스의 이름.
 - Class<?> classBeingRedefined: 이미 정의된 경우, 재정의되고 있는 클래스.
 - ProtectionDomain protectionDomain: 클래스의 보호 도메인.
 - byte[] classfileBuffer: 원본 클래스 파일의 바이트코드.

 3. 바이트코드 적용
 transform 메서드의 반환값을 통해 바이트코드가 반환되면
 JVM은 이 코드를 사용하여 Class를(바이트코드에 의해 변경된 클래스를) 정의한다.

 

package org.agent;

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

public class SimpleClassTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        // 여기에서 클래스 바이트코드를 조작하는 로직을 구현할 수 있다.
        return classfileBuffer;
    }
}

 

 

JAR 파일 패키징과 MAINFEST.MF

Agent 프로그램의 코드를 작성 후 JAR 파일로 패키징을 해야 한다. (그래야 일반적으로 라이브러리나 종속성 등으로 가져오기 편하니까)

 

하지만 이 패키징 과정에서 MANIFEST.MF 파일에 Premain-Class 속성을 설정해야 한다. 이 설정은 JVM에게 premain 메서드를 포함하는 클래스를 알려주는 것이다. 본 서버 역시 이 jar 파일이 premain 메서드가 어디 있는지 알아야 하니까.

 

Manifest-Version: 1.0
Premain-Class: MyAgent

 

내 premain은 MyAgent Class이니 이를 패키징 과정에 넣어주자.

 

Agent 실행

원본의 파일에 실행 시 내 Agent 파일을 동봉하여 실행할 수 있도록 java 실행 시 명령어를 동봉하자.

java -javaagent:'내 Agent 파일'.jar -jar '원래 실행하려고 했던 파일'.jar

# -javaagent 옵션을 통해 jar 파일을 삽입할 수 있다.

 

 

실습 : 실제 Springboot 안에 적용하기

 

이러한 베이스를 가지고 JAR 파일 압축 후 내 다른 Springboot 파일에 삽입하여 적용해보자!

 

1. Manifest 파일 만들기

이전에 설명했듯, Manifest 파일(MANIFEST.MF)은 JAR 파일의 메타데이터를 포함하는 텍스트 파일이다.

이 파일은 JAR 파일 내 META-INF 디렉토리에 위치해야 한다.

 

1-1. 수동으로 파일 생성하기

 

텍스트 에디터를 사용하여 MANIFEST.MF 파일을 생성하자.

일반적으로 ' src/main/resources/META-INF' 경로가 사용된다고 한다.

 

Manifest-Version: 1.0
Premain-Class: org.agent.MyAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true

 

프로젝트 아래, 다음과 같은 경로이다.

 

 

1-2. build.gradle로 자동으로 생성하기

Gradle 빌드 스크립트에서 해당 파일을 JAR 파일에 포함시키도록 설정할 수 있다.

build.gradle에서 다음과 같이 스크립트를 작성한다면, 이전과 달리 기본적으로 MF파일이 만들어진다.

 

plugins {
    id 'java'
}

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation platform('org.junit:junit-bom:5.9.1')
    testImplementation 'org.junit.jupiter:junit-jupiter'
}

test {
    useJUnitPlatform()
}

//Build.gradle에서 다음과 같이 지정하면, 이전처럼 수동으로 만들 필요 없이 기본적으로 MANIFEST.MF 파일을 만들어준다.
jar {
    manifest {
        attributes(
                'Manifest-Version': '1.0',
                'Premain-Class': 'org.agent.MyAgent',
                'Can-Redefine-Classes': 'true',
                'Can-Retransform-Classes': 'true'
        )
    }
}

 

2. JAR 파일 빌드

파일 생성 준비가 끝났다면 파일을 빌드하자. gradlew를 사용해도 상관없다.

 

gradle - task - build - jar

 

 

빌드된 jar 파일을 보면 MANIFEST.MF가 자동으로 생성된 것을 확인할 수 있다.

 

3. Springboot 서버에 적용하기

직접 Tomcat에 설정으로 넣거나,

Spring Boot 애플리케이션을 실행할 때 커맨드 java 명령을 사용할 수도 있다.

 

java -javaagent:/full/path/to/agent.jar -jar your-spring-boot-application.jar

 

 

InteliJ 적용의 경우 아래의 포스팅을 참조하자.

https://januaryman.tistory.com/120

 

[IntelliJ] IntelliJ 외부 라이브러리 추가하기(SpringBoot 외부 라이브러리 추가)

[IntelliJ] IntelliJ 외부 라이브러리 추가하기(SpringBoot 외부 라이브러리 추가) 안녕하세요. 갓대희 입니다. 이번 포스팅은 [ [IntelliJ] IntelliJ 외부 라이브러리 추가하기(gradle 외부 라이브러리 추가) ]

januaryman.tistory.com

 

 

 

JVM 인수를 통한 Agent 등록

 

의존성을 넣었으나, Java 에이전트를 사용하기 위해서는, -javaagent JVM 인수를 통해 명시적으로 JVM에 에이전트를 등록해야 한다.

 

다음 단계를 따라 실행 구성에 -javaagent 인수를 추가하자.

 

Run/Debug Configurations에 접근한다.

VM options 필드를 찾는다 (없다면, 'Modify options' 를 클릭하여 추가할 수 있다).

 

 

VM 옵션 필드에 인수를 추가하자.

-javaagent:full/path/to/AgentV1_0201-1.0-SNAPSHOT.jar

여기서 말하는 full path는 jar파일이 정말 물리적으로 존재하는 위치를 이야기한다.

 

이를 적용하고 실행 시, spring boot 로고 이전 premain에 있던 로그가 실행됨을 확인할 수 있다.