使用 Spring Boot 和 GraalVM 构建原生镜像

1、概览

本年将带你了解原生镜像(Native Image)的相关知识,以及如何使用 Spring Boot 和 GraalVM 构建原生镜像应用。

本文使用的是 Spring Boot 3,但是在末尾会教你如何解决与 Spring Boot 2 的差异问题。

2、原生镜像

原生(本地)镜像是一种将 Java 代码构建为独立可执行文件的技术。该可执行文件包括应用程序类、其依赖项的类、运行时库类以及来自 JDK 的静态链接本地代码。JVM 被打包到原生镜像中,因此在目标系统上不需要任何 Java 运行环境,但构建产物依赖于平台。因此,需要为每个支持的目标系统进行一次构建,在使用 Docker 等容器技术时会更加简单,将容器构建为一个目标系统,可以部署到任何 Docker 运行时。

2.1、GraalVM 和 Native Image Builder

通用递归应用和算法语言虚拟机(Graal VM)是一个高性能的 JDK 发行版,专为 Java 和其他 JVM 语言编写,同时支持 JavaScript、Ruby、Python 和其他几种语言。它提供了一个原生镜像生成器(Native Image builder),这是一个从 Java 应用中生成原生代码并将其与 VM 一起打包成独立可执行文件的工具。Spring Boot MavenGradle Plugin 除了少数 例外情况(Mockito 目前不支持原生测试),正式支持该工具。

2.2、两个特性

在构建原生镜像时,会遇到两个典型特性。

Ahead-Of-Time(AOT)编译是将高级 Java 代码编译成本地可执行代码的过程。通常由 JVM 的即时编译器 (JIT) 在运行时进行编译,这样可以在执行应用程序时进行观察和优化。在 AOT 编译的情况下,这一优势就不复存在了。

通常,在进行 AOT(Ahead-of-Time)编译之前,可以选择进行一个单独的步骤,称为 AOT 处理,即从代码中收集元数据并提供给 AOT 编译器。将这两个步骤分开是有意义的,因为 AOT 处理可以是针对特定框架的,而 AOT 编译器更加通用。下面的图片给出了一个概览:

原生镜像构建步骤的概览

Java 平台的另一个特点是,只需将 JAR 放入类路径,就能在目标系统上进行扩展。通过启动时的反射和注解扫描,就能在应用中获得扩展行为。

不幸的是,这会减慢启动时间,而且不会带来任何好处,尤其是对于云原生应用,因为在云原生应用中,服务器运行时和 Java 基类都打包到了 JAR 中。因此,可以放弃这一功能,然后可以使用闭环优化(Closed World Optimization)来构建应用。

这两项特性都减少了运行时所需的工作量。

2.3、优势

原生镜像具有各种优势,如即时启动和减少内存消耗。它们可以打包成轻量级的容器镜像,以实现更快、更高效的部署,并减少攻击面。

2.4、限制

由于采用了 “闭环优化”,在编写应用代码和使用框架时必须注意一些 限制。简而言之:

  • 类初始化器可以在构建时执行,以实现更快的启动和更好的性能峰值。但必须意识到,这可能会破坏代码中的一些假设,例如,在加载文件时,该文件必须在构建时可用。
  • 反射和动态代理在运行时成本很高,因此在 “闭环优化” 假设下,在构建时进行了优化。在构建时执行时,可以不受限制地在类初始化器中使用。任何其他用途都必须向 AOT 编译器公布,Native Image builder 会尝试通过执行静态代码分析来达到这一目的。如果分析失败,就必须通过 配置文件 等方式提供相关信息。
  • 这同样适用于所有基于反射的技术,如 JNI 和序列化(Serialization)
  • 此外,Native Image builder 还提供了自己的原生接口,比 JNI 简单得多,开销也更低。
  • 对于原生镜像构建,字节码在运行时不再可用,因此无法使用针对 JVMTI 的工具进行调试和监控。因此,必须使用本地调试器和监控工具。

对于 Spring Boot,运行时不再完全支持 配置文件、条件 Bean 和 .enable 属性等功能。如果使用配置文件,则必须在构建时指定。

3、基本设置

在构建原生镜像之前,必须先安装工具。

3.1、GraalVM 和 Native Image

首先,按照 安装说明 安装当前版本的 GraalVM 和 Native Image Builder(Spring Boot 需要 22.3 版)。确保安装目录可通过 GRAALVM_HOME 环境变量获取,并将 <GRAALVM_HOME>/bin 添加到 PATH 变量中。

3.2、Native 编译器

