스프링부트

Spring Boot GraalVM Native Image 빌드 하기

알쓸개잡 2023. 9. 4.

GraalVM 네이티브 이미지는 컴파일된 자바 애플리케이션을 미치 처리하여 생성할 수 있는 독립 실행형 실행 파일이다. 도커 이미지와 같은 이미지를 의미하는 것이 아니다. 네이티브 이미지는 일반적으로 메모리 사용 공간이 더 작고 JVM 이미지 보다 빠르게 시작할 수 있다는 장점이 있다. 컨테이너 이미지를 사용하여 배포하는 애플리케이션에 적합하며, 특히 서비스형 기능(FaaS) 플랫폼과 결합할 때 유용하다. GraalVM 네이티브 이미지는 완전한 플랫폼별 실행파일이다. 이번 포스팅에서는 Spring Boot 의 GraalVM 네이티브 이미지 빌드를 위해서 지원되는 사항에 대한 공식 문서를 정리하고자 한다.

 

JVM 배포와 차이점

  • 애플리케이션의 정적 분석은 메인 엔트리 포인트에서 빌드 시 수행된다.
  • 네이티브 이미지가 생성될 때 도달할 수 없는 코드는 제거되며 실행 파일의 일부가 되지 않는다.
  • GraalVM 은 코드의 동적 요소를 직접 인식하지 못하므로 리플렉션, 리소스, 직렬화 및 동적 프록시에 대해 미리 알려줘야 한다.
  • 애플리케이션 클래스 경로는 빌드 시점에 고정되며 변경할 수 없다.
  • 지연 클래스 로딩이 없으며, 실행 파일에 포함된 모든 것이 시작시 메모리에 로드된다.
  • 자바 애플리케이션의 일부 측면에 대해 완전히 지원되지 않는 몇가지 제약 사항이 있다.

 

Spring 사전 처리

일반적인 Spring Boot 애플리케이션은 매우 동적이며 구성은 런타임에 수행된다. 실제로 Spring Boot Auto Configuration 의 개념은 런타임의 상태에 반응하여 올바르게 구성하는데 크게 의존한다. 애플리케이션의 동적 측면에 대해서 GraalVM 에 알려줄 수도 있지만, 그렇게 하면 정적 분석의 이점을 대부분 상실하게 되므로 Spring Boot를 사용하여 네이티브 이미지를 생성할 때는 closed-world 로 가정되고 애플리케이션의 동적 측면이 제한된다.

    • 클래스 경로는 빌드 시점에 고정되고 완전히 정의된다.
    • 애플리케이션에 정의된 빈은 런타임에 변경할 수 없다.
      • Spring @Prifile 어노테이션 및 프로필별 구성에는 제한이 있다.
      • 빈이 생성되면 변경되는 속성(@ConditionalOnProperty 및 .enable 속성 등)은 지원되지 않는다.

이러한 제약사항이 적용되면 Spring 이 빌드 시간 동안 사전 처리를 수행하고 GraalVM이 사용할 수 있는 추가 자산을 생성할 수 있다. 

Spring AOT(Ahead-of-Time) 처리된 애플리케이션은 일반적으로 다음과 같이 생성된다.

  • Java source code
  • Bytecode (for dynmic proxies etc)
  • GraalVM JSON hint files
    • 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

아래는 일반적인 @Configuration 클래스 코드다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class MyConfiguration {

    @Bean
    public MyBean myBean() {
        return new MyBean();
    }

}

Spring 은 MyBean 인스턴스가 필요한 경우 myBean() 메소드를 호출하고 그 결과를 사용해야 한다는 것을 알고 있다. JVM 에서 실행되는 경우 애플리케이션이 시작될 때 @Configuration 클래스 구문 분석이 수행되고 리플렉션을 사용하여 @Bean 메서드가 호출된다.

