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
J
结果当晚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_05
正常机器java内存情况
Java服务总在半夜挂,背后的真相竟然是... | 京东云技术团队_定时任务_06
按照这个 文档描述,使用docker cgroups限制内存可能会导致JVM进程被终止,原因是Java读取的还是宿主机的CPU,而不是docker cgroups限制的CPU,高版本的Java解决了这个问题,文档解决方案截图如下:
Java服务总在半夜挂,背后的真相竟然是... | 京东云技术团队_Java_07
对此我们表示怀疑,因为我们的程序里设置了JVM参数
保持着试一试的心态,我们增加了一个实验组,实验组使用的Java版本是11.0.8
结果当晚实验组的Java进程还是死了,看来和Java版本也没关系
容器上存在定时任务导致的?
由于基础镜像是jdos官方提供的镜像,所以之前从来没有怀疑过是定时任务的问题,但是现在别无他法了,检查下容器的定时任务
虽然有定时任务,但是这个执行的时间点和Java挂掉的时间对不上,为此我们决定删除定时任务试试
结果当晚Java进程还是挂了,并且这次有dmesg的日志,发现Java被kill的同时crond也被kill了,被kill的原因是crond内存过高导致oom
J
难道还有系统级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”进程杀掉。
以下是一些主要的进程选择策略:
注:不同版本的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