Java 应用程序在 Kubernetes 上棘手的内存管理(下)

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: Java 应用程序在 Kubernetes 上棘手的内存管理

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 内存的 requestslimits,以及 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 驱逐。在这种情况下 — 内存requestlimit是不同的。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,例如etcdcoredns)。

对于这种情况,我们调整了 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 的requestlimit 都设置为 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)

这可能是不言而喻的 —— 这个场景仅用于说明目的。在现实生活中的应用程序中,我不建议使用如此少的资源进行操作。您所感到舒适的程度将取决于您可观察性实践的成熟程度(换句话说——您多快注意到有问题),工作负载的重要性以及其他因素,例如故障转移。

结语

感谢您坚持阅读这篇长文章!我想提供一些建议,帮助您远离麻烦:

  1. 设置内存的 requestlimit 一样,这样你就可以避免由于节点资源不足而导致 pod 被驱逐(缺点就是会导致节点资源利用率降低)。
  2. 仅在出现 Java OutOfMemory 错误时增加 pod 的内存限制。如果发生 OOMKilled 崩溃,请将更多内存留给非堆使用。
  3. 将最大和初始堆大小设置为相同的值。这样,您将在堆分配增加的情况下防止性能损失,并且如果堆百分比/非堆内存/pod 限制错误,您将“快速失败”。有关此建议的更多信息,请点击此处。
相关实践学习
通过Ingress进行灰度发布
本场景您将运行一个简单的应用,部署一个新的应用用于新的发布,并通过Ingress能力实现灰度发布。
容器应用与集群管理
欢迎来到《容器应用与集群管理》课程,本课程是“云原生容器Clouder认证“系列中的第二阶段。课程将向您介绍与容器集群相关的概念和技术,这些概念和技术可以帮助您了解阿里云容器服务ACK/ACK Serverless的使用。同时,本课程也会向您介绍可以采取的工具、方法和可操作步骤,以帮助您了解如何基于容器服务ACK Serverless构建和管理企业级应用。 学习完本课程后,您将能够: 掌握容器集群、容器编排的基本概念 掌握Kubernetes的基础概念及核心思想 掌握阿里云容器服务ACK/ACK Serverless概念及使用方法 基于容器服务ACK Serverless搭建和管理企业级网站应用
目录
相关文章
|
28天前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
30 0
|
10天前
|
安全 算法 Java
Java CAS原理和应用场景大揭秘:你掌握了吗?
CAS(Compare and Swap)是一种乐观锁机制,通过硬件指令实现原子操作,确保多线程环境下对共享变量的安全访问。它避免了传统互斥锁的性能开销和线程阻塞问题。CAS操作包含三个步骤:获取期望值、比较当前值与期望值是否相等、若相等则更新为新值。CAS广泛应用于高并发场景,如数据库事务、分布式锁、无锁数据结构等,但需注意ABA问题。Java中常用`java.util.concurrent.atomic`包下的类支持CAS操作。
41 2
|
30天前
|
存储 监控 算法
Java内存管理深度剖析:从垃圾收集到内存泄漏的全面指南####
本文深入探讨了Java虚拟机(JVM)中的内存管理机制,特别是垃圾收集(GC)的工作原理及其调优策略。不同于传统的摘要概述,本文将通过实际案例分析,揭示内存泄漏的根源与预防措施,为开发者提供实战中的优化建议,旨在帮助读者构建高效、稳定的Java应用。 ####
39 8
|
28天前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
1月前
|
存储 算法 Java
Java 内存管理与优化:掌控堆与栈,雕琢高效代码
Java内存管理与优化是提升程序性能的关键。掌握堆与栈的运作机制,学习如何有效管理内存资源,雕琢出更加高效的代码,是每个Java开发者必备的技能。
55 5
|
2月前
|
缓存 Java 开发者
Java多线程并发编程:同步机制与实践应用
本文深入探讨Java多线程中的同步机制,分析了多线程并发带来的数据不一致等问题,详细介绍了`synchronized`关键字、`ReentrantLock`显式锁及`ReentrantReadWriteLock`读写锁的应用,结合代码示例展示了如何有效解决竞态条件,提升程序性能与稳定性。
152 6
|
30天前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
30天前
|
安全 Java 程序员
Java内存模型的深入理解与实践
本文旨在深入探讨Java内存模型(JMM)的核心概念,包括原子性、可见性和有序性,并通过实例代码分析这些特性在实际编程中的应用。我们将从理论到实践,逐步揭示JMM在多线程编程中的重要性和复杂性,帮助读者构建更加健壮的并发程序。
|
1月前
|
监控 Java 数据库连接
Java线程管理:守护线程与用户线程的区分与应用
在Java多线程编程中,线程可以分为守护线程(Daemon Thread)和用户线程(User Thread)。这两种线程在行为和用途上有着明显的区别,了解它们的差异对于编写高效、稳定的并发程序至关重要。
35 2
|
2月前
|
算法 Java 开发者
Java内存管理与垃圾回收机制深度剖析####
本文深入探讨了Java虚拟机(JVM)的内存管理机制,特别是其垃圾回收机制的工作原理、算法及实践优化策略。不同于传统的摘要概述,本文将以一个虚拟的“城市环卫系统”为比喻,生动形象地揭示Java内存管理的奥秘,旨在帮助开发者更好地理解并调优Java应用的性能。 ####