네이티브 이미지를 생성할 때 Spring 은 다른 방식으로 작동한다. 런타임에 @Configuration 클래스를 파싱하고 빈 정의를 생성하는 대신 빌드 타임에 이를 수행한다. @Bean 정의가 발견되면 이를 처리하여 GraalVM 컴파일러에서 분석할 수 있는 소스 코드로 변환한다.

Spring AOT 프로세스는 위의 Configuration 클래스를 다음과 같은 코드로 변환한다.

import org.springframework.beans.factory.aot.BeanInstanceSupplier;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.support.RootBeanDefinition;

/**
 * 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;
    }

}

Spring AOT 는 모든 빈 정의에 대해서 이와 같은 코드를 생성한다. 빈 사후 처리가 필요한 경우(@Autowired 메서드 호출 같은) 코드를 생성한다. 또한 AOT 처리된 애플리케이션이 실제로 실행될 때 Spring Boot 에서 ApplicationContext를 초기화 하는데 사용되는 ApplicationContextInitializer가 생성된다.

AOT로 생성된 코드는 Maven 을 사용하는 경우에는 target/spring-aot/main/sources 에서, gradle 을 사용하는 경우 build/generated/aotSources 에서 찾을 수 있다.

GraalVM 빌드 후 생성된 source 파일

 

Hint File Generation

소스 파일을 생성하는 것 외에도 Spring AOT 엔진은 GraalVM 에서 사용하는 힌트 파일도 생성한다. 힌트 파일에는 코드를 직접 검사하여 이해할 수 없는 사항을 GraalVM이 어떻게 처리해야 하는지를 설명하는 JSON 데이터가 포함되어 있다. 예를 들어, private 메소드에 Spring 어노테이션을 사용한 경우 Spring은 GraalVM에서도 private 메소드를 호출하기 위해 리플렉션을 사용해야 한다. 이러한 상황이 발생하면 Spring은 리플렉션 힌트를 작성하여 private 메소드가 직접 호출되지 않더라도 네이티브 이미지에서 사용할 수 있어야 한다는 것을 GraalVM이 알 수 있도록 한다. 힌트 파일은 target/spring-aot/main/resources/META-INF/native-image 에 생성되며 GraalVM에서 자동으로 가져온다.

생성된 힌트 파일은 생성 경로는 아래와 같다.

  • Maven
    • target/spring-aot/main/resources
  • Gradle
    • build/generated/aotResources

maven에서 GraalVM 빌드 후 생성된 hint 파일

Proxy Class Generation

Spring은 추가 기능으로 작성한 코드를 향상시키기 위해 프록시 클래스를 생성한다. 이를 위해 바이트코드를 직접 생성하는 cglib 라이브러리를 사용한다. 애플리케이션이 JVM에서 실행될 때 프록시 클래스는 애플리케이션이 실행됨에 따라 동적으로 생성된다. 네이티브 이미지를 생성할 때 이러한 프록시 클래스는 빌드 타임에 생성해야 GraalVM에 포함될 수 있다. 소스 코드 생성과 달리 생성된 바이트코드는 애플리케이션을 디버깅 할 때 특별히 유용하지 않다. 그러나 javap와 같은 도구를 사용하여 .class 파일의 내용을 검사해야 하는 경우 maven 의 경우 target/spring-aot/main/classes, gradle의 경우 build/generated/aotClasses 에서 해당 파일을 찾을 수 있다.

 

GraalVM 네이티브 애플리케이션 개발하기

네이티브 애플리케이션을 빌드하는 방법에는 크게 두가지 방법이 있다.

  • Spring Boot에서 지원하는 cloud native buildpack을 이용하여 네이티브 실행파일이 포함된 경량 컨테이너를 생성하는 방법
  • GraalVM 네이티브 빌드 툴을 사용하여 네이티브 실행 파일을 생성하는 방법

새로운 native spring boot 프로젝트를 시작하는 가장 쉬운 방법은 start.spring.io 사이트에서 'GraalVM Native Support' 종속성을 추가하여 프로젝트를 생성하는 것이다. 포함된 HELP.md 파일은 시작 힌트를 제공한다.

 

아래와 같은 기본 애플리케이션 코드가 있다.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@SpringBootApplication
public class MyApplication {

    @RequestMapping("/")
    String home() {
        return "Hello World!";
    }

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

BuildPacks 를 이용하여 Native Image 빌드하기

Spring Boot에는 maven과 gradle 모두에 대해서 네이티브 이미지에 대한 빌드팩 지원이 포함되어 있다. 명령어 하나만 입력하면 로컬에서 실행 중인 Docker 데몬에 적절한 이미지를 빠르게 가져올 수 있다. 결과 이미지에는 JVM이 포함되지 않고 네이티브 이미지가 정적으로 컴파일된다. JVM이 포함되지 않기 때문에 컨테이너 이미지 크기는 더 작아진다.

빌드를 위해서 사용되는 이미지는 paketobuildpacks/builder:tiny 이다. 빌드를 위한 이미지에 더 많은 툴을 사용하기 위해서는 paketobuildpacks/builder-jammy-base 혹은 paketobuildpacks/builder-jammy-full을 사용할 수도 있다.

이 방법은 Docker 데몬이 설치되어 있어야 한다.

macOS 에서는 Docker에 할당된 메모리를 8GB 이상으로 늘리고 CPU도 더 추가할 것을 권장한다. Docker Desktop 에서 설정 > Resources > Advanced 항목에서 설정할 수 있다. Windows에서는 더 나은 성능을 위해 Docker WSL2 백엔드를 사용하도록 설정해야 한다.

maven을 사용하여 native 이미지 컨테이너 빌드

maven을 사용하여 native 이미지 컨테이너를 빌드하려면 pom.xml 파일에 spring-boot-starter-parent 및 org.graalvm.buildtools:native-maven-plugin 이 사용되도록 해야 한다. 아래와 같은 <parent> 섹션이 있어야 한다.

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

spring-boot-starter-parent 의 pom 파일을 보면 <profiles> 섹션에 아래와 같이 native profile 이 정의되어 있다.

<profile>
  <id>native</id>
  <build>
    <pluginManagement>
      <plugins>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-jar-plugin</artifactId>
          <configuration>
            <archive>
              <manifestEntries>
                <Spring-Boot-Native-Processed>true</Spring-Boot-Native-Processed>
              </manifestEntries>
            </archive>
          </configuration>
        </plugin>
        <plugin>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-maven-plugin</artifactId>
          <configuration>
            <image>
              <builder>paketobuildpacks/builder:tiny</builder>
              <env>
                <BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
              </env>
            </image>
          </configuration>
          <executions>
            <execution>
              <id>process-aot</id>
              <goals>
                <goal>process-aot</goal>
              </goals>
            </execution>
          </executions>
        </plugin>
        <plugin>
          <groupId>org.graalvm.buildtools</groupId>
          <artifactId>native-maven-plugin</artifactId>
          <configuration>
            <classesDirectory>${project.build.outputDirectory}</classesDirectory>
            <metadataRepository>
              <enabled>true</enabled>
            </metadataRepository>
            <requiredVersion>22.3</requiredVersion>
          </configuration>
          <executions>
            <execution>
              <id>add-reachability-metadata</id>
              <goals>
                <goal>add-reachability-metadata</goal>
              </goals>
            </execution>
          </executions>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</profile>

<build><plugins> 섹션에 아래와 같은 플러그인이 추가되어야 한다.

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

spring-boot-starter-parent는 네이티브 이미지를 생성하기 위해 실행해야 하는 실행을 구성하는 네이티브 프로파일을 선언한다. 명령줄에서 -P 플래그를 사용하여 프로파일을 활성화 할 수 있다.

spring-boot-starter-parent를 사용하지 않으려면 Spring Boot 플러그인의 process-aot goal 과 네이티브 빌드 도구 플러그인에서 add-reachability-metadata goal을 구성해야 한다.

native 이미지가 포함된 컨테이너 이미지를 빌드하려면 native 프로필이 활성화된 상태에서 spring-boot:build-image 를 실행한다.

$>mvn -Pnative spring-boot:build-image

 

Gradle을 사용하여 native 이미지 컨테이너 빌드

Spring Boot Gradle 플러그인은 GraalVM 네이티브 이미지 플러그인이 적용될 때 AOT 작업을 자동으로 구성한다. build.gradle 파일에서 플러그인 블록에서 org.graalvm.buildtools.native가 포함되어야 한다. org.graalvm.buildtools.native 플러그인이 적용되어 있는 한 bootBuildImage 작업은 JVM 이미지가 아닌 네이티브 이미지 포함한 컨테이너 이미지를 생성한다.

$>gradle bootBuildImage

아래 명령으로 빌드된 이미지를 docker 환경에서 실행할 수 있다.

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

 

Native Build Tools 를 사용하여 Native 이미지 빌드하기

Docker 를 사용하지 않고 네이티브 실행 파일을 직접 생성하려면 GraalVM 네이티브 빌드 도구를 사용할 수 있다. 네이티브 빌드 도구는 maven과 gradle 모두를 위해 GraalVM에서 제공하는 플러그인이다. 네이티브 빌드 도구를 사용하여 네이티브 이미지 생성을 비롯한 다양한 GraalVM작업을 수행할 수 있다. 여기서 말하는 네이티브 이미지는 컨테이너 이미지가 아닌 실행파일이라고 생각하면 되겠다.

 

사전조건

네이티브 빌드 툴을 사용하여 네이티브 이미지를 빌드하려면 머신에 GraalVM 배포판이 필요하다. Liberica Native Image Kit page 에서 수동으로 다운로드 하여 설치하거나 SDKMAN과 같은 SDK 관리자 도구를 사용하여 설치할 수 있다.

SDKMAN 설치 및 사용법은 아래 포스팅을 참고

2023.09.02 - [분류 전체보기] - SDKMAN - 개발 도구 손쉽게 관리하기

macOS 혹은 리눅스 환경에서는 SDKMAN 을 사용하여 네이티브 이미지 컴파일러를 설치하는 것이 편하다. SDKMAN을 설치 후 아래 명령을 사용하여 GraalVM 배포를 설치한다.

-nik 는 Liberica 에서 제공하는 Native Image Kit 의 약자이다.

$>sdk install java 22.3.3.r17-nik
$>sdk use java 22.3.3.r17-nik

Using java version 22.3.3.r17-nik in this shell.
$>java -version
openjdk version "17.0.8" 2023-07-18 LTS
OpenJDK Runtime Environment GraalVM 22.3.3 (build 17.0.8+7-LTS)
OpenJDK 64-Bit Server VM GraalVM 22.3.3 (build 17.0.8+7-LTS, mixed mode, sharing)

주의 할 것은 sdk use 명령으로 java 의 버전을 22.3.3.r17-nik 패키지 버전으로 변경하였지만 해당 쉘에만 적용된다는 것이다. 영구적으로 변경하려면 아래 명령을 사용한다.

$>sdk default java 22.3.3.r17-nik

윈도우즈의 경우에는 링크를 참고한다.

 

Maven

Spring Boot buildpack 지원과 마찬가지로 네이티브 프로파일을 상속하려면 pom.xml에서 spring-boot-starter-parent를 사용하고 org.graalvm.buildtools:native-maven-plugin 을 추가해야 한다.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.3</version>
</parent>
<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
</plugin>

아래 명령으로 빌드하면 target 디렉토리에서 native 이미지 실행 파일을 찾을 수 있다.

$>mvn -Pnative native:compile
$>file target/<실행파일명>
ex)
$>file target/rest-doc
target/rest-doc: Mach-O 64-bit executable arm64

file 명령으로 실행 파일임을 확인할 수 있다.

 

Gradle

Native Build Tools Gradle 플러그인이 gradle 프로젝트에 적용되면 Spring Boot Gradle 플러그인이 자동으로 Spring AOT 엔진을 트리거 한다. task 종속성이 자동으로 구성되므로 표준 nativeCompile task 를 실행하여 네이티브 이미지를 생성한다.

$>gradle nativeCompile

native 이미지 실행 파일은 build/native/nativeCompile 디렉토리에서 찾을 수 있다.

 

 

실행속도는 확실히 GraalVM으로 빌드한 바이너리가 빠른듯 하다.

 

native 이미지 실행

2023-09-04T00:16:55.602+09:00  INFO 8824 --- [           main] com.example.restdoc.RestDocApplication   : Starting AOT-processed RestDocApplication using Java 17.0.8 with PID 8824
2023-09-04T00:16:55.602+09:00  INFO 8824 --- [           main] com.example.restdoc.RestDocApplication   : No active profile set, falling back to 1 default profile: "default"
2023-09-04T00:16:55.620+09:00  INFO 8824 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2023-09-04T00:16:55.621+09:00  INFO 8824 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2023-09-04T00:16:55.621+09:00  INFO 8824 --- [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.10]
2023-09-04T00:16:55.627+09:00  INFO 8824 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2023-09-04T00:16:55.627+09:00  INFO 8824 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 24 ms
2023-09-04T00:16:55.665+09:00  INFO 8824 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2023-09-04T00:16:55.665+09:00  INFO 8824 --- [           main] com.example.restdoc.RestDocApplication   : Started RestDocApplication in 0.081 seconds (process running for 0.098)

java 어플리케이션 실행

2023-09-04T00:18:12.410+09:00  INFO 8926 --- [  restartedMain] com.example.restdoc.RestDocApplication   : Starting RestDocApplication using Java 17.0.6 with PID 8926
2023-09-04T00:18:12.411+09:00  INFO 8926 --- [  restartedMain] com.example.restdoc.RestDocApplication   : No active profile set, falling back to 1 default profile: "default"
2023-09-04T00:18:12.430+09:00  INFO 8926 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
2023-09-04T00:18:12.431+09:00  INFO 8926 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
2023-09-04T00:18:12.765+09:00  INFO 8926 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2023-09-04T00:18:12.769+09:00  INFO 8926 --- [  restartedMain] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2023-09-04T00:18:12.769+09:00  INFO 8926 --- [  restartedMain] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.10]
2023-09-04T00:18:12.790+09:00  INFO 8926 --- [  restartedMain] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2023-09-04T00:18:12.791+09:00  INFO 8926 --- [  restartedMain] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 359 ms
2023-09-04T00:18:12.952+09:00  INFO 8926 --- [  restartedMain] o.s.b.d.a.OptionalLiveReloadServer       : LiveReload server is running on port 35729
2023-09-04T00:18:12.964+09:00  INFO 8926 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2023-09-04T00:18:12.969+09:00  INFO 8926 --- [  restartedMain] com.example.restdoc.RestDocApplication   : Started RestDocApplication in 0.703 seconds (process running for 0.968)

시작 속도가 약 9배 가량 차이가 남을 알 수 있다.

 

지금까지 Spring Boot 에서 지원하는 GraalVM 빌드 도구를 이용한 native 이미지를 빌드 하는 방법에 대해서 공식 문서를 정리해 보았다. 아래 관련 링크를 참고하면 많은 도움이 될 것 같다.

 


관련링크

https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html#native-image.introducing-graalvm-native-images

 

GraalVM Native Image Support

When writing native image applications, we recommend that you continue to use the JVM whenever possible to develop the majority of your unit and integration tests. This will help keep developer build times down and allow you to use existing IDE integration

docs.spring.io

https://www.graalvm.org/22.3/reference-manual/native-image/

 

Getting Started

 

www.graalvm.org

 

댓글

💲 추천 글