使用GraalVM和本地图像改善JAVA AWS Lambda函数的冷启动时间

301 阅读15分钟

使用GraalVM和本地图像改善Java AWS Lambda函数的冷启动时间

TL;DR

我们都听说过用Java编写的Lambdas很慢,但我们开发人员可以做更多的事情来帮助改善Java Lambda的执行时间,特别是在冷启动时。在热启动时也可以有明显的改善。

Java的冷启动时间为0.7秒,已经不像以前那么糟糕了,但与JavaScript的0.16秒相比,它可能有点慢,尤其是在连锁几个Lambdas时。我们可以通过使用Quarkus框架对我们的Java Lambda项目进行提前编译(AOT),并使用GraalVM创建一个本地镜像和自定义运行时,来缩小用Java和JavaScript编写的Lambdas之间的差距。运行本地镜像和自定义运行时可以产生大约0.31秒的冷启动时间,使运行在亚马逊运行时上的Java Lambda黯然失色,并大大缩小了与JavaScript Lambdas的差距。

简而言之,如果你对在Java中编写Lambda感兴趣,那么它就值得一试。


简介

Java已经存在了很长时间,由于其安全的编译代码、易于编码和可扩展性,它是一种值得信赖的用于大规模服务器应用的语言。然而,多年来,我们看到,随着Node.js的出现,以及最近无服务器函数(或AWS术语中的Lambdas)的出现,JavaScript在服务器应用程序中的应用越来越受欢迎。它甚至更容易编码,而且很多人说它比Java运行得更快。

记忆

在Lambdas中,JavaScript受欢迎的主要原因似乎是它的执行速度,特别是Serverless Functions的冷启动时间,以及它的整体内存消耗。面对现实吧,在处理内存的方式上,Java可能是一头猪。仅仅为了在运行前加载其依赖关系,它的JAR文件就可能是巨大的。减少分配给Java无服务器功能的内存会降低其运行速度,或者在极端情况下,根本不允许其加载

无服务器功能也是按每GB秒的消耗量收费的,所以小的内存占用和快速的执行时间是降低成本的关键,而JavaScript正好涵盖了这两种情况。

执行时间

增加分配给无服务器函数的内存,会产生提高执行速度的副作用,但这并不一定会使其成本降低,因为成本是时间和内存的函数。

在这两方面,JavaScript似乎比Java更有优势。

编译器和运行时间

Java使用的是及时编译器(JIT),正如其名字所暗示的,最初编译启动应用程序所需的最低限度的内容,然后在运行时根据需要编译其他的类。这样做的好处是能够有效地运行类,只在使用时加载它需要的类,并允许使用反射。这种灵活性也使应用程序在第一次运行时变慢。让我们重申一下:当应用程序第一次执行一段代码时,由于JIT编译器在执行前编译了下一段代码,因此速度很慢。在随后的运行中,Java要快得多。

另一方面,JavaScript没有一个编译器。它是一种解释性语言,所以它被立即加载,然后通过一个解释器运行,执行代码。

让我们把它放在无服务器函数的背景下。这些是在云端某处的计算能力上运行的小段代码。你不需要管理它,不需要保持它的运行,你也不会因为没有运行的服务器而被收费。很好,是吧?是的,无服务器函数的最大好处之一是,你只需为它们实际运行的时间付费。

Lambda的生命周期

然而,无服务器函数并不一定会在周围徘徊,等待被调用。它们在被执行之前会被加载和卸载到任何可用的服务器中。在执行前将一个函数加载到内存中的做法被称为 "冷启动"。一旦无服务器函数被加载一次,它就只需要在随后的调用中被执行,而不会受到每一次加载时间的影响。这就是所谓的 "暖启动"。

这听起来非常像Java!

问题示例

我们将开发一个无服务器函数,该函数在主体中接受两个参数,一个名字和一个问候语,并返回格式为"<问候语> <名字>"的消息,除非名字是 "Stuart"(向所有Stuarts的人道歉,这不是针对他们)。如果名字是 "Stuart",则会返回 "只能问候昵称 "的消息。

我们的目标是看看哪种语言和运行时能够最快速、最有效地执行无服务器函数,最终使我们花费最少。

初步结果

如果我们用JavaScript和Java编写这个无服务器函数,并将其分别部署到AWS Lambda上,我们可能会看到这样的响应时间。

