在Kubernetes + Knative中测试GraalVM和虚拟线程的原生Java应用性能


在本文中,您将学习如何使用虚拟线程、使用 GraalVM 构建本机镜像并在 Kubernetes 上运行Java 应用程序。

目前,原生编译(GraalVM)和虚拟线程(Project Loom)可能是Java界最热门的话题。它们提高了应用程序的总体性能,包括内存使用和启动时间。由于启动时间和内存使用一直是 Java 的问题,因此对本机镜像或虚拟线程的期​​望非常高。

当然,我们通常在微服务或无服务器应用程序的上下文中考虑此类性能问题:它们不应该消耗很多操作系统资源,并且应该很容易自动扩展。我们可以很容易地通过控制 Kubernetes 上资源使用实现这一目标。

步骤:

  • 第一步中,我们将创建一个简单的 Java Web 应用程序,它使用虚拟线程来处理传入的 HTTP 请求。
  • 在我们运行示例应用程序之前,我们将在 Kubernetes 上安装 Knative 以快速测试基于 HTTP 流量的自动缩放。
  • 我们还将在 Kubernetes 上安装 Prometheus。这个监控堆栈允许我们比较应用程序在没有/有 GraalVM 和 Kubernetes 上的虚拟线程的情况下的性能。
  • 然后,我们可以继续部署。为了在 Kubernetes 上轻松构建和运行我们的原生应用程序,我们将使用 Cloud Native Buildpacks。
  • 最后,我们将执行一些负载测试并比较指标。

源代码 GitHub 存储库


使用虚拟线程创建 Java 应用程序
我们将创建一个简单的Java应用,作为一个HTTP服务器,处理传入的请求。
为了做到这一点,我们可以使用核心Java API中的HttpServer对象。一旦我们创建了服务器,我们可以用setExecutor方法覆盖一个默认的线程执行器。
最后,我们将尝试将使用标准线程的应用程序与使用虚拟线程的相同应用程序进行比较。因此,我们允许用一个环境变量来覆盖执行器的类型。它的名字是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接口的处理程序。在我们的例子中,处理程序是在SimpleCPUConsumeHandler类中实现的,如下所示。它消耗了大量的CPU,因为它用构造函数创建了一个BigInteger的实例,在引擎盖下执行了大量的计算。它也会消耗一些时间,所以我们在同一步骤中对处理时间进行了模拟。作为回应,我们只是返回序列中带有Hello_前缀的下一个数字。

public class SimpleCPUConsumeHandler implements HttpHandler {

   Logger LOG = Logger.getLogger("handler");
   AtomicLong i = new AtomicLong();
   final Integer cpus = Runtime.getRuntime().availableProcessors();

