最近线上遇到了好几次由于内存泄漏导致OOM的问题,且大部分都是整个模块被kill掉woker进程,只剩下接入的epoll进程和统计进程的情况,从而导致拨测程序在没有做逻辑拨测的情况下,不会重新拉起程序,导致机器无法服务。
我们的svr的worker进程都有一个用于守护的父进程,在worker子进程挂掉或者运行正常退出之后,会由父进程重新拉起
考虑到线上的内存泄漏都是很缓慢不容易发现的,因此我们希望能够让父进程在OOM的情况下不被os干掉,而是让os kill掉泄漏的子进程,然后由父进程重新拉起子进程,从而让模块可以继续服务。
同时对于在OOM的时候,都是worker进程被kill掉,而epoll进程存活的情况也存有疑问,因此研究了一下OOM Killer的相关机制,这里简单总结一下。
首先说明一下在OOM的时候哪些进程会优先被os干掉。
直观的感觉应该是操作系统会选择内存占用最多的进程将其kill掉,这个大概是对的,但是受很多其他因素的影响。
由于现网出问题的机器的内核版本为2.6.16,所以这里是根据2.6.16的oom killer源码做一下简单分析,具体的源文件为mm/oom_kill.c
需要说明的是,不同版本的内核的oom killer的实现机制有所不同。
在内存爆掉的时候,内核会调用到out_of_memory这个函数,简化之后的代码如下:
void out_of_memory(struct zonelist *zonelist, gfp_t gfp_mask, int order)
{
struct mm_struct *mm = NULL;
task_t *p;
unsigned long points = 0;
........
/*
* Rambo mode: Shoot down a process and hope it solves whatever
* issues we may have.
*/
p = select_bad_process(&points);
if (PTR_ERR(p) == -1UL)
goto out;
/* Found nothing?!?! Either we hang forever, or we panic. */
if (!p) {
read_unlock(&tasklist_lock);
cpuset_unlock();
panic("Out of memory and no killable processes...\n");
}
mm = oom_kill_process(p, points, "Out of memory");
if (!mm)
goto retry;
break;
}
基本上这个函数就是调用select_bad_process选择一个进程,然后调用oom_kill_process将其kill掉
而select_bad_process中则是遍历所有进程,逐个调用badness函数,得到一个point,最终返回point最大的进程
但这里有一点需要提及
/* skip the init task with pid == 1 */
if (p->pid == 1)
continue;
if (p->oomkilladj == OOM_DISABLE)
continue;
可以看到,init进程是会被忽略的,同时,如果进程的oomkilladj == OOM_DISABLE的话,则这个进程也会被跳过
那oomkilladj是什么呢?其实就是每个进程的oom权重
对于2.6.16来说,可以通过设置每个进程在/proc下对应节点的oom_adj节点来调整这个权重
oom_adj的取值范围为[-17, 15],默认值为0,值越大,badness里面计算的时候得到的point就会越大
而如果oom_adj被设置为 -17 的话,则会满足上面的oomkilladj == OOM_DISABLE这个条件,从而使得OOM Killer跳过这个进程。
也就是说,如果我们希望OOM Killer不要干掉某个进程的话,最简单的做法是设置这个进程的oom_adj,或者是进程在启动的时候,自己将/proc/self/oom_adj设置为-17
但是这种做法有个比较明显的缺点,对oom_adj的设置需要root权限。
另外需要提及的是,从Linux 2.6.36开始oom_adj被替换为oom_score_adj。
对oom_adj进行的设置,在内核内部进行变换后的值也是针对oom_score_adj设置的。
oom_score_adj可以设置[-1000, 1000]之间的值。设置为-1000时,等同于上面提及的oom_adj设置为-17的情况。
另外/proc/<pid>/下还有一个oom_score节点,这个节点保存的即是当前进程的point值,值越大被OOM Killer选中的几率越大。
badness函数的主要功能如上所述,即根据各种条件计算进程的point值。
point的初始值为进程占用的内存大小。
/*
* The memory size of the process is the basis for the badness.
*/
points = p->mm->total_vm;
接着,会遍历当前进程的所有子进程,将与父进程不共享内存的子进程占用的一半内存大小加到父进程的point里面
/*
* Processes which fork a lot of child processes are likely
* a good choice. We add half the vmsize of the children if they
* have an own mm. This prevents forking servers to flood the
* machine with an endless amount of children. In case a single
* child is eating the vast majority of memory, adding only half
* to the parents will make the child our kill candidate of choice.
*/
list_for_each(tsk, &p->children) {
struct task_struct *chld;
chld = list_entry(tsk, struct task_struct, sibling);
if (chld->mm != p->mm && chld->mm)
points += chld->mm->total_vm/2 + 1;
}
也就是说,如果一个进程fork了一堆子进程,每个子进程又分配了大量内存,则即使这个父进程本身没有分配什么内存,父进程的point值还是可能大于子进程。
从而导致父进程被OOM Killer选中。
举个例子,以下是两个进程的oom_score值,其中 4865 是 25266 的守护父进程,可以看到,父进程的omm_score明显大于子进程的oom_score。
接着代码会计算进程占用的cpu时间和运行时间,并point除与这两个时间的开平方值,也就是说,如果进程占用的cpu时间、存活时间越长,其point值会越小。
同时还会考虑进程的nice值,如果nice值大于0,则将point×2,这个基于被设置了nice值的进程重要性低这一点。
对于超级用户的进程或者直接对硬件进行操作的进程,其point值会被除与4,以减少被选中的可能性。
最后,会根据oom_adj的值进行修正。
/*
* Adjust the score by oomkilladj.
*/
if (p->oomkilladj) {
if (p->oomkilladj > 0)
points <<= p->oomkilladj;
else
points >>= -(p->oomkilladj);
}
至此,一个进程的point值已经被计算完毕,select_bad_process最终会返回一个point值最大的进程,然后在oom_kill_process中kill掉这个进程。
这里需要提及的是,如果这个进程有子进程,则会优先kill掉其一个不与父进程共享内存的子进程。
/* Try to kill a child first */
list_for_each(tsk, &p->children) {
c = list_entry(tsk, struct task_struct, sibling);
if (c->mm == p->mm)
continue;
mm = oom_kill_task(c, message);
if (mm)
return mm;
}
只要kill掉一个子进程,则这个函数就会返回,于是父进程可能不会被kill掉。
这也是在OOM的时候,我们可能会在dmesg中看到类似如下的日志的原因
[1911080.461584] Out of Memory: Kill process 23009 (qspoint) score 1660489 and children.
[1911080.461642] Out of memory: Killed process 23012 (qspoint).
即dmesg显示要kill某个进程(上例中为23009)和其子进程,最终却只kill了另外一个进程(23012)的原因。
对OOM Killer的分析大概如上,回到一开始提到的问题上来,如何避免worker的父进程不被OOM Killer干掉呢?
经过以上的分析之后,可以知道一种解决方案是设置父进程的oom_adj为-17。
经过测试,这种方案是可以解决问题。
不过最终我们并没有采用这种方式,原因在于这个操作需要root权限,但是我们并不想用root来起这个模块,当然也可以通过脚本在外部设置或者在程序中切换用户,但是最终我们还是选择了通过在epoll进程中起一个线程定期检查worker父进程是否存活,发现不存活就kill掉整个进程组的方式来解决这个问题。
检查进程是否存活的方式也很简单,直接kill(pid, 0),如果返回-1,则可以认为进程已经不存在,具体请man kill。
对于另一个问题,为什么epoll进程没有被kill掉,而总是worker进程被kill掉,从上面的分析也可以得到大概的解释,对于worker子进程来说,被kill掉是因为本身有内存泄漏,确实占用了大量内存导致,而对于worker的守护进程来说,则是由于其fork了worker子进程,导致在计算point的时候,子进程的一半内存大小被计算到守护进程的point中,使得守护进程在本身没有泄漏和占用大量内存的情况下,也仍然被OOM Killer选中。
不过这里还有一点存疑,按照上面的分析,即使是在选中父进程的情况下,只要能够kill掉一个子进程,则OOM Killer就会退出,简单的测试程序测试的结果也的确如此,那为什么现网会出现父进程也被kill掉的情况呢?
OOM Killer在kill掉某个进程的时候,是通过发送SIGKILL信号的方式来实现的,如下:
force_sig(SIGKILL, p);
这里一种可能性是,线上内存爆掉的时候情况远比测试环境复杂,虽然OOM Killer发送了信号给子进程,但并不能立刻kill掉子进程,从而使得OOM Killer多次被触发,最终把父进程也kill掉,而我们的worker子进程是有运行次数限制的,即处理的请求数达到一定程度之后就会退出,然后由父进程重新拉起,而由于父进程已经被kill掉,最终导致所有worker全部挂掉。
参考资料:
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。