@levinzhang
2023-01-20T01:34:51.000000Z
字数 9990
阅读 963
原生编译(GraalVM)和虚拟线程(Loom项目)可能是Java领域最热门的话题。它们提升了应用的整体性能,包括内存使用和启动时间。长期以来,启动时间和内存使用一直是Java的一个大问题,所以业界对原生镜像或虚拟线程的期望很高。在本文中,我们将会学习如何使用虚拟线程,基于GraalVM构建原生镜像,并将这样的Java应用运行在Kubernetes上
本文最初发表于作者的个人博客网站,经原作者Piotr Mińkowski授权,由InfoQ中文站翻译分享。
在本文中,我们将会学习如何使用虚拟线程,基于GraalVM构建原生镜像,并将这样的Java应用运行在Kubernetes上。当前,原生编译(GraalVM)和虚拟线程(Loom项目)可能是Java领域最热门的话题。它们提升了应用的整体性能,包括内存使用和启动时间。长期以来,启动时间和内存使用一直是Java的一个大问题,所以业界对原生镜像或虚拟线程的期望很高。
当然,我们通常会在微服务或Serverless应用的场景中考虑此类性能问题。它们不应该消耗太多的操作系统资源,并且应该能够自动扩展。在Kubernetes上,我们可以很容易地控制资源的使用。如果你对Java虚拟线程感兴趣的话,请参阅我之前写的关于如何使用虚拟线程创建HTTP服务器的文章。关于如何在Kubernetes上,使用Knative运行Serverless应用的细节,请参阅该文。
我们看一下本文的学习计划。第一步,我们会创建一个简单的Java web应用,它会使用虚拟线程来处理传入的HTTP请求。在运行样例应用之前,我们会在Kubernetes上安装Knative,以便于快速测试基于HTTP流量的自动扩展。我们还会在Kubernetes上安装Prometheus。这个监控技术栈能够让我们对比在Kubernetes上有/无GraalVM和虚拟线程时运行应用的性能。然后,我们就可以进行部署了。为了方便在Kubernetes上构建和运行原生应用,我们会使用Cloud Native Buildpacks。最后,我们会执行一些负载测试并对比度量指标。
如果你想自己尝试的话,随时可以参阅我的源码。为此,你需要克隆我的GitHub仓库。完成之后,你就可以按照我的指南来执行了。
在第一步中,我们会创建一个简单的Java应用,它会作为一个HTTP服务器,处理传入的请求。为了实现这一点,我们可以使用来自核心Java API的HttpServer。创建完服务器之后,我们可以使用setExecutor方法覆盖默认的线程执行器(executor)。最终,我们需要基于同一个应用,对比标准线程和虚拟线程的性能。因此,我们允许使用环境变量来覆盖执行器的类型。该环境变量的名称为THREAD_TYPE。如果想要启用虚拟线程的话,你需要将该环境变量的值设置为virtual。如下是我们应用的主方法。
public class MainApp {public static void main(String[] args) throws IOException {HttpServer httpServer = HttpServer.create(new InetSocketAddress(8080), 0);httpServer.createContext("/example",new SimpleCPUConsumeHandler());if (System.getenv("THREAD_TYPE").equals("virtual")) {httpServer.setExecutor(Executors.newVirtualThreadPerTaskExecutor());} else {httpServer.setExecutor(Executors.newFixedThreadPool(200));}httpServer.start();}}
为了处理传入的请求,HTTP服务器会使用实现了HttpHandler接口的处理器(handler)。在我们的样例中,该处理器是在SimpleCPUConsumeHandler类中实现的,如下所示。它会使用大量的CPU,因为它通过构造器创建了一个BigInteger实例,这在幕后会执行很多计算。这也会消耗一定的时间,所以我们在同一个步骤中对处理耗时进行了模拟。作为响应,我们会返回序列中的数字,并以Hello_作为前缀。
public class SimpleCPUConsumeHandler implements HttpHandler {Logger LOG = Logger.getLogger("handler");AtomicLong i = new AtomicLong();final Integer cpus = Runtime.getRuntime().availableProcessors();@Overridepublic void handle(HttpExchange exchange) throws IOException {new BigInteger(1000, 3, new Random());String response = "Hello_" + i.incrementAndGet();LOG.log(Level.INFO, "(CPU->{0}) {1}",new Object[] {cpus, response});exchange.sendResponseHeaders(200, response.length());OutputStream os = exchange.getResponseBody();os.write(response.getBytes());os.close();}}
为了使用Java 19提供的虚拟线程,我们需要在编译时启用预览模式。在使用Maven时,我们需要使用maven-compiler-plugin启用预览特性,如下所示。
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.10.1</version><configuration><release>19</release><compilerArgs>--enable-preview</compilerArgs></configuration></plugin>
在Kubernetes上运行原生应用并不需要这一步和下一步的步骤。我们将使用Knative,以便于根据传入的流量对应用进行自动扩展。在下一节中,我将会描述如何在Kubernetes上运行监控技术栈。
在Kubernetes上安装Knative的最简单方式是使用kubectl命令。我们只需要Knative Serving组件,并不需要任何额外的特性,也不会用到Knative CLI(kn)。我们将会使用Skaffold,基于YAML清单来部署应用。
首先,我们通过如下命令安装所需的自定义资源:
$ kubectl apply -f https://github.com/knative/serving/releases/download/knative-v1.8.3/serving-crds.yaml
然后,我们可以通过如下命令安装Knative Serving的核心组件:
$ kubectl apply -f https://github.com/knative/serving/releases/download/knative-v1.8.3/serving-core.yaml
为了在Kubernetes集群外部访问Knative服务,我们还需要安装网络层。默认情况下,Knative使用Kourier作为Ingress。我们可以通过如下命令安装Kourier控制器:
$ kubectl apply -f https://github.com/knative/net-kourier/releases/download/knative-v1.8.1/kourier.yaml
最后,我们通过如下的命令,配置Knative Serving来使用Kourier:
kubectl patch configmap/config-network \--namespace knative-serving \--type merge \--patch '{"data":{"ingress-class":"kourier.ingress.networking.knative.dev"}}'
如果你没有配置外部域名或者在本地集群中运行Knative,那么你需要配置DNS。否则,你必须在运行curl时带上主机头信息。Knative提供了一个Kubernetes Job,它会将sslip.io设置为默认DNS后缀。
$ kubectl apply -f https://github.com/knative/serving/releases/download/knative-v1.8.3/serving-default-domain.yaml
生成的URL会包含服务的名称、命名空间和Kubernetes集群的地址。因为我在本地Kubernetes集群的demo-sless命名空间运行服务,所以该服务可以在以下地址访问:

