파란하늘의 지식창고
Published 2023. 11. 8. 20:49
Spring AOT 살펴보기 Study/Java
반응형

spring boot reference 문서를 보면 3.x 이후 GraalVM Native Image Support 문서가 추가되었다.

Spring Boot 3.x부터 GraalVM Native Image를 정식 지원한다.

https://docs.spring.io/spring-boot/docs/current/reference/html/

https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html

https://graalvm.github.io/native-build-tools/latest/maven-plugin.html

빌드 시점에 런타임에 수행되어야 할 부분까지 미리 처리하여 docker image를 생성한다.
이로 인해 빌드 시간이 느려지지만 container 실행이 매우 빨라지는 장점이 있다.

다만

  • profile 별 bean 구성을 후처리로 하지 않기 때문에 profile별로 빌드 처리를 해야 하는 듯하고
  • 빌드 시 빌드된 platform에 종속된 결과물을 생성하기 때문에 플랫폼 별 배포가 필요할 경우에도 profile과 동일하게 platform 별 빌드 처리를 해야 한다.
  • 현시점에 spring framework, spring boot 까진 지원하고 있지만 아직 spring cloud는 일부 지원하지 않는 부분이 있었다.
    (내 경우 spring cloud kubernetes를 사용하고 있었는데 지원하지 않아서 빌드할 수 없었음.)
    https://github.com/spring-cloud/spring-cloud-release/wiki/AOT-transformations-and-native-image-support
  • FaaS를 구축하는 경우엔 좋아 보이긴 하지만 의존성이 많은 application은 아무래도 지원이 아직은 부족하다 보니 사용하기 어려워 보였다.

아래는 (deepl이) 한글로 번역해준 GraalVM Native Image Support 문서 일부이다.


GraalVM Native Image Support

GraalVM Native Image는 compile 된 Java application을 미리 처리하여 생성할 수 있는 standalone executable이다.
Native Image는 일반적으로 memory 사용 공간이 더 작고 JVM Image 보다 빠르게 시작된다.

GraalVM Native Image 소개

GraalVM Native Image는 Java application을 배포하고 실행하는 새로운 방법을 제공한다.
Java Virtual Machine에 비해 Native Image는 더 적은 memory 공간과 훨씬 빠른 startup time으로 실행할 수 있다.

 

container image를 사용하여 배포하는 application에 적합하며, 특히 "Function as a service (Faas)" platform과 결합할 때 유용하다.

JVM 용으로 작성된 기존 application과 달리, GraalVM Native Image application은 실행 파일을 생성하기 위해 사전 처리가 필요하다.

이 사전 처리에는 main entry point에서 application code를 정적으로 분석하는 작업이 포함된다.

 

GraalVM Native Image는 완전한 플랫폼 별 executable이다.
Native Image를 실행하기 위해 Java Virtual Machine을 제공하지 않아도 된다.

JVM 배포와의 주요 차이점

  • application 정적 분석은 main entry point에서 build 시 수행
  • native image가 생성될 때 도달할 수 없는 code는 제거되며 executable의 일부가 되지 않음
  • GraalVM은 code의 동적 요소를 직접 인식하지 못하므로 reflection, resource, serialization, dynamic proxy에 대해 알려주어야 함
  • application classpath는 build time에 고정되며 변경할 수 없음
  • lazy class loading이 없으며 executable에 포함된 모든 것이 시작 시 메모리에 로드됨
  • Java application의 일부 aspect에 대해 완전히 지원되지 않는 몇 가지 제한 사항이 있음

TIP: GraalVM 참조 문서의 Native Image 호환성 가이드 section에서 GraalVM 제한사항에 대한 자세한 내용을 확인할 수 있다.

Spring AOT 처리 이해

일반적인 Spring Boot Application은 매우 동적이며 구성은 runtime에 수행된다.
실제로 Spring Boot의 AutoConfiguration의 개념은 runtime 상태에 반응하여 올바르게 구성하는데 크게 의존한다.

 

