这里以flink1.12 的 flink webUI 来展示内存管理,后续版本的内存可能会有变更不一致的地方,详细的解释主要放在taskManager中
下图的左边标注了每个区域的配置参数名,右边则是一个调优后的、使用 HashMapStateBackend 的作业内存各区域的容量限制:它和默认配置的区别在于 Managed Memory 部分被主动调整为 0,后面我们会讲解何时需要调整各区域的大小,以最大化利用内存空间。
从官方文档和 Flink 源码上来看,托管内存主要有三大使用场景:
直接内存是 JVM 堆外的一类内存,它提供了相对安全可控但又不受 GC 影响的空间,JVM 参数是 -XX:MaxDirectMemorySize. 它主要用于
jvm metaspace存放jvm加载的类的元数据
还有一些想 Network 这种网络开销,一半不涉及修改的内存,没有标出来。
内存数据结构:
flink最小内存分配单元 是内存段MemorySegment 默认32k。
在yarn提交名利中 可以对yarn进行配置来修改内存大小
如使用的是fs memory state,并且没有什么哈希排序操作,就可以修改managed Memory的大小,让更多的资源运用于task的堆上内存中
-yD taskmanager.memory.size=8192 \
-yD taskmanager.memory.fraction=0.3
-yD taskmanager.memory.managed.size=0.3
具体参数可以参考flink官网 :
https://nightlies.apache.org/flink/flink-docs-release-1.3/setup/config.html#managed-memory
在这里我们先说一下基本概念:
用户通过算子 api 所开发的代码,会被 flink 任务提交客户端解析成 jobGraph
然后,jobGraph 提交到集群 JobManager,转化成 ExecutionGraph(并行化后的执行图)
然后,ExecutionGraph 中的各个 task 会以多并行实例(subTask)部署到 taskmanager 上执行;
subTask 运行的位置是 taskmanager 所提供的槽位(task slot),槽位简单理解就是线程;
一个算子的逻辑,可以封装在一个独立的 task 中(可以有多个运行时实例:subTask);
也可把多个算子的逻辑 chain 在一起后封装在一个独立的 task 中(可以有多个运行时实例:subTask);
简单点说 并行度>=slot*taskmanager ;
在 yarn 中提交一个 flink 任务, container 数量计算方式如下
container.num == taskmanager.num == ( parallelism.default / taskmanager.numberOfTaskSlots )
parallelism 是指 taskmanager 实际使用的并发能力。假设我们把 parallelism.default 设置
为 1,那么 9 个 TaskSlot 只能用 1 个,有 8 个空闲。 并发数<=slot*tm数 slot不够就会自动起tm,来补充
设置parallelism有多中方式,优先级为api>env>p>file
假设设置单个taskmanager为14g,taskmanager.memory.managed.fraction为0.5,将会得到以下内容:
JVM Heap Size:5.19 GB Flink Managed Memory:6.45 GB
JVM (Heap/Non-Heap) : Heap:5.19 GB Non-Heap:1.33 GB Total:6.52 GB
Outside JVM:Capacity:1.01GB
NetWork: count: xxxxx
可以计算得到6.45+6.52+1.01 = 13.98 等于14
taskmanager.memory.process.size 设置的是容器的内存大小。
计算过程在org.apache.flink.runtime.clusterframework.TaskExecutorProcessUtils中processSpecFromConfig方法,TaskExecutorProcessSpec类展示了1.10版本整个内存的组成。
计算方法分成3种:
totalProcessMemorySize = 设置的值 14g
jvmMetaspaceSize = taskmanager.memory.jvm-metaspace.size 默认96m
这个对应参数-XX:MaxMetaspaceSize=100663296。
jvmOverheadSize:
公式 14g * 0.1 = 1.4g 必须在[192m, 1g]之间,所以jvmOverheadSize的大小是1g
totalFlinkMemorySize = 14g - 1g - 96m = 13216m
frameworkHeapMemorySize:taskmanager.memory.framework.heap.size 默认128m
frameworkOffHeapMemorySize:taskmanager.memory.framework.off-heap.size 默认128m
taskOffHeapMemorySize:taskmanager.memory.task.off-heap.size 默认0
确定好上面这些参数后,就是最重要的三个指标的计算了:taskHeapMemorySize,networkMemorySize,managedMemorySize
计算分成确定了:taskmanager.memory.task.heap.size还是没确定。
1)确定taskmanager.memory.task.heap.size
taskHeapMemorySize = 设置值
managedMemorySize = 设置了使用设置值,否则使用 0.4 * totalFlinkMemorySize
如果 taskHeapMemorySize + taskOffHeapMemorySize + frameworkHeapMemorySize + frameworkOffHeapMemorySize + managedMemorySize > totalFlinkMemorySize异常
networkMemorySize 等于剩余的大小,之后还会check这块内存是否充足,可以自己查看对应代码
2)未设置heap大小
先确定 managedMemorySize = 设置了使用设置值,否则使用 0.4 * totalFlinkMemorySize,这里就是 0.5 * 13216m = 6608 = 6.45g (这里就是dashboard的显示内容)
再确定network buffer大小,这个也是有两种情况,不细说。 [64mb, 1g] 0.1 * totalFlinkMemorySize = 1321.6, 所以是1g
最后剩余的就是taskHeapMemorySize,不能为负数,这里等于 13216 - 6608 - 1024 - 128 - 128 = 5328 = 5.2g (这里约等于dashboard的显示heap大小)
3)最后jvm的参数的计算过程:
jvmHeapSize = frameworkHeapSize + taskHeapSize = 5328 + 128 = 5456
jvmDirectSize = frameworkOffHeapMemorySize + taskOffHeapSize + networkMemSize = 128 + 1024 = 1152
jvmMetaspaceSize = 96m
本部分内容转载自:
本文作者:林小铂 (Paul Lin)
本文链接: 2021/01/02/详解-Flink-容器化环境下的-OOM-Killed/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 CN 许可协议。转载请注明出处!
实践中导致 OOM Killed 的常见原因基本源于 Native 内存的泄漏或者过度使用。因为虚拟内存的 OOM Killed 通过资源管理器的配置很容易避免且通常不会有太大问题,所以下文只讨论物理内存的 OOM Killed。
从表中可以看到,使用 Heap、Metaspace 和 Direct 内存都是比较安全的,但非 Direct 的 Native 内存情况则比较复杂,可能是 JVM 本身的一些内部使用(比如下文会提到的 MemberNameTable),也可能是用户代码引入的 JNI 依赖,还有可能是用户代码自身通过 sun.misc.Unsafe 申请的 Native 内存。理论上讲,用户代码或第三方 lib 申请的 Native 内存需要用户来规划内存用量,而 Internal 的其余部分可以并入 JVM 本身的内存消耗。而实际上 Flink 的内存模型也遵循了类似的原则。
众所周知,RocksDB 通过 JNI 直接申请 Native 内存,并不受 Flink 的管控,所以实际上 Flink 通过设置 RocksDB 的内存参数间接影响其内存使用。然而,目前 Flink 是通过估算得出这些参数,并不是非常精确的值,其中有以下的几个原因。
首先是部分内存难以准确计算的问题。RocksDB 的内存占用有 4 个部分:
Block Cache: OS PageCache 之上的一层缓存,缓存未压缩的数据 Block。
Indexes and filter blocks: 索引及布尔过滤器,用于优化读性能。
Memtable: 类似写缓存。
Blocks pinned by Iterator: 触发 RocksDB 遍历操作(比如遍历 RocksDBMapState 的所有 key)时,Iterator 在其生命周期内会阻止其引用到的 Block 和 Memtable 被释放,导致额外的内存占用。
前三个区域的内存都是可配置的,但 Iterator 锁定的资源则要取决于应用业务使用模式,且没有提供一个硬限制,因此 Flink 在计算 RocksDB StateBackend 内存时没有将这部分纳入考虑。
其次是 RocksDB Block Cache 的一个 bug,它会导致 Cache 大小无法严格控制,有可能短时间内超出设置的内存容量,相当于软限制。
对于这个问题,通常我们只要调大 JVM Overhead 的阈值,让 Flink 预留更多内存即可,因为 RocksDB 的内存超额使用只是暂时的。
另外一个常见的问题就是 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 来分配内存,造成意外的内存使用。
通常来说,这个问题会出现在需要频繁创建线程的应用里,比如 HDFS Client 会为每个正在写入的文件新建一个 DataStreamer 线程,所以比较容易遇到 Thread Arena 的问题。如果怀疑你的 Flink 应用遇到这个问题,比较简单的验证方法就是看进程的 pmap 是否存在很多大小为 64MB 倍数的连续 anon 段,比如下图中蓝色几个的 65536 KB 的段就很有可能是 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 。
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。
在 JDK-8013267[14] 以后,MemeberNameTable 从 Native 内存被移到 Java Heap 当中,修复了这个问题。然而,JVM 的 Native 内存泄漏问题不止一个,比如 C2 编译器的内存泄漏问题[15],所以对于跟笔者一样没有专门 JVM 团队的用户来说,升级到最新版本的 JDK 是修复问题的最好办法。
众所周知,YARN 会根据 /proc/${pid} 下的进程信息来计算整个 container 进程树的总体内存,但这里面有一个比较特殊的点是 mmap 的共享内存。mmap 内存会全部被算进进程的 VIRT,这点应该没有疑问,但关于 RSS 的计算则有不同标准。
依据 YARN 和 Linux smaps 的计算规则,内存页(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 分区容量来解决。