   @Override
   public 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 上运行本机应用程序不需要此步骤和下一步。我们将使用 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 作为入口。我们可以通过运行以下命令来安装 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 提供了一个Job 设置 sslip.io为默认 DNS 后缀的 Kubernetes。

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

生成的 URL 包含服务名称、命名空间和 Kubernetes 集群的地址。由于我在demo-sless命名空间中的本地 Kubernetes 集群上运行我的服务,因此我的服务在以下地址下可用:


但在我们将示例应用程序部署到 Knative 之前,让我们做一些其他的事情。


在 Kubernetes 上安装 Prometheus Stack
正如我之前提到的,我们还可以在 Kubernetes 上安装一个监控堆栈。

安装它的最简单方法是使用kube-prometheus-stackHelm chart。该软件包包含 Prometheus 和 Grafana。它还包括所有必需的规则和仪表板,以可视化 Kubernetes 集群的基本指标。首先,让我们添加包含图表的 Helm 存储库:

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

然后我们可以使用以下命令kube-prometheus-stack在命名空间中安装 Helm chart :prometheus

$ helm install prometheus-stack prometheus-community/kube-prometheus-stack  \
    -n prometheus \
    --create-namespace

如果一切顺利,您应该会看到类似的 Kubernetes 服务列表:

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

我们将使用内存和 CPU 统计信息分析 Grafana 仪表板。我们可以启用port-forward在定义的端口上本地访问它,例如9080:

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

Grafana 的默认用户名admin和密码prom-operator。

我们将在自定义 Grafana 仪表板中创建两个面板。首先将显示命名空间中每个 pod 的内存使用情况demo-sless。

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

第二个将显示命名空间中每个 pod 的平均 CPU 使用率demo-sless。k8s/grafana-dasboards.json您可以从 GitHub存储库的文件中将这两个直接导入到 Grafana 。

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

构建和部署本机 Java 应用程序
我们已经创建了示例应用程序,然后配置了 Kubernetes 环境。现在,我们可以进入部署阶段。我们的目标是尽可能简化构建原生镜像并在 Kubernetes 上运行它的过程。因此,我们将使用 Cloud Native Buildpacks 和 Skaffold。使用 Buildpacks,除了 Docker,我们不需要在笔记本电脑上安装任何东西。Skaffold 可以轻松地与 Buildpacks 集成,以自动化在 Kubernetes 上构建和运行应用程序的整个过程。你只需要skaffold在你的机器上安装 CLI。

为了构建 Java 应用程序的本机映像,我们可以使用 Paketo Buildpacks。它为 GraalVM 提供了一个名为Paketo GraalVM Buildpack的专用构建包。我们应该使用名称将其包含在配置中paketo-buildpacks/graalvm。由于 Skaffold 支持 Buildpacks,我们应该在skaffold.yaml文件中设置所有属性。我们需要用环境变量覆盖一些默认设置。首先,我们必须将 Java 版本设置为 19 并启用预览功能(虚拟线程)。Kubernetes 部署清单在该k8s/deployment.yaml路径下可用。

apiVersion: skaffold/v2beta29
kind: Config
metadata:
  name: sample-java-concurrency
build:
  artifacts:
  - image: piomin/sample-java-concurrency
    buildpacks:
      builder: paketobuildpacks/builder:base
      buildpacks:
        - paketo-buildpacks/graalvm
        - paketo-buildpacks/java-native-image
      env:
        - BP_NATIVE_IMAGE=true
        - BP_JVM_VERSION=19
        - BP_NATIVE_IMAGE_BUILD_ARGUMENTS=--enable-preview
  local:
    push: true
deploy:
  kubectl:
    manifests:
    - k8s/deployment.yaml


Knative 不仅简化了自动缩放,还简化了 Kubernetes 清单。这是文件中可用的示例应用程序的清单k8s/deployment.yaml。我们需要定义一个Service包含应用程序容器详细信息的对象。我们会将自动缩放目标从默认200并发请求更改为80. 这意味着如果应用程序的单个实例将同时处理超过 80 个请求,Knative 将创建一个新的应用程序实例(或者更准确地说是一个 pod)。为了为我们的应用程序启用虚拟线程,我们还需要将环境变量设置THREAD_TYPE为virtual.

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: sample-java-concurrency
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/target: "80"
    spec:
      containers:
        - name: sample-java-concurrency
          image: piomin/sample-java-concurrency
          ports:
            - containerPort: 8080
          env:
            - name: THREAD_TYPE
              value: virtual
            - name: JAVA_TOOL_OPTIONS
              value: --enable-preview


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

$ skaffold run -n demo-sless

或者您可以从我在 Docker Hub 上的注册表中部署一个现成的镜像。但是,在这种情况下,您需要将deployment.yaml清单中的镜像标签更改为virtual-native.

部署应用程序后,您可以验证 Knative 列表Service。我们的目标服务的名称是sample-java-concurrency。服务的地址在 URL 字段中返回。

...
测试过程点击标题

测试结果
非虚拟线程测试:

  • 50 个用户的测试结果。该应用程序能够在 2 分钟内处理大约 105k 个请求。最高处理时间值为~3 秒。
  • 100 个用户的测试结果。该应用程序能够在 2 分钟内处理 ~130k 请求,平均响应时间为~90ms。
  • 有200 个用户测试的结果。该应用程序能够在 2 分钟内处理 ~135k 请求,平均响应时间为~175ms。失败阈值为0.02%的水平。

使用本机原生编译和虚拟线程的测试结果:

  • 50 个用户的测试结果。该应用程序能够在 2 分钟内处理大约 75k 个请求。最高处理时间值为~2 秒。
  • 100 个用户的测试结果。该应用程序能够在 2 分钟内处理约 85k 个请求,平均响应时间约 140 毫秒
  • 200 个用户测试的结果。该应用程序能够在 2 分钟内处理约 10 万个请求,平均响应时间为~240 毫秒。

概括
在本文中,我尝试使用标准方法比较用于 GraalVM 原生编译的 Java 应用程序与 Kubernetes 上的虚拟线程的行为。运行所有描述的测试后有几个结论:

  • 在资源使用或请求处理时间方面,标准线程和虚拟线程之间没有显着差异。虚拟线程的资源使用率略低。另一方面,标准线程的处理时间略低。但是,如果我们的处理程序方法需要更多时间,则此比例会发生变化,有利于虚拟线程。
  • 自动缩放对于虚拟线程来说效果更好。但是,我不知道为什么 总之,实例的数量是按100个用户的比例增加的,目标是虚拟线程的水平为80。当然,虚拟线程在设置自动缩放目标时为我们提供了更大的灵活性。对于标准线程,我们必须选择一个小于线程池大小的值,而对于虚拟线程,我们可以设置任何合理的值。
  • 本机编译显着减少应用内存使用。对于本机应用程序,它是~50MB而不是~900MB。另一方面,本机应用程序的 CPU 消耗略高。
  • 本机应用程序处理请求比标准应用程序慢。在所有测试中,它比标准应用程序处理的请求数量低30% 。