application의 이러한 dynamic aspect에 대해 GraalVM에 알려줄 수도 있지만, 그렇게 하면 정적 분석의 이점을 대부분 상실하게 된다.
따라서 Spring Boot를 사용하여 native image를 생성할 때는 closed-world를 가정하고 application의 dynamic aspect를 제한한다.

 

closed-world 가정에는 다음과 같은 제약이 따른다.

  • classpath는 build time에 고정되고 완전히 정의된다.
  • application에 정의된 bean은 runtime에 변경할 수 없다.
    • Spring @Profile annotation 및 Profile별 구성에 제한이 있다.
    • bean이 생성되면 변경되는 properties (예: @ConditionaOnProperty.enable properties)는 지원되지 않는다.

이러한 제한이 적용되면 Spring이 build-time 동안 사전 처리를 수행하고 GraalVM이 사용할 수 있는 추가 자산을 생성할 수 있게 된다.
일반적으로 Spring AOT 처리 application이 생성된다.

  • Java source code
  • Bytecode (dynamic proxy 등의 경우)
  • GraalVM JSON hint file들
    • Resource hints (resource-config.json )
    • Reflection hints (reflect-config.json )
    • Serialization hints (serialization-config.json )
    • Java Proxy hints (proxy-config.json )
    • JNI hints (jni-config.json )

Source Code Generation

Spring application은 Spring Bean으로 구성된다.
내부적으로 SpringFramework는 두 가지 다른 개념을 사용하여 bean을 관리한다.
생성되어 다른 bean에 주입할 수 있는 실제 instance인 bean instance가 있다.
또한 bean의 속성과 해당 instance를 생성하는 방법을 정의하는 데 사용되는 bean 정의도 있다.

 

일반적인 @Configuration class를 예로 들어보자.

@Configuration(proxyBeanMethods = false)
public class MyConfiguration {
    @Bean
    public MyBean myBean() {
        return new MyBean();
    }
}

bean 정의는 @Configuration class를 구문 분석하고 @Bean method를 찾아서 생성된다.
위의 예제에서는 myBean 이라는 singleton bean에 대한 BeanDefinition 을 정의하고 있다.
또한 MyConfiguration class 자체에 대한 BeanDefinition 도 생성하고 있다.

 

myBean instance가 필요한 경우 Spring은 myBean() method를 호출하고 그 결과를 사용해야 한다는 것을 알고 있다.
JVM에서 실행되는 경우 application이 시작될 때 @Configuration class 구문 분석이 수행되고 reflection을 사용하여 @Bean method가 호출된다.

 

Native Image를 생성할 때 Spring은 다른 방식으로 작동된다.

runtime에 @Configuration class를 parsing 하고 bean 정의를 생성하는 대신 build time에 이를 수행한다.
bean 정의가 발견되면 이를 처리하여 GraalVM Compiler에서 분석할 수 있는 source code로 변환한다.

 

Spring AOT process는 위의 configuration class를 다음과 같은 code로 변환한다.

/**
 * Bean definitions for {@link MyConfiguration}.
 */
public class MyConfiguration__BeanDefinitions {
    /**
     * Get the bean definition for 'myConfiguration'.
     */
    public static BeanDefinition getMyConfigurationBeanDefinition() {
        Class<?> beanType = MyConfiguration.class;
        RootBeanDefinition beanDefinition = new RootBeanDefinition(beanType);
        beanDefinition.setInstanceSupplier(MyConfiguration::new);
        return beanDefinition;
    }
    /**
     * Get the bean instance supplier for 'myBean'.
     */
    private static BeanInstanceSupplier<MyBean> getMyBeanInstanceSupplier() {
        return BeanInstanceSupplier.<MyBean>forFactoryMethod(MyConfiguration.class, "myBean")
            .withGenerator((registeredBean) -> registeredBean.getBeanFactory().getBean(MyConfiguration.class).myBean());
    }
    /**
     * Get the bean definition for 'myBean'.
     */
    public static BeanDefinition getMyBeanBeanDefinition() {
        Class<?> beanType = MyBean.class;
        RootBeanDefinition beanDefinition = new RootBeanDefinition(beanType);
        beanDefinition.setInstanceSupplier(getMyBeanInstanceSupplier());
        return beanDefinition;
    }
}

