[关闭]
@levinzhang 2023-01-20T01:34:51.000000Z 字数 9990 阅读 204

使用GraalVM和虚拟线程实现Kubernetes上的原生Java

摘要

原生编译(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应用

在第一步中,我们会创建一个简单的Java应用,它会作为一个HTTP服务器,处理传入的请求。为了实现这一点,我们可以使用来自核心Java API的HttpServer。创建完服务器之后,我们可以使用setExecutor方法覆盖默认的线程执行器(executor)。最终,我们需要基于同一个应用,对比标准线程和虚拟线程的性能。因此,我们允许使用环境变量来覆盖执行器的类型。该环境变量的名称为THREAD_TYPE。如果想要启用虚拟线程的话,你需要将该环境变量的值设置为virtual。如下是我们应用的主方法。

  1. public class MainApp {
  2. public static void main(String[] args) throws IOException {
  3. HttpServer httpServer = HttpServer
  4. .create(new InetSocketAddress(8080), 0);
  5. httpServer.createContext("/example",
  6. new SimpleCPUConsumeHandler());
  7. if (System.getenv("THREAD_TYPE").equals("virtual")) {
  8. httpServer.setExecutor(
  9. Executors.newVirtualThreadPerTaskExecutor());
  10. } else {
  11. httpServer.setExecutor(Executors.newFixedThreadPool(200));
  12. }
  13. httpServer.start();
  14. }
  15. }

为了处理传入的请求,HTTP服务器会使用实现了HttpHandler接口的处理器(handler)。在我们的样例中,该处理器是在SimpleCPUConsumeHandler类中实现的,如下所示。它会使用大量的CPU,因为它通过构造器创建了一个BigInteger实例,这在幕后会执行很多计算。这也会消耗一定的时间,所以我们在同一个步骤中对处理耗时进行了模拟。作为响应,我们会返回序列中的数字,并以Hello_作为前缀。

  1. public class SimpleCPUConsumeHandler implements HttpHandler {
  2. Logger LOG = Logger.getLogger("handler");
  3. AtomicLong i = new AtomicLong();
  4. final Integer cpus = Runtime.getRuntime().availableProcessors();
  5. @Override
  6. public void handle(HttpExchange exchange) throws IOException {
  7. new BigInteger(1000, 3, new Random());
  8. String response = "Hello_" + i.incrementAndGet();
  9. LOG.log(Level.INFO, "(CPU->{0}) {1}",
  10. new Object[] {cpus, response});
  11. exchange.sendResponseHeaders(200, response.length());
  12. OutputStream os = exchange.getResponseBody();
  13. os.write(response.getBytes());
  14. os.close();
  15. }
  16. }

为了使用Java 19提供的虚拟线程,我们需要在编译时启用预览模式。在使用Maven时,我们需要使用maven-compiler-plugin启用预览特性,如下所示。

  1. <plugin>
  2. <groupId>org.apache.maven.plugins</groupId>
  3. <artifactId>maven-compiler-plugin</artifactId>
  4. <version>3.10.1</version>
  5. <configuration>
  6. <release>19</release>
  7. <compilerArgs>
  8. --enable-preview
  9. </compilerArgs>
  10. </configuration>
  11. </plugin>

在Kubernetes上安装Knative

在Kubernetes上运行原生应用并不需要这一步和下一步的步骤。我们将使用Knative,以便于根据传入的流量对应用进行自动扩展。在下一节中,我将会描述如何在Kubernetes上运行监控技术栈。

在Kubernetes上安装Knative的最简单方式是使用kubectl命令。我们只需要Knative Serving组件,并不需要任何额外的特性,也不会用到Knative CLI(kn)。我们将会使用Skaffold,基于YAML清单来部署应用。

首先,我们通过如下命令安装所需的自定义资源:

  1. $ kubectl apply -f https://github.com/knative/serving/releases/download/knative-v1.8.3/serving-crds.yaml

然后,我们可以通过如下命令安装Knative Serving的核心组件:

  1. $ kubectl apply -f https://github.com/knative/serving/releases/download/knative-v1.8.3/serving-core.yaml

为了在Kubernetes集群外部访问Knative服务,我们还需要安装网络层。默认情况下,Knative使用Kourier作为Ingress。我们可以通过如下命令安装Kourier控制器:

  1. $ kubectl apply -f https://github.com/knative/net-kourier/releases/download/knative-v1.8.1/kourier.yaml

最后,我们通过如下的命令,配置Knative Serving来使用Kourier:

  1. kubectl patch configmap/config-network \
  2. --namespace knative-serving \
  3. --type merge \
  4. --patch '{"data":{"ingress-class":"kourier.ingress.networking.knative.dev"}}'

如果你没有配置外部域名或者在本地集群中运行Knative,那么你需要配置DNS。否则,你必须在运行curl时带上主机头信息。Knative提供了一个Kubernetes Job,它会将sslip.io设置为默认DNS后缀。

  1. $ 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上安装Prometheus技术栈

正如我在前文所述,我们还可以在Kubernetes上安装监控技术栈。

最简单的安装方式是使用kube-prometheus-stack Helm chart。该包中包含了Prometheus和Grafana。它还包含了所有需要的规则和仪表盘,以便于可视化Kubernetes集群的基本度量指标。首先,我们添加包含该chart的Helm仓库:

  1. $ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts

然后,我们使用如下的命令安装位于prometheus命名空间中的kube-prometheus-stack Helm chart:

  1. $ helm install prometheus-stack prometheus-community/kube-prometheus-stack \
  2. -n prometheus \
  3. --create-namespace

如果一切正常的话,我们会看到类似于如下所示的Kubernetes服务:

  1. $ kubectl get svc -n prometheus
  2. NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
  3. alertmanager-operated ClusterIP None <none> 9093/TCP,9094/TCP,9094/UDP 11s
  4. prometheus-operated ClusterIP None <none> 9090/TCP 10s
  5. prometheus-stack-grafana ClusterIP 10.96.218.142 <none> 80/TCP 23s
  6. prometheus-stack-kube-prom-alertmanager ClusterIP 10.105.10.183 <none> 9093/TCP 23s
  7. prometheus-stack-kube-prom-operator ClusterIP 10.98.190.230 <none> 443/TCP 23s
  8. prometheus-stack-kube-prom-prometheus ClusterIP 10.111.158.146 <none> 9090/TCP 23s
  9. prometheus-stack-kube-state-metrics ClusterIP 10.100.111.196 <none> 8080/TCP 23s
  10. prometheus-stack-prometheus-node-exporter ClusterIP 10.102.39.238 <none> 9100/TCP 23s

我们将会分析Grafana仪表盘中的CPU和内存统计数据。为此,我们可以启用port-forward,以便于在本地预定义的端口访问它,例如使用9080端口:

  1. $ kubectl port-forward svc/prometheus-stack-grafana 9080:80 -n prometheus

Grafana的默认用户名和密码分别为adminprom-operator

运行环境
我自己使用的是Docker Desktop自带的本地Kubernetes来完成该练习。它并没有提供任何简化方式来运行Prometheus和Knative。但是,你可以使用任意其他的Kubernetes发行版。比如,在OpenShift中,借助它所提供的operator支持,我们可以在UI仪表盘上一键完成安装过程。

我们将会在自定义Grafana仪表盘中创建两个面板。第一个面板会展示demo-sless命名空间中每个Pod的内存使用情况。

  1. sum(container_memory_working_set_bytes{namespace="demo-sless"} / (1024 * 1024)) by (pod)

第二个面板将显示demo-sless命名空间中每个Pod的平均内存使用情况。你可以基于GitHub仓库的k8s/grafana-dasboards.json文件直接将它们导入Grafana中。

  1. rate(container_cpu_usage_seconds_total{namespace="demo-sless"}[3m])

Prometheus Staleness
默认情况下,如果没有新的返回值的话,Prometheus会将不带时间戳的度量保存5分钟。例如,如果Pod被杀死的话,你会看到5分钟内的内存和CPU使用率的度量。要改变这种行为,在安装kube-prometheus-stack chart时,可以设置prometheus.prometheusSpec.query.lookbackDelta的值,比如将其设置为1m

构建和部署原生Java应用

我们已经创建完了示例应用并配置了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路径找到。

  1. apiVersion: skaffold/v2beta29
  2. kind: Config
  3. metadata:
  4. name: sample-java-concurrency
  5. build:
  6. artifacts:
  7. - image: piomin/sample-java-concurrency
  8. buildpacks:
  9. builder: paketobuildpacks/builder:base
  10. buildpacks:
  11. - paketo-buildpacks/graalvm
  12. - paketo-buildpacks/java-native-image
  13. env:
  14. - BP_NATIVE_IMAGE=true
  15. - BP_JVM_VERSION=19
  16. - BP_NATIVE_IMAGE_BUILD_ARGUMENTS=--enable-preview
  17. local:
  18. push: true
  19. deploy:
  20. kubectl:
  21. manifests:
  22. - k8s/deployment.yaml

Knative不仅简化了自动扩展,而且还简化了Kubernetes清单。下面是示例应用的清单,可以在k8s/deployment.yaml文件中找到。我们需要定义一个包含应用容器详情的Service对象,并将自动扩展的目标从默认的200个并发请求改为80个。这意味着,如果应用的单个实例同时处理80个请求,Knative就会创建该应用的新实例(确切的说,是Pod)。为了给我们的应用启用虚拟线程,需要将环境变量THREAD_TYPE设置为virtual

  1. apiVersion: serving.knative.dev/v1
  2. kind: Service
  3. metadata:
  4. name: sample-java-concurrency
  5. spec:
  6. template:
  7. metadata:
  8. annotations:
  9. autoscaling.knative.dev/target: "80"
  10. spec:
  11. containers:
  12. - name: sample-java-concurrency
  13. image: piomin/sample-java-concurrency
  14. ports:
  15. - containerPort: 8080
  16. env:
  17. - name: THREAD_TYPE
  18. value: virtual
  19. - name: JAVA_TOOL_OPTIONS
  20. value: --enable-preview

假设已经安装了Skaffold,你唯一需要做的就是运行如下命令:

  1. $ 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个用户。

  1. $ k6 run -u 50 -d 120s k6-test.js

然后,我们要模拟100个用户。

  1. $ k6 run -u 100 -d 120s k6-test.js

最后,我们要为200个用户测试两次。因此,一共会有四次测试,分别是50、100、200和200个用户,共耗时8分钟。

  1. $ k6 run -u 200 -d 120s k6-test.js

我们来验证一下结果。如下是使用JavaScript为k6工具编写的测试。

  1. import http from 'k6/http';
  2. import { check } from 'k6';
  3. export default function () {
  4. const res = http.get(`http://sample-java-concurrency.demo-sless.127.0.0.1.sslip.io/example`);
  5. check(res, {
  6. 'is status 200': (res) => res.status === 200,
  7. 'body size is > 0': (r) => r.body.length > 0,
  8. });
  9. }

标准编译和标准线程的测试

下面展示了测试场景的每个阶段中的内存使用情况。在模拟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应用与标准方式进行了对比。在运行完上述的所有测试后,我们可以得出如下结论。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注