详解 Flink 容器化环境下的 OOM Killed

简介: 本文将解析 JVM 和 Flink 的内存模型,并总结在工做中遇到和在社区交流中了解到的形成 Flink 内存使用超出容器限制的常见缘由。因为 Flink 内存使用与用户代码、部署环境、各类依赖版本等因素都有紧密关系,本文主要讨论 on YARN 部署、Oracle JDK/OpenJDK 八、Flink 1.10+ 的状况。

在生产环境中,Flink 一般会部署在 YARN 或 k8s 等资源管理系统之上,进程会以容器化(YARN 容器或 docker 等容器)的方式运行,其资源会受到资源管理系统的严格限制。另外一方面,Flink 运行在 JVM 之上,而 JVM 与容器化环境并非特别适配,尤为 JVM 复杂且可控性较弱的内存模型,容易致使进程因使用资源超标而被 kill 掉,形成 Flink 应用的不稳定甚至不可用。java

针对这个问题,Flink 在 1.10 版本对内存管理模块进行了重构,设计了全新的内存参数。在大多数场景下 Flink 的内存模型和默认已经足够好用,能够帮用户屏蔽进程背后的复杂内存结构,然而一旦出现内存问题,问题的排查和修复都须要比较多的领域知识,一般令普通用户望而却步。node

为此,本文将解析 JVM 和 Flink 的内存模型,并总结在工做中遇到和在社区交流中了解到的形成 Flink 内存使用超出容器限制的常见缘由。因为 Flink 内存使用与用户代码、部署环境、各类依赖版本等因素都有紧密关系,本文主要讨论 on YARN 部署、Oracle JDK/OpenJDK 八、Flink 1.10+ 的状况。此外,特别感谢 @宋辛童(Flink 1.10+ 新内存架构的主要做者)和 @唐云(RocksDB StateBackend 专家)在社区的答疑,令笔者受益不浅。算法

JVM 内存分区

对于大多数 Java 用户而言,平常开发中与 JVM Heap 打交道的频率远大于其余 JVM 内存分区,所以常把其余内存分区统称为 Off-Heap 内存。而对于 Flink 来讲,内存超标问题一般来自 Off-Heap 内存,所以对 JVM 内存模型有更深刻的理解是十分必要的。docker

根据 JVM 8 Spec[1],JVM 管理的内存分区以下图:缓存

img1. JVM 8 内存模型安全

除了上述 Spec 规定的标准分区,在具体实现上 JVM 经常还会加入一些额外的分区供进阶功能模块使用。以 HotSopt JVM 为例,根据 Oracle NMT[5] 的标准,咱们能够将 JVM 内存细分为以下区域:服务器

● Heap: 各线程共享的内存区域,主要存放 new 操做符建立的对象,内存的释放由 GC 管理,可被用户代码或 JVM 自己使用。
● Class: 类的元数据,对应 Spec 中的 Method Area (不含 Constant Pool),Java 8 中的 Metaspace。
● Thread: 线程级别的内存区,对应 Spec 中的 PC Register、Stack 和 Natvive Stack 三者的总和。
● Compiler: JIT (Just-In-Time) 编译器使用的内存。
● Code Cache: 用于存储 JIT 编译器生成的代码的缓存。
● GC: 垃圾回收器使用的内存。
● Symbol: 存储 Symbol (好比字段名、方法签名、Interned String) 的内存,对应 Spec 中的 Constant Pool。
● Arena Chunk: JVM 申请操做系统内存的临时缓存区。
● NMT: NMT 本身使用的内存。
● Internal: 其余不符合上述分类的内存,包括用户代码申请的 Native/Direct 内存。
● Unknown: 没法分类的内存。架构

理想状况下,咱们能够严格控制各分区内存的上限,来保证进程整体内存在容器限额以内。可是过于严格的管理会带来会有额外使用成本且缺少灵活度,因此在实际中为了 JVM 只对其中几个暴露给用户使用的分区提供了硬性的上限,而其余分区则能够做为总体被视为 JVM 自己的内存消耗。app

具体能够用于限制分区内存的 JVM 参数以下表所示(值得注意的是,业界对于 JVM Native 内存并无准确的定义,本文的 Native 内存指的是 Off-Heap 内存中非 Direct 的部分,与 Native Non-Direct 能够互换)。框架

