一、消失的服务
最近笔者所在的公司对历史代码进行了大量重构,同时为了降本,避免增加大量的部署单元,选择了在原有项目里面增加了一个新的maven模块,并在这个模块中增加了代码,设计了很多微服务接口,并将这些微服务接口通过nacos注册到注册中心,提供给关联的业务方使用。
一天中午 测试反馈测试环境的服务挂了,接口测试脚本不能运行了,于是笔者直接重启,可是很快啊,注册中心又不见这个服务了。下游业务“礼貌”的问候都过来了,咋整 还能搞个脚本 观察到组件挂了就重启一遍?
于是我开始登录其中一个挂掉的pod上,寻求服务消失之谜,首先查看组件的日志文件,发现组件的日志已经不再新增,使用jps命令发现组件进程已经不存在,于是检查下组件的GC日志
2024-11-05T18:57:10.122+0800: 3274.527: [GC (Allocation Failure) 2024-11-05T18:57:10.122+0800: 3274.527: [ParNew: 1922432K->174720K(1922432K), 0.5609667 secs] 2752898K->1051838K(4019584K), 0.5615698 secs] [Times: user=1.11 sys=0.01, real=0.56 secs]
2024-11-05T18:59:51.071+0800: 3435.478: [GC (Allocation Failure) 2024-11-05T18:59:51.081+0800: 3435.486: [ParNew: 1922432K->132210K(1922432K), 0.3097193 secs] 2799550K->1035405K(4019584K), 0.3234300 secs] [Times: user=0.57 sys=0.01, real=0.32 secs]
2024-11-05T19:05:10.978+0800: 3755.383: [GC (Allocation Failure) 2024-11-05T19:05:11.146+0800: 3755.555: [ParNew: 1879922K->154619K(1922432K), 0.7207988 secs] 2783117K->1057814K(4019584K), 0.9557755 secs] [Times: user=0.00 sys=0.94, real=0.95 secs]
2024-11-05T19:07:21.582+0800: 3885.987: [GC (Allocation Failure) 2024-11-05T19:07:21.781+0800: 3886.192: [ParNew: 1902331K->123792K(1922432K), 39.5498568 secs] 2805526K->1086794K(4019584K), 42.5153543 secs] [Times: user=0.46 sys=31.55, real=43.29 secs]
检查最后一条GC日志发现在这次垃圾回收之前,整个堆(包括年轻代和老年代)占用的内存大小为 2805526K(约 2.67GB),经过垃圾回收后,整个堆占用的内存大小减少到 1086794K(约 1.04GB)。 (4019584K):表示整个堆的总容量为 4019584K(约 3.83GB))但是这次回收耗时竟然达到令人发指的42秒
那组件GC时间长,就会导致程序挂掉吗?
二、深究服务消失之谜
在正式讲解本文微服务消失之谜之前,请读者先考虑下面两个问题
问题1:如果java程序最大堆内存和最小堆内存设置的都是4GB 那么这个程序是否可以在一台物理内存为4GB的机器启动 ?
问题2:如果java程序最大堆内存和最小堆内存设置的都是4GB 但是如果实例对象创建出现了内存泄露,导致对象个数无限制增加,此时会发生什么?
2.1 Linux 内存“超额分配”机制
不知道大家有没有想过,linux系统上的进程如果一开始申请了4gb内存占用,是不是操作系统就会傻乎乎的给它4gb内存呢?
事实上一般情况下进程可能刚启动的时候使用的内存很低,如果直接分配4gb内存给这个进程,那就存在大量内存浪费。所以为了提升系统的整体性能并且避免不必要的内存浪费,内核允许这部分未使用的内存被用于其他用途。(这里特别备注下,现代操作系统是使用的虚拟内存管理办法,并不是直接分配物理内存 图中仅是为了示意)
这部分未使用的内存从某种程度上说是属于每个应用程序进程的,内核怎么回收内存呢,因为这涉及到复杂的进程内存管理问题,每个进程都有自己的内存空间,包括代码段、数据段、堆和栈等部分,直接回收可能会破坏进程的正常运行逻辑。所以,内核采用“超额分配”的内存分配策略。简单来说,就是内核允许系统分配的内存总量超过实际物理内存和交换空间(swap)的总和。这就好比是在内存使用上打了一个 “提前量”。例如,物理内存有 8GB,交换空间有 2GB,但是内核允许系统分配总计 12GB 的内存,多出来的 2GB 就是基于对应用程序不会同时用完所分配内存的预期。
但是这样超额分配就会带来一个问题 加入后面多个应用程序的内存需求真的都到达自己申请的最大内存的时候,这时候就会使得它们的内存需求总和超过了物理内存和交换空间的总量时,此时内存不足就出现了,这时候另一个机制 Linux OOM Killer机制 就要开始起作用了。
2.2 Linux OOM Killer机制
Linux中的OOM Killer(Out Of Memory Killer)是内核级的一种机制,旨在解决系统内存耗尽时的崩溃风险。其主要功能是在系统物理内存和交换区都耗尽时,通过选择性的终止一个或多个进程来释放内存。 那现在新的问题出现了
1、当多个进程内存占用都比较大,linux OOM Killer是都杀掉还是随机挑选一个“幸运儿”?
2、如果我有一个进程很重要不想让OOM Killer机制 kill 应该怎么做?
3、我想知道最近一段时间OOM killer 杀掉了哪些进程,哪里能查看?
这里直接找Linux内核中的关键代码一探究竟:
static void __out_of_memory(gfp_t gfp_mask, int order)
{
struct task_struct *p;
unsigned long points;
if (sysctl_oom_kill_allocating_task)
if (!oom_kill_process(current, gfp_mask, order, 0, NULL,
"Out of memory (oom_kill_allocating_task)"))
return;
retry:
/*
* Rambo mode: Shoot down a process and hope it solves whatever
* issues we may have.
*/
p = select_bad_process(&points, NULL);
if (PTR_ERR(p) == -1UL)
return;
/* Found nothing?!?! Either we hang forever, or we panic. */
if (!p) {
read_unlock(&tasklist_lock);
panic("Out of memory and no killable processes...\n");
}
if (oom_kill_process(p, gfp_mask, order, points, NULL,
"Out of memory"))
goto retry;
}
// ... 省略无关代码
static struct task_struct *select_bad_process(unsigned long *ppoints,struct mem_cgroup *mem)
{
struct task_struct *p;
struct task_struct *chosen = NULL;
struct timespec uptime;
*ppoints = 0;
do_posix_clock_monotonic_gettime(&uptime);
for_each_process(p) {
//遍历所有的进程包括用户进程和内核进程
unsigned long points;
/*
* skip kernel threads and tasks which have already released
* their mm. 跳过内核进程
*/
if (!p->mm)
continue;
/* skip the init task 跳过Init进程*/
if (is_global_init(p))
continue;
if (mem && !task_in_mem_cgroup(p, mem))
continue;
if (test_tsk_thread_flag(p, TIF_MEMDIE))
return ERR_PTR(-1UL);
if (p->flags & PF_EXITING) {
if (p != current)
return ERR_PTR(-1UL);
chosen = p;
*ppoints = ULONG_MAX;
}
//这里就是 #define OOM_DISABLE (-17) 也就是/proc/<pid>/oom_adj这个值
if (p->signal->oom_adj == OOM_DISABLE)
continue;
//对其它的进程调用badness()函数来计算相应的score,score最高的将被选中
points = badness(p, uptime.tv_sec);
if (points > *ppoints || !chosen) {
chosen = p;
*ppoints = points;
}
}
return chosen;
}
上述代码的关键就是这个 select_bad_process ,简单看下这段代码,大致意思就是要选一个得分高的“坏程序”,然后kill掉。那么怎么计算的得分呢?从代码中可以发现init进程 正在退出的进程 禁止被OOM杀死的进程都会选择性忽略不会被选中,然后其他进程调用badness方法计算下得分并把得分最高的进程给返回,最后杀死该进程 那么badness怎么计算得分的呢?主要考量就是进程的运行时间和当前的虚拟内存占用,具体细节如下:
(1)score初始值为该进程占用的total_vm;
(2)如果该进程有子进程,子进程独自占用的total_vm/2加到本进程score;
(3)score随着该进程的cpu_time以及run_time的增长而减少,也就是运行的时间越长,被kill掉的几率越小
(4)对于拥有超级权限的进程,或者直接磁盘交互的进程降低score;
(5)如果和current进程在内存上没有交集,则该进程降低score;
所以那些内存占用大,存活时间短,子进程多且不受保护的进程就会被优先kill,那么如果我的进程想进入白名单怎么处理?实际上从前面的代码就可以看出可以通过调整/proc/[pid]/oom_adj (注意不同系统内核这里的文件名称不同)的数值来增加或减少特定进程被选为OOM目标的可能性。此外被kill掉的进程我们可以从 /var/log/syslog 或 /var/log/messages 文件中找到相关信息。
最后我们回到本节开始提出的两个问题
首先对于问题1, 答案是不确定的,比如我们的java程序实际可能堆内存只需要512MB即可启动,那么即使配置了虚拟机参数是4GB 仍然可以启动成功。但是反之如果我们的Java程序在启动时确实需要4GB内存 那么就极有可能启动失败。以本文的实际案例为例 通过 tsar命令 (tsar --cpu --load --mem
)查看主机内存增长情况即可发现
最大的内存占用已经来到了4.3GB 显然这个组件在4GB内存的机器上会触发Linux OOM killer机制进而导致无法存活。
对于问题2 则需要考虑进程所在主机的性能,如果主机存在很多空闲内存,这个组件虽然不断地在报错产生OOM异常,这时候Linux系统也不会去kill掉这个进程。但是反之如果Linux内存已经吃紧,在没有设置进程保护的情况下,则会直接kill掉这个进程。
三、服务容错
由此可见,假定我们是服务的调用方,如果下游业务开展降本,缩减了服务器配置,极有可能“城门失火,殃及池鱼” 下游服务挂掉,直接导致上游业务不可用。导致上游业务躺枪,为了避免这种情况,出现了很多工具,这些工具有的从基础设施层面例如K8s集群层面解决,有的则从代码层面解决,这些工具为提高业务的高可用提供了极大便利。
总之对于在微服务架构里,服务不可用出现的概率还是比较大的,有位大师说过“不要信任任何第三方” ,做高风险的业务代码开发要充分考虑中间件、三方服务挂了所带来的风险能不能做到可控。