本文仅作为学习记录,非商业用途,侵删,如需转载需作者同意。

一、问题再现

容器在系统中被杀掉,只有一种情况,那就是容器中的进程使用了太多的内存。
具体来说就是:容器里所有进程使用的内存量,超过了容器所在Memory Cgroup 里的内存限制,这时 Linux 系统就会主动杀死容器中的一个进程,往往这会导致整个容器的退出。

测试代码: https://github.com/chengyli/training/tree/main/memory/oom

启动一个容器,然后给容器的Cgroup 内存上限设置为 512MB


#!/bin/bash
docker stop mem_alloc;docker rm mem_alloc
docker run -d --name mem_alloc registry/mem_alloc:v1

sleep 2
CONTAINER_ID=$(sudo docker ps --format "{{.ID}}\t{{.Names}}" | grep -i mem_alloc | 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的时候,就会发现这个容器消失了。

这个时候使用 docker inspect 命令查看容器退出的原因,就会看到容器处于 “exited” ,并且 “OOMKilled” 是true

在这里插入图片描述

二、如何理解OOM Killer

OOM 是out of Memory 的缩写,就是内存不足的意思。
OOM Killer :Linux 系统里如果内存不足时,就需要杀死一个正在运行的进程来释放一些内存。

Linux 里的程序都是调用 malloc() 来申请内存,如果内存不足,直接 malloc() 返回失败就可以了,为什么杀死正在运行的进程呢?

其实这个和Linux 进程的内存申请策略有关,Linux 允许进程在申请内存的时候是 overcommit 的,就是允许进程申请超过物理上限的内存。

例如:节点上实际内存是512MB,如果一个进程调用了 malloc() 申请了600MB的内存,这次申请还是被允许的。

这是因为malloc() 申请的内存的是虚拟地址,系统只是给了程序的一个地址范围,由于没有写入数据,所以并没有得到真正的物理内存。 物理内存只有程序真正往这个地址写入数据,才会分配给程序。

overcommit 的内存申请模式
好处:提供系统的内存使用效率
坏处:内存不够用的时候怎么办? 只能采取某种措施,杀死正在运行的某个进程了。


Linux内核中的 oom_badness() 函数,来定义选择进程的标准,OOM的时候来杀死对应的进程,判断的标准有如下两个

  • 进程已经使用的物理内存页面数
  • 每个进程的OOM校准值oom_score_adj ,在/proc文件系统中,每个进程都有一个 /proc//oom_score_adj 的接口文件,里面是一个-1000 到 1000 之间的任意的一个数值,调整进程被OOM Kill 的几率。

结合这两个条件,函数 oom_badness() 里的最终计算方法是这样的:
用系统总的可用页面数,去乘以OOM校准值oom_score_adj,再加上进程已经使用的物理页面数,计算出来的值越大,那么这个进程被OOM Kill的几率也就越大

三、如何理解Memory Cgroup

容器发生OOM Kill 大多数是因为Memory Cgroup 的限制导致的,我们还需要理解Memory Cgroup的运行机制。

Memory Cgroup 也是Linux Cgroups 子系统之一,它的作用是对一组进程的Memory 使用做限制。 Memory Cgroup 的虚拟文件系统的挂载点一般在 /sys/fs/cgroup/memory 这个目录下,这个和CPU Cgroup 类似,我们可以在Memory Cgroup的挂载点目录下,创建一个子目录作为控制组。

这里只讲跟OOM 最相关的3个参数:

  • memory.limit_in_bytes
  • memory.oom_control
  • memory.usage_in_bytes
3.1、memory.limit_in_bytes

这个参数限制了,每个控制组里所有进程可使用内存的最大值

3.1、memory.oom_control

当控制组中的进程内存使用达到上限值,这个参数能够决定会不会触发OOM Killer。
该值的缺省值是会触发 OOM Killer。
控制组内的OOM Killer和系统内的OOM Killer 功能类似,区别在作用范围。 控制组内的只能杀死控制组内的进程。

echo 1 > memory.oom_control
这样就可以改变它的缺省值,也就是不希望触发OOM Killer。
这样即使控制组里所有进程使用的内存达到 memory.limit_in_bytes 设置的上限值,控制组也不会杀掉里面的进程。

这样会影响控制组里正在申请物理内存页面的进程,这些进程会处于一个停止状态,不能往下运行了

3.3、memory.usage_in_bytes

参数是只读的,里面的数值是当前控制组所有进程使用内存之和。


控制组之间同样是树状的层级结构,在这个结构中,父节点的控制组里的memory.limit_in_bytes 值,就可以限制它的子节点中所有进程的内存使用。

在这里插入图片描述

如上图,group3的内存最大使用值就不能超过group1的内存设置是最大值。而不是group3的最大值500MB。

总结一下:

  • memory Cgroup 中每一个控制组可以作为一组进程限制内存使用量,一旦所有进程使用内存总量达到限制值,缺省情况下,就会触发OOM Killer ,这样一来控制组里的,某个进程就会被杀死。
  • 杀死进程的标准是:控制组中的总的可用页面*进程的校准值(oom_score_adj) + 进程已经使用的物理内存页面,所得值最大的进程,就会被系统选中杀死。

四、解决问题

容器创建后,系统都会为它创建一个Memory Cgroup 控制组,容器的所有进程都在这个控制组里。
容器内存的上限值会被写入到控制组里的 memory.limit_in_bytes 这个参数文件中。
一旦触发了容器内存的上限,OOM Killer 会杀死进程使容器退出。

通过查看日志可以及时的发现容器是否发生OOM

使用journalctl -k 或者查看日志文件/var/log/message ,容器发生OOM Killer的时候,内核日志大概有如下三部分信息:如图
在这里插入图片描述

  • 容器里每一个进程使用的内存页面数量,在"rss" 列里,“rss” 是 Resident set size 的缩写,指的就是进程真正使用的物理内存页面数量
    上面图片中 init 进程的rss 是1个页面,mem_alloc 进程的 “rss” 是 130801个页面,内存页面的大小一般是4KB,130801 * 4KB 大致等于 512MB。

ps:1KB=1024B ; 8b(bit位)=1B(bytes字节)

  • oom_kill这行,列出了发生OOM 的Memory Cgroup 的控制组,我们可以从控制组的信息中知道OOM 发生在哪个容器中

  • “Killed process 7445 (mem_alloc)” 这行,它显示了最终被 OOM Killer 杀死的进程。

通过图中的日志可以知道,容器是因为OOM退出的,并且还知道是哪个进程消耗了最多的Memory。

知道哪个进程消耗内存最多,就可以针对性的分析,一般有两种情况:

  • 进程本身需要很大的内存,分配的内存小了,需要增大内存的上限值
  • 进程代码中有bug,有内存泄漏,这就需要解决代码里的问题

五、重点总结

OOM Killer 行为在Linux 中很早就存在了,是一种内存过载后的保护机制,通过牺牲个别的进程,来保证整个节点的内存不会被全部消耗掉。

在Cgroup 概念出现后,Memory Cgroup 中每一个控制组可以对一组进程限制内存使用,一旦所有进程使用内存的总量达到限制值,在缺省情况下,就会触发OOM Killer ,控制组里的某个进程就会被杀死。

杀掉进程的标准涉及到内核函数 oom_badness() ,计算方法是:
系统总的可用页面数 * 进程的OOM校准值 oom_score_adj + 进程已经使用的物理内存页面数,计算出来的数值越大,该进程被OOM Kill 的几率就越大。

Memory Cgroup 中和OOM 有关系的三个参数
在这里插入图片描述

容器OOM,然后查看日志排查,使用内存最大的进程。
要么提高容器的最大内存限制
要么排查进程代码的问题

六、问答

1、
如果将memory oom control的参数设置为1,那么容器里的进程在使用内存到达memory limit in bytes之后,不会被oom killer杀死,但memalloc进程会被暂停申请内存,状态会变成因等待资源申请而变成task interruptable

2、
问题:老师请教下 k8s中limit 是 改的 limit in bytes。那k8s request是改的mem cg中哪个指呀?
回答:k8s request不修改Memory Cgroup里的参数。只是在kube scheduler里调度的时候看做个计算,看节点上是否还有内存给这个新的container。

3、
问题:请问老师:这边提到的cgroup底下的memory.usage_in_bytes是不是可以理解为通过top看到的usage与buffer/cached内存之和(这边指在容器中执行top且该容器有绑定lxcfs或者直接是kata容器即理解为top看的就是该容器的),因为我们这边用prometheus采集监控指标的时候发现container_memory_usage_bytes这个指标与cgroup中memory.usage_in_bytes是对应的而container_memory_usage_bytes这个指标实际是算上buffer/cached的内存,同时这边衍生出一个问题假如oom的评判标准是包含buffer/cached的内存使用,那是不是意味着我们在做容器内存监控的时候是应该把这个值也展示在监控中?

回答:
。> 这边提到的cgroup底下的memory.usage_in_bytes是不是可以理解为通过top看到的usage与buffer/cached内存之和

是的

。> 同时这边衍生出一个问题假如oom的评判标准是包含buffer/cached的内存使用

OOM是不包含buf/cache的。总的memory usage如果超过memory limit, 那么应该是先发生memory reclaim去释放cache。

4、
问题:CPU应该是可压缩资源,即便达到设置的资源限额也不会退出,而内存属于不可压缩资源,资源不足时就会发生OOM了。
回答:
可以这么理解

5、
问题:老师,我在ubuntu上按照文章进行操作,容器没有按照预期那样发生 oom kill,查看 state ,“OOMKilled”: false。
回答:
可以用free看一下,是不是swap打开了?

6、
问题:
请问控制组之间是什么关系呢?按照本章的树状的层级结构图所示,group1 分出 group2 和 group3 。
此时 group1 里的 memory.limit_in_bytes 设置的值是 200MB
回答:
是指 group2 + group3 + group1 的所有进程使用的内存总值就不能超过 200MB。

7、
问题:
k8s的memory的request,limit限制对应cgroup的参数是什么?
回答:
limit 对应 Memory Cgroup中的memory.limit_in_bytes
k8s request不修改Memory Cgroup里的参数。只是在kube scheduler里调度的时候看做个计算,看节点上是否还有内存给这个新的container。

8、
作者回复: 容器中进程CPU过高是不会被系统杀死的,只是一直限制进程的最高CPU.

七、补充知识

7.1、进程状态
含义
TASK_RUNNING可执行状态(执行状态、执行等待状态)
TASK_INTERRUPTIBLE等待状态。等待状态可被信号解除
TASK_UNINTERRUPTIBLE等待状态。等待状态不可被信号解除
  • task_running :正在使用CPU或者正在等待CPU的进程,ps命令看到的,处于R状态(Running 或 Runnable)的进

  • task_interruptible:socket 或终端等待等等。等待状态恢复时间不可预测的事件的状态,可以被信号和wake_up()唤醒的,当信号到来时,进程会被设置为可运行。

  • task_uninterruptible:磁盘输入输出等等。即使接收到信号,也保留信号继续等待,只能被wake_up()唤醒。

在这里插入图片描述

虚拟内存和物理内存之间存在映射,虚拟机内存是连续的,物理内存可以是非连续的。

虚拟内存和物理内存的关系:
https://blog.csdn.net/lvyibin890/article/details/82217193

7.2、VSS,RSS,PSS,USS 的区别
  • vss:virtual set size,虚拟耗用内存
  • rss:resident set size 实际使用物理内存,包含共享库占用的内存,这个不太准确在于包括该进程使用共享库全部内存大小。对于一个共享库,可能被多个进程使用,实际该共享库只会被内存装入内存一次。
  • pss:proportional set size 实际使用物理内存,比例分配共享库占用的内存。pss相对于rss计算共享库内存大小是按比例的,N个进程共享,该库对pss大小的贡献只有1/N
  • uss:uique set size 进程独自占用的物理内存,即单个进程私有内存的大小,即该进程独占的内存部分。uss 揭示了运行一个特定进程在的真实内存增量大小,如果进程终止,uss 就是实际返还给系统的内存大小。

一般情况下有:VSS >= RSS >= PSS >= USS
在这里插入图片描述

7.3、top 显示的内存VIRT,RES,SHR 含义

在这里插入图片描述

VIRT:
1、进程需要的虚拟机内存大小,包括进程使用的库、代码、数据,以及malloc、new分配的堆空间和栈空间
2、加入进程新申请10MB 内存,但实际只用了1MB,那么它会增长10MB,而不是实际的1MB的量
3、VIRT=SWAP+RES

RES:
1、进程当前使用的大小,包括使用中的malloc、new分配的堆空间和栈空间,但不包括swap out 量
2、包含其他进程的共享
3、如果申请10MB的内存,实际只用了1MB,它会增长1MB
4、关于库占用内存的情况,它只统计加载的库文件所占内存大小
5、RES=CODE+DATA

SHR:
1、除了自身的共享内存,也包括其他进程的共享内存
2、虽然进程只使用了几个共享库的函数,但它包含了整个共享库的大小
3、计算某个进程所占用的物理内存大小公式:RES - SHR
4、swap out 后,它会降下来。

top 命令显示:
N – 以 PID 的大小的顺序排列表示进程列表
P – 以 CPU 占用率大小的顺序排列进程列表
M – 以内存占用率大小的顺序排列进程列表

按 o 键可以改变列的显示顺序。按小写的 a-z 可以将相应的列向右移动,而大写的 A-Z 可以将相应的列向左移动。最后按回车键确定。
按大写的 F 或 O 键,然后按 a-z 可以将进程按照相应的列进行排序。而大写的 R 键可以将当前的排序倒转。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