Java服务总在半夜挂,背后的真相竟然是...

问题排查

问题复现

为了复现该问题,写了个springboot的demo部署在测试环境,其中demo里只做了hello world功能,应用类型为web_tomcat (war包部署),基础镜像是base_tomcat/java-centos6-jdk18-60-tom8050-ngx197,镜像使用的Java版本是1.8.0_60,有了上次 MySQL被kill的经验,盲猜是linux limit惹的祸,因此将打好的镜像分别部署了两批不同的机器,果不其然,新机器当晚挂掉了,老机器服务正常

看一下挂掉的limit设置

排查过程

Java进程会受到limits影响?

按理说Java进程是不会受到系统limit open files(系统最大句柄数)影响的,但是为了验证这个问题,我们将他修改为正常机器的值,由于demo是web_tomcat应用,没法修改启动脚本,因此我们通过prlimit修改java进程的limit

prlimit -p 32672 --nofile=1048576

JJ

结果当晚00:00左右还是挂了,看来open files和java进程挂掉没关系,看dmesg也没发现什么问题

Java版本过低导致内存分配不合理?

通过寻求jdos研发组的帮助,jdos研发组的同学认为是java版本的问题,低版本可能没有限制住申请的内存大小,具体原因如下

https://blog.softwaremill.com/docker-support-in-new-java-8-finally-fd595df0ca54?gi=a0cc6736ed14

异常机器java内存情况

Java服务总在半夜挂,背后的真相竟然是... | 京东云技术团队_EXEC_05Java服务总在半夜挂,背后的真相竟然是... | 京东云技术团队_EXEC_05

正常机器java内存情况

Java服务总在半夜挂,背后的真相竟然是... | 京东云技术团队_定时任务_06Java服务总在半夜挂,背后的真相竟然是... | 京东云技术团队_定时任务_06

按照这个 文档描述,使用docker cgroups限制内存可能会导致JVM进程被终止,原因是Java读取的还是宿主机的CPU,而不是docker cgroups限制的CPU,高版本的Java解决了这个问题,文档解决方案截图如下:

Java服务总在半夜挂,背后的真相竟然是... | 京东云技术团队_Java_07Java服务总在半夜挂,背后的真相竟然是... | 京东云技术团队_Java_07

对此我们表示怀疑,因为我们的程序里设置了JVM参数

保持着试一试的心态,我们增加了一个实验组,实验组使用的Java版本是11.0.8

结果当晚实验组的Java进程还是死了,看来和Java版本也没关系

容器上存在定时任务导致的?

由于基础镜像是jdos官方提供的镜像,所以之前从来没有怀疑过是定时任务的问题,但是现在别无他法了,检查下容器的定时任务

虽然有定时任务,但是这个执行的时间点和Java挂掉的时间对不上,为此我们决定删除定时任务试试

结果当晚Java进程还是挂了,并且这次有dmesg的日志,发现Java被kill的同时crond也被kill了,被kill的原因是crond内存过高导致oom

JJ

难道还有系统级cron任务?于是查了一下/etc/crontab,发现果然还有cron任务(这是谁打的镜像!!!)

这个时间点和Java进程挂掉的时间点吻合,但是问题来了,执行的任务并没有logrotate.sh这个脚本,应该不会出现问题才对

到底是不是定时任务的问题,我们修改下cron的时间验证下,调整时间为中午11:00,验证下Java进程是否会挂,同时使用strace打印进程trace log

果然Java进程在中午11.00挂了,看来真的是cron任务导致的,让我们一起看一下strace

