You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

180 lines
14 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 08 | 容器内存:我的容器为什么被杀了?
你好,我是程远。
从这一讲内容开始我们进入容器内存这个模块。在使用容器的时候一定会伴随着Memory Cgroup。而Memory Cgroup给Linux原本就复杂的内存管理带来了新的变化下面我们就一起来学习这一块内容。
今天这一讲,我们来解决容器在系统中消失的问题。
不知道你在使用容器时有没有过这样的经历一个容器在系统中运行一段时间后突然消失了看看自己程序的log文件也没发现什么错误不像是自己程序Crash但是容器就是消失了。
那么这是怎么回事呢?接下来我们就一起来“破案”。
## 问题再现
容器在系统中被杀掉其实只有一种情况那就是容器中的进程使用了太多的内存。具体来说就是容器里所有进程使用的内存量超过了容器所在Memory Cgroup里的内存限制。这时Linux系统就会主动杀死容器中的一个进程往往这会导致整个容器的退出。
我们可以做个简单的容器模拟一下这种容器被杀死的场景。做容器的Dockerfile和代码你可以从[这里](https://github.com/chengyli/training/tree/master/memory/oom)获得。
接下来我们用下面的这个脚本来启动容器我们先把这个容器的Cgroup内存上限设置为512MB536870912 bytes
```
#!/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的时候你就会发现我们启动的这个容器消失了。
![](https://static001.geekbang.org/resource/image/2d/5c/2db8dc6de63ae22c585e64fbf1a0395c.png)
这时候,如果我们运行`docker inspect` 命令查看容器退出的原因,就会看到容器处于"exited"状态,并且"OOMKilled"是true。
![](https://static001.geekbang.org/resource/image/da/2a/dafcb895b0c49b9d01b10d0bbac9102a.png)
那么问题来了什么是OOM Killed呢它和之前我们对容器Memory Cgroup做的设置有什么关系又是怎么引起容器退出的想搞清楚这些问题我们就需要先理清楚基本概念。
### 如何理解OOM Killer
我们先来看一看OOM Killer是什么意思。
OOM是Out of Memory的缩写顾名思义就是内存不足的意思而Killer在这里指需要杀死某个进程。那么OOM Killer就是**在Linux系统里如果内存不足时就需要杀死一个正在运行的进程来释放一些内存。**
那么讲到这里你可能会有个问题了Linux里的程序都是调用malloc()来申请内存如果内存不足直接malloc()返回失败就可以,为什么还要去杀死正在运行的进程呢?
其实这个和Linux进程的内存申请策略有关Linux允许进程在申请内存的时候是overcommit的这是什么意思呢就是说允许进程申请超过实际物理内存上限的内存。
为了让你更好地理解我给你举个例子说明。比如说节点上的空闲物理内存只有512MB了但是如果一个进程调用malloc()申请了600MB那么malloc()的这次申请还是被允许的。
这是因为malloc()申请的是内存的虚拟地址,系统只是给了程序一个地址范围,由于没有写入数据,所以程序并没有得到真正的物理内存。物理内存只有程序真的往这个地址写入数据的时候,才会分配给程序。
可以看得出来这种overcommit的内存申请模式可以带来一个好处它可以有效提高系统的内存利用率。不过这也带来了一个问题也许你已经猜到了就是物理内存真的不够了又该怎么办呢
为了方便你理解我给你打个比方这个有点像航空公司在卖飞机票。售卖飞机票的时候往往是超售的。比如说实际上有100个位子航空公司会卖105张机票在登机的时候如果实际登机的乘客超过了100个那么就需要按照一定规则不允许多出的几位乘客登机了。
同样的道理遇到内存不够的这种情况Linux采取的措施就是杀死某个正在运行的进程。
那么你一定会问了在发生OOM的时候Linux到底是根据什么标准来选择被杀的进程呢这就要提到一个在Linux内核里有一个 **oom\_badness()函数**,就是它定义了选择进程的标准。其实这里的判断标准也很简单,函数中涉及两个条件:
第一,进程已经使用的物理内存页面数。
第二每个进程的OOM校准值oom\_score\_adj。在/proc文件系统中每个进程都有一个 /proc/<pid>/oom\_score\_adj的接口文件。我们可以在这个文件中输入-1000 到1000之间的任意一个数值调整进程被OOM Kill的几率。
```shell
adj = (long)p->signal->oom_score_adj;
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +mm_pgtables_bytes(p->mm) / PAGE_SIZE;
adj *= totalpages / 1000;
points += adj;
```
结合前面说的两个条件函数oom\_badness()里的最终计算方法是这样的:
**用系统总的可用页面数去乘以OOM校准值oom\_score\_adj再加上进程已经使用的物理页面数计算出来的值越大那么这个进程被OOM Kill的几率也就越大。**
### 如何理解Memory Cgroup
前面我们介绍了OOM Killer容器发生OOM Kill大多是因为Memory Cgroup的限制所导致的所以在我们还需要理解Memory Cgroup的运行机制。
在这个专栏的[第一讲](http://time.geekbang.org/column/article/308108)中我们讲过Cgroups是容器的两大支柱技术之一在CPU的章节中我们也讲到了CPU Cgroups。那么按照同样的思路我们想理解容器Memory自然要讨论一下Memory Cgroup了。
Memory Cgroup也是Linux Cgroups子系统之一它的作用是对一组进程的Memory使用做限制。Memory Cgroup的虚拟文件系统的挂载点一般在"/sys/fs/cgroup/memory"这个目录下这个和CPU Cgroup类似。我们可以在Memory Cgroup的挂载点目录下创建一个子目录作为控制组。
每一个控制组下面有不少参数在这一讲里这里我们只讲跟OOM最相关的3个参数**memory.limit\_in\_bytesmemory.oom\_control和memory.usage\_in\_bytes**。其他参数如果你有兴趣了解,可以参考内核的[文档说明](https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt)。
首先我们来看第一个参数叫作memory.limit\_in\_bytes。请你注意这个memory.limit\_in\_bytes是每个控制组里最重要的一个参数了。这是因为一个控制组里所有进程可使用内存的最大值就是由这个参数的值来直接限制的。
那么一旦达到了最大值,在这个控制组里的进程会发生什么呢?
这就涉及到我要给你讲的第二个参数memory.oom\_control了。这个memory.oom\_control又是干啥的呢当控制组中的进程内存使用达到上限值时这个参数能够决定会不会触发OOM Killer。
如果没有人为设置的话memory.oom\_control的缺省值就会触发OOM Killer。这是一个控制组内的OOM Killer和整个系统的OOM Killer的功能差不多差别只是被杀进程的选择范围控制组内的OOM Killer当然只能杀死控制组内的进程而不能选节点上的其他进程。
如果我们要改变缺省值也就是不希望触发OOM Killer只要执行 `echo 1 > memory.oom_control` 就行了这时候即使控制组里所有进程使用的内存达到memory.limit\_in\_bytes设置的上限值控制组也不会杀掉里面的进程。
但是,我想提醒你,这样操作以后,就会影响到控制组中正在申请物理内存页面的进程。这些进程会处于一个停止状态,不能往下运行了。
最后我们再来学习一下第三个参数也就是memory.usage\_in\_bytes。这个参数是只读的它里面的数值是当前控制组里所有进程实际使用的内存总和。
我们可以查看这个值然后把它和memory.limit\_in\_bytes里的值做比较根据接近程度来可以做个预判。这两个值越接近OOM的风险越高。通过这个方法我们就可以得知当前控制组内使用总的内存量有没有OOM的风险了。
控制组之间也同样是树状的层级结构在这个结构中父节点的控制组里的memory.limit\_in\_bytes值就可以限制它的子节点中所有进程的内存使用。
我用一个具体例子来说明比如像下面图里展示的那样group1里的memory.limit\_in\_bytes设置的值是200MB它的子控制组group3里memory.limit\_in\_bytes值是500MB。那么我们在group3里所有进程使用的内存总值就不能超过200MB而不是500MB。
![](https://static001.geekbang.org/resource/image/6c/07/6c65856f5dce81c064a63d6ffe0ca507.jpeg)
好了我们这里介绍了Memory Cgroup最基本的概念简单总结一下
第一Memory Cgroup中每一个控制组可以为一组进程限制内存使用量一旦所有进程使用内存的总量达到限制值缺省情况下就会触发OOM Killer。这样一来控制组里的“某个进程”就会被杀死。
第二,这里杀死“某个进程”的选择标准是,**控制组中总的可用页面乘以进程的oom\_score\_adj加上进程已经使用的物理内存页面所得值最大的进程就会被系统选中杀死。**
## 解决问题
我们解释了Memory Cgroup和OOM Killer后你应该明白了为什么容器在运行过程中会突然消失了。
对于每个容器创建后系统都会为它建立一个Memory Cgroup的控制组容器的所有进程都在这个控制组里。
一般的容器云平台比如Kubernetes都会为容器设置一个内存使用的上限。这个内存的上限值会被写入Cgroup里具体来说就是容器对应的Memory Cgroup控制组里memory.limit\_in\_bytes这个参数中。
所以一旦容器中进程使用的内存达到了上限值OOM Killer会杀死进程使容器退出。
**那么我们怎样才能快速确定容器发生了OOM呢这个可以通过查看内核日志及时地发现。**
还是拿我们这一讲最开始发生OOM的容器作为例子。我们通过查看内核的日志使用用 `journalctl -k` 命令,或者直接查看日志文件/var/log/message我们会发现当容器发生OOM Kill的时候内核会输出下面的这段信息大致包含下面这三部分的信息
第一个部分就是**容器里每一个进程使用的内存页面数量。**在"rss"列里,"rss'是Resident Set Size的缩写指的就是进程真正在使用的物理内存页面数量。
比如下面的日志里我们看到init进程的"rss"是1个页面mem\_alloc进程的"rss"是130801个页面内存页面的大小一般是4KB我们可以做个估算130801 \* 4KB大致等于512MB。
![](https://static001.geekbang.org/resource/image/f6/ec/f681cd4d97a34ebb8a9458b7a0d5a9ec.png)
第二部分我们来看上面图片的 **"oom-kill:"** 这行这一行里列出了发生OOM的Memroy Cgroup的控制组我们可以从控制组的信息中知道OOM是在哪个容器发生的。
第三部分是图中 **"Killed process 7445 (mem\_alloc)" 这行它显示了最终被OOM Killer杀死的进程。**
我们通过了解内核日志里的这些信息可以很快地判断出容器是因为OOM而退出的并且还可以知道是哪个进程消耗了最多的Memory。
那么知道了哪个进程消耗了最大内存之后,我们就可以有针对性地对这个进程进行分析了,一般有这两种情况:
第一种情况是**这个进程本身的确需要很大的内存**这说明我们给memory.limit\_in\_bytes里的内存上限值设置小了那么就需要增大内存的上限值。
第二种情况是**进程的代码中有Bug会导致内存泄漏进程内存使用到达了Memory Cgroup中的上限。**如果是这种情况,就需要我们具体去解决代码里的问题了。
## 重点总结
这一讲我们从容器在系统中被杀的问题学习了OOM Killer和Memory Cgroup这两个概念。
OOM Killer这个行为在Linux中很早就存在了它其实是一种内存过载后的保护机制通过牺牲个别的进程来保证整个节点的内存不会被全部消耗掉。
在Cgroup的概念出现后Memory Cgroup中每一个控制组可以对一组进程限制内存使用量一旦所有进程使用内存的总量达到限制值在缺省情况下就会触发OOM Killer控制组里的“某个进程”就会被杀死。
请注意这里Linux系统肯定不能随心所欲地杀掉进程那具体要用什么选择标准呢
杀掉“某个进程”的选择标准涉及到内核函数oom\_badness()。具体的计算方法是 **系统总的可用页面数乘以进程的OOM校准值oom\_score\_adj再加上进程已经使用的物理页面数计算出来的值越大那么这个进程被OOM Kill的几率也就越大。**
接下来我给你讲解了Memory Cgroup里最基本的三个参数分别是**memory.limit\_in\_bytes memory.oom\_control 和 memory.usage\_in\_bytes。**我把这三个参数的作用,给你总结成了一张图。第一个和第三个参数,下一讲中我们还会用到,这里你可以先有个印象。
![](https://static001.geekbang.org/resource/image/2e/81/2e3121a256b34bab80799002b2549881.jpeg)
容器因为OOM被杀要如何处理呢我们可以通过内核日志做排查查看容器里内存使用最多的进程然后对它进行分析。根据我的经验解决思路要么是提高容器的最大内存限制要么需要我们具体去解决进程代码的BUG。
## 思考题
在我们的例子[脚本](https://github.com/chengyli/training/blob/main/memory/oom/start_container.sh)基础上你可以修改一下在容器刚一启动就在容器对应的Memory Cgroup中禁止OOM看看接下来会发生什么
欢迎留言和我分享你的想法和疑问。如果读完这篇文章有所收获,也欢迎分享给你的朋友。