从表中能够看到,使用 Heap、Metaspace 和 Direct 内存都是比较安全的,但非 Direct 的 Native 内存状况则比较复杂,多是 JVM 自己的一些内部使用(好比下文会提到的 MemberNameTable),也多是用户代码引入的 JNI 依赖,还有多是用户代码自身经过 sun.misc.Unsafe 申请的 Native 内存。理论上讲,用户代码或第三方 lib 申请的 Native 内存须要用户来规划内存用量,而 Internal 的其他部分能够并入 JVM 自己的内存消耗。而实际上 Flink 的内存模型也遵循了相似的原则。

Flink TaskManager 内存模型

首先回顾下 Flink 1.10+ 的 TaskManager 内存模型。

img2. Flink TaskManager 内存模型

显然,Flink 框架自己不只会包含 JVM 管理的 Heap 内存,也会申请本身管理 Off-Heap 的 Native 和 Direct 内存。在笔者看来,Flink 对于 Off-Heap 内存的管理策略能够分为三种:

● 硬限制(Hard Limit): 硬限制的内存分区是 Self-Contained 的,Flink 会保证其用量不会超过设置的阈值(若内存不够则抛出相似 OOM 的异常),
● 软限制(Soft Limit): 软限制意味着内存使用长期会在阈值如下,但可能短暂地超过配置的阈值。
● 预留(Reserved): 预留意味着 Flink 不会限制分区内存的使用,只是在规划内存时预留一部分空间,但不能保证明际使用会不会超额。

结合 JVM 的内存管理来看,一个 Flink 内存分区的内存溢出会致使何种后果,判断逻辑以下:

一、如果 Flink 有硬限制的分区,Flink 会报该分区内存不足。不然进入下一步。
二、若该分区属于 JVM 管理的分区,在其实际值增加致使 JVM 分区也内存耗尽时,JVM 会报其所属的 JVM 分区的 OOM (好比 java.lang.OutOfMemoryError: Jave heap space)。不然进入下一步。
三、该分区内存持续溢出,最终致使进程整体内存超出容器内存限制。在开启严格资源控制的环境下,资源管理器(YARN/k8s 等)会 kill 掉该进程。

为直观地展现 Flink 各内存分区与 JVM 内存分区间的关系,笔者整理了以下的内存分区映射表:

img3. Flink 分区及 JVM 分区内存限制关系

根据以前的逻辑,在全部的 Flink 内存分区中,只有不是 Self-Contained 且所属 JVM 分区也没有内存硬限制参数的 JVM Overhead 是有可能致使进程被 OOM kill 掉的。做为一个预留给各类不一样用途的内存的大杂烩,JVM Overhead 的确容易出问题,但同时它也能够做为一个兜底的隔离缓冲区,来缓解来自其余区域的内存问题。

举个例子,Flink 内存模型在计算 Native Non-Direct 内存时有一个 trick:

Although, native non-direct memory usage can be accounted for as a part of the framework off-heap memory or task off-heap memory, it will result in a higher JVM’s direct memory limit in this case.

虽然 Task/Framework 的 Off-Heap 分区中可能含有 Native Non-Direct 内存,而这部份内存严格来讲属于 JVM Overhead,不会被 JVM -XX:MaxDirectMemorySize 参数所限制,但 Flink 仍是将它算入 MaxDirectMemorySize 中。这部分预留的 Direct 内存配额不会被实际使用,因此能够留给没有上限 JVM Overhead 占用,达到为 Native Non-Direct 内存预留空间的效果。

OOM Killed 常见缘由

与上文分析一致,实践中致使 OOM Killed 的常见缘由基本源于 Native 内存的泄漏或者过分使用。由于虚拟内存的 OOM Killed 经过资源管理器的配置很容易避免且一般不会有太大问题,因此下文只讨论物理内存的 OOM Killed。

RocksDB Native 内存的不肯定性

众所周知,RocksDB 经过 JNI 直接申请 Native 内存,并不受 Flink 的管控,因此实际上 Flink 经过设置 RocksDB 的内存参数间接影响其内存使用。然而,目前 Flink 是经过估算得出这些参数,并非很是精确的值,其中有如下的几个缘由。

