Post

GraalVM으로 native image compile 하기 (with Spring Boot)

JVM + JIT compile과 native image

TBD

참고 JVM, JIT compile

native image build 기본 설정

build.gradle.kts 설정

1
2
3
4
5
6
7
8
9
10
plugins {  
    id("org.graalvm.buildtools.native")  
}

graalvmNative {  
    binaries.all {  
        val reflectConfigPath = file("src/main/resources/native-config/reflect-config-additional.json").absolutePath  
        buildArgs.add("-H:ReflectionConfigurationFiles=$reflectConfigPath")  
    }  
}
  • graalvm compiler는 정적 분석을 통해 런타임에 필요한 코드들을 탐색하고, reachable code만 컴파일 대상으로 잡는다. (main에서 시작해서 쭉 따라가본다.)
  • 그러나 컴파일 시점의 정적 분석만으로는 탐지되지 않는 reachable code들이 있다.
    • JNI, reflection, dynamic proxy, class path resource 등으로 인해 reachable code가 되는 경우
      • e.g. jackson (de)serialization에 사용되는 DTO. (jackson은 reflection을 사용한다)
    • 이 항목들이 빠진 채로 컴파일하면, 당연히 런타임에 에러가 발생한다.
  • 이렇게 runtime에 reachable 해지는 클래스들은 .json 형태의 메타데이터로 컴파일러에게 따로 알려줘야만 한다.
  • native-image-agent 를 이용하면 실제로 jar를 실행해서 런타임 정보를 수집한 뒤 .json 형태로 export 해준다.
1
java -agentlib:native-image-agent=config-output-dir=./native-config-dir -jar your-app.jar
  • gradle plugin 사용하면 직접 커맨드 호출 할 필요 없이 위 작업을 자동으로 처리해준다. (reflect-config.json을 자동으로 생성해준다)
    • gradle processAot로 트리거 가능하다.

native-image-agent가 runtime reachable code를 탐색하는 원리는, 실제로 애플리케이션을 실행하고 그 동안 호출된 class를 기록하는 방식이다.
그래서 애플리케이션 시작 시점에 호출되지 않는 class는 .json으로 기록되지 않는다!
생각보다 너무 rough하다… ㅎ;

리플렉션 대상 클래스가 다른 곳에서 일반적으로 사용되어 이미 컴파일이 되었다고 해도, .json에 해당 클래스가 포함되어 있지 않다면 리플렉션 부분 실행하다가 에러가 발생한다. 아마도 .json을 테이블 형태로 binary가 가지고 있다가, 리플렉션 코드가 등장하면 해당 테이블 참조해서 처리하는 방식이지 않을까?

native-image-agent로 안잡히는 class들이 있다.

  • Controller request/response 로 들어가는 DTO는 metadata 생성 대상으로 포함되는데, (Spring Bean은 앱 시작 시점에 생성되므로)
  • WebClient request/response 로 들어가는 DTO는 대상으로 포함되지 않는다. (이는 앱 시작 시점에 호출되지 않으므로)
  • => 전반적인 REST request/response DTO에 reflection 설정이 필요하다.
1
2
3
4
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: 
  Cannot construct instance of `dev.umbum...dto.ExchangeInfo`: 
    cannot deserialize from Object value (no delegate- or property-based Creator): 
      this appears to be a native image, in which case you may need to configure reflection for the class that is to be deserialized
  • 이런 클래스들은 직접 reflect-config-additional.json에 추가하고, 컴파일 시 이를 적용하기 위해 -H:ReflectionConfigurationFiles 설정 필요하다.
  • reflection hint option을 패키지에 대해서 줄 수는 없는지? => 아직 안된다ㅠ
  • 일일히 클래스 명시해줘야 한다.

find 사용하여 class 추출하는 방법

1
2
3
$ find . -type f
./dto/Account.kt
./dto/MarketItem.kt
  • 추출한 class 들을 reflect-config-additional.json에 추가
  • 단점)
    • 가장 간단한 방법이지만, nested class가 검출되지 않는다.
    • nested class가 있다면 그 것도 다 등록해줘야 해서, 이 방법은 좋지 않다.

ClassLoader 사용해서 nested class까지 추출하는 방법

RuntimeHintsRegistrar 이용해서 native-image-agent 스캔 대상으로 포함되도록 만드는 방법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import io.github.classgraph.ClassGraph

@Configuration  
@ImportRuntimeHints(ReflectionHintRegisterConfig.HintRegister::class)  
class ReflectionHintRegisterConfig {  
    class HintRegister : RuntimeHintsRegistrar {  
        override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) {  
            val classes = getClassesInNestedPackages("dev.umbum.external")  
            val typeReferences = TypeReference.listOf(*classes.toTypedArray())  
  
            hints
                .reflection()  
                .registerTypes(typeReferences) {  
                    it.withMembers(  
                        MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,  
                        MemberCategory.INVOKE_DECLARED_METHODS,  
                        MemberCategory.DECLARED_FIELDS,  
                    )  
                }  
        }  
  
