使用Spring Boot和GraalVM的原生镜像

2023/05/11

1. 概述

在本文中,我们将了解原生镜像以及如何从Spring Boot应用程序和GraalVM的原生镜像构建器创建原生镜像。我们指的是Spring Boot 3,但我们将在文章末尾解决与Spring Boot 2的差异

2. 原生镜像

原生镜像是一种将Java代码构建为独立可执行文件的技术。此可执行文件包括应用程序类、来自其依赖项的类、运行时库类以及来自JDK的静态链接本机代码。JVM被打包到原生镜像中,因此目标系统上不需要任何Java运行时环境,但构建工件是平台相关的。因此我们需要为每个支持的目标系统构建一个,当我们使用像Docker这样的容器技术时,这会更容易,我们可以在其中构建一个容器作为可以部署到任何Docker运行时的目标系统。

2.1 GraalVM和原生镜像构建器

通用递归应用和算法语言虚拟机(General Recursive Applicative and Algorithmic Language Virtual Machine)Graal VM是为Java和其他JVM语言编写的高性能JDK发行版,同时支持JavaScript、Ruby、Python和其他几种语言。它提供了一个原生镜像构建器-一种从Java应用程序构建本机代码并将其与VM一起打包成独立可执行文件的工具。它由Spring Boot MavenGradle插件正式支持,但有少数例外(最糟糕的是Mockito目前不支持本机测试)。

2.2 特殊功能

在构建原生镜像时,我们会遇到两个典型功能。

提前(AOT)编译是将高级Java代码编译为本机可执行代码的过程。通常,这是由JVM的即时编译器(JIT)在运行时完成的,它允许在执行应用程序时进行观察和优化。在AOT编译的情况下,这个优势就失去了。

通常,在AOT编译之前,可以选择有一个称为AOT处理的单独步骤,即从代码中收集元数据并将它们提供给AOT编译器。分为这两个步骤是有意义的,因为AOT处理可以是特定于框架的,而AOT编译器更通用。下图给出了一个概览:

Java平台的另一个特点是它在目标系统上的可扩展性,只需将JAR放入类路径即可。由于启动时的反射和注解扫描,我们随后在应用程序中获得了扩展行为。

不幸的是,这会减慢启动时间并且不会带来任何好处,尤其是对于云原生应用程序,甚至服务器运行时和Java基类都被打包到JAR中。因此,我们省去了此功能,然后可以使用Closed World Optimization构建应用程序。

这两个功能都减少了需要在运行时执行的工作量。

2.3 优点

原生镜像提供各种优势,例如即时启动和减少内存消耗。它们可以打包到轻量级容器镜像中,以实现更快、更高效的部署,并且它们呈现出更小的攻击面。

2.4 限制

由于封闭世界优化,存在一些限制,我们在编写应用程序代码和使用框架时必须注意这些限制。目前:

  • 可以在构建时执行类初始值设定项,以加快启动速度并提高峰值性能。但我们必须意识到,这可能会破坏代码中的一些假设,例如,当加载一个文件时,该文件必须在构建时可用。
  • 反射和动态代理在运行时成本高昂,因此在封闭世界假设下在构建时进行了优化。在构建时执行时,我们可以在类初始值设定项中不受限制地使用它。必须向AOT编译器声明任何其他用法,原生镜像构建器会尝试通过执行静态代码分析来实现。如果失败,我们必须提供此信息,例如,通过配置文件
  • 这同样适用于所有基于反射的技术,如JNI和序列化。
  • 此外,原生镜像构建器提供了自己的本机接口,该接口比JNI简单得多且开销更低。
  • 对于原生镜像构建,字节码在运行时不再可用,因此无法使用针对JVMTI的工具进行调试和监视。然后,我们必须使用本机调试器和监控工具。

关于Spring Boot,我们必须意识到Profiles、条件bean和.enable属性等功能在运行时不再完全受支持。如果我们使用Profile,则必须在构建时指定它们。

3. 基本设置

在我们构建原生镜像之前,我们必须安装这些工具。

3.1 GraalVM和原生镜像

首先,我们按照安装说明安装当前版本的GraalVM和原生镜像构建器(Spring Boot要求版本22.3)。我们应该确保安装目录可以通过GRAALVM_HOME环境变量获得,并且“<GRAALVM_HOME>/bin”已添加到PATH变量中。

