OOM

一 OOM案例演示

强调:演示之前,先把swap关掉,防止内存不够用之后使用swap影响你看到OOM的效果,至于swap的影响我们后续会详细介绍。

C语言内存空间分配函malloc()数简介:

linux系统里的程序都是调用接口malloc()来申请内存

调用形式:
(类型说明符*)malloc(size)
功能:在内存的动态存储区中分配一块长度为“size”字节的连续区域。函数的返回值为该区域的首地址。
说明:
(1)“类型说明符”表示把该区域用于何种数据类型。
(2)(类型说明符*)表示把返回值强制转换为该类型指针。
(3)“size”是一个无符号数。
例如:
pc=(char*)malloc(100);
表示分配100个字节的内存空间,并强制转换为字符数组类型,函数的返回值为指向该字符数组的指针,把该指针赋予指针变量pc。

mem_alloc.c

[root@test04 test]# cat mem_alloc.c 
#include <stdio.h>
#include <malloc.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>

#define BLOCK_SIZE (1024*1024) 

int main(int argc, char **argv)
{

    int thr, i;
    char *p1;

    if (argc != 2) {
        printf("Usage: mem_alloc <num (MB)>\n");
        exit(0);
    }

    thr = atoi(argv[1]); /*此处指定我们for循环的次数,后面我们启一个for循环每次申请1M*/

    printf("Allocating," "set to %d MBytes\n", thr);
    sleep(30);
    for (i = 0; i < thr; i++) {
        p1 = malloc(BLOCK_SIZE); /*单位为Bytes,此处代表申请1M的内存,得到的就是一个虚拟地址*/
        memset(p1, 0x00, BLOCK_SIZE); /*单位为Bytes,此处是根据虚拟地址真正应用物理内存*/
    }

    sleep(6000);

    return 0;
}

编译生成可执行文件

gcc -o mem_alloc mem_alloc.c

运行测试,该进程启动后30s后,会开始循环申请内容,每次申请1M,当内存不足时,就会被系统杀死,你可以调整下面1000这个值来控制总共申请内存的大小

./mem_alloc 1000

接下来我们把该程序打包到容器里,是一样的效果,因为容器本身就是进程嘛

# 1、Dockerfile
FROM centos:7

ADD mem_alloc /opt

ENV memSize 1000

CMD /opt/mem_alloc $memSize

# 2、构建镜像
docker build -t mem_alloc:v1.0 ./

# 3、测试
docker run -e memSize=1000 --name mem_test mem_alloc:v1.0

然后紧接着我们为该该容器设置mem cgroup上限为515MB

sleep 2
CONTAINER_ID=$(sudo docker ps --format "{{.ID}}\t{{.Names}}" | grep -i mem_test | awk '{print $1}')
echo $CONTAINER_ID

CGROUP_CONTAINER_PATH=$(find /sys/fs/cgroup/memory/ -name "*$CONTAINER_ID*")
echo $CGROUP_CONTAINER_PATH

echo 536870912 > $CGROUP_CONTAINER_PATH/memory.limit_in_bytes
cat $CGROUP_CONTAINER_PATH/memory.limit_in_bytes

容器启动后运行mem_alloc,不断申请内存,直到512MB,会触发OOM被干掉

[root@test04 test]# docker inspect mem_test |grep -i status -A 5
            "Status": "exited",
            "Running": false,
            "Paused": false,
            "Restarting": false,
            "OOMKilled": true,  # 该值为true
            "Dead": false,

二 OOM介绍

2.1 什么是OOM?

OOM 全称 Out of Memory,是linux系统的一种保护机制,当物理内存不够用时,linux系统的killer会杀死某个正在运行的进程来释放内存,当然不会随机杀进程,具体杀哪个进程是会对进程进行评分的,后续将详细介绍

2.2 何时会发生OOM?

OOM分为整个系统级别,以及cgroup控制组级别

1、无论如何,只要内存不足时肯定会触发OOM,是站在整个系统的角度去杀进程,所有进程都是目标

