首先,Kubernetes Pod里面容器上的内存遵守的是“用多了就会被干掉”的原则。这和物理机/虚拟机里的内存使用体验完全不一样。在物理机/虚拟机中,内存不够了 -> 申请内存失败 -> 程序正常运行,处理错误。在Kubernetes的container里面,内存超了-> 申请内存成功 -> 程序被杀掉。这就需要我们在Kubernetes上运行内存需求比较大的程序时要额外留心。

在这次出问题的data service里面,与内存限制有关的配置在两个地方。一个是服务Pod的container的memory limit,另一个是Pod挂载的内存volume,即emptyDir的size。对于这两个,Kubernetes处理方式也不一样。

Kubernetes POD Qos

先说一下Pod container的memory。我们知道,Kubernetes的POD可以有不同级别:Guaranteed/Burstable/BestEffort。

  • BestEffort:容器的memory不设置limit。所以理论上,容器中的程序可以用完整个机器上的内存。只要机器内存压力不大,Kubernetes不会去管这个容器,但是如果机器内存资源紧张,这一类的POD优先被干掉。
  • Burstable:容器的limit大于request。容器内存使用可以超过请求值,但不应该超过给定限制。当机器内存资源紧张时,在BestEffort被干完后,优先干内存使用超过request的POD
  • Guranteed:容器的limit等于request。即容器服务内存使用不应该超过请求值。

上面三种服务级别,会导致机器资源紧张时Kubernetes处理方式不同。下面我们来看,资源不紧张时候,如果容器内存用超怎么办。

可以通过 kubectl top pod 来查看容器内存占用,

![image-20220323214324745](/Users/houmin/Library/Application Support/typora-user-images/image-20220323214324745.png)

Cgroup Memory

第一个问题是,我们怎么判断容器用了多少内存。

对于常用的linux命令,例如free,是无法反应出容器的内存使用的。如果你exec到一个Kubernetes上的容器里面,看到free的结果实际上是整个机器(物理机或虚拟机)的内存。

Kubernetes容器资源隔离上基于cgroup,所以我们要看cgroup的内存使用。在Linux里面,查看容器cgroup内存使用可以在***/sys/fs/cgroup/memory/memory.usage_in_bytes***这个文件里面看到。

Virtual Memory / RES Memory

这里还有一个点可以讲一下。在通过例如top命令,查看出来的内存往往是很大的。例如这个例子:

1
2
  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
  386 service     20   0 14.560g 3.213g   7204 S  57.1  0.6  22388:16 beam.smp

这个进程的总虚拟内存有14.56g。但实际进程并没有用到那么大空间。这里面包含了并不实际再内存中的,或者共享的空间,例如shared library等。实际进程使用的空间要看RES那一列,也就是说,这个进程实际上使用了3.21g的内存。

也可以通过 ps aux 查看进程的内存占用情况,RES 是进程的内存

![image-20220323214223070](/Users/houmin/Library/Application Support/typora-user-images/image-20220323214223070.png)

Cache

Kuberetes是不是只看RES的内存使用呢?答案是no。除了RES内存,还有一块内存被算到了container头上。

如果把当前容器的进程中所有RES加起来,还是小于memory.usage_in_bytes这个文件中的值。我们看下这个文件***/sys/fs/cgroup/memory/memory.state***。其中有两行,加起来正好是memory.usage_in_bytes的值。

![image-20220323215039713](/Users/houmin/Library/Application Support/typora-user-images/image-20220323215039713.png)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[houminwei@VM-34-20-centos ~]$ sudo crictl ps | grep "CONT\|enilb"
CONTAINER ID        IMAGE               CREATED             STATE               NAME                          ATTEMPT             POD ID
ecb9af6598168       9fdf88a3ef431       4 hours ago         Running             eks-enilb-controller          0                   c9d0053437b47
[houminwei@VM-34-20-centos ~]$ sudo crictl inspect ecb9af6598168 | grep "pod.uid"
      "io.kubernetes.pod.uid": "f7bbafd5-3e99-41d2-a046-5fb266a5dcf2"
        "io.kubernetes.pod.uid": "f7bbafd5-3e99-41d2-a046-5fb266a5dcf2"