在部署样例应用到Knative之前,我们再做一些其他的工作。
正如我在前文所述,我们还可以在Kubernetes上安装监控技术栈。
最简单的安装方式是使用kube-prometheus-stack Helm chart。该包中包含了Prometheus和Grafana。它还包含了所有需要的规则和仪表盘,以便于可视化Kubernetes集群的基本度量指标。首先,我们添加包含该chart的Helm仓库:
$ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
然后,我们使用如下的命令安装位于prometheus命名空间中的kube-prometheus-stack Helm chart:
$ helm install prometheus-stack prometheus-community/kube-prometheus-stack \-n prometheus \--create-namespace
如果一切正常的话,我们会看到类似于如下所示的Kubernetes服务:
$ kubectl get svc -n prometheusNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEalertmanager-operated ClusterIP None <none> 9093/TCP,9094/TCP,9094/UDP 11sprometheus-operated ClusterIP None <none> 9090/TCP 10sprometheus-stack-grafana ClusterIP 10.96.218.142 <none> 80/TCP 23sprometheus-stack-kube-prom-alertmanager ClusterIP 10.105.10.183 <none> 9093/TCP 23sprometheus-stack-kube-prom-operator ClusterIP 10.98.190.230 <none> 443/TCP 23sprometheus-stack-kube-prom-prometheus ClusterIP 10.111.158.146 <none> 9090/TCP 23sprometheus-stack-kube-state-metrics ClusterIP 10.100.111.196 <none> 8080/TCP 23sprometheus-stack-prometheus-node-exporter ClusterIP 10.102.39.238 <none> 9100/TCP 23s
我们将会分析Grafana仪表盘中的CPU和内存统计数据。为此,我们可以启用port-forward,以便于在本地预定义的端口访问它,例如使用9080端口:
$ kubectl port-forward svc/prometheus-stack-grafana 9080:80 -n prometheus
Grafana的默认用户名和密码分别为admin和prom-operator。
运行环境
我自己使用的是Docker Desktop自带的本地Kubernetes来完成该练习。它并没有提供任何简化方式来运行Prometheus和Knative。但是,你可以使用任意其他的Kubernetes发行版。比如,在OpenShift中,借助它所提供的operator支持,我们可以在UI仪表盘上一键完成安装过程。
我们将会在自定义Grafana仪表盘中创建两个面板。第一个面板会展示demo-sless命名空间中每个Pod的内存使用情况。
sum(container_memory_working_set_bytes{namespace="demo-sless"} / (1024 * 1024)) by (pod)
第二个面板将显示demo-sless命名空间中每个Pod的平均内存使用情况。你可以基于GitHub仓库的k8s/grafana-dasboards.json文件直接将它们导入Grafana中。
rate(container_cpu_usage_seconds_total{namespace="demo-sless"}[3m])
Prometheus Staleness
默认情况下,如果没有新的返回值的话,Prometheus会将不带时间戳的度量保存5分钟。例如,如果Pod被杀死的话,你会看到5分钟内的内存和CPU使用率的度量。要改变这种行为,在安装kube-prometheus-stackchart时,可以设置prometheus.prometheusSpec.query.lookbackDelta的值,比如将其设置为1m。
我们已经创建完了示例应用并配置了Kubernetes环境。现在,我们可以进入部署阶段了。在这里,我们的目标是尽可能简化构建原生镜像和在Kubernetes上运行的过程。因此,我们会使用Cloud Native Buildpacks和Skaffold。有了Buildpacks之后,除了Docker,我们不需要在本地笔记本上安装任何东西。Skaffold可以很容易地与Buildpacks集成,使得在Kubernetes上构建和运行应用的整个过程实现自动化。你只需要在机器上安装skaffold CLI即可。
要构建Java应用的原生镜像,我们可以使用Paketo Buildpacks。它为GraalVM提供了一个专门的buildpack,叫做Paketo GraalVM Buildpack。我们应该在配置中通过paketo-buildpacks/graalvm名称来包含它。因为Skaffold支持Buildpack,我们应该在skaffold.yaml文件中设置所有的属性。此外,我们还需要使用环境变量覆盖一些默认配置。首先,我们需要将Java版本设置为19,并启用预览特性(虚拟线程)。Kubernetes部署清单可以在k8s/deployment.yaml路径找到。
apiVersion: skaffold/v2beta29kind: Configmetadata:name: sample-java-concurrencybuild:artifacts:- image: piomin/sample-java-concurrencybuildpacks:builder: paketobuildpacks/builder:basebuildpacks:- paketo-buildpacks/graalvm- paketo-buildpacks/java-native-imageenv:- BP_NATIVE_IMAGE=true- BP_JVM_VERSION=19- BP_NATIVE_IMAGE_BUILD_ARGUMENTS=--enable-previewlocal:push: truedeploy:kubectl:manifests:- k8s/deployment.yaml
Knative不仅简化了自动扩展,而且还简化了Kubernetes清单。下面是示例应用的清单,可以在k8s/deployment.yaml文件中找到。我们需要定义一个包含应用容器详情的Service对象,并将自动扩展的目标从默认的200个并发请求改为80个。这意味着,如果应用的单个实例同时处理80个请求,Knative就会创建该应用的新实例(确切的说,是Pod)。为了给我们的应用启用虚拟线程,需要将环境变量THREAD_TYPE设置为virtual。
apiVersion: serving.knative.dev/v1kind: Servicemetadata:name: sample-java-concurrencyspec:template:metadata:annotations:autoscaling.knative.dev/target: "80"spec:containers:- name: sample-java-concurrencyimage: piomin/sample-java-concurrencyports:- containerPort: 8080env:- name: THREAD_TYPEvalue: virtual- name: JAVA_TOOL_OPTIONSvalue: --enable-preview
假设已经安装了Skaffold,你唯一需要做的就是运行如下命令:
$ skaffold run -n demo-sless
你也可以直接从我的Docker Hub注册中心部署一个准备就绪的镜像。但是,在这种情况下,你需要将deployment.yaml清单中的镜像标签修改为virtual-native。