NOTE: 생성되는 정확한 code는 bean 정의의 특성에 따라 다를 수 있다.

 

위에서 생성된 code가 @Configuration class와 동등한 bean 정의를 생성하는 것을 볼 수 있지만, GraalVM이 이해할 수 있는 직접적인 방식으로 생성된다.

 

myConfiguration bean에 대한 bean 정의와 myBean 에 대한 bean 정의가 있다.
myBean instance가 필요한 경우 BeanInstanceSupplier 가 호출된다.
이 supplier는 myConfiguration bean에서 myBean() method를 호출한다.

 

NOTE: Spring AOT 처리 중에는 application이 bean 정의를 사용할 수 있는 시점까지 시작된다. AOT 처리 단계에서는 bean instance가 생성되지 않는다.

 

Spring AOT는 모든 bean 정의에 대해 이와 같은 code를 생성한다.
또한 bean 사후 처리가 필요한 경우 (예: @Autowired method 호출) code를 생성한다.
또한 AOT 처리된 application이 실제로 실행될 때 Spring Boot에서 ApplicationContext 를 초기화하는 데 사용되는 ApplicationContextInitializer 가 생성된다.

 

TIP: AOT로 생성된 source code는 장황할 수 있지만 가독성이 뛰어나며 application을 degugging 할 때 유용할 수 있다. 생성된 source file은 Maven을 사용하는 경우 target/spring-aot/main/sources 에서, Gradle을 사용하는 경우 build/generated/aotSources 에서 찾을 수 있다.

Hint File Generation

source file을 생성하는 것 외에도 Spring AOT engine은 GraalVM에서 사용하는 hit file도 생성한다.
hit file에는 code를 직접 검사하여 이해할 수 없는 사항을 GraalVM이 어떻게 처리해야 하는지를 설명하는 JSON data가 포함되어 있다.

 

예를 들어, private method에 Spring annotation을 사용할 수 있다.
Spring은 GraalVM에서도 private method를 호출하기 위해 reflection을 사용해야 한다.
이러한 상황이 발생하면 Spring은 reflection hint를 작성하여 private method가 직접 호출되지 않더라도 native image에서 사용할 수 있어야 한다는 것을 GraalVM이 알 수 있도록 한다.

 

hit file은 META-INF/native-image 아래에 생성되며, GraalVM이 자동으로 가져온다.

 

TIP: 생성된 hit file은 Maven을 사용하는 경우 target/spring-aot/main/resources 에서, Gradle을 사용하는 경우 build/generated/aotResources 에서 찾을 수 있다.

Proxy Class Generation

Spring은 때때로 추가 기능으로 작성한 code를 향상하기 위해 proxy class를 생성해야 한다.
이를 위해 bytecode를 직접 생성하는 cglib library를 사용한다.

 

application이 JVM에서 실행될 때 proxy class는 application 이 실행됨에 따라 동적으로 생성된다.
native image를 생성할 때 이러한 proxy는 build-time에 생성해야 GraalVM에 포함될 수 있다.

 

NOTE: source code 생성과 달리 생성된 bytecode는 application을 debugging 할 때 특별히 유용하지 않다. 그러나 javap 와 같은 도구를 사용하여 .class file의 내용을 검사해야 하는 경우 Maven의 경우 target/spring-aot/main/classes , Gradle의 경우 build/generated/aotClasses 에서 해당 file을 찾을 수 있다.

첫 번째 GraalVM Native Application 개발하기

이제 GraalVM Native Image와 Spring ahead-of-time engine 작동 방식에 대한 개요를 살펴봤으니 application을 생성하는 방법을 살펴볼 수 있다.

 

Spring Boot native image application을 build 하는 방법에는 크게 두 가지가 있다.

  • Cloud Native Buildpack에 대한 Spring Boot 지원을 사용하여 native executable을 포함하는 lightweight container 생성
  • GraalVM Native Build Tool을 사용하여 nateive executable 생성

