@levinzhang
2023-01-20T09:34:51.000000Z
字数 9990
阅读 378
原生编译(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();
@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上运行监控技术栈。
在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 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
我们将会分析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-stack
chart时,可以设置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/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
。
运行非原生应用
借助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应用与标准方式进行了对比。在运行完上述的所有测试后,我们可以得出如下结论。