[houminwei@VM-34-20-centos /sys/fs/cgroup/memory/kubepods/burstable/podf7bbafd5-3e99-41d2-a046-5fb266a5dcf2]$ cat ecb9af6598168c9ebbd498503a0b3964e8c29e7de0598dfbdb4ef8a20b63503c/memory.stat
cache 53248
rss 348082176
rss_huge 0
shmem 0
mapped_file 0
dirty 0
writeback 0
swap 0
pgpgin 86842
pgpgout 1848
pgfault 96346
pgmajfault 0
inactive_anon 0
active_anon 251305984
inactive_file 96784384
active_file 45056
unevictable 0
hierarchical_memory_limit 1073741824
hierarchical_memsw_limit 9223372036854771712

其中rss就是容器中所有进程的RES。还有一个是cache,Cache被用来缓存IO数据的。例如,我们有时候会挂载一块远程存储。重复读取存储中某些数据,速度远远快于第一次读取速度,这就是cache起了作用。一般来说这块不会很大,但Kubernetes的一个功能,让这块用了很大空间。这个下面会说。

OOM kill

当容器内的进程用超了memory limit会发生什么行为呢?

Kubernetes并不会把整个POD或容器都干掉。首先,Kubernetes会把当前容器中的进程列出来。

1
2
3
4
5
6
[Thu Jun 10 01:03:18 2021] [  pid  ]   uid  tgid total_vm      rss pgtables_bytes swapents oom_score_adj name
[Thu Jun 10 01:03:18 2021] [   3121]     0  3121      955      245    65536        0          -998 pause
[Thu Jun 10 01:03:18 2021] [  49498]     0 49498     4528       82    86016        0          -998 test.sh
[Thu Jun 10 01:03:18 2021] [  49518]     0 49518     1097       21    57344        0          -998 runsvdir
[Thu Jun 10 01:03:18 2021] [  49557]     0 49557     1059       16    49152        0          -998 runsv
[Thu Jun 10 01:03:18 2021] [  49558]  1000 49558  2913984    24388  1617920        0          -998 beam.smp

首先会看oom score这一列,先把oom score高的干掉。如果我们想配置容器oom时候被干掉进程的顺序,可以从这个地方着手。

如果像在这个例子中,大家oom score都一样,kubernetes会把内存使用最多的进程干掉。如果这个进程kill导致了容器主进程出错,我们就可以看到container重启或退出等行为。如果主进程能很好处理这个错误,就不会导致container的错误。当然,如果直接kill了容器的主进程,那container也就完了。

EmptyDir

Kubernetes提供Memory Volume功能。你可以申请一块EmptyDir类型的volume,然后挂载到container里使用。EmptyDir也可以指定size。

EmptyDir的size限制仍然是遵守Kubernetes的风格,就是你要自己管理好用量,用超了我把你干掉。这里的干掉比较暴力,直接把整个POD弹出来了。

这里一个很有意思的点是,这块EmptyDir其实是算到了前面提到的container内存中cache那一项上。也就是说,如果你写了很多东西到EmptyDir上,如果container容器memory limit比较小,在POD Evict前,container就会触发OOM行为。

cadvisor 与 cgroup 的内存指标对应关系

cadvisor既然是采集的cgroup的数据,那两者指标之间肯定是关联的,找到对应关系有助于分析监控数据是否正确。

1
2
3
4
container_memory_cache = cache = inactive_file + active_file 
container_memory_rss = rss = inactive_anon + active_anon 
container_memory_swap = swap 
container_memory_usage_bytes = container_memory_rss + container_memory_cache container_memory_working_set_bytes = usage - total_inactive(inactive_anon + inactive_file)

