JAVA Instrumentation API

Instrumentation API와 Application Performance Monitoring

최근 굉장히 많은 APM(Application Performance Monitoring) 툴이 생겨나고 있습니다. 오픈소스를 비롯하여 많은 상용소스들이 java application의 performance monitoring 정확히는 application의 실행 상태를 확인하기 위해 stack profiling을 가능 하게 해주고 있습니다. 여기서는 datadog, pinpoint, scouter 과 같은 오픈소스들에서 사용한 핵심 기술인 Instrumentation API에 대해서 알아보고 예제 코드를 통해 어떻게 구현되어 있는지 테스트 해보도록 하겠습니다.

Java Agent란?

Instrumentation API 를 구현 한 특별한 jar파일로 jvm으로 로딩되는 java bytecode를 변형하기 위한 것입니다. 이를 구현하기 위해서 특별한 두가지 메소드가 필요합니다.

  1. premain : jvm이 시작될 때 `-javaagent` 옵션을 사용하고 정적으로  로딩합니다.
  2. agentMain: Java Attach API를 이용해서 JVM에서 agent를 동적으로 로딩합니다.

이 글에서는 위의 premain 메소드와 관련된 정적 로딩 방식을 설명 하도록 하겠습니다.

정적 로딩을 위한 -javaagent

java application이 시작시점에 java-agent를 로딩하는것을 일반적을 정적 로딩이라고 부르며, 이 정적 로딩은 코드가 실행되기 전에 byte-code를 수정합니다. java application 시작시점에 premain 메소드의 구현내용이 실행되며, byte-code를 수정 할 수 있는 application을 구현해 보겠습니다. 그리고 byte-code의 수정을 위해서 javassist library를 사용해보도록 하겠습니다.

우선 개발 환경은 다음과 같습니다.

  1. OS : macOS monterey 12.4
  2. Java : Azul zulu version 11.0.14
  3. javassist : 3.29.0-GA
  4. IDE: IntelliJ IDEA 2021.3.3 (Ultimate Edition)

개발 경로인 src의 하위 디렉토리 및 파일 구조는 다음과 같습니다.

src
  /com
    /apmtest
      /apm
        /agent
          ApmInstrumentationAgent.java
          ApmTransformer.java
        /application
          ApmApplication.java
          BusinessApplication.java

Instrumentation을 위한 파일

먼저 premain 메소드를 구현하는 파일인 ApmInstrumentationAgent 의 소스코드를 보겠습니다.

package com.apmtest.apm.agent;

import java.lang.instrument.Instrumentation;

public class ApmInstrumentationAgent {
    //byte-code의 수정이 필요한 대상 클래스
    private final static String targetClass = "com.apmtest.apm.application.BusinessApplication";
    
    /**
      premain 메소드를 선언하고 내용을 구현하면 -javaagent 옵션에서 지정해준
      jar내부의 META-INF에 다음과 같은 내용을 선언 하게되면,
      Premain-Class: com.apmtest.apm.agent.ApmInstrumentationAgent
      를 통해 이 메소드를 실행 하게 됩니다.
    */
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("=========================== premain method");
        System.out.println("[targetClass] : "+targetClass);
        transformClass(targetClass,inst);
    }

    //본문에서는 아래의 동적 로딩 관련된 메소드는 사용하지 않습니다.
    public static void agentmain(String agentArgs, Instrumentation inst) {
        System.out.println("=========================== agentmain method");
        System.out.println("[targetClass] : "+targetClass);
        transformClass(targetClass,inst);
    }

    private static void transformClass(String className, Instrumentation instrumentation) {
        Class<?> targetCls = null;
        ClassLoader targetClassLoader = null;
        // see if we can get the class using forName
        try {
            targetCls = Class.forName(className);
            targetClassLoader = targetCls.getClassLoader();
            transform(targetCls, targetClassLoader, instrumentation);
            return;
        } catch (Exception ex) {
            System.out.println("Class [{}] not found with Class.forName");
        }
        // otherwise iterate all loaded classes and find what we want
        for(Class<?> clazz: instrumentation.getAllLoadedClasses()) {
            if(clazz.getName().equals(className)) {
                targetCls = clazz;
                targetClassLoader = targetCls.getClassLoader();
                transform(targetCls, targetClassLoader, instrumentation);
                return;
            }
        }
        throw new RuntimeException("Failed to find class [" + className + "]");
    }

    private static void transform(Class<?> clazz, ClassLoader classLoader, Instrumentation instrumentation) {
        /**
          java.lang.instrument.ClassFileTransformer 
          인터페이스를 구현한 클래스의 인스턴스를 여기서 생성 해주고,
          instrumentation.addTransformer 메소드에 그 구현체를 넘겨 줘야 합니다.
        */
        ApmTransformer dt = new ApmTransformer(clazz.getName(), classLoader);
        instrumentation.addTransformer(dt, true);
        try {
            instrumentation.retransformClasses(clazz);
        } catch (Exception ex) {
            throw new RuntimeException("Transform failed for class: [" + clazz.getName() + "]", ex);
        }
    }

}