2、内存充足,但是所有进程对内存的占用超过了规定的限制(使用mem cgroup限制),也会触发OOM killer,只能杀死本控制组内进程

2.3 为何一定要发生OOM?

在linux中,内存申请是通过调用malloc()完成的,一旦内存不足,该方法调用失败就可以了,不是吗?为何要杀死进程呢?

这个理解是错误的,因为linux系统的内存都是超配的overcommit。即程序申请的内存其实是虚拟内存,操作系统给程序的只是一个地址范围,该地址范围对应到物理内存上,而且只有在程序往这个地址对应的空间里写入数据时才会真正占用物理内存,所以程序申请的内存是可以超过物理内存大小的。例如物理内存有1G,但是malloc()可以申请2G的内存。

示例1:申请内存,获得虚拟地址,过30s后,根据虚拟地址应用物理内存

[root@test04 test]# cat overcommit_test.c 
#include <stdio.h>
#include <malloc.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>

int main(int argc, char **argv)
{
    char *p1;

    p1 = malloc(100 * 1024 * 1024); /*申请100M的内存,得到的就是一个虚拟地址,拿到指针p1*/
    sleep(30);
    memset(p1, 0x00, 50 * 1024 * 1024); /*根据p1指针的指向的虚拟地址真正应用物理内存*/

    sleep(6000);
    return 0;
}

[root@test04 test]# gcc -o overcommit_test  overcommit_test.c

[root@test04 test]# ./overcommit_test

打开另外一个终端查看,第一次查看VIRT为106624KB(~100M),RSS只有616KB

[root@test04 ~]# ps aux |head -1
USER        PID  %CPU %MEM    VSZ      RSS   TTY      STAT  START   TIME COMMAND
[root@test04 ~]# ps aux |grep overcommit_test |grep -v grep 
root      91190  0.0  0.0     106624   616   pts/3     S+   14:13   0:00 ./overcommit_test
[root@test04 ~]# 
[root@test04 ~]# 

过大概30s后查看。VIRT不变,RSS物理内存被占用到了53960KB(~50M),RSS才是真正占用物理内存的大小,RRS内存包括了代码段内存、栈内存、堆内存、共享库的内存,这些内存是进程运行必须要有的,我们代码里malloc/memset得到的内存就是堆内存,具体RSS大小可以查看/proc/[pid]/smaps文件

[root@test04 ~]# ps aux |head -1
USER        PID  %CPU %MEM    VSZ      RSS   TTY      STAT  START   TIME COMMAND
[root@test04 ~]# ps aux |grep overcommit_test |grep -v grep
root      91190  0.0  2.6      106624  53960  pts/3    S+   14:13   0:00 ./overcommit_test

这种overcommit的内存申请模式的好处是,可以有效提高系统内存的利用率,但坏处就是在所有进程都试图真的用完申请的内存空间时,物理内存可能会发生不够用,系统为了保命,只能杀掉一些进程,这就是OOM。这有点像航空公司多卖几张票,一百个座位卖105张票,如果105个人真的都去坐飞机了,那就告诉多出来的5个人,今天由于天气原因航班取消了,感谢您的体谅

当然oom并不会随便拉出一个容器干掉
而是有规则的,

系统可用的页面数乘以oom_score_adj,然后再加上已用的页面数
得到的结果越大,那oom时被杀掉的几率就越大

详见2.5小节

2.4 储备知识:了解MMU、TLB与大页内存HugePages

我们知道,CPU是通过寻址来访问内存的。32位CPU的寻址宽度是 0~0xFFFFFFFF ,16^8 计算后得到的大小是4G,也就是说可支持的物理内存最大是4G。

但在实践过程中,碰到了这样的问题,程序需要使用4G内存,而可用物理内存小于4G,导致程序不得不降低内存占用。
为了解决此类问题,现代CPU引入了 MMU(Memory Management Unit 内存管理单元)。

MMU 的核心思想是利用虚拟地址替代物理地址,即CPU寻址时使用虚址,由 MMU 负责将虚址映射为物理地址。
MMU的引入,解决了对物理内存的限制,对程序来说,就像自己在使用4G内存一样。