3.2 本机编译器

在构建期间,原生镜像构建器调用特定于平台的本机编译器。因此,我们需要这个本机编译器,遵循我们平台的“先决条件”说明。这将使构建依赖平台。我们必须意识到,只能在特定于平台的命令行中运行构建。例如,使用Git Bash在Windows上运行构建将不起作用。我们需要改用Windows命令行。

3.3 Docker

作为先决条件,我们将确保安装Docker,稍后需要它来运行原生镜像。Spring Boot Maven和Gradle插件使用Paketo Tiny Builder构建容器。

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

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

4.1 Maven

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

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

我们不需要这样做,因为Spring Boot父POM定义了一个将这些目标绑定到构建的native Profile。我们需要使用这个激活的Profile进行构建:

mvn clean package -Pnative

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

mvn clean package -Pnative,nativeTest

如果我们要构建一个原生镜像,我们必须添加相应的native-maven-plugin目标。因此,我们也可以定义一个native Profile。因为这个插件是由父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。因此,我们可以排除Mocking测试,或者通过将其添加到我们的POM来简单地跳过本机测试:

<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 在没有父POM的情况下使用Spring Boot

如果我们不能从Spring Boot父POM继承,而是将其用作import范围依赖项,我们必须自己配置插件和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 Plugin为AOT处理(即,不是AOT编译本身,而是为AOT编译器收集元数据,例如,在代码中注册反射的使用)和构建可以与Docker一起运行的OCI镜像提供任务:

gradle processAot
gradle processTestAot
gradle bootBuildImage

如果我们想要构建原生镜像,我们必须添加Gradle插件来构建GraalVM原生镜像:

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插件都可以在AOT处理期间使用我们的代码来生成构建配置。

指定额外本机配置的一种可能性是Native Hints。因此,让我们看一下当前缺少内置支持的两个示例,以及如何将其添加到我们的应用程序以使其正常工作。

5.1 示例:Jackson的PropertyNamingStrategy

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

spring.jacksonproperty-naming-strategy=SNAKE_CASE

SNAKE_CASE是PropertyNamingStrategies类型的静态成员的名称。不幸的是,这个成员是通过反射解决的。所以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模式文件

如果我们想要实现一个GraphQL API,我们需要创建一个模式文件并将其定位在“classpath:/graphql/*.graphqls”下,Springs GraphQL自动配置会自动检测到它。这是通过类路径扫描以及集成的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);
    }
}

要使用本机模式对其进行测试,我们必须运行本机测试:

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

如果我们需要为Spring Boot测试提供特定于测试的AOT支持,我们可以使用AotTestExecutionListener接口实现TestRuntimeHintsRegistrarTestExecutionListener。我们可以在官方文档中找到详细信息。

7. Spring Boot 2

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

7.1 Spring Native依赖

首先,我们必须为Spring Native添加Maven依赖:

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

但是,对于Gradle项目,Spring Native是由Spring AOT插件自动添加的

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

7.2 Buildpacks

要构建OCI镜像,我们需要显式配置构建包

对于Maven,我们需要使用Paketo Java buildpacks的原生镜像配置的spring-boot-maven-plugin

<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>

在这里,我们将使用各种可用构建器中的微型构建器,例如base和full来构建原生镜像。此外,我们通过为BP_NATIVE_IMAGE环境变量提供true值来启用buildpack。

同样,在使用Gradle时,我们可以将tiny构建器连同BP_NATIVE_IMAGE环境变量添加到build.gradle文件中:

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

7.3 Spring AOT 插件

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

因此,让我们将最新的spring-aot-maven-plugin Maven依赖项添加到我们的pom.xml中:

<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'
}

此外,正如我们之前提到的,这会自动将Spring Native依赖项添加到Gradle项目中。

Spring AOT插件提供了几个选项来确定源代码生成。例如,像removeYamlSupport和removeJmxSupport这样的选项分别删除了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将在package阶段从构建中调用native-image编译器。

但是,在使用Gradle时,我们会将最新的org.graalvm.buildtools.native插件添加到build.gradle文件中:

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

就是这样!我们已准备好通过在Maven package命令中提供native Profile来构建我们的原生镜像:

mvn clean package -Pnative

8. 总结

在本教程中,我们探索了使用Spring Boot和GraalVM的原生构建工具构建原生镜像。我们了解了Spring的内置原生支持。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章