| | 初始化
(ms) | 冷启动时间
(ms) | 热启动持续时间
(ms) | 最大内存
(MB - 冷/热) | | --- | --- | --- | --- | --- | | 脚本 | 150.228 | 8.654 | 1.151 | 65/66 | | **Java 11 - Vanilla AWS
(Amazon Corretto RT)

JavaScript与Java Lambda的时间关系

在5次测试执行的平均值中,我们可以看到,Java和JavaScript Lambdas在热状态下的运行情况非常相似,尽管Java多用了50%的内存。但是Java Lambdas在冷态时真的很挣扎。这是一个众所周知的事实,常常导致人们在编写Lambdas时不选择Java。

上面的结果也证明了运行时之间的差异。JavaScript解释代码并立即执行,而Java需要很长的时间来加载所有的库,并以JIT的方式编译代码。一旦Java Lambda热身完毕(即完全加载和编译),它的平均速度几乎比JavaScript慢500µs。

关于Lambdas需要注意的是:这些都是按照AWS的指示使用指令和库编写的。

在一个温暖的生产环境中,500µs可能是一个可以接受的延迟,但冷启动时间就不一样了。对于冷启动来说,大约650ms并不算太差,但如果Java要打败我们的Hare,还有很长的路要走。

是时候让JavaScript休息一下了。它已经把这场比赛收入囊中了!

GraalVM

所以我们知道,当它们都很热的时候,Java几乎可以跟上JavaScript的步伐。但是,我们怎样才能在冷启动时加快进度呢?

进入GraalVM

GraalVM是一个高性能的JDK发行版,旨在加速用Java和其他JVM语言编写的应用程序的执行,同时支持JavaScript、Ruby、Python和其他一些流行语言。

graalvm.org/docs/introduction

是的,这很好,但它是如何帮助可怜的Java加速的呢?

这就是它的Native Image Runtime模式的优势所在。GraalVM有能力将Java代码编译成一个独立的二进制可执行文件。在本地镜像构建过程中处理的Java字节码包括所有应用程序类、依赖关系、第三方依赖库和任何需要的JDK类。一个独立的本地可执行文件被生成,具体到每个操作系统和机器架构,不需要JVM来运行。

因此,从本质上讲,它将所需的东西捆绑到自己的二进制文件中,而不是字节码,从而消除了Java运行时。它通过对应用程序(在我们的例子中是无服务器功能)进行预先编译(AOT)来实现这一点,因此在运行时不需要进一步编译。

但我们如何将GraalVM纳入我们的Lambda准备中呢?

使用另一个框架进行编码:Quarkus

让我们试试另一种框架来改善我们的Lambda执行时间。Quarkus声称符合这个要求,它声称是 "超音速亚原子Java",它有超快的统计和图表,还有Developer Joy!不要再看了!!!。

让我们简单地仔细看看Quarkus,只是为了了解它如何帮助Java Lambda实现Lambda的优越性。

Quarkus是为GraalVM和OpenJDK HotSpot定制的Kubernetes Native Java栈,由最好的Java库和标准精心打造。它还专注于开发人员的体验,使事情在几乎没有配置的情况下就能运行。

Java将不再是Lambda世界的笑柄了

项目设置

Quarkus提供了一个Maven原型,可以为一个非常简单的Lambda项目提供支架:

mvn archetype:generate \
    -DarchetypeGroupId=io.quarkus \
    -DarchetypeArtifactId=quarkus-amazon-lambda-archetype \
    -DarchetypeVersion=2.1.1.Final

如果我们仔细看看里面,就会发现有一个简单的启动项目,看起来和我们的例子一模一样!!。开发者确实很高兴!

让我们来构建这个项目:

mvn clean package

这将创建你的部署包和一个manage.sh 脚本,用于部署和更新你在/target 目录中的应用程序。每次构建时,manage.sh 将被重新生成。

调整manage.sh

在我们继续将这个Lambda部署到云端之前,让我们复制一个manage.sh 脚本并对其进行一些修改。这将使以后部署Lambda更容易。

cp target/manage.sh .

创建一个执行Lambda函数的角色,并复制其ARN,或复制现有角色的ARN。在脚本的顶部(确保你编辑你的副本,而不是在构建中定义的脚本,否则你会在下次构建时失去你的修改),定义一个名为LAMBDA_ROLE_ARN的变量,并将其设置为你之前复制的ARN。