在构建过程中,Native Image builder 会调用特定于平台的本地编译器。因此,需要根据平台遵循 “先决条件” 说明 来获取这个本地编译器。这将使构建依赖于平台。必须意识到,只能在特定于平台的命令行中运行构建。例如,使用 Git Bash 在 Windows 上运行构建是行不通的。需要使用 Windows 命令行。

3.3、Docker

首先,必须安装 Docker,这是稍后运行原生镜像所必需的。Spring Boot Maven 和 Gradle 插件使用 Paketo Tiny Builder 构建容器。

4、使用 Spring Boot 配置和构建项目

使用 Spring Boot 的原生构建功能非常简单。例如,使用 Spring Initializr 创建项目并添加应用代码。然后,使用 GraalVM 的 Native Image builder 构建原生镜像,需要使用 GraalVM 本身提供的 Maven 或 Gradle 插件扩展我们的构建。

4.1、Maven

Spring Boot Maven 插件 的 Goal (目标)包括 AOT 处理(即不是 AOT 编译本身,而是为 AOT 编译器收集元数据,例如在代码中注册反射的使用)和构建可通过 Docker 运行的 OCI 镜像。可以直接调用这些 Goal:

mvn spring-boot:process-aot
mvn spring-boot:process-test-aot
mvn spring-boot:build-image

我们不需要这样做,因为 Spring Boot Parent POM 定义了一个 native 配置文件,将这些 Goal 绑定到构建中。需要使用这个已激活的配置文件(Profile)进行构建:

mvn clean package -Pnative

如果还想执行本地测试,还可以激活第二个 Profile:

mvn clean package -Pnative,nativeTest

如果要构建原生镜像,就必须添加 native-maven-plugin 的相应 Goal。因此,也可以定义一个 native Profile。由于该插件由 parent POM 管理,因此可以不声明版本号:

<profiles>
    <profile>
        <id>native</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <executions>
                        <execution>
                            <id>build-native</id>
                            <goals>
                                <goal>compile-no-fork</goal>
                            </goals>
                            <phase>package</phase>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

目前,本地测试执行不支持 Mockito。因此,可以将此配置添加到 POM 中,从而排除 Mocking 测试或直接跳过本地测试:

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.graalvm.buildtools</groupId>
                <artifactId>native-maven-plugin</artifactId>
                <configuration>
                    <skipNativeTests>true</skipNativeTests>
                </configuration>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

4.2、在没有 Parent POM 的情况下使用 Spring Boot

如果不能从 Spring Boot Parent POM 继承,而是将其作为 import scope 依赖,就必须自己配置插件和 Profile。然后,必须将其添加到 POM 中:

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.graalvm.buildtools</groupId>
                <artifactId>native-maven-plugin</artifactId>
                <version>${native-build-tools-plugin.version}</version>
                <extensions>true</extensions>
            </plugin>
        </plugins>
    </pluginManagement>
</build>
<profiles>
    <profile>
        <id>native</id>
        <build>
            <plugins>
                <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>
        </build>
    </profile>
    <profile>
        <id>nativeTest</id>
        <dependencies>
            <dependency>
                <groupId>org.junit.platform</groupId>
                <artifactId>junit-platform-launcher</artifactId>
                <scope>test</scope>
            </dependency>
        </dependencies>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <executions>
                        <execution>
                            <id>process-test-aot</id>
                            <goals>
                                <goal>process-test-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>native-test</id>
                            <goals>
                                <goal>test</goal>
                            </goals>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>
<properties>
    <native-build-tools-plugin.version>0.9.17</native-build-tools-plugin.version>
</properties>

4.3、Gradle

Spring Boot Gradle 插件 为 AOT 处理(即不是 AOT 编译本身,而是为 AOT 编译器收集元数据,例如在代码中注册反射的使用)和构建可通过 Docker 运行的 OCI 镜像提供了 Task(任务):

gradle processAot
gradle processTestAot
gradle bootBuildImage

构建原生镜像,必须添加用于 构建 GraalVM 原生镜像的 Gradle 插件

plugins {
    // ...
    id 'org.graalvm.buildtools.native' version '0.9.17'
}

然后,通过如下命令运行测试并构建项目。

gradle nativeTest
gradle nativeCompile

目前,本地测试执行不支持 Mockito。因此,可以通过如下配置 graalvmNative 扩展来排除 Mocking 测试或跳过本地测试:

graalvmNative {
    testSupport = false
}

5、扩展原生镜像构建配置

