乐趣区

关于flink:详解-Flink-容器化环境下的-OOM-Killed

作者:林小铂

在生产环境中,Flink 通常会部署在 YARN 或 k8s 等资源管理零碎之上,过程会以容器化(YARN 容器或 docker 等容器)的形式运行,其资源会受到资源管理零碎的严格限度。另一方面,Flink 运行在 JVM 之上,而 JVM 与容器化环境并不是特地适配,尤其 JVM 简单且可控性较弱的内存模型,容易导致过程因应用资源超标而被 kill 掉,造成 Flink 利用的不稳固甚至不可用。

针对这个问题,Flink 在 1.10 版本对内存治理模块进行了重构,设计了全新的内存参数。在大多数场景下 Flink 的内存模型和默认曾经足够好用,能够帮用户屏蔽过程背地的简单内存构造,然而一旦呈现内存问题,问题的排查和修复都须要比拟多的畛域常识,通常令普通用户望而生畏。

为此,本文将解析 JVM 和 Flink 的内存模型,并总结在工作中遇到和在社区交换中理解到的造成 Flink 内存应用超出容器限度的常见起因。因为 Flink 内存应用与用户代码、部署环境、各种依赖版本等因素都有严密关系,本文次要探讨 on YARN 部署、Oracle JDK/OpenJDK 8、Flink 1.10+ 的状况。此外,特别感谢 @宋辛童(Flink 1.10+ 新内存架构的次要作者)和 @唐云(RocksDB StateBackend 专家)在社区的答疑,令笔者受益匪浅。

JVM 内存分区

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

依据 JVM 8 Spec[1],JVM 治理的内存分区如下图:

<p style=”text-align:center”>img1. JVM 8 内存模型 </p>

除了上述 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 自身的内存耗费。

具体能够用于限度分区内存的 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 内存模型。

<p style=”text-align:center”>img2. Flink TaskManager 内存模型
</p>

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

● 硬限度(Hard Limit): 硬限度的内存分区是 Self-Contained 的,Flink 会保障其用量不会超过设置的阈值(若内存不够则抛出相似 OOM 的异样),
● 软限度(Soft Limit): 软限度意味着内存应用长期会在阈值以下,但可能短暂地超过配置的阈值。
● 预留(Reserved): 预留意味着 Flink 不会限度分区内存的应用,只是在布局内存时预留一部分空间,但不能保障理论应用会不会超额。

联合 JVM 的内存治理来看,一个 Flink 内存分区的内存溢出会导致何种结果,判断逻辑如下:

1、若是 Flink 有硬限度的分区,Flink 会报该分区内存不足。否则进入下一步。
2、若该分区属于 JVM 治理的分区,在其理论值增长导致 JVM 分区也内存耗尽时,JVM 会报其所属的 JVM 分区的 OOM(比方 java.lang.OutOfMemoryError: Jave heap space)。否则进入下一步。
3、该分区内存继续溢出,最终导致过程总体内存超出容器内存限度。在开启严格资源管制的环境下,资源管理器(YARN/k8s 等)会 kill 掉该过程。

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

<p style=”text-align:center”>img3. Flink 分区及 JVM 分区内存限度关系
</p>

依据之前的逻辑,在所有的 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。

<p style=”text-align:center”>img4. pmap 64 MB arena
</p>

这个问题的修复方法比较简单,将 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。

<p style=”text-align:center”>img5. JDK8 MemberNameTable Native 内存透露
</p>

在 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 内存计算的不精确。因为笔者程度无限,不能保障全部内容均正确无误,若读者有不同意见,十分欢送留言指教一起探讨。

退出移动版