容器技术回顾 - Kubernetes memory limit 产生的 OOM


作者的同事遇到一个问题,就是容器中的进程被 OOM 了,但是容器却没有退出,这个会和我们的一般直觉不一样,今天就让我们来对这个问题做剖析,同时来回顾一下相关的知识点。
一次由于超过 cgroup memory limit 引发的 OOM 问题
我们用 Kubernetes 创建了一个 Pod:
spec: containers: - args: - tail - -f - /var/log/xxx.log image: xxx imagePullPolicy: Always name: test resources: limits: cpu: "1" memory: 64Mi requests: cpu: "1" memory: 64Mi
容器的启动脚本很简单:
tail -f /var/log/xxx.log
我们会将主机的 /var/log 目录挂载到容器中,目的是观测某个程序的日志。我们对容器的 CPU 和 内存资源做了限制,可以看到其中的 request 和 limit 相等,因此它的 QoS 级别时 guaranteed。
接下来我通过:
#进入到容器中kubectl exec -it <pod_name> -n xxx -- /bin/bash# 执行一些 python 的分析任务python xxx.py
到第二天的时候,突然发现 python 进程被干掉了,dmesg 中出现了:
kernel: memory: usage 65536kB, limit 65536kB, failcnt 102kernel: memory+swap: usage 65536kB, limit 9007199254740928kB, failcnt 0kernel: kmem: usage 0kB, limit 9007199254740928kB, failcnt 0kernel: Memory cgroup stats for /kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b: cache:0KB rss:0KB rss_huge:0KB shmem:0KB mapped_file:0KB dirty:0KB writeback:0KB swap:0KB inactive_anon:0KB active_anon:0KB inactive_file:0KB active_file:0KB unevictable:0KBkernel: Memory cgroup stats for /kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b/cb394dff5fe5b9a45a7d5f8b3ddc460093d08ff191263af717ab156f016a0841: cache:0KB rss:0KB rss_huge:0KB shmem:0KB mapped_file:0KB dirty:0KB writeback:0KB swap:0KB inactive_anon:256KB active_anon:64KB inactive_file:0KB active_file:0KB unevictable:0KBkernel: Memory cgroup stats for /kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b/3e77eef0f35af7bddc844f3b9946989e210173461c4941bdf4444a3098b4bc95: cache:0KB rss:60672KB rss_huge:0KB shmem:0KB mapped_file:2112KB dirty:0KB writeback:0KB swap:0KB inactive_anon:33216KB active_anon:29504KB inactive_file:0KB active_file:0KB unevictable:0KBkernel: Tasks state (memory values in pages):kernel: [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj namekernel: [ 825743] 0 825743 19 1 393216 0 -998 pausekernel: [ 825818] 0 825818 65 12 393216 0 -998 tailkernel: [ 926737] 0 926737 92 57 327680 0 -998 bashkernel: [ 957122] 0 957122 94 62 327680 0 -998 bashkernel: [ 958774] 0 958774 168 97 393216 0 -998 python3.6kernel: [ 461863] 0 461863 94 59 327680 0 -998 bashkernel: [ 507323] 0 507323 94 56 327680 0 -998 bashkernel: [ 517851] 0 517851 973 887 393216 0 -998 python3.6kernel: Memory cgroup out of memory: Kill process 517851 (python3.6) score 0 or sacrifice child
的确容器的内存超过了 limit,最终 Python 进程(基于内存使用:887 以及 oom_score_adj 进行打分)被 OOM kill 了。
但是奇怪的是,容器却运行正常,并没有重启:
docker ps | grep tail3e77eef0f35a xxxx "tail -f /var/log/xxx.log" 15 hours ago Up 15 hourskubectl get po -n xxx | grep testxxxx 1/1 Running 0 14h
在我们的印象中,只要发生 cgroup oom 了,容器就会被干掉。但是实际上,当 OOM 发生的时候,只是其中一个进程被干掉了,容器还是正常运行。这里我们就需要回顾一下相关知识点,来解释一下原理。
Docker 的架构
首先让我们回顾一下 Docker 的架构,有助于我们理解后面的一些相关概念。

Kubectl exec 和 Docker exec
kubectl exec 本质上就是使用了 Docker exec,让我们用几张图来形象的了解一下,exec 到底做了什么吧(包括 attach)。
Docker attach

容器运行时 shim 实际上充当服务器的角色。它提供了 RPC 方式(例如,UNIX 套接字)来连接它。当我们attach,它就将容器的 stdout 和 stderr 定向到我们的套接字端。它还从此套接字读取数据并将数据转发到容器的标准输入。因此,我们通过 attach 命令实现了对容器的标准输入/输出流的重定向。
当我们使用 docker attach 命令的时候,交互的大致的逻辑关系如下:
terminal <-> docker <-> dockerd <-> shim <-> container's stdio streams
Docker exec

从图上可以看到,exec 命令启动一个新的容器。这里关键点,由 exec 命令创建的辅助容器共享了目标容器的所有隔离边界(namespace)!也就是它们有相同的 net、pid、mount 等命名空间、相同的 cgroups 层次结构等。因此,从外部来看,感觉就像在现有容器内运行命令。
当我们使用 docker attach 命令的时候,交互的大致的逻辑关系如下:
terminal <-> docker-cli <-> dockerd <-> shim <-> command's stdio streams
摘自:Containers 101: attach vs. exec - what's the difference? - https://iximiuz.com/en/posts/containers-101-attach-vs-exec/
容器中的 0 号进程与 1 号进程
当我们执行了 kubectl exec 之后,从主机上看容器进程,结构如下:
# 容器内执行ps -efUID PID PPID C STIME TTY TIME CMDroot 1 0 0 Dec27 ? 00:00:00 tail -f /dev/nullroot 7 0 0 Dec27 pts/0 00:00:00 bashroot 42 0 0 Dec27 pts/1 00:00:00 bashroot 89 0 0 Dec28 pts/2 00:00:00 bashroot 111 0 0 Dec28 pts/3 00:00:00 bashroot 150 0 0 Dec28 pts/4 00:00:00 bashroot 192 0 0 09:24 pts/5 00:00:00 /bin/bashroot 209 192 0 09:25 pts/5 00:00:00 ps -ef#主机上执行docker inspect --format {{.State.Pid}} 3e77eef0f35a825818#查找进程树pstree -pls 825792systemd(1)───dockerd(142978)───docker-containe(142993)───docker-containe(825792)─┬─bash(198130) ├─bash(461863) ├─bash(507323) ├─bash(926737) ├─bash(957122) ├─bash(1010142) ├─tail(825818) ├─{docker-containe}(825794) ├─{docker-containe}(825795) ├─{docker-containe}(825796) ├─{docker-containe}(825797) ├─{docker-containe}(825798) ├─{docker-containe}(825799) ├─{docker-containe}(825800) └─{docker-containe}(926744)
注意:这是一个老版本的 docker,所以进程树和新版本不同。
由《容器技术回顾  - 容器中的 0 号进程和 1 号进程》一文可知,容器中的 1 号进程退出了,则容器就会结束。如果是父进程不是 1 号的进程退出,通常不影响 1 号进程,因此容器也不会退出。
为什么 docker exec 的进程会被 OOM,这里的关键就是要得到容器的 cgroup 结构,以及我们上面配置的 memory limit 是在哪个层级。
Cgroup 简介
cgroups 的全称是control groups,cgroups为每种可以控制的资源定义了一个子系统。典型的子系统介绍如下:
cpu 子系统,主要限制进程的 cpu 使用率。
cpuacct 子系统,可以统计 cgroups 中的进程的 cpu 使用报告。
cpuset 子系统,可以为 cgroups 中的进程分配单独的 cpu 节点或者内存节点。
memory 子系统,可以限制进程的 memory 使用量。
blkio 子系统,可以限制进程的块设备 io。
devices 子系统,可以控制进程能够访问某些设备。
net_cls 子系统,可以标记 cgroups 中进程的网络数据包,然后可以使用 tc 模块(traffic control)对数据包进行控制。
freezer 子系统,可以挂起或者恢复 cgroups 中的进程。
ns 子系统,可以使不同 cgroups 下面的进程使用不同的 namespace。
这里面每一个子系统都需要与内核的其他模块配合来完成资源的控制,比如对 cpu 资源的限制是通过进程调度模块根据 cpu 子系统的配置来完成的;对内存资源的限制则是内存模块根据 memory 子系统的配置来完成的,而对网络数据包的控制则需要 Traffic Control 子系统来配合完成。本文不会讨论内核是如何使用每一个子系统来实现资源的限制,而是重点放在内核是如何把 cgroups 对资源进行限制的配置有效的组织起来的,和内核如何把cgroups 配置和进程进行关联的,以及内核是如何通过 cgroups 文件系统把cgroups的功能暴露给用户态的。
cgroups 层级结构(Hierarchy)
内核使用 cgroup 结构体来表示一个 control group 对某一个或者某几个 cgroups 子系统的资源限制。cgroup 结构体可以组织成一颗树的形式,每一棵cgroup 结构体组成的树称之为一个 cgroups 层级结构。cgroups层级结构可以 attach 一个或者几个 cgroups 子系统,当前层级结构可以对其 attach 的 cgroups 子系统进行资源的限制。每一个 cgroups 子系统只能被 attach 到一个 cpu 层级结构中。

摘自:Linux资源管理之cgroups简介 - https://tech.meituan.com/2015/03/31/cgroups.html
memory cgroup 相关的请参考:Memory Resource Controller - https://docs.kernel.org/admin-guide/cgroup-v1/memory.html
一个 Pod 的 cgroup 结构
tree /sys/fs/cgroup/memory/kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b//sys/fs/cgroup/memory/kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b/├── 3e77eef0f35af7bddc844f3b9946989e210173461c4941bdf4444a3098b4bc95│ ├── cgroup.clone_children│ ├── cgroup.event_control│ ├── cgroup.procs│ ├── memory.failcnt│ ├── memory.force_empty│ ├── memory.kmem.failcnt│ ├── memory.kmem.limit_in_bytes│ ├── memory.kmem.max_usage_in_bytes│ ├── memory.kmem.slabinfo│ ├── memory.kmem.tcp.failcnt│ ├── memory.kmem.tcp.limit_in_bytes│ ├── memory.kmem.tcp.max_usage_in_bytes│ ├── memory.kmem.tcp.usage_in_bytes│ ├── memory.kmem.usage_in_bytes│ ├── memory.limit_in_bytes│ ├── memory.max_usage_in_bytes│ ├── memory.memsw.failcnt│ ├── memory.memsw.limit_in_bytes│ ├── memory.memsw.max_usage_in_bytes│ ├── memory.memsw.usage_in_bytes│ ├── memory.move_charge_at_immigrate│ ├── memory.numa_stat│ ├── memory.oom_control│ ├── memory.pressure_level│ ├── memory.soft_limit_in_bytes│ ├── memory.stat│ ├── memory.swappiness│ ├── memory.usage_in_bytes│ ├── memory.use_hierarchy│ ├── notify_on_release│ └── tasks├── cb394dff5fe5b9a45a7d5f8b3ddc460093d08ff191263af717ab156f016a0841│ ├── cgroup.clone_children│ ├── cgroup.event_control│ ├── cgroup.procs│ ├── memory.failcnt│ ├── memory.force_empty│ ├── memory.kmem.failcnt│ ├── memory.kmem.limit_in_bytes│ ├── memory.kmem.max_usage_in_bytes│ ├── memory.kmem.slabinfo│ ├── memory.kmem.tcp.failcnt│ ├── memory.kmem.tcp.limit_in_bytes│ ├── memory.kmem.tcp.max_usage_in_bytes│ ├── memory.kmem.tcp.usage_in_bytes│ ├── memory.kmem.usage_in_bytes│ ├── memory.limit_in_bytes│ ├── memory.max_usage_in_bytes│ ├── memory.memsw.failcnt│ ├── memory.memsw.limit_in_bytes│ ├── memory.memsw.max_usage_in_bytes│ ├── memory.memsw.usage_in_bytes│ ├── memory.move_charge_at_immigrate│ ├── memory.numa_stat│ ├── memory.oom_control│ ├── memory.pressure_level│ ├── memory.soft_limit_in_bytes│ ├── memory.stat│ ├── memory.swappiness│ ├── memory.usage_in_bytes│ ├── memory.use_hierarchy│ ├── notify_on_release│ └── tasks├── cgroup.clone_children├── cgroup.event_control├── cgroup.procs├── memory.failcnt├── memory.force_empty├── memory.kmem.failcnt├── memory.kmem.limit_in_bytes├── memory.kmem.max_usage_in_bytes├── memory.kmem.slabinfo├── memory.kmem.tcp.failcnt├── memory.kmem.tcp.limit_in_bytes├── memory.kmem.tcp.max_usage_in_bytes├── memory.kmem.tcp.usage_in_bytes├── memory.kmem.usage_in_bytes├── memory.limit_in_bytes├── memory.max_usage_in_bytes├── memory.memsw.failcnt├── memory.memsw.limit_in_bytes├── memory.memsw.max_usage_in_bytes├── memory.memsw.usage_in_bytes├── memory.move_charge_at_immigrate├── memory.numa_stat├── memory.oom_control├── memory.pressure_level├── memory.soft_limit_in_bytes├── memory.stat├── memory.swappiness├── memory.usage_in_bytes├── memory.use_hierarchy├── notify_on_release└── tasks2 directories, 93 files# 父 cgroupcat /sys/fs/cgroup/memory/kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b/memory.limit_in_bytes67108864# 输出关联进程,为空cat /sys/fs/cgroup/memory/kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b/tasks# 子 cgroupcat /sys/fs/cgroup/memory/kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b/3e77eef0f35af7bddc844f3b9946989e210173461c4941bdf4444a3098b4bc95/memory.limit_in_bytes67108864#输出关联进程cat /sys/fs/cgroup/memory/kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b/3e77eef0f35af7bddc844f3b9946989e210173461c4941bdf4444a3098b4bc95/tasks | xargs -I {} ps -h -f -p {} 198130 ? Ss+ 0:00 /bin/bash 461863 ? Ss+ 0:00 bash 507323 ? Ss+ 0:00 bash 825818 ? Ss 0:00 tail -f /dev/null 926737 pts/0 Ss+ 0:00 bash 957122 pts/1 Ss+ 0:00 bash1010142 ? Ss+ 0:00 bash#pause 容器的 cgroupcat /sys/fs/cgroup/memory/kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b/cb394dff5fe5b9a45a7d5f8b3ddc460093d08ff191263af717ab156f016a0841/memory.limit_in_bytes9223372036854710272#memory.limit_in_bytes 设置为 9223372036854710272 通常表示内存使用的限制几乎是无限制的。这个值对应于 8 exabytes,对于大多数实际用途而言,这几乎是一个非常大且无限制的内存量。#输出关联进程cat /sys/fs/cgroup/memory/kubepods/pod0ea26204-aade-41c3-b207-768d3273cf1b/cb394dff5fe5b9a45a7d5f8b3ddc460093d08ff191263af717ab156f016a0841/tasks | xargs -I {} ps -h -f -p {} 825743 ? Ss 0:00 /pause
以上信息可知,docker exec 启动进程会和容器启动的进程公用一个 cgroup,因此它们会受 memory limit 限制。
Linux 上的 OOM
OOM Killer(Out of Memory Killer)是Linux内核在系统内存严重不足时,强行释放进程内存的一种机制。
系统出现 OOM Killer 表示内存不足,内存不足可以分为实例全局内存不足和实例内cgroup的内存不足。目前常见的出现OOM Killer的原因有以下几种:
可能原因
系统出现OOM Killer表示内存不足,内存不足可以分为实例全局内存不足和实例内cgroup的内存不足。目前常见的出现OOM Killer的原因有以下几种:
摘自:出现OOM Killer的原因与解决方案 - https://help.aliyun.com/zh/alinux/support/causes-of-and-solutions-to-the-issue-of-oom-killer-being-triggered
原因类型 场景示例
cgroup 内存使用达到上限 如下日志记录的出现OOM Killer场景示例中,进程test所在的cgroup /mm_test发生了OOM Killer。


[Wed Sep 8 18:01:32 2021] test invoked oom-killer: gfp_mask=0x240****(GFP_KERNEL), nodemask=0, order=0, oom_score_adj=0
[Wed Sep 8 18:01:32 2021] Task in /mm_test killed as a result of limit of /mm_test
[Wed Sep 8 18:01:32 2021] memory: usage 204800kB, limit 204800kB, failcnt 26


原因:cgroup /mm_test的内存使用率达到上限(200 MB)。
父 cgroup 内存使用达到上限 如下日志记录的出现OOM Killer场景示例中,进程test属于cgroup /mm_test/2,而发生OOM Killer的cgroup为/mm_test。


[Fri Sep 10 16:15:14 2021] test invoked oom-killer: gfp_mask=0x240****(GFP_KERNEL), nodemask=0, order=0, oom_score_adj=0
[Fri Sep 10 16:15:14 2021] Task in /mm_test/2 killed as a result of limit of /mm_test
[Fri Sep 10 16:15:14 2021] memory: usage 204800kB, limit 204800kB, failcnt 1607


原因:cgroup /mm_test/2 的内存使用率没有达到上限,但父 cgroup /mm_test 的内存使用率达到上限(200 MB)。
系统全局内存的使用率过高 如下日志记录的出现OOM Killer场景示例中,limit of host 表示实例的全局内存出现了不足。在日志记录的数据中,空闲内存(free)已经低于了内存最低水位线(low)。


[六 9月 11 12:24:42 2021] test invoked oom-killer: gfp_mask=0x62****(GFP_HIGHUSER_MOVABLE|__GFP_ZERO), nodemask=(null), order=0,
[六 9月 11 12:24:42 2021] Task in /user.slice killed as a result of limit of host
[六 9月 11 12:24:42 2021] Node 0 DMA32 free:155160kB min:152412kB low:190512kB high:228612kB
[六 9月 11 12:24:42 2021] Node 0 Normal free:46592kB min:46712kB low:58388kB high:70064kB


原因:由于实例的空闲内存低于内存最低水位线,无法通过内存回收机制解决内存不足的问题,因此触发了OOM Killer。
内存节点(Node)的内存不足 如下日志记录的出现OOM Killer场景示例中,部分日志记录说明:limit of host表示内存节点的内存出现了不足。实例存在Node 0和Node 1两个内存节点。内存节点Node 1的空闲内存(free)低于内存最低水位线(low)。实例的空闲内存还有大量剩余(free:4111496)。


[Sat Sep 11 09:46:24 2021] main invoked oom-killer: gfp_mask=0x62****(GFP_HIGHUSER_MOVABLE|__GFP_ZERO), nodemask=(null), order=0, oom_score_adj=0
[Sat Sep 11 09:46:24 2021] main cpuset=mm_cpuset mems_allowed=1
[Sat Sep 11 09:46:24 2021] Task in / killed as a result of limit of host
[Sat Sep 11 09:46:24 2021] Mem-Info:
[Sat Sep 11 09:46:24 2021] active_anon:172 inactive_anon:4518735 isolated_anon:
free:4111496 free_pcp:1 free_cma:0
[Sat Sep 11 09:46:24 2021] Node 1 Normal free:43636kB min:45148kB low:441424kB high:837700kB
[Sat Sep 11 09:46:24 2021] Node 1 Normal: 856*4kB (UME) 375*8kB (UME) 183*16kB (UME) 184*32kB (UME) 87*64kB (ME) 45*128kB (UME) 16*256kB (UME) 5*512kB (UE) 14*1024kB (UME) 0 *2048kB 0*4096kB = 47560kB
[Sat Sep 11 09:46:24 2021] Node 0 hugepages_total=360 hugepages_free=360 hugepages_surp=0 hugepages_size=1048576kB
[Sat Sep 11 09:46:24 2021] Node 0 hugepages_total=0 hugepages_free=0 hugepages_surp=0 hugepages_size=2048kB
[Sat Sep 11 09:46:24 2021] Node 1 hugepages_total=360 hugepages_free=360 hugepages_surp=0 hugepages_size=1048576kB
[Sat Sep 11 09:46:25 2021] Node 1 hugepages_total=0 hugepages_free=0 hugepages_surp=0 hugepages_size=2048kB


原因:在NUMA存储模式下,操作系统可能存在多个内存节点(可运行cat /proc/buddyinfo命令查看相关资源信息)。如果通过cpuset.mems参数指定cgroup只能使用特定内存节点的内存,则可能导致实例在具备充足的空闲内存的情况下,仍出现OOM Killer的情况。
内存碎片化时伙伴系统内存不足 如下日志记录的出现OOM Killer场景示例中,部分日志记录分析说明:操作系统在内存分配的order=3阶段出现了OOM Killer。内存节点Node 0的空闲内存(free)仍高于内存最低水位线(low)。内存节点Node 0对应的伙伴系统内存为0(0*32kB (M))。


[六 9月 11 15:22:46 2021] insmod invoked oom-killer: gfp_mask=0x60****(GFP_KERNEL), nodemask=(null), order=3, oom_score_adj=0
[六 9月 11 15:22:46 2021] insmod cpuset=/ mems_allowed=0
[六 9月 11 15:22:46 2021] Task in /user.slice killed as a result of limit of host
[六 9月 11 15:22:46 2021] Node 0 Normal free:23500kB min:15892kB low:19864kB high:23836kB active_anon:308kB inactive_anon:194492kB active_file:384kB inactive_file:420kB unevi ctable:0kB writepending:464kB present:917504kB managed:852784kB mlocked:0kB kernel_stack:2928kB pagetables:9188kB bounce:0kB
[六 9月 11 15:22:46 2021] Node 0 Normal: 1325*4kB (UME) 966*8kB (UME) 675*16kB (UME) 0*32kB (M) 0*64kB 0*128kB 0*256kB 0*512kB 0*1024kB 0*2048kB 0*4096kB =


原因:操作系统的内存在进行内存分配的过程中,如果伙伴系统的内存不足,则系统会通过OOM Killer释放内存,并将内存提供至伙伴系统。

Kubernetes 环境中,比较常见的 OOM 主要是 cgroup OOM 和节点内存不足时产生的 OOM。
Linux OOM 我们会单独写一篇文章,有兴趣的童鞋可以先参考:Out Of Memory Management - https://www.kernel.org/doc/gorman/html/understand/understand016.html
总结
kubectl exec 或者 docker exec 启动的进程,它的父进程是容器中的 0 号进程,而非 1 号进程;
通过 kubectl exec 或者 docker exec 启动的进程,它会被容器的 cgroup 限制;
当容器中所有进程的内存超过 memory cgroup 的 memory.limit_in_bytes 限制的时候,就会走 Linux OOM kill 过程;
只要 1 号进程不退出,容器就不会退出。
到顶部