如前所述,必须为 AOT 编译器注册反射、类路径扫描、动态代理等每种用法。由于 Spring 的内置原生支持是一项非常新的功能,目前并非所有 Spring 模块都有内置支持,因此目前需要自己添加这一功能。这可以通过手动创建构建配置来实现。不过,使用 Spring Boot 提供的接口会更方便,这样 Maven 和 Gradle Plugins 都能在 AOT 处理过程中使用我们的代码来生成构建配置。

原生提示(Native Hints)是一种指定额外原生配置的方法。

让我们看两个当前缺少内置支持的示例,以及如何将其添加到我们的应用中使其正常工作。

5.1、示例:Jackson 的 PropertyNamingStrategy

在 MVC Web 应用中,Jackson 会将 REST Controller 方法的每个返回值序列化,并将每个属性自动命名为一个 JSON 元素。我们可以通过在应用 application properties 文件中配置 Jackson 的 PropertyNamingStrategy 来影响全局名称映射:

spring.jacksonproperty-naming-strategy=SNAKE_CASE

SNAKE_CASEPropertyNamingStrategies 类型中一个静态成员的名称。不幸的是,该成员是通过反射解析的。因此,AOT 编译器需要知道这一点,否则会抛出异常:

Caused by: java.lang.IllegalArgumentException: Constant named 'SNAKE_CASE' not found
  at org.springframework.util.Assert.notNull(Assert.java:219) ~[na:na]
  at org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
        $Jackson2ObjectMapperBuilderCustomizerConfiguration
        $StandardJackson2ObjectMapperBuilderCustomizer.configurePropertyNamingStrategyField(JacksonAutoConfiguration.java:287) ~[spring-features.exe:na]

为了实现这一点,可以通过以下简单的方法实现并注册 RuntimeHintsRegistrar

@Configuration
@ImportRuntimeHints(JacksonRuntimeHints.PropertyNamingStrategyRegistrar.class)
public class JacksonRuntimeHints {

    static class PropertyNamingStrategyRegistrar implements RuntimeHintsRegistrar {

        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            try {
                hints
                  .reflection()
                  .registerField(PropertyNamingStrategies.class.getDeclaredField("SNAKE_CASE"));
            } catch (NoSuchFieldException e) {
                // ...
            }
        }
    }

}

注:自 3.0.0-RC2 版起,在 Spring Boot 中解决此问题的 Pull Request 求已被合并,因此开箱即可在 Spring Boot 3 中使用。

5.2、示例:GraphQL Schema 文件

如果想实现 GraphQL API,需要创建一个 Schema 文件,并将其放置在 classpath:/graphql/*.graphqls 下,这样它就可以被 Spring 的 GraphQL 自动配置自动检测到。这是通过 classpath 扫描完成的,同时也会在集成的 GraphiQL 测试客户端的欢迎页面上体现出来。因此,为了在本地可执行文件中正确工作,AOT 编译器需要知道这一点。

可以用同样的方法进行注册:

@ImportRuntimeHints(GraphQlRuntimeHints.GraphQlResourcesRegistrar.class)
@Configuration
public class GraphQlRuntimeHints {

    static class GraphQlResourcesRegistrar implements RuntimeHintsRegistrar {

        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            hints.resources()
              .registerPattern("graphql/**/")
              .registerPattern("graphiql/index.html");
        }
    }

}

Spring GraphQL 团队已经开始着手 这项工作,因此可能会在未来的版本中内置这项功能。

6、编写测试

要测试 RuntimeHintsRegistrar 的实现,甚至不需要运行 Spring Boot 测试,而是创建一个简单的 JUnit 测试,如下:

@Test
void shouldRegisterSnakeCasePropertyNamingStrategy() {
    // arrange
    final var hints = new RuntimeHints();
    final var expectSnakeCaseHint = RuntimeHintsPredicates
      .reflection()
      .onField(PropertyNamingStrategies.class, "SNAKE_CASE");
    // act
    new JacksonRuntimeHints.PropertyNamingStrategyRegistrar()
      .registerHints(hints, getClass().getClassLoader());
    // assert
    assertThat(expectSnakeCaseHint).accepts(hints);
}

如果要通过集成测试进行测试,可以检查 Jackson ObjectMapper 的配置是否正确:

@SpringBootTest
class JacksonAutoConfigurationIntegrationTest {

    @Autowired
    ObjectMapper mapper;

    @Test
    void shouldUseSnakeCasePropertyNamingStrategy() {
        assertThat(mapper.getPropertyNamingStrategy())
          .isSameAs(PropertyNamingStrategies.SNAKE_CASE);
    }

}

