如何将 Spring Boot 3应用迁移到原生镜像?


不同于新的spring-boot-docker-compose模块,们要走一条完全不同的路,我们来看看GraalVM Native Image 原生镜像支持

有很多关于此特定功能的文章。不幸的是,其中很多都已经过时了,因为以前,我们可以使用spring-native模块为 spring boot 应用程序生成本机镜像,该模块现已弃用。

此外,大多数资料都在非常简单的应用程序上描述了这种原生本机镜像生成,这可以向我们隐藏一些细节。在本文中,我们将描述如何为比 hello world 更大的东西执行此迁移。

为什么需要原生镜像?
正如我们所说,我们要向你展示如何将你的spring boot应用程序迁移到一个本地应用程序。

现在你可能会问自己,"为什么我想把我的应用程序作为一个原生本地镜像运行?"。
嗯......主要有两个原因:

  1. 一是应用启动速度更快(谁不想让生产中的应用在几毫秒内启动),
  2. 二是内存占用率更低。

这种方法也有一些缺点,当我们要进行迁移时,你会自己看到主要的痛点是什么,回到我们的计划。

首先,我们需要有一个我们要迁移的应用程序,而且必须是一个真正的应用程序,而不仅仅是一个简单的hello world。
一个太简单的应用程序会对我们隐藏一些非常重要的细节。我们不会从头开始写这个应用,我们将使用RealWorld的一个应用,它使用了许多spring boot模块。
这样一来,我们就可以只关注我们需要做哪些改变来把我们的应用程序编译成一个本地的。

对于我们的迁移,有一个理想的候选者:https://github.com/shirohoo/realworld-java17-springboot3,运行在一个带有spring boot 3.0的java 17上。
我们将修改这个应用程序,以便建立一个本地镜像,并将其发布到我们的GitHub仓库。这个应用程序使用了许多我们应用程序中常用的模块,例如spring-boot-starter-jpa、spring-boot-starter-security、spring-boot-starter-web、hibernate、h2数据库,以及其他一些小工具,如Lombok,所以它应该足以向你展示我们在迁移时可能面临的问题。

接下来,我们将一起完成迁移,我们将向你展示最佳实践以及如何解决你的应用程序中的一些迁移问题。

如果你打算将你的spring boot应用程序构建为一个原生本地镜像,你应该先阅读这份关于已知限制的文档,以了解你的应用程序是否使用了该列表中的东西。如果你发现你的应用程序中的某些东西还不被支持,你有两个选择:等待官方支持或将这个特定的东西迁移到其他东西上。例如,由于log4j2在原生本地镜像中不被支持,你应该在尝试构建原生本地镜像之前迁移到logback。

迁移步骤:

目录结构:

├── api
├── build
├── build.gradle.kts
├── database
├── gradle
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src

说明:

  • api - 包含开放的api定义,以postman集合的形式向我们的api发出请求,以及一个简单的脚本,用newman测试我们的api,它将被用来测试我们的应用程序。
  • build - 包含我们所有构建工件的目录
  • build.gradle.kts, settings.gradle.kts - 在kotlin dsl中配置gradle项目(你知道kotlin dsl现在是新gradle构建的默认配置吗?)
  • gradle, gradlew, gradlew.bat - gradle包装器
  • database - 包含我们的数据库模式的目录
  • src --我们的应用程序的源代码

前提条件
我们需要有一个GraalVM发行版,这样我们才能使用本地构建工具构建本地镜像。请使用sdkman来管理你的发行版,而不是手动下载它们。我们将使用基于GraalVM的22.3.2.r17版本的Liberica发行版。

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


启用AOT后运行你的应用程序
在直接进行本地镜像编译之前,我们应该先测试一下我们的应用程序是否在使用AOT生成的初始化代码的JVM上正确运行。这样,我们可以比运行整个本地镜像编译过程更快地发现任何潜在的错误。

首先,我们需要启用初始化代码的生成。这是为什么呢?你可能知道,当你的应用程序启动时,spring会做很多工作,如果你想有一个本地镜像,所有这些工作不能在运行时执行,这就是为什么我们有这个生成初始化代码的阶段,所有在常规spring应用程序运行时发生的事情都会在构建时转化为生成代码。

好吧,为了用gradle生成这些代码,我们只需要在我们的插件部分添加org.graalvm.buildtools.native插件:

plugins {
    java
    id("com.diffplug.spotless") version "6.18.0"
    id(
"org.springframework.boot") version "3.0.5"
    id(
"io.spring.dependency-management") version "1.1.0"
    id(
"org.graalvm.buildtools.native") version "0.9.20"
}
$ ./gradlew build

出现错误:

Execution failed for task ':spotlessJavaCheck'.
> The following files had format violations:
      build/generated/aotSources/com/github/gavlyukovskiy/boot/jdbc/decorator/DataSourceDecoratorAutoConfiguration__BeanDefinitions.java

看起来spotless也在验证我在构建目录中生成的类......让我们通过对spotless配置的简单修改来禁用这个功能:

spotless {
    java {
        target("src/main/java/**/*.java")
        palantirJavaFormat()
        indentWithSpaces()

然后我们可以再次运行./gradlew build。现在我们的应用程序已经建立了,里面有AOT生成的代码。让我们试着运行它,并使用AOT生成的初始化代码。你可以通过启用spring.aot.enabled属性来做到这一点。

$ java -Dspring.aot.enabled=true -jar ./build/libs/realworld.jar

我们应该在日志的开头看到,我们的AOT处理的应用程序正在启动:

Starting AOT-processed RealworldApplication using Java 17.0.7 with PID....

测试验证:
$ ./api/run-api-tests.sh

开始迁移
1、第一次尝试

如果你的应用程序使用AOT生成的代码运行没有任何问题,我们就可以开始尝试以本地模式构建我们的应用程序。但作为第一步,你可能不应该这样做。让我告诉你为什么。要开始我们的本地编译过程,我们只需要运行:

$ ./gradlew nativeCompile

我们有了我们的本地镜像!!!!让我们运行它!

Caused by: java.lang.ExceptionInInitializerError: null
        at com.github.gavlyukovskiy.boot.jdbc.decorator.p6spy.P6SpyConfiguration.init(P6SpyConfiguration.java:112) ~[realworld:na]


所以在我们的依赖关系中的某个地方,我们有一段代码无法初始化一些变量。所以我们有一个空对象,在代码的后面被访问,这就是为什么我们有一个NullPointerException。为什么它没有被初始化?让我们回到开头...

我们希望有一个本地的应用程序,主要是因为它给我们提供了两个非常酷的特性,快速启动应用程序和低内存占用。为了给我们提供这两个特性,GraalVM在工作中假设了一个封闭的世界,在构建时所有的代码都必须是已知的。正因为如此,在最终的应用程序中,我们将只有那些在我们的应用程序中使用的类(低内存占用)。其次,当我们编译我们的应用程序时,native-image编译器也知道哪些对象是在我们的应用程序开始时创建的,它将为我们预先填充一个堆,因此,应用程序的快速启动来自于此。

回到我们的问题上。因为在构建时,native-image编译器无法发现我们试图动态加载一些类,所以它没有把它放在最终的应用程序中。加载这些类的代码将变量初始化为空,在运行时,我们得到一个NullPointerException。

如何解决这个问题呢?因为它是通过反射访问的,所以我们必须告诉编译器,这个类应该在构建时被加载,并在运行时对我们的应用程序可用,为了做到这一点,我们需要以JSON文件的形式定义我们的可达性元数据。是的......我们也许可以这样做,但我们应该在这些文件里放些什么?我们是否需要分析所有的库来找出它们在做什么?

经过5分钟的构建时间,我们出现了第一个错误。我们可以尝试自己定义这个可达性元数据,这肯定会超过5分钟,然后我们将不得不重新编译我们的应用程序,运行它,修复错误,重新编译,运行它,修复错误......谁有时间做这个?你知道为什么你不应该这样做吗?在这样复杂的应用程序中,用手来定义所有这些可达性元数据几乎是不可能的。有一个更好的方法可以做到这一点。

运行追踪代理以收集可达性元数据

我们可以在运行我们的应用程序时附加一个跟踪代理,这样它就会为我们收集所有这些可达性元数据,而不是手工定义这些可达性元数据。但是,由于我们将在运行时收集元数据,我们需要确保我们将在我们的应用程序中执行所有的代码路径,这样这个代理将收集所有需要的信息,当我们运行我们的本地图像时,我们将不会有任何意外。

再一次,让我们把我们的应用程序构建为一个jar文件。
$ ./gradlew build

现在我们可以运行我们的应用程序,并附上本地镜像代理:

$ java -agentlib:native-image-agent=config-output-dir=./src/main/resources/META-INF/native-image/ -Dspring.aot.enabled=true -jar ./build/libs/realworld.jar

我们可以看到,我们在代理中加入了配置选项config-output-dir=./src/main/resources/META-INF/native-image/,因为一旦我们的可达性元数据被放在这个目录中,那么当我们运行./gradlew nativeCompile时,本地图像编译器将自动为我们获取这些元数据。

现在我们需要通过我们的应用程序,以便代理将为我们收集所有这些可及性元数据。要做到这一点,我们可以使用放在./api目录下的api测试。

$ ./api/run-api-tests.sh

现在我们可以关闭我们的应用程序,我们可以看到在src/main/resources/META-INF/native-image目录下的JSON文件中有很多配置。想象一下,这些都是手写的......在我们的repo中,你可以看到最终版本的构建中的所有文件。

建立一个本地镜像
现在我们准备建立我们的本地镜像。让我们开始吧!

./gradlew nativeCompile

运行:
$ ./build/native/nativeCompile/realworld

它正常了!!!!最后,我们让它运行了。看看它启动的速度有多快!半秒就能完成一个spring boot应用程序?这真是太棒了!

Started RealworldApplication in 0.453 seconds (process running for 0.463)

运行测试:
$ ./api/run-api-tests.sh

|                    | executed | failed |
|--------------------|----------|--------|
| iterations         | 1        | 0      |
| requests           | 32       | 0      |
| test-scripts       | 48       | 2      |
| prerequest-scripts | 18       | 0      |
| assertions         | 194      | 69     |

而且,看起来我们在hibernate方面有一些问题:

Caused by: org.hibernate.HibernateException: Generation of HibernateProxy instances at runtime is not allowed when the configured BytecodeProvider is 'none'; your model requires a more advanced BytecodeProvider to be enabled.


好的,所以hibernate试图生成一些代理,例如,用于脏检查或懒惰初始化,但我们不能在本地图像中这样做,因为hibernate中的整个字节码提供者需要被禁用。我们不能在运行时进行字节码操作。

static {
    if (NativeDetector.inNativeImage()) {
        System.setProperty(Environment.BYTECODE_PROVIDER, Environment.BYTECODE_PROVIDER_NAME_NONE);
    }
}

所以我们唯一的选择是在运行时也禁用这个hibernate代理的生成,我们将需要在构建时生成所有这些代理。为此我们有一个专门的gradle插件:Hibernate Enhance Plugin。
在build.gradle.kts中,有两件事要做,以便在构建时生成这段代码。首先,我们需要把它添加到我们的插件中:

id("org.hibernate.orm") version ("6.2.2.Final")

然后我们需要配置我们的代码增强功能:

hibernate {
    enhancement {
        enableDirtyTracking.set(true)
        enableLazyInitialization.set(true)
        enableExtendedEnhancement.set(false)
    }
}

现在我们可以重复整个周期来检查一切是否正常:建立一个标准的应用程序,用native-image-agent运行它,运行测试来收集可达性元数据,最后建立native image。

但在这之前,我们还需要做一件事,以解决hibernate的所有问题。我们还需要在运行常规应用程序时禁用字节码提供者(由hibernate使用):这有两个原因:我们想用代理的方式运行我们的应用程序,就像它在本地环境中运行一样;其次,我们想让我们的常规应用程序以同样的方式运行,就像本地应用程序一样,这样我们就可以在常规应用程序测试中更快地发现任何可能在本地环境中发生的错误。

为此,我们可以hibernate.properties在内部进行src/main/resources以下配置:

hibernate.bytecode.provider=none
hibernate.bytecode.use_reflection_optimizer=false

此配置中的第二行禁用 ReflectionOptimizer,因为如果我们不禁用它,我们的测试将失败并出现异常:

org.hibernate.HibernateException: Using the ReflectionOptimizer is not possible when the configured BytecodeProvider is 'none'. Disable hibernate.bytecode.use_reflection_optimizer or use a different BytecodeProvider

好的,让我们再次运行整个过程:

看到另一个错误......这很累......

Caused by: com.oracle.svm.core.jdk.UnsupportedFeatureError: No classes have been predefined during the image build to load from bytecodes at runtime.
        at org.graalvm.nativeimage.builder/com.oracle.svm.core.util.VMError.unsupportedFeature(VMError.java:89) ~[na:na]
        at org.graalvm.nativeimage.builder/com.oracle.svm.core.hub.PredefinedClassesSupport.throwNoBytecodeClasses(PredefinedClassesSupport.java:76) ~[na:na]
        at org.graalvm.nativeimage.builder/com.oracle.svm.core.hub.PredefinedClassesSupport.loadClass(PredefinedClassesSupport.java:130) ~[na:na]
        at java.base@17.0.7/java.lang.ClassLoader.defineClass(ClassLoader.java:294) ~[realworld:na]
        at net.bytebuddy.utility.dispatcher.JavaDispatcher$DynamicClassLoader.invoker(JavaDispatcher.java:1383) ~[na:na]


这告诉我们增强器插件在运行时进行了一些字节码操作。但是我们不能在运行时进行任何字节码操作,我们的 bytebuddy 被禁用了。那我们如何解决这个问题呢?
为了克服这个问题,我们需要使用 GraalVM 的一项实验性功能:支持预定义类。所以我们将修改我们的命令native-image-agent来运行应用程序。

$ java -agentlib:native-image-agent=config-output-dir=./src/main/resources/META-INF/native-image/,**experimental-class-define-support** -Dspring.aot.enabled=true -jar ./build/libs/realworld.jar

现在,native-image-agent会在src/main/resources/META-INF/native-image目录下保存一些字节码作为我们的可及性元数据,我们可以尝试构建我们的应用程序。

但如果我们尝试这样做,这次我们会遇到JaCoCo的一些问题。JaCoCo是一个测试覆盖率分析器,它通过检测我们的字节码来工作,由于现在我们将在./src/main/resources中生成一些字节码,它也将开始分析这些代码,并且会抛出一个错误,即已经有相同名称的分析类(一个类来自我们的常规源,第二个来自我们的可及性元数据)。最简单的解决方法是把这些生成的代码放在其他地方,但是我们需要重新配置我们的本地图像,以便它从不同的目录中提取我们的可及性元数据。

因此,让我们在项目根目录下创建一个 aot 目录,并配置 native-image-agent 输出目录

$ java -agentlib:native-image-agent=config-output-dir=**./aot/META-INF/native-image/**,experimental-class-define-support -Dspring.aot.enabled=true -jar ./build/libs/realworld.jar

现在,我们需要将这个aot目录添加到我们的native-image工具的classpath中。让我们在build.gradle.kts中这样做吧

graalvmNative {
    binaries {
        names.forEach { binaryName ->
            named(binaryName){
                classpath("./aot/")
            }
        }
    }
}

所以我们把这个aot目录添加到每个二进制文件的classpath中,在我们的例子中是main和test。

现在我们已经准备好构建我们的应用程序了。请记住,我们必须再次重做整个循环。你想喝第二杯咖啡吗?

再经过一次(我不记得我做了多少次)构建周期后,我们可以用我们的api测试来运行我们的应用程序,看起来一切正常!我们的应用程序在半秒内就启动了,这是一个巨大的成功!我们的应用程序在半秒内就启动了,这是一个巨大的成功。

总结
正如你所看到的,将现实世界中的spring boot应用程序作为原生镜像运行是可能的,但这并不是一件容易的事,需要花费大量的时间来最终准备好一些工作。我们还应该将整个流程整合到我们的CI管道中,这也是一个挑战。

让我给你看一下我们的应用程序作为本地镜像和作为普通Java应用程序运行的小对比。

regular application | ~3 seconds | 500 Mb
native application | ~0.5 second | 200 Mb

在较大的应用程序上,启动时间和内存使用的差异将更大。

这些数字看起来相当惊人,但问题在哪里?这些是切换到本地图像的主要缺点:

  • 本机镜像的构建时间比普通构建慢得多,使用temurin分布的Java 17构建一个普通的应用程序只需要20秒,所以5分钟和20秒相比是一个巨大的差别、
  • 不是所有的库都能在本地环境下工作(已知的限制)、
  • 没有JVMTI、Java代理、JMX、JFR支持。你只能用内核功能来检查你的应用程序内部发生的事情。这也意味着,你通过MXBeans收集的关于jvm堆的指标(例如,通过micrometer)是不可用的。一般来说,所有从MXBeans中收集的指标都是不可用的、
  • serialGC是GraalVM社区版中唯一可用的垃圾收集器。你可以使用G1 GC,但只能在Oracle Java SE Subscription下的GraalVM企业版中使用、
  • 没有JIT,这意味着你的应用程序的吞吐量会降低。