이제 java.lang.instrument.ClassFileTransformer인터페이스를 구현하여, javasisst라이브러리를 이용해서 byte-code 변형이 필요한 com.apmtest.apm.application.BusinessApplication 클래스의 메소드를 수정 해보도록 하겠습니다.

package com.apmtest.apm.agent;


import javassist.*;

import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class ApmTransformer implements ClassFileTransformer {

    //변형이 필요한 대상 메소드
    private static final String TARGET_METHOD = "businessMethod";
 
    private String targetClassName; 
    private ClassLoader targetClassLoader;

    public ApmTransformer(String targetClassName, ClassLoader targetClassLoader) {
        this.targetClassName = targetClassName;
        this.targetClassLoader = targetClassLoader;
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        byte[] byteCode = classfileBuffer;

        String finalTargetClassName = this.targetClassName.replaceAll("\\.", "/"); //replace . with /
        if (!className.equals(finalTargetClassName)) {
            return byteCode;
        }

        if (className.equals(finalTargetClassName) && loader.equals(targetClassLoader)) {
            System.out.println("[Agent] Transforming class finalTargetClassName :"+targetClassName);
            try {
                ClassPool cp = ClassPool.getDefault();
                //targetClass가 속해 있는 classLoader를 javaasisst.ClassPool에 추가해 줍니다.
                cp.appendClassPath(new LoaderClassPath(loader)); 
                CtClass cc = cp.get(targetClassName);
                CtMethod m = cc.getDeclaredMethod(TARGET_METHOD);
                
                //businessMethod 메소드 시작 지점에 아래의 startTime 변수를 선언하고 구현합니다.
                m.addLocalVariable("startTime", CtClass.longType);
                m.insertBefore("startTime = System.currentTimeMillis();");


                //businessMethod 메소드 끝 지점에 endTime과 opTime 선언하고
                //메소드의 수행 시간이 얼마나 걸렸는지 확인하는 메소드를 추가합니다.
                StringBuilder endBlock = new StringBuilder();
                m.addLocalVariable("endTime", CtClass.longType);
                m.addLocalVariable("opTime", CtClass.longType); 
                endBlock.append("endTime = System.currentTimeMillis();");
                endBlock.append("opTime = (endTime-startTime)/1000;"); 
                endBlock.append("System.out.println(\"[Application] applicationMethod operation completed in:\" + opTime + \" seconds!\");");

                m.insertAfter(endBlock.toString());

                byteCode = cc.toBytecode();
                cc.detach();
            } catch (NotFoundException | CannotCompileException | IOException e) {
                System.out.println("Exception"+e);
            } catch (Exception e){
                System.out.println("Exception"+e);
            }
        }
        return byteCode;
    }
}

위 소스코드 내부에서 보듯이 bussinessMethod의 전체 실행시간을 알기 위해 시작지점과 끝지점에서 각각의 System.currentTimeMillis()를 구하여 계산해주고 System.out.println로 결과를 보여주는 byte-code를 javaasisst를 통해서 쉽게 추가해 주었습니다.

테스트를 위한 Business 클래스

이제 대상 클래스와 프로그램을 시작할 메인 클래스를 구현해 보겠습니다.

package com.apmtest.apm.application;

