51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

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

1、概览 {#1概览}

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

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

2、原生镜像 {#2原生镜像}

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

2.1、GraalVM 和 Native Image Builder {#21graalvm-和-native-image-builder}

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

2.2、两个特性 {#22两个特性}

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

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、优势 {#23优势}

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

2.4、限制 {#24限制}

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

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

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

3、基本设置 {#3基本设置}

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

3.1、GraalVM 和 Native Image {#31graalvm-和-native-image}

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

3.2、Native 编译器 {#32native-编译器}

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

3.3、Docker {#33docker}

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

4、使用 Spring Boot 配置和构建项目 {#4使用-spring-boot-配置和构建项目}

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

4.1、Maven {#41maven}

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 {#42在没有-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 {#43gradle}

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、扩展原生镜像构建配置 {#5扩展原生镜像构建配置}

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

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

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

5.1、示例:Jackson 的 PropertyNamingStrategy {#51示例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 文件 {#52示例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、编写测试 {#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 {#7spring-boot-2}

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

7.1、Spring Native 依赖 {#71spring-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、构建 {#72构建}

要构建 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 {#73spring-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、构建并运行镜像 {#74构建并运行镜像}

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

$ mvn spring-boot:build-image

7.5、原生镜像构建 {#75原生镜像构建}

接下来,添加一个名为 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、总结 {#8总结}

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


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

赞(2)
未经允许不得转载:工具盒子 » 使用 Spring Boot 和 GraalVM 构建原生镜像