首先是部份内存难以准确计算的问题。RocksDB 的内存占用有 4 个部分[6]:

● Block Cache: OS PageCache 之上的一层缓存,缓存未压缩的数据 Block。
● Indexes and filter blocks: 索引及布隆过滤器,用于优化读性能。
● Memtable: 相似写缓存。
● Blocks pinned by Iterator: 触发 RocksDB 遍历操做(好比遍历 RocksDBMapState 的全部 key)时,Iterator 在其生命周期内会阻止其引用到的 Block 和 Memtable 被释放,致使额外的内存占用[10]。

前三个区域的内存都是可配置的,但 Iterator 锁定的资源则要取决于应用业务使用模式,且没有提供一个硬限制,所以 Flink 在计算 RocksDB StateBackend 内存时没有将这部分归入考虑。

其次是 RocksDB Block Cache 的一个 bug8,它会致使 Cache 大小没法严格控制,有可能短期内超出设置的内存容量,至关于软限制。

对于这个问题,一般咱们只要调大 JVM Overhead 的阈值,让 Flink 预留更多内存便可,由于 RocksDB 的内存超额使用只是暂时的。

glibc Thread Arena 问题

另一个常见的问题就是 glibc 著名的 64 MB 问题,它可能会致使 JVM 进程的内存使用大幅增加,最终被 YARN kill 掉。

具体来讲,JVM 经过 glibc 申请内存,而为了提升内存分配效率和减小内存碎片,glibc 会维护称为 Arena 的内存池,包括一个共享的 Main Arena 和线程级别的 Thread Arena。当一个线程须要申请内存但 Main Arena 已经被其余线程加锁时,glibc 会分配一个大约 64 MB (64 位机器)的 Thread Arena 供线程使用。这些 Thread Arena 对于 JVM 是透明的,但会被算进进程的整体虚拟内存(VIRT)和物理内存(RSS)里。

默认状况下,Arena 的最大数目是 cpu 核数 * 8,对于一台普通的 32 核服务器来讲最多占用 16 GB,不可谓不可观。为了控制整体消耗内存的总量,glibc 提供了环境变量 MALLOC_ARENA_MAX 来限制 Arena 的总量,好比 Hadoop 就默认将这个值设置为 4。然而,这个参数只是一个软限制,全部 Arena 都被加锁时,glibc 仍会新建 Thread Arena 来分配内存[11],形成意外的内存使用。

一般来讲,这个问题会出如今须要频繁建立线程的应用里,好比 HDFS Client 会为每一个正在写入的文件新建一个 DataStreamer 线程,因此比较容易遇到 Thread Arena 的问题。若是怀疑你的 Flink 应用遇到这个问题,比较简单的验证方法就是看进程的 pmap 是否存在不少大小为 64MB 倍数的连续 anon 段,好比下图中蓝色几个的 65536 KB 的段就颇有多是 Arena。

img4. pmap 64 MB arena

这个问题的修复办法比较简单,将 MALLOC_ARENA_MAX 设置为 1 便可,也就是禁用 Thread Arena 只使用 Main Arena。固然,这样的代价就是线程分配内存效率会下降。不过值得一提的是,使用 Flink 的进程环境变量参数(好比 containerized.taskmanager.env.MALLOC_ARENA_MAX=1)来覆盖默认的 MALLOC_ARENA_MAX 参数多是不可行的,缘由是在非白名单变量(yarn.nodemanager.env-whitelist)冲突的状况下, NodeManager 会以合并 URL 的方式来合并原有的值和追加的值,最终形成 MALLOC_ARENA_MAX="4:1" 这样的结果。

最后,还有一个更完全的可选解决方案,就是将 glibc 替换为 Google 家的 tcmalloc 或 Facebook 家的 jemalloc [12]。除了不会有 Thread Arena 问题,内存分配性能更好,碎片更少。在实际上,Flink 1.12 的官方镜像也将默认的内存分配器从 glibc 改成 jemelloc [17]。

JDK8 Native 内存泄漏

Oracle Jdk8u152 以前的版本存在一个 Native 内存泄漏的 bug[13],会形成 JVM 的 Internal 内存分区一直增加。