要使用本地模式进行测试,必须运行 nativeTest

# Maven
mvn clean package -Pnative,nativeTest
# Gradle
gradle nativeTest

如果需要为 Spring Boot 测试提供特定于测试的 AOT 支持,可以实现一个 TestRuntimeHintsRegistrar 或使用 AotTestExecutionListener 接口实现一个 TestExecutionListener。详情请参见 官方文档

7、Spring Boot 2

Spring 6 和 Spring Boot 3 在原生镜像构建方面迈出了一大步。但在之前的主要版本中,这也是可能的。只需知道,目前还没有内置支持,也就是说,有一个 Spring Native Initiative 计划处理此主题。因此,必须在项目中手动加入和配置。对于 AOT 处理,有一个单独的 Maven 和 Gradle 插件,它并没有合并到 Spring Boot 插件中。当然,集成库也没有像现在这样提供原生支持(将来会更多)。

7.1、Spring Native 依赖

添加 spring-native 依赖:

<dependency>
    <groupId>org.springframework.experimental</groupId>
    <artifactId>spring-native</artifactId>
    <version>0.12.1</version>
</dependency>

对于 Gradle 项目,Spring AOT 插件会自动添加 Spring Native。

注意,每个 Spring Native 版本只支持特定的 Spring Boot 版本,例如,Spring Native 0.12.1 只支持 Spring Boot 2.7.1。因此,应确保在 pom.xml 中使用兼容的 Spring Boot Maven 依赖。

7.2、构建

要构建 OCI 镜像,需要明确配置构建包(build pack)。

使用 Maven 时,需要使用 spring-boot-maven-pluginPaketo Java buildpacks 进行原生镜像配置:

<build>
    <pluginManagement>
        <plugins>
            <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>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

使用各种可用的 Builder 中的 tiny Builder (如 basefull)来构建原生镜像。此外,通过将 BP_NATIVE_IMAGE 环境变量设置为 true 来启用 buildpack。

同样,在使用 Gradle 时,可以在 build.gradle 文件中添加 tiny builder 和 BP_NATIVE_IMAGE 环境变量:

bootBuildImage {
    builder = "paketobuildpacks/builder:tiny"
    environment = [
        "BP_NATIVE_IMAGE" : "true"
    ]
}

7.3、Spring AOT Plugin

接下来,需要添加 Spring AOT 插件,它可以执行 AOT 转换,有助于改善原生镜像的占用空间和兼容性。

pom.xml 中添加 spring-aot-maven-plugin Maven 依赖:

<plugin>
    <groupId>org.springframework.experimental</groupId>
    <artifactId>spring-aot-maven-plugin</artifactId>
    <version>0.12.1</version>
    <executions>
        <execution>
            <id>generate</id>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
</plugin>

同样,对于 Gradle 项目,可以在 build.gradle 文件中添加最新的 org.springframework.experimental.aot 依赖:

plugins {
    id 'org.springframework.experimental.aot' version '0.10.0'
}

此外,如前所述,这将自动在 Gradle 项目中添加 Spring Native 依赖。

Spring AOT 插件提供了 几种确定源生成的选项 例如,removeYamlSupportremoveJmxSupport 等选项可分别移除 Spring Boot Yaml 和 Spring Boot JMX 支持。

7.4、构建并运行镜像

使用 Maven 命令来构建和运行 Spring Boot 项目的原生镜像。

$ mvn spring-boot:build-image

7.5、原生镜像构建

接下来,添加一个名为 native 的配置文件(Profile),该配置文件支持一些插件的构建,如 native-maven-plugin 和 spring-boot-maven-plugin

<profiles>
    <profile>
        <id>native</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <version>0.9.17</version>
                    <executions>
                        <execution>
                            <id>build-native</id>
                            <goals>
                                <goal>build</goal>
                            </goals>
                            <phase>package</phase>
                        </execution>
                    </executions>
                </plugin>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <configuration>
                        <classifier>exec</classifier>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

该 Profile 将在打包阶段调用构建中的 native-image 编译器。

不过,在使用 Gradle 时,需要在 build.gradle 文件中添加最新的 org.graalvm.buildtools.native 插件:

plugins {
    id 'org.graalvm.buildtools.native' version '0.9.17'
}

就这样!通过在 Maven package 命令中提供 native Profile,就可以构建原生镜像了:

mvn clean package -Pnative

8、总结

本文介绍了如何使用 Spring Boot 和 GraalVM 的原生构建工具来构建原生镜像。


Ref:https://www.baeldung.com/spring-native-intro