        private fun getClassesInNestedPackages(basePackage: String): List<Class<*>> {  
            val scanResult = ClassGraph()  
                .acceptPackages(basePackage)  
                .scan()  
  
            return scanResult.allClasses.map { it.loadClass() }  
        }  
    }
}
  • native-image-agent에서 스캔 할 때 dev.umbum.external 패키지 하위의 모든 클래스들을 포함하기 때문에 자동으로 .json이 생성되어 따로 관리 할 필요가 없는 방식.

현재 가능한 방법 중 최선으로 보인다.
native compile 하지 않는 경우에는 profile 이용해서 실행되지 않도록 할 수 있음.

기타 troubles

Mockito 호환 안됨

  • kotlin을 사용하고 있다면 어차피 mockk를 쓰고 있을거라 문제는 안됨.
  • 라이브러리 자체도 여러모로 mockk가 더 낫다.

SnakeCaseStrategy 생성자 못찾음

1
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)

사용하고 있다면 아래 에러 발생.

1
2
3
4
5
Caused by: org.springframework.beans.BeanInstantiationException:
  Failed to instantiate [com.fasterxml.jackson.databind.PropertyNamingStrategies$SnakeCaseStrategy]:
    No default constructor found
Caused by: java.lang.NoSuchMethodException:
  com.fasterxml.jackson.databind.PropertyNamingStrategies$SnakeCaseStrategy.<init>()

jackson reflection 설정 필요. reflect-config-additional.json에 추가

1
2
3
4
5
6
7
[  
  {    
    "name": "com.fasterxml.jackson.databind.PropertyNamingStrategies$SnakeCaseStrategy",  
    "allDeclaredConstructors": true,  
    "allPublicMethods": true
  }
]

java.lang.Enum$EnumDesc 못찾음

1
2
Caused by: java.lang.UnsupportedOperationException: Type not found: java.lang.Enum$EnumDesc<E>
	at kotlin.reflect.jvm.internal.impl.descriptors.runtime.structure.ReflectJavaClassifierType.getClassifierQualifiedName(ReflectJavaClassifierType.kt:41) ~[na:na]

reflection 설정 필요. reflect-config-additional.json에 추가

1
2
3
4
5
6
7
8
[  
  {  
    "name": "java.lang.Enum$EnumDesc",  
    "allDeclaredConstructors": true,  
    "allDeclaredMethods": true,  
    "allDeclaredFields": true  
  }
]

@field: 사용 매핑 불가

1
2
@field:JsonProperty("free2")  
val free: BigDecimal,

사용하고 있다면 @field:는 제거해준다. 붙이면 아래 오류 발생.

1
2
Caused by: com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException:
  Instantiation of [simple type, class dev.umbum...dto.Account] value failed for JSON property free due to missing (therefore NULL) value for creator parameter free which is a non-nullable type

caffeineCacheManager 빈 생성 불가

1
2
3
4
2024-06-22 14:27:44.459  WARN 91946 --- [app] [           main] w.s.c.ServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: 
  org.springframework.beans.factory.BeanCreationException: 
    Error creating bean with name 'caffeineCacheManager': 
      Instantiation of supplied bean failed

자원 소모

 JITnative compile
CPU15%20%
RAM13%20%
  • native compile이 자원을 더 많이 사용한다.
  • 중간에 CPU 100% 친 것은 빌드

GraalVM native image 지금 도입해도 될까? (24.06)

  • 시행 착오를 꽤 겪어야 해서 프로덕션에 사용하기에는 불안한 감이 있다.
    • 일단 마이그 하면서 다 수정해서 띄워놓으면 잘 동작하긴 한다만
    • 운영 중 예상치 못한 문제 발생 가능성이 있고,
    • 문제가 발생 했을 때 참고할 만한 레퍼런스가 많지 않다.
  • 빌드가 오래걸린다는 것 또한 확실한 단점.
    • 게다가 native binary라 dockerizing이 필수다.
    • 코드 수정 - 빌드 - 실행 사이클이 너무 오래걸려서, 개발+테스트는 JVM에서 하고 native image는 배포 할 때만 빌드해야 할 듯.
  • 성능은 제대로 테스트해보지는 않았지만 제대로 warmup 된 이후의 JVM과 비슷한 것 같다.
    • https://www.inner-product.com/posts/benchmarking-graalvm-native-image/
    • 벤치마크 결과를 보면, warmed up JVM > native image 이긴 하나…
    • 시스템 구석구석 모든 부분에 warmup을 하는건 거의 불가능한 일이기 때문에, 일반적인 상황에서의 성능은 native image의 손을 들어주고 싶다.
    • 다만 정말로 10ms 라도 응답시간을 줄이는게 더 중요한 비즈니스라면, warm up JVM이 최종 성능은 더 우수하니, warm up을 빡세게 하고 JVM 쓰는게 나아보인다.
      • GraalVM이 아직 컴파일을 충분히 효율적으로 못하고 있는 걸지도.
This post is licensed under CC BY 4.0 by the author.