LAMBDA_ROLE_ARN="arn:aws:iam::1234567890:role/lambda-role"

脚本中间有一些变量,脚本使用这些变量来命名Lambda函数(FUNCTION_NAME)、处理程序(HANDLER)、运行时间(RUNTIME)以及压缩文件的位置和名称(ZIP_FILE)。将函数名称更新为更适合本项目的名称:GreetingLambdaGraal

滚动到接近底部,更新将用于本地实现的函数名称:

FUNCTION_NAME=${FUNCTION_NAME}Native

让我们通过在顶部添加另一个变量,并在脚本中的cmd_create() 函数中使用它,使分配给Lambda的内存易于配置:

MEMORY_SIZE=256
...
--timeout 15
--memory-size ${MEMORY_SIZE} \
${LAMBDA_META}

确保你的脚本有执行能力:

chmod u+x manage.sh

最后,确保你在项目的根目录下有一个payload.json 文件,里面有你的测试参数:

{
    "name": "Darrow",
    "greeting": "Hello"
}

现在我们准备好了!

执行和结果

首先,我们需要在云端创建Lambda函数:

./manage.sh create

从这里开始,每次我们建立这个函数时,我们将用以下命令重新部署它:

./manage.sh update

然后我们就可以从命令行中调用该Lambda:

./manage.sh invoke

你也可以直接从AWS控制台运行Lambda,在这里你需要确保函数的参数被适当地传递。

执行后,让我们回顾一下日志。我们得到了以下结果:

REPORT RequestId: 71fd5d84-dcbd-4a95-8991-f0814cf53620
	Duration: 177.15 ms
	Billed Duration: 178 ms
	Memory Size: 256 MB
	Max Memory Used: 164 MB
	Init Duration: 3112.37 ms

在运行几次并得到平均值后,让我们把它添加到我们的结果表中:

| | 初始化
(ms) | 冷启动持续时间
(毫秒) | 热启动持续时间
(ms) | 最大内存
(MB - 冷/热) | | --- | --- | --- | --- | --- | | 脚本 | 150.228 | 8.654 | 1.151 | 65/66 | | **Java 11 - Vanilla AWS
(Amazon Corretto RT)
** | 197.768 | 456.914 | 1.714 | 92/97 | | **Java 11 Quarkus
(Amazon Correto RT)
** | 3158.056 | 151.228 | 1.302 | 164/164 |

在亚马逊的Corretto Runtime上运行的Quarkus开发的Lambda的时序的补充

在5次测试执行的平均值中,我们可以看到Quarkus Lambdas的运行时间实际上与JavaScript Lambdas在温热时更加相似,只有150µs的差异。即使是冷启动持续时间也只有Java Lambdas的三分之一。但看看初始化持续时间!超过3秒!发生了什么?我们可以从中得出的唯一结论是,用于制作Quarkus Lambda包的库要么是:

  • 太大了
  • 效率低下
  • 没有针对Amazon Corretto Java Runtime进行优化
  • 上述任何/所有问题的组合

再看看内存的消耗!它比JavaScript Lambdas多用了100MB的内存。尽管Quarkus显示了一些承诺,但它并没有完全兑现。

嘿!为什么它这么慢?GraalVM不是应该加速的吗?

D'OH!我们忘了告诉GraalVM要建立一个本地镜像。我们忽略了关键因素--GraalVM。比赛又开始了。

Docker GraalVM构建

在我们开始之前,请确保你有Docker在运行,并且有足够的内存分配给Docker来进行构建(4-8GB应该是足够的)。通过命令行向Maven传递一个最大内存值是不够的,因为需要内存的不是Maven进程,而是Docker。我们将在Docker中构建镜像,这样我们就可以针对我们将要部署的环境来编译本地镜像。我们计划在Linux环境下运行,所以我们将使用Linux Docker镜像。

mvn clean install -Pnative -Dquarkus.native.container-build=true

一个警告:这个过程可能需要几分钟!即使是在随后的构建中。甚至在后续的构建中也是如此。制作原生镜像需要时间,而Ahead Of Time编译也需要时间。我们必须编译整个应用程序,包括所有的依赖项。对我们来说没有JIT!你的风扇(如果你有的话)可能会开始疯狂地呼呼作响。别担心,它很快就会恢复正常。