具体而言,JVM 会缓存字符串符号(Symbol)到方法(Method)、成员变量(Field)的映射对来加快查找,每对映射称为 MemberName,整个映射关系称为 MemeberNameTable,由 java.lang.invoke.MethodHandles 这个类负责。在 Jdk8u152 以前,MemberNameTable 是使用 Native 内存的,所以一些过期的 MemberName 不会被 GC 自动清理,形成内存泄漏。

要确认这个问题,须要经过 NMT 来查看 JVM 内存状况,好比笔者就遇到过线上一个 TaskManager 的超过 400 MB 的 MemeberNameTable。

img5. JDK8 MemberNameTable Native 内存泄漏

在 JDK-8013267[14] 之后,MemeberNameTable 从 Native 内存被移到 Java Heap 当中,修复了这个问题。然而,JVM 的 Native 内存泄漏问题不止一个,好比 C2 编译器的内存泄漏问题[15],因此对于跟笔者同样没有专门 JVM 团队的用户来讲,升级到最新版本的 JDK 是修复问题的最好办法。

YARN mmap 内存算法

众所周知,YARN 会根据 /proc/${pid} 下的进程信息来计算整个 container 进程树的整体内存,但这里面有一个比较特殊的点是 mmap 的共享内存。mmap 内存会所有被算进进程的 VIRT,这点应该没有疑问,但关于 RSS 的计算则有不一样标准。 依据 YARN 和 Linux smaps 的计算规则,内存页(Pages)按两种标准划分:

● Private Pages: 只有当前进程映射(mapped)的 Pages

● Shared Pages: 与其余进程共享的 Pages

● Clean Pages: 自从被映射后没有被修改过的 Pages

● Dirty Pages: 自从被映射后已经被修改过的 Pages

在默认的实现里,YARN 根据 /proc/${pid}/status 来计算总内存,全部的 Shared Pages 都会被算入进程的 RSS,即使这些 Pages 同时被多个进程映射[16],这会致使和实际操做系统物理内存的误差,有可能致使 Flink 进程被误杀(固然,前提是用户代码使用 mmap 且没有预留足够空间)。

为此,YARN 提供 yarn.nodemanager.container-monitor.procfs-tree.smaps-based-rss.enabled 配置选项,将其设置为 true 后,YARN 将根据更准确的 /proc/${pid}/smap 来计算内存占用,其中很关键的一个概念是 PSS。简单来讲,PSS 的不一样点在于计算内存时会将 Shared Pages 均分给全部使用这个 Pages 的进程,好比一个进程持有 1000 个 Private Pages 和 1000 个会分享给另一个进程的 Shared Pages,那么该进程的总 Page 数就是 1500。 回到 YARN 的内存计算上,进程 RSS 等于其映射的全部 Pages RSS 的总和。

在默认状况下,YARN 计算一个 Page RSS 公式为: Page RSS = Private_Clean + Private_Dirty + Shared_Clean + Shared_Dirty 由于一个 Page 要么是 Private,要么是 Shared,且要么是 Clean 要么是 Dirty,因此其实上述公示右边有至少三项为 0 。而在开启 smaps 选项后,公式变为: Page RSS = Min(Shared_Dirty, PSS) + Private_Clean + Private_Dirty 简单来讲,新公式的结果就是去除了 Shared_Clean 部分被重复计算的影响。

虽然开启基于 smaps 计算的选项会让计算更加准确,但会引入遍历 Pages 计算内存总和的开销,不如 直接取 /proc/${pid}/status 的统计数据快,所以若是遇到 mmap 的问题,仍是推荐经过提升 Flink 的 JVM Overhead 分区容量来解决。

总结

本文首先介绍 JVM 内存模型和 Flink TaskManager 内存模型,而后据此分析得出进程 OOM Killed 一般源于 Native 内存泄漏,最后列举几个常见的 Native 内存泄漏缘由以及处理办法,包括 RocksDB 内存占用的不肯定性、glibc 的 64MB 问题、JDK8 MemberNameTable 泄露和 YARN 对 mmap 内存计算的不许确。因为笔者水平有限,不能保证所有内容均正确无误,若读者有不一样意见,很是欢迎留言指教一块儿探讨。

做者:林小铂
原文连接本文为阿里云原创内容,未经容许不得转载

相关文章
相关标签/搜索