Kubernetes 案例分享:如何避免 JVM 应用内存耗尽

2019 Java 开发者跳槽指南.pdf (吐血整理)….>>>

(给ImportNew加星标,提高Java技能)

编译:ImportNew/唐尤华srvaroa.github.io/jvm/kubernetes/memory/docker/oomkiller/2019/05/29/k8s-and-java.html


我最近一直在帮助团队把工作负载从本地或者EC2迁移到Kubernetes。这是一个不错Kubernetes新手训练营。


在本文中,我会讨论一个最近遇到的JVM资源分配问题。在Kubernetes中运行要求我们比平常更注意容量规划,以及对容器做出的一些假设。


开始前,假设您已经熟悉基本的Kubernetes概念(了解什么是节点、Pod等等)并对JVM有所了解。


在线求助!我的应用程序内存耗尽啦


对于刚投入Kubernetes怀抱的团队来说,资源限制是最常见的一个痛点。我们的集群配置主要是方便小型规范的微服务进行水平扩展,内存限制相对宽松。开发人员习惯了自己在EC2实例上自由分配JVM运行的内存。突然限制在内存不足5GB的Pod中,应用程序要么内存耗尽(OOM)要么抛出OutOfMemoryError。


产品团队一名工程师上周就遇到了上面的情况,于是向我求助修改他的配置部署。Kubernetes manifest如下:


resources:
limits:
memory: 4Gi
cpu: 1
requests:
memory: 1Gi
cpu: 1


Dockerfile相关的部分:


FROM openjdk:8u181-jre-slim
...
ENTRYPOINT exec java -Xmx1512m -Xms1g [...]-cp app:app/lib/* com.schibsted.yada.Yada


Pod会不时内存耗尽死掉,希望我能帮他调整Pod和JVM大小。


Kubernetes中Pod request和limits表示什么含义?


首先看一下resources中的参数。


用Kubernetes运行应用程序后,Kubernetes scheduler会在集群中查找可以运行该Pod的节点。搜索会根据多个条件进行,其中最基本的条件是节点是否具备运行容器所需的内存和CPU。这就是resources中参数的作用。requests会设置Pod中每个容器成功启动所需最小资源。Kubernetes只会在合适的节点中调度我们的Pod。如果空间不足,那么Pod会变成Unschedulable。上面的配置中,应用程序requests声明至少需要1Gi内存和1个CPU。


然而requests并不是严格限制。运行中,如果容器需要增加内存或CPU资源,可以向内核申请从其节点获得。这种弹性对于处理突发负载非常有用,但也会让一些Pod有机会占用过多资源。limits用来设置Pod中的每个容器允许使用的最大内存与CPU。


Kubernetes文档对此给出了很好的说明,开发人员可以通过requests和limits对资源配置进行权衡。


通过为集群中运行的容器设置内存请求和限制,
可以有效地利用集群节点上
可用的内存资源。在request中设置较低的内存,
可以让pod能够获得更好被调度的机会。设置内存请求大于内存限制,
可以实现
两件事:

- 遇到突发负载时,pod能充分利用
当前可用内存。
- pod在突发负载时可以使用的内存
会被限制在一个合理的范围。


为什么我们的Pod会内存耗尽?


回到之前的Dockerfile,首先引起我们注意的是:


ENTRYPOINT exec java -Xmx1512m -Xms1g [...]-cp app:app/lib/* com.schibsted.yada.Yada


JVM会预先分配1GiB的堆空间,花掉了容器通过requests获得的全部内存。JVM与操作系统类似,还需要代码缓存、堆外内存、线程堆栈、GC数据结构等额外内存,我们的容器天生就尺寸不足。


很明显,requests设得太小了


等等:Hotspot 1.8在跟踪容器内存大小上会不会有问题?


第一个值得怀疑的是,容器化之后JVM会遇到内存耗尽。我们的团队已经遇到了好几次。简而言之,如果Hotspot JVM版本低于8u121,虽然可以在容器(上限2GB)里启动JVM,但是会发现JVM尝试启用更大的堆空间,最终被杀死。容器最终超出了内存限制,但这都是因为JVM过于贪婪。为什么会这样?


问题原因在于JVM获取可用内存的方式。JVM查看系统文件时看到的是宿主机内存大小,不是容器内存大小(或更确切地说是实现该容器的kernel cgroup)。JDK-8189497解决了这个问题。从u181开始,Hotspot JVM添加了几个标志,可以支持启用cgroup内存。从u121开始,这些选项会默认启用。我们使用版本 > u181。


但是,上面只在JVM自己计算min或max heap大小时才有意义。由于直接指定了-Xmx和-Xms标志,因此我们不受影响。可以确认,上面两个堆内存comitted 统计在1.5GiB以内,如下所示。


那么requests值设为多少比较合适?


下面是正常负载下的应用程序快照。这里显示committed值,因为它表示JVM实际保留的内存,而非usage值(committed-未使用)。


metric  memory
jvm_memory_committed_bytes{area=”nonheap”,id=”Code Cache”,} 0.05 GiB
jvm_memory_committed_bytes{area=”nonheap”,id=”Metaspace”,} 0.07 GiB
jvm_memory_committed_bytes{area=”nonheap”,id=”Compressed Class Space”,} 0.01 GiB
jvm_memory_committed_bytes{area=”heap”,id=”PS Eden Space”,} 0.52 GiB
jvm_memory_committed_bytes{area=”heap”,id=”PS Survivor Space”,} 0.01 GiB
jvm_memory_committed_bytes{area=”heap”,id=”PS Old Gen”,} 1.06 GiB
total committed 1.72 GiB


注意:我们的JVM heap不是1Gib,而是1.5GiB。这是预料之中的,因为一旦应用程序开始实际工作会给heap施加压力(特别是在有负载的情况下)。JVM可能会增大heap空间。但是,由于我们指定了-Xmx标志,因此可以确保heap内存不会超过1.5GiB。requests=1Gi设置得过小。尽管如此,limits=4Gi应该提供了足够的预留空间防止程序因内存耗尽而结束。


还有什么可能导致内存占用超出limits?


一个答案在JVM之外。我们查看了tmpfs挂载的内容。这些volume在我们的文件系统中看起来像是普通目录,但是它们实际上会驻留在内存中,因此会加剧整体内存消耗。我们的应用程序基于Kafka Streams,使用RocksDB实例作为内部缓存保留在磁盘上。团队决定使用/tmp作为挂载点避免IO开销。这是一个好主意,但是没有考虑到由此额外增加的内存。更糟糕的是,/tmp文件夹还存放了应用程序的日志。总而言之,RocksDB和日志使容器的内存占用量增加了近2GiB。把limits设为4GiB,意味着随时可能碰到内存耗尽。


这是从物理机或虚拟机切换到容器时的典型变化。通常前者假定的内存较大,而在集群中通常会变小。


我们的第二项措施是把RocksDB存储重新放置到磁盘上(在IO负载上减小内存利用率),同时设置应用程序使用较小的日志文件并启用日志轮转。进行上述更改后,/tmp大小保持在20MiB以下。


内存耗尽仍然持续出现


尽管这些更改让情况暂时发生了好转,但内存耗尽导致程序退出的情况仍会发生。

到目前为止只关注了heap,但是我们知道JVM会将内存用于其他目的(上面列举了一些:代码缓存、元空间、压缩类空间等等)。让我们看一下JVM进程实际的内存使用情况。


root@myapp-5567b547f-tk54j:/# cat /proc/1/status | grep Rss
RssAnon: 3677552 kB
RssFile: 15500 kB
RssShmem: 5032 kB


相信这三个加起来就是RSS(Resident Set Size),它代表了此时JVM进程在主内存中占用的内存。注意:仅仅JVM就消耗了4GiB limit中的3.6GiB。


而且也没有考虑操作系统。在容器中的内存信息位于/sys/fs/cgroup下,让我们检查一下:


root@myapp-5567b547f-tk54j:/# cat /sys/fs/cgroup/memory/memory.stat
cache 435699712
rss 3778998272
rss_huge 3632267264
shmem 9814016
mapped_file 5349376
dirty 143360
writeback 8192
swap 0
pgpgin 2766200501
pgpgout 2766061173
pgfault 2467595
pgmajfault 379
inactive_anon 2650112
active_anon 3786137600
inactive_file 198434816
active_file 227450880
unevictable 0
hierarchical_memory_limit 4294967296
hierarchical_memsw_limit 8589934592
total_cache 435699712
total_rss 3778998272
total_rss_huge 3632267264
total_shmem 9814016
total_mapped_file 5349376
total_dirty 143360
total_writeback 8192
total_swap 0
total_pgpgin 2766200501
total_pgpgout 2766061173
total_pgfault 2467595
total_pgmajfault 379
total_inactive_anon 2650112
total_active_anon 3786137600
total_inactive_file 198434816
total_active_file 227450880
total_unevictable 0


所有数据单位都是字节。容器完整的Resident Set Size=rss+mapped_file,约为3.8GiB。


可以看到,由于把RocksDB数据从/tmp中移出,包含了tmpfs的mapped_file降到很低。因此,实际上非JVM占用内存很小(约0.2 GiB)。其余的3.6GiB都被我们的JVM用掉了。我们的容器只提供200MiB左右的备用内存,因此内存使用量的任何突增都可能导致超出“limit”设置。cat /sys/fs/cgroup/memory/memory.failcnt会告诉我们达到内存使用限制的次数。


有趣的是,虽然我们的JVM消耗了3.6GiB,但根据上面的统计显示,仅使用的committed heap只有约1.72GiB 。这意味着JVM了不到2GiB。第一个怀疑对象应该是堆外内存。快速查看JVM数据就可以排除这种情况:


jvm_buffer_total_capacity_bytes{id="direct",} 284972.0
jvm_buffer_total_capacity_bytes{id="mapped",} 0.0


勉强只有0.2MiB,在这个JVM中可以忽略不计。要了解JVM中究竟是什么正在占用另外的2GiB内存,会在下一篇博客介绍。


现在,我将重点回顾一下文中学到的知识点及实践要点。


requests代表保证,limits表示义务。


当我们从requests转到limits时,表达的语义会有细微变化。对于应用开发者,requests是Kubernetes提供的保证,即Pod被调度时应具备的最小内存。limits是一种义务,即Pod在多少最大内存之下运行,由内核强制执行。


换句话说:容器不能期望从一开始设定的requests内存大小增加到limits中设定的最大内存。


这是有问题的。也许是JVM需要更多内存才能满足heap增长。如果问题无法解决,可以预期heap饱和后会GC定期执行、应用程序出现暂停和CPU负载增大。最糟糕的时候,会发生OutOfMemoryError。还有一种可能是操作系统需要更多内存。无论如何,如果无法在需要时增加内存,内存耗尽都可能出现并找到某个进程干掉。JVM被Kill的可能性最大,因为它是目前为止使用内存最多的进程。


建议


首先,尽管与内存耗尽无关,但还是要设置-Xmx=-Xms确保JVM预先保留将使用的所有堆。


如果JVM和Docker(或更确切地说是cgroup)动态增加内存,会让计算内存与理解问题变得更加困难。


其次,requests应该至少比-Xmx大,为JVM和操作系统留出足够的内存空间。还需要多少内存?取决于容器中运行的程序。普通的微服务requests比-Xmx高出25%左右可能就足够了。但是你的微服务(或者用到的开发库)是否使用了堆外内存?是否会把日志写到tmpfs volume里?Read/Write volume是否会与Pod中的其他容器共享?所有这些及其它因素都会堆内存占用产生影响。如果想要避免意外,需要详细了解这一点。


第三,把limits设为远高于requests可以让Pod利用“当时的可用内存”应对突发负载。注意选择恰当的配置。如果应用程序需要更多的内存完成工作,请不要抱有侥幸心理,用requests设置预留足够的内存就好。


第四,在容器中运行应用程序迫使我们对内存需求要有扎实的了解。本文没有回答为什么我们的JVM尽管只有1.5GiB的最大堆,而且没有堆外内存,却仍占用了大约3.6GiB。我将通过后续文章进行更深入的介绍。但是,假设我们知道需要多少内存,我的直觉(非Kubernetes专家)应该至少把内存设置requests==limits。


最后一点:不要尝试预测操作系统占用的内存大小


前面提到我们应该确保requests为操作系统留出更多内存。开始,我试图从/sys/fs/cgroup/memory/memory.stat中得到的数据预测操作系统占用的内存大小(约0.2GiB)。通过阅读kernel和Docker文档,我意识到这可能不是一种明智的方法。


当内核监视分配给容器的内存限制时,会计算所有RSS以及一部分页面缓存(cache数据参见2.2.1和2.3了解具体数据)。操作系统通过页面缓存加快对磁盘中数据的访问,即把访问量最大的页面保留在内存中(基于内存利用率考虑)。但是,如果几个容器访问同一个文件会怎样?Docker文档对此进行了清晰的解释:

计算页面缓存中的内存非常复杂。如果不同cgroup中的两个进程
同时读取相同的文件
(最终依赖磁盘上相同的块),那么相应的内存开销
将在cgroup之间分配。虽然很好,但
也意味着一个cgroup终止时,会增加
另一个cgroup使用的内存,
因为它们不再为那些内存页面分担开销。


因此,容器的内存占用大小取决于容器的邻居以及它们在节点磁盘上读取的文件。随着容器不断变化,特定容器占用的内存份额可能会变化。如果我们设定的limits已经到达极限,可能已经进入内存耗尽的雷区。


推荐阅读

(点击标题可跳转阅读)

深度解析默认 hashCode() 的工作机制

Java 问答:用 ArrayList 还是 LinkedList

Netflix 工程师分享:如何检测与处理不健康的 JVM


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

原文始发于微信公众号(ImportNew):Kubernetes 案例分享:如何避免 JVM 应用内存耗尽