这条命令告诉Maven构建一个本地镜像,并将其置于Docker容器中。完成后,它将编译并创建一个本地可执行镜像,以及一个生成于target/function.zip 的压缩文件。该压缩文件包含重命名为bootstrap的本地可执行镜像,这是AWS Lambda自定义(提供)运行时的要求。

等一下!让我们先消化一下这一点。我们不仅创建了一个二进制可执行文件,而且还提供了一个自定义的运行时间来运行我们的Lambda函数!双重超级充电!!JavaScript不会看到我们的到来!

友情提示:如果我们是在Linux环境下开发的,我们根本不需要Docker,可以直接在我们的机器上创建本地可执行镜像,也不需要讨厌的quarkus.native.container-build=true 参数。但由于我是在Mac上开发的,我需要这个额外的参数。

因此,让我们创建一个新的lambda函数,为本地镜像创建一个。

./manage.sh native create

注意在上面的命令中加入了 "本地 "参数。

最终结果

现在调用本地Lambda函数...

./manage.sh native invoke

看看输出结果...

REPORT RequestId: 1c34d93d-8de4-425d-9297-d4921405ae50
	Duration: 2.82 ms
	Billed Duration: 320 ms
	Memory Size: 256 MB
	Max Memory Used: 76 MB
	Init Duration: 316.57 ms

这真快。让我们把它添加到我们的表中。

| | 初始化
(ms) | 冷启动时间
(毫秒) | 热启动持续时间
(ms) | 最大内存
(MB - 冷/热) | | --- | --- | --- | --- | --- | | 脚本 | 150.228 | 8.654 | 1.151 | 65/66 | | **Java 11 - Vanilla AWS
(Amazon Corretto RT)
** | 197.768 | 456.914 | 1.714 | 92/97 | | **Java 11 Quarkus
(Amazon Correto RT)
** | 3158.056 | 151.228 | 1.302 | 164/164 | | **Java 11 Quarkus / GraalVM native
(Custom RT)
** | 308.480 | 2.756🔥 | 0.838🔥 | 76/76 |

在定制的GraalVM Runtime上运行的Quarkus本地镜像Lambda的时间增加了

冷启动时间,包括初始化和执行时间

哇!这个运行时长令人难以置信。运行时长令人难以置信,在冷启动和热启动中都超过了JavaScript。我们几乎看不到图表上的执行时长!但在初始化过程中,我们仍然有一些冲击,GraalVM初始化环境的时间是JavaScript的两倍。这仍然相当于不到三分之一的时间,所以对我来说听起来很不错,这也包括了它的执行时间。与标准的Java Lambda相比,它从冷启动到执行所需的时间只有一半多一点。

热启动执行时间

看看那个热启动!不到1毫秒!!!这真是太疯狂了!!!。它比所有测试过的Lambda都要强。

内存使用量

所有这些都是在只使用76MB内存的情况下实现的,这只比JavaScript多15%。运行起来还是很便宜的。

总结

随着GraalVM和本地镜像的加入,以及它们的AOT编译和自定义运行时间,Java Lambdas的速度可以很快,即使是冷启动。初始化需要一点时间,但这些时间真的可以忽略不计。AOT编译是其他大多数可编译语言的优点,比如C/C++和Rust,使它们看起来比Java更快。但采用AOT的Java和它们一样快。

即使是Vanilla Java的Lambdas也不是大家说的那种树懒。亚马逊一直在努力实现某种形式的速度提升。过去,冷启动是以秒为单位的(有点像上面的非本地Quarkus Lambda),而现在,我们是以几分之一秒为单位,尽管我们仍然可以提供一些帮助,使这些分数更小。

其他框架

Quarkus并不是唯一能与GraalVM很好整合的框架。其他的微型框架,如Helidon,允许创建本地图像用于GraalVM中。更大的框架,如我们信赖的朋友Spring,也有实现这种整合的方法(见Spring Native项目)。

最后的想法

我承认这只是一个非常微不足道的例子(但我觉得它展示了使用GraalVM、本地镜像和AOT编译的好处。

关于AOT的最后一句话。反射并没有从Java应用程序中完全删除,但需要多做一些工作才能实现。你可以提供一个配置文件,其中包括所有的类和所有可用的方法的列表,以便以反射的方式使用,这样任何现有的反射使用就不会中断。我们总是被告知反射很慢,所以这是一个你真的需要考虑你是否真的需要反射的时候,特别是当你看到AOT编译你的代码并让它为生产优化的好处时。