内存分页(Paging)是在使用MMU的基础上,提出的一种内存管理机制。它将虚拟地址和物理地址按固定大小(4K)分割成页(page)和页帧(page frame),并保证页与页帧的大小相同。

这种机制,从数据结构上,保证了访问内存的高效,并使OS能支持非连续性的内存分配。
在程序内存不够用时,还可以将不常用的物理内存页转移到其他存储设备上,比如磁盘,这就是大家耳熟能详的虚拟内存。

在上文中提到,虚拟地址与物理地址需要通过映射,才能使CPU正常工作。
而映射就需要存储映射表。在现代CPU架构中,映射关系通常被存储在物理内存上一个被称之为页表(page table)的地方。
如下图:

从这张图中,可以清晰地看到CPU与页表,物理内存之间的交互关系。

进一步优化,引入TLB(Translation lookaside buffer,页表寄存器缓冲)
由上一节可知,页表是被存储在内存中的。我们知道CPU通过总线访问内存,肯定慢于直接访问寄存器的。
为了进一步优化性能,现代CPU架构引入了TLB,用来缓存一部分经常访问的页表内容。
如下图:

对比前面那张图,在中间加入了TLB。

为什么要支持大内存分页?
TLB是有限的,这点毫无疑问。当超出TLB的存储极限时,就会发生 TLB miss,之后,OS就会命令CPU去访问内存上的页表。如果频繁的出现TLB miss,程序的性能会下降地很快。

为了让TLB可以存储更多的页地址映射关系,我们的做法是调大内存分页大小。

如果一个页4M,对比一个页4K,前者可以让TLB多存储1000个页地址映射关系,性能的提升是比较可观的。

调整OS内存分页

在[Linux]()和windows下要启用大内存页,有一些限制和设置步骤。

Linux:
限制:需要2.6内核以上或2.4内核已打大内存页补丁。
确认是否支持,请在终端敲如下命令:

# cat /proc/meminfo | grep Huge
HugePages_Total: 0
HugePages_Free: 0
Hugepagesize: 2048 kB # hugepagesize设置见文档:https://www.jianshu.com/p/b9470fc331dd

如果有HugePage字样的输出内容,说明你的OS是支持大内存分页的。Hugepagesize就是默认的大内存页size。
接下来,为了让JVM可以调整大内存页size,需要设置下OS 共享内存段最大值 和 大内存页数量。

共享内存段最大值
建议这个值大于Java Heap size,这个例子里设置了4G内存。

# echo 4294967295 > /proc/sys/kernel/shmmax

大内存页数量

# echo 154 > /proc/sys/vm/nr_hugepages

这个值一般是 Java进程占用最大内存/单个页的大小 ,比如java设置 1.5G,单个页 10M,那么数量为 1536/10 = 154。
注意:因为proc是内存FS,为了不让你的设置在重启后被冲掉,建议写个脚本放到 init 阶段(rc.local)。

numa

numa是一种关于多个cpu如何访问内存的架构模型,现在的cpu基本都是numa架构,linux内核2.5开始支持numa。

numa架构简单点儿说就是,一个物理cpu(一般包含多个逻辑cpu或者说多个核心)构成一个node,这个node不仅包括cpu,还包括一组内存插槽,也就是说一个物理cpu以及一块内存构成了一个node。每个cpu可以访问自己node下的内存,也可以访问其他node的内存,但是访问速度是不一样的,自己node下的更快。numactl –hardware命令可以查看node状况。

通过numactl启动程序,可以指定node绑定规则和内存使用规则。可以通过cpunodebind参数使进程使用固定node上的cpu,使用localalloc参数指定进程只使用cpu所在node上分配的内存。如果分配的node上的内存足够用,这样可以减少抖动,提供性能。如果内存紧张,则应该使用interleave参数,否则进程会因为只能使用部分内存而out of memory或者使用swap区造成性能下降。