//byte-code 수정 대상 클래스
public class BusinessApplication {

    public static void businessMethod(int count) throws Exception{
        int sum = 0;
        for(int i=0; i<count; i++){
            sum += i;
            //테스트를 위해 200ms를 쉬는 로직을 강제로 입력했습니다.
            Thread.sleep(200);
        }
        System.out.println("======================== businessMethod");
        System.out.println("[result] : "+sum);
    }
}
package com.apmtest.apm.application;

//프로그램 실행을 위한 메인 클래스
public class ApmApplication {

    public static void main(String[] args) throws Exception {
        System.out.println("[Application] Starting APM application");
        BusinessApplication.businessMethod(Integer.parseInt(args[0]));
    }
}

테스트 클래스들은 비교적 간단한 코드로 구성되었습니다.

이제 이를 실행해 보도록 하겠습니다.

Agent.jar 파일 만들기

jar에 포함될 MANIFEST.MF 파일을 아래 경로에 생성합니다.

그리고 내용을 다음과 같이 작성합니다.

Agent-Class: com.apmtest.apm.agent.ApmInstrumentationAgent
Premain-Class: com.apmtest.apm.agent.ApmInstrumentationAgent
Main-Class: com.apmtest.apm.application.ApmApplication
Can-Redefine-Classes: true
Can-Retransform-Classes: true

빌드를 위해 pom.xml 을 사용하였습니다. 프로젝트 자체를 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>

    <packaging>jar</packaging>
    <groupId>com.apmtest</groupId>
    <artifactId>apm</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <!-- https://mvnrepository.com/artifact/javassist/javassist -->
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.29.0-GA</version>
        </dependency> 
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>jar</goal>
                        </goals>
                        <configuration>
                            <classifier>application</classifier>
                            <classesDirectory>target/classes</classesDirectory>
                            <archive>
                                <manifest>
                                    <addClasspath>true</addClasspath>
                                    <mainClass>com.apmtest.apm.application.ApmApplication</mainClass>
                                </manifest>
                                <manifestFile>${project.build.outputDirectory}/META-INF/MANIFEST.MF</manifestFile>
                            </archive>

                            <includes>
                                <include>com/apmtest/apm/application/BusinessApplication.class</include>
                                <include>com/apmtest/apm/application/ApmApplication.class</include>
                            </includes>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build> 
</project>

그리고 maven에서 package로 빌드를 하여 agent.jar파일을 생성해 줍니다. 생성 후 agent.jar안의 MANIFEST.MF파일이 제대로 생생 되어 있는지 확인해야 합니다.

IntelliJ에서 테스트

우선 테스트를 하기전에 Run Configuration에서 다음처럼 vm option과 parameter 옵션을 지정해 줍니다.

vmoption => -javaagent:/Users/sangwonku/IdeaProjects/apm/target/agent/agent.jar
program arguments => 10 (테스트 메소드의 sum을 계산할때 사용하는 count 값입니다.)

-javaagent 옵션에 위에서 생성한 agent.jar 파일을 꼭 추가해 주야 합니다. 그리고 프로그램을 run 해주면 다음 처럼 프로그램이 실행되는 것을 확인 할 수 있습니다.

자 위에 byte-code로 추가된 [Application] applicationMethod operation completed in:2 seconds!

문장이 추가되어 실제 이 메소드가 2초에 걸쳐서 수행 되었다는 것을 알 수 있습니다.

Conclusion

위의 아주 간단한 예제를 통해 어떻게 우리가 작성한 클래스를 많은 APM툴에서 우리도 모르게(?) 수정하는 지 알게 되었습니다. 본문의 예제는 정말 단순 하게 구성 되었지만 정말 핵심 기능인 Instrumentation API 를 어떻게 이용하는지 이제 알수 있을 것입니다. 쉽게 설명해서 JVM에 byte-code가 로딩되어 실행되기 전에 bytec-code를 중간에 가로챈 다음 그 코드를 변형하여 다시 JVM에게 알리는 방식을 통해 동작하는 것입니다. 이제 이 기능을 알았으니 한번 APM툴을 만들어 볼까요^^?

Leave a Comment