运行非原生应用
借助SkaffoldPak和eto Buildpacks,你也可以基于我的仓库构建和部署非原生应用。只需要在Skaffold配置文件中,使用paketo-buildpacks/java buildpack替换paketo-buildpacks/graalvm即可。
我们会运行三个测试场景。在第一个场景中,我们将会测试标准编译和大小为100的标准线程池。在第二个场景中,我们将会测试使用虚拟线程的标准编译。最后一个场景会检查原生编译和虚拟场景。在所有的场景中,我们都会设置相同的自动扩展目标,即80个并发请求。我会使用k6工具进行负载测试。每个测试场景包含四个步骤,每个步骤持续两分钟。在第一步中,我们模拟50个用户。
$ k6 run -u 50 -d 120s k6-test.js
然后,我们要模拟100个用户。
$ k6 run -u 100 -d 120s k6-test.js
最后,我们要为200个用户测试两次。因此,一共会有四次测试,分别是50、100、200和200个用户,共耗时8分钟。
$ k6 run -u 200 -d 120s k6-test.js
我们来验证一下结果。如下是使用JavaScript为k6工具编写的测试。
import http from 'k6/http';import { check } from 'k6';export default function () {const res = http.get(`http://sample-java-concurrency.demo-sless.127.0.0.1.sslip.io/example`);check(res, {'is status 200': (res) => res.status === 200,'body size is > 0': (r) => r.body.length > 0,});}
下面展示了测试场景的每个阶段中的内存使用情况。在模拟200个用户之后,Knative扩展了实例的数量。理论上,在100个用户的测试时,它就应该这样做。但是,Knative会根据Pod中sideecar容器来测量传入的流量。第一个实例的内存使用大约是900MB(包含sidecar容器的使用量)。

