Spring Boot 3.0 手上过 - GraalVM原生镜像

“手上过”是方言,跟那句“Talk is cheap. Show me the code.”的意思差不多。

GraalVM Native Image,原生镜像是独立的可执行镜像,这个独立是指不需要JVM。原生镜像通常具有镜像文件小,启动速度快,内存占用少等特点。这几个特点刚好解决了大规模容器化部署和Serverless应用场景的分发效率、(冷启动)响应速度和资源消耗等问题。

本文使用start.spring.io创建了一个Spring Boot 3.0的项目,并使用GraalVM编译为原生镜像,从构建速度、镜像大小、启动速度和内存占用等方面,跟传统镜像进行了比较。

Spring官方文档: GraalVM Native Image Support

Initializr

start.spring.io 上初始化一个全新的Maven项目,Spring Boot 3.0.2,Java 17,增加Spring Web和GraalVM Native Support依赖项。

pom.xml中关于GraalVM原生镜像构建的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
  <build>
<plugins>
+ <plugin>
+ <groupId>org.graalvm.buildtools</groupId>
+ <artifactId>native-maven-plugin</artifactId>
+ </plugin>

<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

安装GraalVM

用GraalVM来构建原生镜像,因为网络的原因,在国内的主机上操作,真的的是徒增烦恼。给人的感受,就跟当初学习Kubernetes一样,很容易被劝退,所以笔者按量付费在阿里云上买的海外主机(ECS),操作系统Ubuntu。

安装 GraalVM跟安装JDK一样。

下载与解压
1
2
$ wget https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-22.3.1/graalvm-ce-java17-linux-amd64-22.3.1.tar.gz
$ tar -xvf graalvm-ce-java17-linux-amd64-22.3.1.tar.gz
增加环境变量
1
2
3
4
5
6
7
8
9
10
11
12
#修改.bashrc文件
$ vi ~/.bashrc

#增加下述两个环境变量
export PATH=/usr/local/java/graalvm-ce-java17-22.3.1/bin:$PATH
export JAVA_HOME=/usr/local/java/graalvm-ce-java17-22.3.1

#让配置生效
$ source ~/.bashrc

#检查一下是否安装成功
$ java -version
安装native-image
1
2
3
4
5
6
7
$ gu install native-image

# 也可以下载后指定文件来安装
$ gu install --local-file native-image-installable-svm-java17-linux-amd64-22.3.1.jar

# 检查一下
$ gu list

构建原生镜像

使用buildpacks构建原生镜像

buildpacks需要系统已经安装好了Docker,但本身我们这里一直在讲的原生镜像,就是Docker镜像啊,所以系统上安装Docker是个隐含条件。

1
2
3
4
5
6
7
$ mvn -Pnative spring-boot:build-image

# 第二次运行可以增加选项来避免重复下载相同的镜像和jar包
$ mvn -Pnative \
-Dspring-boot.build-image.pullPolicy=IF_NOT_PRESENT \
-Dorg.springframework.boot.buildpack.platform.build.pullPolicy=IF_NOT_PRESENT \
spring-boot:build-image

使用Native Image构建

Native Image不需要本机安装Docker,但是需要本机安装GraalVM。

1
$ mvn -Pnative package

资源消耗

编译为原生镜像,非常耗时耗资源,阿里云1核(vCPU) 2GiB内存的ECS,直接把CPU和内存打满,跑了30分钟无果,直接卡死了。强制重启后调整为4核(vCPU) 8GiB内存,耗时5分钟完成。

两种原生镜像构建方式的比较

构建方式 先决条件(本机安装的软件) 编译速度 结果镜像
buildpacks Docker 5 Min 93.6MB
native-image GraalVM和native-image 5 Min 93.6MB

可见buildpacks和native-imange这两种方式,只是对本机上的软件安装要求不同,编译速度差不多,最终得到的原生镜像相同。

原生镜像跟传统镜像的比较

传统镜像即mvn spring-boot:build-image生成的镜像。

镜像类型 是否包含JVM 构建时间 镜像大小 启动时间 内存占用
传统镜像 01:33 min 279MB 2.358 seconds 131.8MiB
原生镜像 06:32 min 93.6MB 0.061 seconds 23.39MiB
5倍 三分之一 39倍 五分之一

可见除了构建速度,原生镜像在镜像大小、启动时间和内存占用上,都明显占优。Serverless的冷启动如果能做到几十毫秒话,已经够了。当然实际的应用比这个要复杂很多。比如加上数据库连接的话,时间会更长。现在数据库的发展趋势,云原生和Serverless,也是一个主要的方向,除了解决缩放的问题,连接应该也是一个要优化的点。

跟Golang和Node.js比较,笔者这里没有详细的数据,大概是Golang还是全面占优,但是不是数量级上的差距。Node.js的镜像文件应该更大,它内置了一个V8,启动后内存占优,启动速度差不多,但都不是数量级的差距。就是说Java应用构建为原生镜像后,镜像大小、启动速度、内存占用这几项就不是劣势了。