TIP: 새로운 native Spring Boot project를 시작하는 가장 쉬운 방법은 start.spring.io로 이동하여 "GraalVM Native Support" dependency를 추가하고 project를 생성하는 것이다. 포함된 HELP.md file은 시작 hit를 제공한다.

Sample Application

native image를 만드는 데 사용할 수 있는 예제 application이 필요하다.
여기서는 "getting-started.html" section에 있는 간단한 "Hello World!" web application으로 충분하다.

 

요약하자면, main application code는 다음과 같다.

@RestController
@SpringBootApplication
public class MyApplication {
    @RequestMapping("/")
    String home() {
        return "Hello World!";
    }
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

이 application은 GraalVM native image와 함께 작동되도록 test 및 검증된 Spring MVC와 embedded Tomcat을 사용한다.

Buildpack을 사용하여 Native Image build

Spring Boot에는 Maven과 Gradle 모두에 대해 native image에 대한 buildpack 지원이 포함되어 있다.
즉, 명령어 하나만 입력하면 로컬에서 실행 중인 Docker deamon에 적절한 image를 빠르게 가져올 수 있다.
결과 image에는 JVM이 포함되지 않고 native image가 정적으로 complie 된다.
따라서 image 크기가 더 작아진다.

 

NOTE: image에 사용된 builder는 paketobuildpacks/builder:tiny 이다. 설치 공간이 작고 attack surface가 줄어들지만, image에 더 많은 툴을 사용하려면 paketobuildpacks/builder-jammy-base 또는 paketobuildpacks/builder-jammy-full 을 사용할 수도 있다.

System 요구 사항

Docker가 설치되어 있어야 한다.
자세한 내용은 Get Docker를 참조한다.
Linux를 사용하는 경우 root user가 아닌 non-root user를 허용하도록 구성한다.

 

NOTE: sudo 없이 docker run hello-world 를 실행하여 Docker daemon이 예상대로 연결되는지 확인할 수 있다. 자세한 내용은 Maven 또는 Gradle Spring Boot plugin documentation을 참조한다.

 

TIP: macOS에서는 Docker에 할당된 m emory를 8GB 이상으로 늘리고 CPU도 더 추가하는 것이 좋다. 자세한 내용은 이 stack overflow 답변을 참조한다. Microsoft Windows에서는 더 나은 성능을 위해 Docker WSL 2 backend를 사용하도록 설정해야 한다.

Maven 사용

Maven을 사용하여 native image container를 build 하려면 pom.xml file에 spring-boot-starter-parentorg.graalvm.buildtools:native-maven-plugin 이 사용되도록 해야 한다.
다음과 같은 <parent> section이 있어야 한다.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.5</version>
</parent>

<build> <plugins> section에 추가로 이 항목이 있어야 한다.

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
</plugin>

spring-boot-starter-parent 는 native image를 생성하기 위해 실행해야 하는 실행을 구성하는 native profile을 선언한다.
command line에서 -P flag를 사용하여 profile을 활성화할 수 있다.

TIP: spring-boot-starter-parent 를 사용하지 않으려면 spring-boot plugin의 process-aot goal과 native build tools plugin의 add-reachability-metadata goal에 대한 execution을 구성해야 한다.

image를 build 하려면 native profile이 활성화된 상태에서 spring-boot:build-image goal을 실행하면 된다.

$ mvn -Pnative spring-boot:build-image

Gradle 사용

Spring Boot Gradle plugin은 GraalVM Native Image plugin이 적용될 때 자동으로 AOT task를 구성한다.
Gradle build에 org.graalvm.buildtools.native 가 포함된 plugins block이 포함되어 있는지 확인해야 한다.

 

org.graalvm.buildtools.native plugin이 적용되어 있는 한 bootBuildImage task는 JVM image가 아닌 native image를 생성한다.

다음 처럼 task를 실행할 수 있다:

$ gradle bootBuildImage

예제 실행

적절한 build command를 실행하면 docker image를 사용할 수 있다.
docker run 을 사용하여 application을 시작할 수 있다.

$ docker run --rm -p 8080:8080 docker.io/library/myproject:0.0.1-SNAPSHOT

다음과 유사한 출력이 표시된다.

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::  (v3.1.5)
....... . . .
....... . . . (log output here)
....... . . .
........ Started MyApplication in 0.08 seconds (process running for 0.095)

NOTE: startup time은 machine 마다 다르지만, JVM에서 실행되는 Spring Boot application보다 훨씬 빠르다.

 

localhost:8080 으로 web browser를 열면 다음과 같은 출력이 표시된다.

Hello World!

application을 정상적으로 종료하려면 Ctrl + C 를 누른다.

Native Build Tool을 사용하여 Native Image build

Docker를 사용하지 않고 native executable을 직접 생성하려면 GraalVM Native Build Tools를 사용할 수 있다.
Native Build Tools는 Maven과 Gradle 모두를 위해 GraalVM에서 제공하는 plugin이다.
Native Build Tools를 사용하여 native image 생성을 비롯한 다양한 GraalVM task를 수행할 수 있다.

전제 조건

Native Build Tools를 사용하여 native image를 build 하려면 machine에 GraalVM 배포판이 필요하다.
Liberica Native image Kit page에서 수동으로 download 하거나 SDKMAN과 같은 download manager를 사용할 수 있다.

 

Linux and macOS

macOS 또는 Linux에 native image compiler를 설치하려면 SDKMAN을 사용하는 것이 좋다.
sdkman.io에서 SDKMAN! 을 다운로드 하고 다음 command를 사용하여 Liberica GraalVM 배포를 설치한다.

$ sdk install java 22.3.r17-nik
$ sdk use java 22.3.r17-nik

java -version 의 출력을 확인하여 올바른 version이 구성되었는지 확인한다.

$ java -version
openjdk version "17.0.5" 2022-10-18 LTS
OpenJDK Runtime Environment GraalVM 22.3.0 (build 17.0.5+8-LTS)
OpenJDK 64-Bit Server VM GraalVM 22.3.0 (build 17.0.5+8-LTS, mixed mode)

Windows

 

Windows의 경우, 다음 지침에 따라 version 22.3의 GraalVM 또는 Liberica Native Image Kit, Visual Studio Build Tools 및 Windows SDK를 설치한다.
Windows 관련 command line 최대 길이로 인해 Maven 또는 Gradle plugin을 실행하려면 일반 Windows command line 대신 x64 Native Tools Command Prompt를 사용해야 한다.

Maven 사용

buildpack 지원과 마찬가지로 native profile을 상속하려면 spring-boot-starter-parent 를 사용하고 있는지, 그리고 org.graalvm.buildtools:native-maven-plugin plugin이 사용되었는지 확인해야 한다.

 

native profile이 활성화된 상태에서 native:compile goal을 호출하여 native-image compile을 trigger 할 수 있다.

$ mvn -Pnative native:compile

native image executable은 target directory에서 찾을 수 있다.

Gradle 사용

Native Build Tools Gradle plugin이 project에 적용되면 Spring Boot Gradle plugin이 자동으로 Spring AOT engine을 trigger 한다.
task dependencies가 자동으로 구성되므로 표준 nativeCompile task를 실행하여 native image를 생성하기만 하면 된다.

$ gradle nativeCompile

native image executable은 build/native/nativeCompile directory에서 찾을 수 있다.

예제 실행

이 시점에서 application이 동작한다.
이제 application을 직접 실행하여 시작할 수 있다.

다음과 유사한 출력이 표시된다.

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::  (v3.1.5)
....... . . .
....... . . . (log output here)
....... . . .
........ Started MyApplication in 0.08 seconds (process running for 0.095)

NOTE: 시작 시간은 machine마다 다르지만, JVM에서 실행되는 Spring Boot application 보다 훨씬 빠르다.

 

localhost:8080 으로 web browser를 열면 다음과 같은 출력이 표시된다.

Hello World!

application을 정상적으로 종료하려면 Ctrl + C 를 누른다.

반응형
profile

파란하늘의 지식창고

@Bluesky_

내용이 유익했다면 광고 배너를 클릭 해주세요