如下是一个类似的视图,只不过反映的是CPU的使用情况。最高的消耗量是在自动扩展发生之前,大约是1.2个内核。然后,根据所使用的实例数量的不同,范围从大约0.4到0.7个内核。正如我在前文所述,我们使用一个比较耗时的BigInteger构造器来模拟负载下的CPU使用。

如下是50个用户的测试结果。该应用能够在2分钟内处理大约10.5万个请求。最长的处理时间约为3秒钟。

如下是100个用户的测试结果。该应用能够在2分钟内处理大约13万个请求,平均响应时间为90毫秒。

最后,我们看一下200个用户的测试结果。应用能够在2分钟内处理大约13.5万个请求,平均响应时间为175毫秒。故障阈值在0.02%左右。

与上一节类似,下面展示了测试场景的每个阶段中的内存使用情况。在模拟100个用户之后,Knative扩展了实例的数量。理论上,在200个用户的测试时,它应该运行第三个实例。第一个实例的内存使用大约是850MB(包含sidecar容器的使用量)。

如下是一个类似的视图,只不过反映的是CPU的使用情况。最高的消耗量是在自动扩展发生之前,大约是1.1个内核。然后,根据所使用的实例数量的不同,范围从大约0.3到0.7个内核。

如下是50个用户的测试结果。该应用能够在2分钟内处理大约10.5万个请求。最长的处理时间约为2.2秒钟。

如下是100个用户的测试结果。该应用能够在2分钟内处理大约11.5万个请求,平均响应时间为100毫秒。

最后,我们看一下200个用户的测试结果。应用能够在2分钟内处理大约13.5万个请求,平均响应时间为180毫秒。故障阈值在0.02%左右。

与上一节类似,下面展示了测试场景的每个阶段中的内存使用情况。在模拟100个用户之后,Knative扩展了实例的数量。理论上,在200个用户的测试时,它应该运行第三个实例(图中第三个Pod实际上在一段时间内处于Terminating阶段)。第一个实例的内存使用大约是50MB。

如下是一个类似的视图,只不过反映的是CPU的使用情况。最高的消耗量是在自动扩展发生之前,大约是1.3个内核。然后,根据所使用的实例数量的不同,范围从大约0.3到0.9个内核。

如下是50个用户的测试结果。该应用能够在2分钟内处理大约7.5万个请求。最长的处理时间约为2秒钟。

如下是100个用户的测试结果。该应用能够在2分钟内处理大约8.5万个请求,平均响应时间为140毫秒。

最后,我们看一下200个用户的测试结果。应用能够在2分钟内处理大约10万个请求,平均响应时间为200毫秒。另外,在第二次200个用户的测试中,没有出现故障。

在本文中,我试图在Kubernetes上对基于GraalVM原生编译和虚拟线程的Java应用与标准方式进行了对比。在运行完上述的所有测试后,我们可以得出如下结论。