从命名来看通常以为容器的使用内存是 container_memory_usage_bytes,实际上这个指标包含了文件系统缓存,通常比container_memory_working_set_byte 要大,不能正确反应容器的内存大小。container_memory_working_set_bytes 才是容器 oom的判断标准。

以上为例:

  • Usage: 355123200
  • inactive_anon: 0
  • inactive_file: 96784384
  • container_memory_working_set_bytes: 355123200 - 96784384 = 258338816 B = 252284 KiB 这个与 kubectl top pod 一致

cadvisor 内存相关的主要指标

cAdvisor是谷歌公司用来分析运行中的Docker容器的资源占用以及性能特性的工具,prometheus上的容器监控数据即是通过cAdvisor获取的。要弄明白prometheus上的内存监控就需要先了解下cAdvisor的内存相关的指标。

metric description
container_memory_cache 页面缓存大小。
container_memory_rss RSS内存,即常驻内存集(Resident Set Size),是分配给进程使用实际物理内存。RSS内存包括所有分配的栈内存和堆内存,以及加载到物理内存中的共享库占用的内存空间,但不包括进入交换分区的内存。
container_memory_swap 虚拟内存使用量。虚拟内存(swap)指的是用磁盘来模拟内存使用。当物理内存快要使用完或者达到一定比例,就可以把部分不用的内存数据交换到硬盘保存,需要使用时再调入物理内存。
container_memory_usage_bytes 当前使用的内存量,包括所有使用的内存,不管有没有被访问 (包括 cache, rss, swap等)。
container_memory_max_usage_bytes 最大内存使用量的记录。
container_memory_working_set_bytes 当前内存工作集(working set)使用量。
container_memory_failcnt 申请内存失败次数计数
container_memory_failures_total 累计的内存申请错误次数

容器中查看cgroup内存

docker使用cgroup限制容器资源,因此可以在cgroup路径下看到容器真正使用的资源大小。 /sys/fs/cgroup/memory目录就是docker真正的内存数据。

metric description
memory.usage_in_bytes 已使用的内存量(包含cache和buffer)(字节)
memory.limit_in_bytes 限制的内存总量(字节)
memory.failcnt 申请内存失败次数计数
memory.memsw.usage_in_bytes 已使用的内存和swap(字节)
memory.memsw.limit_in_bytes 限制的内存和swap容量(字节)
memory.memsw.failcnt 申请内存和swap失败次数计数
memory.stat 内存相关状态

其中,memory.stat 中包含有的内存信息

metric description
cache 高速缓存,包括 tmpfs(shmem),单位为字节
rss 匿名页和swap,包含transparent hugepages
rss_huge 匿名页的transparent hugepages(动态分配内存页)
mapped_file memory-mapped 映射的文件大小,包括 tmpfs(shmem)
swap 交换分区大小
pgpgin 存入内存中的页数
pgpgout 从内存中读出的页数
active_anon 在活跃的最近最少使用(least-recently-used,LRU)列表中的匿名和 swap 缓存,包括 tmpfs(shmem)。表示 anonymous pages,用户进程中与文件无关的内存(比如进程的堆栈,用malloc申请的内存),在发生换页时,是对交换区进行读/写操作
inactive_anon 不活跃的 LRU 列表中的匿名和 swap 缓存,包括 tmpfs(shmem)
active_file 活跃 LRU 列表中的 file-backed 内存。表示file-backed pages,用户进程中与文件关联的内存(比如程序文件、数据文件所对应的内存页),在发生换页(page-in或page-out)时,是从它对应的文件读入或写出
inactive_file 不活跃 LRU 列表中的 file-backed 内存
unevictable 无法再生的内存
hierarchical_memory_limit 包含 memory cgroup 的层级的内存限制
hierarchical_memsw_limit 包含 memory cgroup 的层级的内存加 swap 限制