NUMA的内存分配策略有localalloc、preferred、membind、interleave。

  • localalloc规定进程从当前node上请求分配内存;
  • preferred比较宽松地指定了一个推荐的node来获取内存,如果被推荐的node上没有足够内存,进程可以尝试别的node。
  • membind可以指定若干个node,进程只能从这些指定的node上请求分配内存。
  • interleave规定进程从指定的若干个node上以RR(Round Robin 轮询调度)算法交织地请求分配内存。

2.5 OOM发生时什么进程会被杀掉

当OOM发生时,并不会随机杀进程,具体杀哪个进程是会调用内核了一个oom_badness()函数来评定,主要就是两个指标

1、进程已经使用的物理内存页面数

2、每个进程的OOM校准值oom_score_adj

在/proc文件系统中,每个进程都有一个/proc/oom_score_adj的接口文件,我们可以在该文件中输入-1000到1000之间的任意一个数值,来调整
该进程被OOM kill的几率

oom_badness()函数会计算一个评分

评分=系统总的可用页面数 * oom_score_adj值 + 进程已经使用的物理页面数

该评分的值越大,被OOM kill掉的几率就越大。

三 memory cgroup

每个Cgroups的子系统都是通过一个虚拟文件系统挂载点的方式,挂到一个缺省的目录下。在linux发行版里,memory cgroup一般是挂载到/sys/fs/cgroup/memory目录下,我们创建一个子目录作为控制组。

控制组参数有很多,我们主要关注3个

参数1:memory.limit_in_bytes:

最关键的一个参数,用于控制该控制组内所有进程可用内存的最大值,一旦超过该值默认就会触发OOM,但是该OOM是控制组级的而不是系统级的,你看系统的内存完全有可能是充足的,这只是在限制控制组级发生了超出限制的事情,所以只会杀掉该控制组内的某些进程。而且是否一定会发生OOM,得看下面参数memory.oom_control.

补充:
k8s的是yaml文件里指定的limit就是在改这个参数,至于request那只是创建容器时的一个调度指标,并不修改任何参数
也就是说k8s request不会修改Memory Cgroup里的参数,它只是在kube scheduler里调度的时候,看做个计算,看节点上是否还有内存给这个新的container。

参数2、memory.oom_control

控制当控制组内所有进程占用内存达到上限时,是否会触发OOM,默认为触发,设置为1代表不触发,一旦容器内存达到最大限制值,并不会被oom,但是malloc申请内存的行为会中止,并且会因为长期等待而进入可中断睡眠状态

cd /sys/fs/cgroup/memory/system.slice/docker-xxx

[root@test04 xxx]# cat memory.oom_control 
oom_kill_disable 0
under_oom 0
oom_kill 0
[root@test04 xxx]# echo 1 > memory.oom_control 
[root@test04 xxx]# cat memory.oom_control 
oom_kill_disable 1
under_oom 0
oom_kill 0

强调:
oom只针对内存超出的情况,cpu使用率过高是不会被杀掉的,但是会一直限制cpu的最大使用,(cpu时间片都是共享的,你超出了最多不给你用了,不会杀你。但是内存在申请的时候都是超配的,你执行系统调用malloc得到的就只是一个虚拟的地址与物理内存对应,这就是overcommit机制,这有效增强了内存利用率,同时也埋下了隐患,就是如果所有进程都来真的,都真的去用完这些内存,把内存占满了,这就必须杀掉几个来释放了。最后说一句:内存你占用了,你不释放你就一直占用着,但是cpu可不是这样,操作系统会随时夺走你的cpu执行权限,不管你是谁,不管你在干什么)

参数3、memory.usage_in_bytes

该参数只是只读的,代表的就是控制组内所有进程使用的内存量,通过判断该值得大小是否接近memory.limit_in_bytes可以断定发生OOM的风险

注意对于memory cgroup的控制组目录树来说,如果父级group1的memory.limit_in_bytes设置为500M,那么子级group3设置的1000M是无效的,最大也只能到500M

四 如何定位OOM问题与解决

启动容器,占用1000M内存

联系管理员微信tutu19192010,注册账号

上一篇
下一篇
Copyright © 2022 Egon的知识星球 egonlin.com 版权所有 帮助IT小伙伴学到真正的技术