19:59:01 close(3)                        = 0
19:59:01 stat("/etc/pam.d", {st_mode=S_IFDIR|0755, st_size=4096, ...}) = 0
19:59:01 open("/etc/pam.d/crond", O_RDONLY) = 3
19:59:01 fstat(3, {st_mode=S_IFREG|0644, st_size=293, ...}) = 0
19:59:01 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fd770804000
19:59:01 read(3, "#n# The PAM configuration file f"..., 4096) = 293
19:59:01 open("/lib64/security/pam_access.so", O_RDONLY) = 5
19:59:01 read(5, "177ELF211

可以看到最后用 mmap 一次分配了 4G 内存,然后就被kill了。

mmap前调用了getrlimit,和上次 MySQL的问题一样,都是根据系统资源限制来分配内存

为了确定就是cron导致java挂掉的元凶,我们把cron进程手动kill掉,这样就不会执行定时任务了,这次我们在验证下Java进程是否会挂掉

果不其然,Java进程并没有挂掉,看来真的是cron任务导致的

高版本CentOS是否也会出现类似问题?

按理说oom killer应该只kill掉占用内存最高的才对,Java进程占用内存又不是最高的,高版本的CentOS系统oom killer策略会不会有升级?

让我们来一起验证下高版本的CentOS系统是否有这个问题

当前镜像的CentOS版本是CentOS release 6.6 (Final),为了验证高版本的CentOS是否也有类似的问题,我们将增加两个实验组,分别升级基础镜像至CentOS release 6.10 (Final)和CentOS Linux release 7.9.2009 (Core),也添加相同的cron任务

结果发现CentOS release 6.10 (Final)和CentOS Linux release 7.9.2009 (Core)都没有kill掉Java进程,只kill掉了cron的子进程

结论

由于容器limit open files(系统最大句柄数)设置不合理导致cron执行任务时使容器内存飙升,存在内存溢出的风险,linux由于保护机制会kill掉占用内存高的进程,导致cron子任务进程和Java进程一起被kill(但是问题来了,这个jdos基础镜像为什么会执行一个完全不存在的shell脚本,而且还是执行两次???),高版本的CentOS系统不会kill java进程,猜测不同版本的CentOS的kill选择策略略有不同

问题分析

Cron任务执行逻辑

在Linux中,crontab工具是由croine软件包提供的,让我们一起看下cron的执行过程

其中child_process()执行了cron子进程,cron执行子进程时会有发送mail的动作

cron_popen在执行时会按照open files(系统最大句柄数)清除内存

综上,cron oom的原因找到了,是由于open files设置过大且cron任务没有标准输出,导致执行了发送mail逻辑,而清除的内存大小超出了容器本身内存的大小,导致oom。

croine 1.5.4 版本之后修复了该问题,如果想查看当前容器croine版本可执行如下命令:

1.rpm -q cronie

Linux内核OOM killer机制

Linux 内核有个机制叫OOM killer(Out Of Memory killer),该机制会监控那些占用内存过大,尤其是瞬间占用内存很快的进程,然后防止内存耗尽而自动把该进程杀掉。内核检测到系统内存不足、挑选并杀掉某个进程的过程可以参考内核源代码linux/mm/oom_kill.c,当系统内存不足的时候,out_of_memory()被触发,然后调用select_bad_process()选择一个”bad”进程杀掉。

以下是一些主要的进程选择策略:

  • 内存使用情况:OOM Killer首先倾向于选择占用内存最多的进程,因为终止这些进程可以释放最多的内存。
  • OOM分数:每个进程都有一个OOM分数,该分数是基于其内存使用情况和其他因素计算出来的。OOM Killer倾向于终止OOM分数最高的进程。
  • 进程优先级:在选择要终止的进程时,OOM Killer通常会避免终止对系统至关重要的系统进程。这些进程通常具有较高的优先级,因此它们更不容易成为终止目标。
  • 进程资源需求:OOM Killer还会考虑进程的资源需求。它倾向于终止那些请求较少资源的进程,以最小化影响其他进程的运行。
  • 进程属性:某些进程可能被标记为不可终止,例如通过设置/proc/[PID]/oom_score_adj的值来调整OOM分数。这些进程通常不容易被OOM Killer终止。
  • 注:不同版本的Linux oom killer机制可能会存在一些差异

    解决方案

    使用高版本稳定的CentOS系统,如果业务无法升级CentOS,则需要设置合理的limit open files数量,application_worker类型应用可以在启动脚本中手动修改limit,web_tomcat类型应用没法修改启动脚本,可以选择kill掉cron进程或删除系统cron任务,也可以手动升级cronie的版本至1.5.7-5

    写在后面

    open files这个坑很大,栽这个坑两次了,大家一定要检查自己服务对应容器的CentOS版本和limit设置是否合理,本次案例发生在测试环境,尚不会引起事故,如果在生产出现类似情况,后果不堪设想

    由于测试环境新增的这批机器都存在这个问题,我们团队已经联系机器提供方上报了该问题,后续这批机器会由提供方统一修改系统最大句柄数,如果当前问题影响到了业务的正常使用,可以临时删除容器中/etc/crontab中的任务

    参考文献

    https://cloud.tencent.com/developer/article/1183262

    https://github.com/cronie-crond/cronie