升级前

1
2
3
4
5
6
7
8
9
[houminwei@VM-46-151-centos ~]$ caasro sh -n eks-sh top pod | grep enilb
W0323 22:34:30.334272   30504 top_pod.go:140] Using json format to get metrics. Next release will switch to protocol-buffers, switch early by passing --use-protocol-buffers flag
eks-enilb-controller-5547cc949d-c5khg                1m           47Mi
eks-enilb-controller-5547cc949d-pvk5z                39m          1311Mi

[houminwei@VM-46-151-centos ~]$ caasro gz -n eks-gz top pod | grep enilb
W0323 22:35:03.299729    1058 top_pod.go:140] Using json format to get metrics. Next release will switch to protocol-buffers, switch early by passing --use-protocol-buffers flag
eks-enilb-controller-65576bcf84-26klw                7m           246Mi
eks-enilb-controller-65576bcf84-w2pfr                1m           10Mi

Go 版本关系

RSS( Resident Set Size )常驻内存集合大小,表示相应进程在RAM中占用了多少内存,并不包含在SWAP中占用的虚拟内存。即使是在内存中的使用了共享库的内存大小也一并计算在内,包含了完整的在stack和heap中的内存。

VSZ (Virtual Memory Size),表明是虚拟内存大小,表明了该进程可以访问的所有内存,包括被交换的内存和共享库内存。

go 的 runtime 在释放内存返回到内核时,有两种系统调用

  • MADV_DONTNEED:内核会在进程的页表中将这些页标记为 “未分配”,从而进程的 RSS 就会尽快释放和变小。OS 后续可以将对应的物理页分配给其他进程。
  • MADV_FREE:内核只会在页表中将这些进程页面标记为可回收,在需要的时候才回收这些页面。

版本变迁:

  • Go 1.12 版本前:Go Runtime 在 Linux 上默认使用的是 MADV_DONTNEED 策略。从整体效果来看,进程 RSS 可以下降的比较快,但从性能效率上来看差点。

    1
    2
    
    // 没有任何奇奇怪怪的判断
    madvise(v, n, _MADV_DONTNEED)
    
  • Go 1.12 ~ 1.15 版本:当前 Linux 内核版本 >=4.5 时,Go Runtime 在 Linux 上默认使用了性能更为高效的 MADV_FREE 策略。从整体效果来看,进程RSS 不会立刻下降,要等到系统有内存压力了才会释放占用,RSS 才会下降。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
     var advise uint32
     if debug.madvdontneed != 0 {
      advise = _MADV_DONTNEED
     } else {
      advise = atomic.Load(&adviseUnused)
     }
     if errno := madvise(v, n, int32(advise)); advise == _MADV_FREE && errno != 0 {
      // MADV_FREE was added in Linux 4.5. Fall back to MADV_DONTNEED if it is
      // not supported.
      atomic.Store(&adviseUnused, _MADV_DONTNEED)
      madvise(v, n, _MADV_DONTNEED)
     }
    
    • 结合社区里所遇到的案例可得知,该次调整带来了许多问题:
      • 引发用户体验的问题:Go issues 上总是出现以为内存泄露,但其实只是未满足条件,内存没有马上释放的案例。
      • 混淆统计信息和监控工具的情况:在 Grafana 等监控上,发现容器进程内存较高,释放很慢,告警了,很慌。
      • 导致与内存使用有关联的个别管理系统集成不良:例如 Kubernetes HPA ,或者自定义了扩缩容策略这类模式,难以评估。
      • 挤压同主机上的其他应用资源:并不是所有的 Go 程序都一定独立跑在单一主机中,自然就会导致同一台主机上的其他应用受到挤压,这是难以评估的。
  • 从 v1.16 起,Go 在 Linux 下的默认内存管理策略会从MADV_FREE 改回 MADV_DONTNEED 策略。

参考资料