在 GraalVM 上运行 Javalin(总大小 22MB)
在 GraalVM 上运行 Javalin(总大小 22MB)
原文:https://javalin.io/2018/09/27/javalin-graalvm-example.html
Oracle 的 GraalVM 允许提前 (AOT) 编译 JVM 应用程序。这意味着,编译器不会运行 JVM 进程来执行应用程序,而是构建原生二进制文件。它是如何工作的?从非常高的层次来看,一个基本的运行时(称为 SubstrateVM)会被编译到二进制文件以及实际的应用程序中。听起来有点像 Go,它也包含一个小型运行时,例如用于垃圾收集。在本文中,我将展示如何使用 GraalVM 原生编译构建一个小型示例 RESTful Web 服务。该示例应用程序是用 Java 编写的。为什么有人会对 JVM 应用程序的原生编译感兴趣?在 E.ON 的日常工作中,遗憾的是我仍然需要与 Java 应用程序打交道。我们的技术栈完全是云原生的——我们几乎所有的东西都在 Kubernetes 上运行。我们的应用程序是“典型的” 12 因素应用程序。在这样的 dockerized 环境中,我认为原生编译之所以有趣主要有三个原因。
应用程序启动时间
这或许并非完全是 Java 的错。我们使用的是 Spring Boot,启动速度非常慢。我通常需要调整 Kubernetes 就绪性探针,使其在启动 Pod 后 20 秒内不再进行检查。这还是针对一个只有 500 行代码的小型应用程序而言的。内存占用
根据我的经验,Spring Boot 应用的内存最好不要低于 512MB。否则启动可能需要几分钟。虽然 Java 和 JVM 的开销是罪魁祸首,但这也可能是框架的问题。Spring 非常臃肿,并且使用了大量反射,这已经不是什么秘密了。应用程序大小
Java 应用程序容器的大小是另一个问题。这很烦人,但并非关键。我能找到的最小 JRE 大约 65MB(基于 alpine 的 openJDK)。如果你使用 Spring Boot,你的应用程序至少需要一个大约 40MB 的大型 Fat Jar 文件。如果你的应用程序更大,显然会更多。也就是说每个容器至少 100MB。需要注意的是,Docker 镜像的 JRE 层可能会被多个 Docker 镜像复用,因此你的应用程序的每个镜像实际上并不会超过 100MB。虽然我认为在 2018 年拥有 100MB 以上的大型 Hello World 应用程序是可以忍受的,但如果我的 Go 二进制文件只有 6MB,那就太弱了。
GraalVM AOT 编译可能会改善这种情况。我预计启动时间几乎是即时的,不需要 JVM,并且应用程序大小会显著减小。GraalVM 有一些严重的限制,因为 JVM 的几个特性不能很好地与静态编译配合使用。完整列表可以在这里找到。文档在这里非常清楚:动态类加载现在和将来都不受支持。相反,编译器会分析代码并将所有需要的类编译到二进制文件中。与反射结合,这成为当前 Java 生态系统的噩梦。许多库和框架使用反射来动态实例化类。GraalVM 不能很好地处理这个问题,在许多情况下必须提供额外的编译器配置。原因之一是,对 Class.forName() 的调用可能基于运行时信息。一个非常简单的例子:
if (someVariable) {
Class.forName("SomeClazz")
...
}
由于 someVariable 的值在编译时是未知的,因此编译器无法知道是否要包含“SomeClazz”。更不用说它只是一个字符串,编译器必须在编译时在类路径上搜索此类。如果编译器决定包含此类,它就会这样做,如果找不到该类则抛出错误。这很好。但是,这只是尽力而为。无法保证在编译时包含所有必需的类 - 这意味着类可能会丢失,并且在实例化它们时会抛出运行时错误。还有更多限制,有关完整参考,请参阅 GraalVM 的文档。作为概念证明,我正在寻找一个没有过度使用反射的休息库。显然它不是 spring boot - 我选择了javalin.io。它只是 Jetty 之上的一个休息库,仅此而已。
入门
虽然我建议在 Docker 中执行构建,但在本地安装 GraalVM 也非常有帮助。我使用sdkman,它可以简化 JDK 的管理。如果您尚未安装 sdkman:
curl -s "https://get.sdkman.io" | bash
安装 GraalVM JDK:
sdk install java 1.0.0-rc-16-grl && sdk use java 1.0.0-rc-16-grl
让我们从一个非常简单的 Hello World 开始:
public class Main {
public static void main(String[] args) {
Test t = new Test();
t.setSomeValue("Hello World!");
Javalin app = Javalin.create().start(7000);
app.get("/", ctx -> ctx.json(t));
}
}
此外,我们一定不要忘记声明必要的依赖项。我们必须包含 Jackson,因为它会在运行时加载(呃)。对于 SLF4J 绑定,情况也类似,Javalin 建议使用 slf4j-simple。
compile group: 'io.javalin', name: 'javalin', version: '2.2.0'
compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.9.6'
compile group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.25'
compile group: 'org.graalvm', name: 'graal-sdk', version: '1.0.0-rc6'
此外,我们需要构建一个包含我们应用程序的所有类和 jar 的 fat jar。
task fatJar(type: Jar) {
manifest {
attributes 'Implementation-Title': 'Gradle Jar File Example',
'Implementation-Version': version,
'Main-Class': 'de.nerden.samples.graal.Main'
}
baseName = project.name + '-all'
from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } }
with jar
}
到目前为止还没有什么特别的。为了构建原生应用程序可执行文件,GraalVM 提供了工具native-image。我们来尝试一下:
j0e@thinkpad ~/projects/graal-javalin master ● ? ⍟1 native-image -jar ./build/libs/graal-javalin-all-1.0-SNAPSHOT.jar
Build on Server(pid: 28578, port: 34643)*
[graal-javalin-all-1.0-SNAPSHOT:28578] classlist: 2,977.05 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] (cap): 963.06 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] setup: 1,663.57 ms
[ForkJoinPool-3-worker-3] INFO org.eclipse.jetty.util.log - Logging initialized @5682ms to org.eclipse.jetty.util.log.Slf4jLog
[graal-javalin-all-1.0-SNAPSHOT:28578] (typeflow): 10,510.28 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] (objects): 6,598.95 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] (features): 110.60 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] analysis: 17,612.10 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] universe: 859.27 ms
error: unsupported features in 8 methods
Detailed message:
Error: Unsupported method sun.nio.ch.InheritedChannel.soType0(int) is reachable: Native method. If you intend to use the Java Native Interface (JNI), specify -H:+JNI and see also -H:JNIConfigurationFiles=<path> (use -H:+PrintFlags for details)
To diagnose the issue, you can add the option --report-unsupported-elements-at-runtime. The unsupported element is then reported at run time when it is accessed the first time.
...
...
好的,我们需要 -H:+JNI 这个标志。这个很简单,只需在命令中添加这个标志,问题就解决了:
j0e@thinkpad ~/projects/graal-javalin master ● ? ⍟1 native-image -jar ./build/libs/graal-javalin-all-1.0-SNAPSHOT.jar -H:+JNI
Build on Server(pid: 28578, port: 34643)
[graal-javalin-all-1.0-SNAPSHOT:28578] classlist: 753.67 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] (cap): 528.63 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] setup: 776.76 ms
[ForkJoinPool-15-worker-0] INFO org.eclipse.jetty.util.log - Logging initialized @616692ms to org.eclipse.jetty.util.log.Slf4jLog
[graal-javalin-all-1.0-SNAPSHOT:28578] (typeflow): 5,934.19 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] (objects): 6,646.13 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] (features): 83.06 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] analysis: 13,491.56 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] universe: 519.25 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] (parse): 2,360.81 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] (inline): 3,674.24 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] (compile): 15,925.13 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] compile: 22,729.43 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] image: 1,426.49 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] write: 280.71 ms
[graal-javalin-all-1.0-SNAPSHOT:28578] [total]: 40,064.13 ms
编译显然是成功的。但运行之后,问题就开始了:
j0e@thinkpad ~/projects/graal-javalin master ● ? ⍟1 ./graal-javalin-all-1.0-SNAPSHOT ✔ 33695 00:53:54
[main] INFO io.javalin.Javalin -
_________________________________________
| _ _ _ |
| | | __ ___ ____ _| (_)_ __ |
| _ | |/ _` \ \ / / _` | | | '_ \ |
| | |_| | (_| |\ V / (_| | | | | | | |
| \___/ \__,_| \_/ \__,_|_|_|_| |_| |
|_________________________________________|
| |
| https://javalin.io/documentation |
|_________________________________________|
-------------------------------------------------------------------
Missing dependency 'Slf4j simple'. Add the dependency.
pom.xml:
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.25</version>
</dependency>
build.gradle:
compile "org.slf4j:slf4j-simple:1.7.25"
-------------------------------------------------------------------
Visit https://javalin.io/documentation#logging if you need more help
[main] INFO io.javalin.Javalin - Starting Javalin ...
[main] ERROR io.javalin.Javalin - Failed to start Javalin
java.lang.IllegalArgumentException: Class org.eclipse.jetty.servlet.ServletMapping[] is instantiated reflectively but was never registered. Register the class by using org.graalvm.nativeimage.RuntimeReflection
at java.lang.Throwable.<init>(Throwable.java:265)
at java.lang.Exception.<init>(Exception.java:66)
at java.lang.RuntimeException.<init>(RuntimeException.java:62)
at java.lang.IllegalArgumentException.<init>(IllegalArgumentException.java:52)
at com.oracle.svm.core.genscavenge.graal.AllocationSnippets.checkDynamicHub(AllocationSnippets.java:162)
at org.eclipse.jetty.util.ArrayUtil.addToArray(ArrayUtil.java:91)
at org.eclipse.jetty.servlet.ServletHandler.addServletWithMapping(ServletHandler.java:907)
at org.eclipse.jetty.servlet.ServletContextHandler.addServlet(ServletContextHandler.java:462)
at io.javalin.core.util.JettyServerUtil.initialize(JettyServerUtil.kt:71)
at io.javalin.Javalin.start(Javalin.java:136)
at io.javalin.Javalin.start(Javalin.java:103)
at de.nerden.samples.graal.Main.main(Main.java:10)
at com.oracle.svm.core.JavaMainWrapper.run(JavaMainWrapper.java:163)
反射不起作用。这并不奇怪,但却暴露了 GraalVM 的根本弱点:即使应用程序编译通过,它也无法保证其正常运行。
要解决这个问题,我们必须告诉 GraalVM 类 ServletMapping 必须包含在二进制文件中。由于它是反射,并且没有代码的“正常”部分,所以它没有检测到它。有两种方法可以实现这一点:基于代码和基于 JSON 配置。我测试了这两种方法,我想我更喜欢 JSON 格式,但最终它并不重要。在你的项目中添加一个包含以下内容的文件:
[
{
"name": "[Lorg.eclipse.jetty.servlet.ServletMapping;",
"allDeclaredFields": true,
"allPublicFields": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.slf4j.impl.StaticLoggerBinder",
"allDeclaredFields": true,
"allPublicFields": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "com.fasterxml.jackson.databind.ObjectMapper",
"allDeclaredFields": true,
"allPublicFields": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "de.nerden.samples.graal.Test",
"allDeclaredFields": true,
"allPublicFields": true,
"allDeclaredMethods": true,
"allPublicMethods": true
}
]
注意特殊符号[Lorg.eclipse.jetty.servlet.ServletMapping;
。这是必要的,因为在本例中,一个 ServletMapping 对象数组正在通过反射实例化。此外,我还添加了 slf4j 和 Jackson 类,以便它们在运行时被找到。在这两种情况下,由于反射不起作用,都会引发运行时错误。此外,我们还必须将自己的类添加到反射列表中。如果不这样做,执行请求时将抛出以下神秘异常:
[qtp1024494636-165] WARN io.javalin.core.ExceptionMapper - Uncaught exception
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class de.nerden.samples.graal.Test and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)
at java.lang.Throwable.<init>(Throwable.java:265)
at java.lang.Exception.<init>(Exception.java:66)
at java.io.IOException.<init>(IOException.java:58)
at com.fasterxml.jackson.core.JsonProcessingException.<init>(JsonProcessingException.java:33)
at com.fasterxml.jackson.databind.JsonMappingException.<init>(JsonMappingException.java:237)
at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.<init>(InvalidDefinitionException.java:38)
at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77)
at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1191)
at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:312)
at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.failForEmpty(UnknownSerializer.java:71)
at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.serialize(UnknownSerializer.java:33)
at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider._serialize(DefaultSerializerProvider.java:480)
at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:319)
at com.fasterxml.jackson.databind.ObjectMapper._configAndWriteValue(ObjectMapper.java:3905)
at com.fasterxml.jackson.databind.ObjectMapper.writeValueAsString(ObjectMapper.java:3219)
at io.javalin.json.JavalinJackson.toJson(JavalinJackson.kt:26)
at io.javalin.json.JavalinJson$toJsonMapper$1.map(JavalinJson.kt:28)
at io.javalin.json.JavalinJson.toJson(JavalinJson.kt:32)
at io.javalin.Context.json(Context.kt:510)
at de.nerden.samples.graal.Main.lambda$main$0(Main.java:11)
at de.nerden.samples.graal.Main$$Lambda$925/1179449634.handle(Unknown Source)
at io.javalin.security.SecurityUtil.noopAccessManager(SecurityUtil.kt:22)
at io.javalin.Javalin$$Lambda$928/1713301975.manage(Unknown Source)
at io.javalin.Javalin.lambda$addHandler$0(Javalin.java:485)
at io.javalin.Javalin$$Lambda$931/1107122283.handle(Unknown Source)
at io.javalin.core.JavalinServlet$service$2$1.invoke(JavalinServlet.kt:48)
at io.javalin.core.JavalinServlet$service$2$1.invoke(JavalinServlet.kt:20)
at io.javalin.core.JavalinServlet$service$1.invoke(JavalinServlet.kt:145)
at io.javalin.core.JavalinServlet$service$2.invoke(JavalinServlet.kt:43)
at io.javalin.core.JavalinServlet.service(JavalinServlet.kt:109)
at io.javalin.core.util.JettyServerUtil$initialize$httpHandler$1.doHandle(JettyServerUtil.kt:59)
at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:203)
at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:473)
at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:1564)
at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:201)
at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1242)
at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:144)
at org.eclipse.jetty.server.handler.HandlerList.handle(HandlerList.java:61)
at org.eclipse.jetty.server.handler.StatisticsHandler.handle(StatisticsHandler.java:174)
at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:132)
at org.eclipse.jetty.server.Server.handle(Server.java:503)
at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:364)
at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:260)
at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:305)
at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:103)
at org.eclipse.jetty.io.ChannelEndPoint$2.run(ChannelEndPoint.java:118)
at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:765)
at org.eclipse.jetty.util.thread.QueuedThreadPool$2.run(QueuedThreadPool.java:683)
at java.lang.Thread.run(Thread.java:748)
at com.oracle.svm.core.posix.thread.PosixJavaThreads.pthreadStartRoutine(PosixJavaThreads.java:238)
原因是:Jackson 使用反射来编组/解组 JSON。只要正确配置,它就可以正常工作。
你可以自己尝试一下!docker run --net=host birdy/graal-javalin
执行示例调用:curl localhost:7000
j0e@thinkpad ~/projects/graal-javalin master ? ⍟2 curl localhost:7000 ✔ 33707 01:15:12
{"abc":"LOL"}%
太棒了!而且启动速度很快,无需 JVM!
那么,从我的 3 点批评来看,结果如何?
应用程序启动时间
应用程序立即启动。虽然 Javalin 即使在 JVM 上启动也非常快(约 1-2 秒),但这对 CLI 工具来说非常有吸引力。内存占用
测量进程的内存使用情况并非易事。有几种指标可供选择——根据Stackoverflow上的一些帖子,RSS 是一个很好的指标:让我们来看看这个:cat /proc/7812/status VmRSS: 18260 kB
18MB,看起来相当不错。需要注意的是,我没有进行任何负载测试,这些只是一些空载的手动测试。使用 curl 进行了一些请求后,它的大小上升到了 25MB。好消息是:我们可以直接将其与 JVM 上的同一个应用程序进行比较。
VmRSS: 183580 kB
在这个特定情况下,它大约占内存使用量的 1⁄10。
应用程序大小
该应用程序的 Fat Jar 文件大小为 5.7MB,最小的 JRE 大小为 57MB:https://hub.docker.com/r/library/openjdk/tags/ 。为了简单起见,我们假设总共 60MB。原生二进制文件大小约为 22MB:
-rwxr-xr-x 1 j0e users 22M Sep 24 01:38 graal-javalin
这大约是 Go 二进制文件的 1/3 大小。这完全可以接受,并且几乎与 Go 二进制文件的大小相当。请注意,在 JDK 9 中,文件大小可能会更小。所以我认为这里的优势是存在的,但可能不会很大。
总的来说,GraalVM 很酷。只是感觉像个肮脏的 hack。我真的很讨厌 GraalVM 总是在编译时无法预测的运行时错误(如果我错了,请纠正我)。我不确定这是否是 Java 的未来,但它真的有这样的未来吗?;) 值得注意的是,一些库/框架的作者正在积极投入时间支持 GraalVM。事实上,micronaut.io 现在已经兼容了:https://github.com/graemerocher/micronaut-graal-experiments。
完整代码(包括 Dockerfile)可在 GitHub 上找到:https://github.com/birdayz/graal-javalin。
此外,我制作了一个 Docker 镜像,您可以将其用作基础镜像来构建仅包含静态可执行文件的容器,类似于对 Go 应用程序的操作。
FROM birdy/graalvm:latest
WORKDIR /tmp/build
ENV GRADLE_USER_HOME /tmp/build/.gradle
ADD . /tmp/build
RUN ./gradlew build fatJar
RUN native-image -jar /tmp/build/build/libs/graal-javalin-all-1.0-SNAPSHOT.jar -H:ReflectionConfigurationFiles=reflection.json -H:+JNI \
-H:Name=graal-javalin --static --delay-class-initialization-to-runtime=io.javalin.json.JavalinJson
FROM scratch
COPY --from=0 /tmp/build/graal-javalin /
ENTRYPOINT ["/graal-javalin"]
此 Dockerfile 使用 Docker 多阶段构建。有两个容器:一个仅用于构建,另一个是最终输出容器,其中仅包含应用程序。
最后编辑:Jeebiz 更新时间:2025-05-04 00:55