JVM 和 Kubernetes
Java 10 引入了一个新的 JVM 标志 —— -XX:+UseContainerSupport
(默认设置为 true),如果 JVM 在资源有限的容器环境中运行,它允许 JVM 检测可用内存和 CPU。该标志与 -XX:MaxRAMPercentage
一起使用,让我们根据总可用内存的百分比设置最大堆大小。在 Kubernetes 的情况下,容器上的 limits 设置被用作此计算的基础。例如 —— 如果 pod 具有 2GB 的限制,并且将 MaxRAMPercentage
标志设置为 75%,则结果将是 1500MB 的最大堆大小。
这需要一些技巧,因为正如我们之前看到的,Java 应用程序的总体内存占用量高于堆(还有 Metaspace 、线程、垃圾回收、APM 代理等)。这意味着,需要在最大堆空间、非堆内存使用量和 pod 限制之间取得平衡。具体来说,前两个的总和不能超过最后一个,因为它会导致 OOMKilled
(参见上一节)。
为了观察这两种机制的作用,我们将使用相同的示例项目,但这次我们将把它部署在(本地)Kubernetes 集群上。为了在 Kubernetes 上部署应用程序,我们将其打包为一个 Pod:
apiVersion: v1 kind: Pod metadata: name: heapkiller spec: containers: - name: heapkiller image: heapkiller imagePullPolicy: Never resources: requests: memory: "500Mi" cpu: "500m" limits: memory: "500Mi" cpu: "500m" env: - name: JAVA_TOOL_OPTIONS value: '-XX:MaxRAMPercentage=70.0'
快速复习第一部分 —— 我们确定应用程序需要至少 380MB的堆内存才能正常运行。
场景 1 — Java Out Of Memory 错误
让我们首先了解我们可以操作的参数。它们是 — pod 内存的 requests
和 limits
,以及 Java 的最大堆大小,在我们的例子中由 MaxRAMPercentage
标志控制。
在第一种情况下,我们将总内存的 70% 分配给堆。pod 请求和限制都设置为 500MB,这导致最大堆为 350MB(500MB 的 70%)。
我们执行 kubectl apply -f pod.yaml
部署 pod ,然后用 kubectl get logs -f pod/heapkiller
观察日志。应用程序启动后不久,我们会看到以下输出:
INFO Started HeapDestroyerKt in 5.599 seconds (JVM running for 6.912) INFO Used Free Total INFO 17.00 MB 5.00 MB 22.00 MB ... INFO 260.00 MB 78.00 MB 338.00 MB ... Exception in thread "main" java.lang.reflect.InvocationTargetException Caused by: java.lang.OutOfMemoryError: Java heap space
如果我们执行 kubectl describe pod/heapkiller
拉出 pod 详细信息,我们将找到以下信息:
Containers: heapkiller: .... State: Waiting Reason: CrashLoopBackOff Last State: Terminated Reason: Error Exit Code: 1 ... Events: Type Reason Age From Message ---- ------ ---- ---- ------- ... Warning BackOff 7s (x7 over 89s) kubelet Back-off restarting failed container
简而言之,这意味着 pod 以状态码 1 退出(Java Out Of Memory 的退出码),Kubernetes 将继续使用标准退避策略重新启动它(以指数方式增加重新启动之间的暂停时间)。下图描述了这种情况。
这种情况下的关键要点是 —— 如果 Java 因 OutOfMemory 错误而失败,您将在 pod 日志中看到它👌。
场景 2 — Pod 超出内存 limit 限制
为了实现这个场景,我们的 Java 应用程序需要更多内存。我们将 MaxRAMPercentage
从 70% 增加到 90%,看看会发生什么。我们按照与之前相同的步骤并查看日志。该应用程序运行良好了一段时间:
... ... INFO 323.00 MB 83.00 MB 406.00 MB INFO 333.00 MB 73.00 MB 406.00 MB
然后 …… 噗。没有更多的日志。我们运行与之前相同的 describe 命令以获取有关 pod 状态的详细信息。
Containers: heapkiller: State: Waiting Reason: CrashLoopBackOff Last State: Terminated Reason: OOMKilled Exit Code: 137 Events: Type Reason Age From Message ---- ------ ---- ---- ------ ... ... Warning BackOff 6s (x7 over 107s) kubelet Back-off restarting failed container
乍看之下,这与之前的场景类似 —— pod crash,现在处于 CrashLoopBackOff(Kubernetes 一直在重启),但实际上却大不相同。之前,pod 中的进程退出(JVM 因内存不足错误而崩溃),在这种情况下,是 Kubernetes 杀死了 pod。该 OOMKill
状态表示 Kubernetes 已停止 pod,因为它已超出其分配的内存限制。这怎么可能?
通过将 90% 的可用内存分配给堆,我们假设其他所有内容都适合剩余的 10% (50MB),而对于我们的应用程序,情况并非如此,这导致内存占用超过 500MB 限制。下图展示了超出 pod 内存限制的场景。
要点 —— OOMKilled
在 pod 的状态中查找。
场景 3 — Pod 超出节点的可用内存
最后一种不太常见的故障情况是 pod 驱逐。在这种情况下 — 内存request
和limit
是不同的。Kubernetes 根据request
参数而不是limit
参数在节点上调度 pod。如果一个节点满足请求,kube-scheduler将选择它,而不管节点满足限制的能力如何。在我们将 pod 调度到节点上之前,让我们先看一下该节点的一些详细信息:
~ kubectl describe node/docker-desktop Allocatable: cpu: 4 memory: 1933496Ki Allocated resources: (Total limits may be over 100 percent, i.e., overcommitted.) Resource Requests Limits -------- -------- ------ cpu 850m (21%) 0 (0%) memory 240Mi (12%) 340Mi (18%)
我们可以看到该节点有大约 2GB 的可分配内存,并且已经占用了大约 240MB(由kube-system pod,例如etcd和coredns)。
对于这种情况,我们调整了 pod 的参数 —— request: 500Mi
(未更改),limit: 2500Mi
我们重新配置应用程序以将堆填充到 2500MB(之前为 350MB)。当 pod 被调度到节点上时,我们可以在节点描述中看到这种分配:
Allocated resources: (Total limits may be over 100 percent, i.e., overcommitted.) Resource Requests Limits -------- -------- ------ cpu 1350m (33%) 500m (12%) memory 740Mi (39%) 2840Mi (150%)
当 pod 到达节点的可用内存时,它会被杀死,我们会在 pod 的描述中看到以下详细信息:
~ kubectl describe pod/heapkiller Status: Failed Reason: Evicted Message: The node was low on resource: memory. Containers: heapkiller: State: Terminated Reason: ContainerStatusUnknown Message: The container could not be located when the pod was terminated Exit Code: 137 Reason: OOMKilled
这表明由于节点内存不足,pod 被逐出。我们可以在节点描述中看到更多细节:
~ kubectl describe node/docker-desktop Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning SystemOOM 1s kubelet System OOM encountered, victim process: java, pid: 67144
此时,CrashBackoffLoop 开始,pod 不断重启。下图描述了这种情况。
关键要点 —— 在 pod 的状态中查找 Evicted 以及通知节点内存不足的事件。
场景 4 — 参数配置良好,应用程序运行良好
最后一个场景显示应用程序在正确调整的参数下正常运行。为此,我们将pod 的request
和 limit
都设置为 500MB,将 -XX:MaxRAMPercentage
设置为 80%。
让我们收集一些统计数据,以了解节点级别和更深层次的 Pod 中正在发生的情况。
~ kubectl describe node/docker-desktop Allocated resources: (Total limits may be over 100 percent, i.e., overcommitted.) Resource Requests Limits -------- -------- ------ cpu 1350m (33%) 500m (12%) memory 740Mi (39%) 840Mi (44%)
节点看起来很健康,有空闲资源👌。让我们看看 pod 的内部。
# Run from within the container ~ cat /sys/fs/cgroup/memory.current 523747328
这显示了容器的当前内存使用情况。那是 499MB,就在边缘。让我们看看是什么占用了这段内存:
# Run from within the container ~ ps -o pid,rss,command ax PID RSS COMMAND 1 501652 java -XX:NativeMemoryTracking=summary -jar /app.jar 36 472 /bin/sh 55 1348 ps -o pid,rss,command ax
RSS,*Resident Set Size,*是对正在占用的内存进程的一个很好的估计。上面显示 490MB(501652 bytes)被 Java 进程占用。让我们再剥离一层,看看 JVM 的内存分配。我们传递给 Java 进程的标志 -XX:NativeMemoryTracking
允许我们收集有关 Java 内存空间的详细运行时统计信息。
~ jcmd 1 VM.native_memory summary Total: reserved=1824336KB, committed=480300KB - Java Heap (reserved=409600KB, committed=409600KB) (mmap: reserved=409600KB, committed=409600KB) - Class (reserved=1049289KB, committed=4297KB) (classes #6760) ( instance classes #6258, array classes #502) (malloc=713KB #15321) (mmap: reserved=1048576KB, committed=3584KB) ( Metadata: ) ( reserved=32768KB, committed=24896KB) ( used=24681KB) ( waste=215KB =0.86%) ( Class space:) ( reserved=1048576KB, committed=3584KB) ( used=3457KB) ( waste=127KB =3.55%) - Thread (reserved=59475KB, committed=2571KB) (thread #29) (stack: reserved=59392KB, committed=2488KB) (malloc=51KB #178) (arena=32KB #56) - Code (reserved=248531KB, committed=14327KB) (malloc=800KB #4785) (mmap: reserved=247688KB, committed=13484KB) (arena=43KB #45) - GC (reserved=1365KB, committed=1365KB) (malloc=25KB #83) (mmap: reserved=1340KB, committed=1340KB) - Compiler (reserved=204KB, committed=204KB) (malloc=39KB #316) (arena=165KB #5) - Internal (reserved=283KB, committed=283KB) (malloc=247KB #5209) (mmap: reserved=36KB, committed=36KB) - Other (reserved=26KB, committed=26KB) (malloc=26KB #3) - Symbol (reserved=6918KB, committed=6918KB) (malloc=6206KB #163986) (arena=712KB #1) - Native Memory Tracking (reserved=3018KB, committed=3018KB) (malloc=6KB #92) (tracking overhead=3012KB) - Shared class space (reserved=12288KB, committed=12224KB) (mmap: reserved=12288KB, committed=12224KB) - Arena Chunk (reserved=176KB, committed=176KB) (malloc=176KB) - Logging (reserved=5KB, committed=5KB) (malloc=5KB #219) - Arguments (reserved=1KB, committed=1KB) (malloc=1KB #53) - Module (reserved=229KB, committed=229KB) (malloc=229KB #1710) - Safepoint (reserved=8KB, committed=8KB) (mmap: reserved=8KB, committed=8KB) - Synchronization (reserved=48KB, committed=48KB) (malloc=48KB #574) - Serviceability (reserved=1KB, committed=1KB) (malloc=1KB #14) - Metaspace (reserved=32870KB, committed=24998KB) (malloc=102KB #52) (mmap: reserved=32768KB, committed=24896KB) - String Deduplication (reserved=1KB, committed=1KB) (malloc=1KB #8)
这可能是不言而喻的 —— 这个场景仅用于说明目的。在现实生活中的应用程序中,我不建议使用如此少的资源进行操作。您所感到舒适的程度将取决于您可观察性实践的成熟程度(换句话说——您多快注意到有问题),工作负载的重要性以及其他因素,例如故障转移。
结语
感谢您坚持阅读这篇长文章!我想提供一些建议,帮助您远离麻烦:
- 设置内存的
request
和limit
一样,这样你就可以避免由于节点资源不足而导致 pod 被驱逐(缺点就是会导致节点资源利用率降低)。 - 仅在出现 Java
OutOfMemory
错误时增加 pod 的内存限制。如果发生OOMKilled
崩溃,请将更多内存留给非堆使用。 - 将最大和初始堆大小设置为相同的值。这样,您将在堆分配增加的情况下防止性能损失,并且如果堆百分比/非堆内存/pod 限制错误,您将“快速失败”。有关此建议的更多信息,请点击此处。