揭秘容器内存统计

2024/09/05 16:17 下午 posted in  Kubernetes

最近工作中经常会被问到关于容器内存统计的问题:

  1. PageCache 的使用会不会算到 Memory limit 的计算中?
  2. 不同的 Pod 访问同一个文件时,产生的 PageCache 能否共享?如果能共享,PageCache 算谁的?
  3. 当容器的内存使用达到了 limit 值,会 Kill 哪个进程?会重启容器吗?
  4. 容器使用缓存盘时,会影响容器的内存统计吗?

本文将整理容器内存统计相关内容,并尝试回答并验证以上几个问题。

内存记账

进程消耗的内存主要包括以下两部分:

  1. 虚拟地址空间映射的物理内存。
  2. 通过读写磁盘生成的 PageCache 消耗的内存。

虚拟地址空间映射的物理内存涵盖了堆、栈等内存使用情况。除了通过 MMap 文件直接映射外,进程还可以通过系统调用进行 I/O 操作,在 Flush 到磁盘之前,会先将数据写入 PageCache。因此,PageCache 也会占用一部分内存,如下图所示。

Cgroup

Cgroup 是一种用于限制、管理和隔离一组进程资源的技术,也是容器实现隔离的重要机制。Cgroup 采用分层管理方式,每个节点包含一组文件,用于统计该节点所涵盖的控制组的各项指标。其中,内存相关统计指标如下:

Memory Cgroup 文件中需要关注的指标:

  • memory.limit_in_bytes:限制当前控制组可以使用的内存大小。对应 K8s、Docker 下 memory limit 指标。
  • memory.usage_in_bytes:当前控制组里所有进程实际使用的内存总和。
  • memory.stat:当前控制组的内存统计详情。

memory.stat 中的字段含义:

  • cache:PageCache 缓存页大小。
  • rss:控制组中所有进程的 anno_rss 内存之和。
  • mapped_file:控制组中所有进程的 file_rss 和 shmem_rss 内存之和。
  • active_anon:活跃 LRU 列表中所有 Anonymous 进程使用内存和 Swap 缓存,包括 tmpfs(shmem)。
  • inactive_anon:不活跃 LRU 列表中所有 Anonymous 进程使用内存和 Swap 缓存,包括 tmpfs(shmem)。
  • active_file:活跃 LRU 列表中所有 file-backed 进程使用内存。
  • inactive_file:不活跃 LRU 列表中所有 file-backed 进程使用内存。

总的来说:

cache = active_file + inactive_file
usage_in_bytes = rss + cache

kubectl top

kubectl top 命令通过 Metric-server 获取 Cadvisor 中 working_set 的值,表示 Pod 实例使用的内存大小(不包括 Pause 容器)。

Cadvisor 内存 WorkingSet 算法如下:

func setMemoryStats(s *cgroups.Stats, ret *info.ContainerStats) {
    ret.Memory.Usage = s.MemoryStats.Usage.Usage
    ret.Memory.MaxUsage = s.MemoryStats.Usage.MaxUsage
    ret.Memory.Failcnt = s.MemoryStats.Usage.Failcnt

    if s.MemoryStats.UseHierarchy {
        ret.Memory.Cache = s.MemoryStats.Stats["total_cache"]
        ret.Memory.RSS = s.MemoryStats.Stats["total_rss"]
        ret.Memory.Swap = s.MemoryStats.Stats["total_swap"]
        ret.Memory.MappedFile = s.MemoryStats.Stats["total_mapped_file"]
    } else {
        ret.Memory.Cache = s.MemoryStats.Stats["cache"]
        ret.Memory.RSS = s.MemoryStats.Stats["rss"]
        ret.Memory.Swap = s.MemoryStats.Stats["swap"]
        ret.Memory.MappedFile = s.MemoryStats.Stats["mapped_file"]
    }
    if v, ok := s.MemoryStats.Stats["pgfault"]; ok {
        ret.Memory.ContainerData.Pgfault = v
        ret.Memory.HierarchicalData.Pgfault = v
    }
    if v, ok := s.MemoryStats.Stats["pgmajfault"]; ok {
        ret.Memory.ContainerData.Pgmajfault = v
        ret.Memory.HierarchicalData.Pgmajfault = v
    }

    workingSet := ret.Memory.Usage
    if v, ok := s.MemoryStats.Stats["total_inactive_file"]; ok {
        if workingSet < v {
            workingSet = 0
        } else {
            workingSet -= v
        }
    }
    ret.Memory.WorkingSet = workingSet
}

总的来说,kubectl top pod 命令查询到的 Memory Usage 与 Cgroup 中的指标关系:

Memory WorkingSet 
= usage_in_bytes - inactive_file 
= RSS + active cache

当 WorkingSet 的统计值达到 Memory Limit 时,则会触发 OOM。

OOM

有了以上的内存统计说明后,再来回到开头提到的几个问题。

PageCache 对 OOM 的影响

WorkingSet 包含了 RSS 和 active cache,所以 PageCache 中的 active file cache 会影响到进程的 OOM。

启动一个 Pod,运行两个进程,一个不断地申请内存,一个不断地写文件,设置 100MB 的 Memory Limit。查看其监控信息:

可以看到,container_memory_usage_bytes 确实包含了 PageCache。我们还可以看到当 usage_in_bytes 达到 limit 后,并不会立刻 OOM,PageCache 会释放 inactive file cache,直到 WorkingSet 达到 Memory limit 后,才触发 OOM。这是 make sense 的,因为 PageCache 随时可以从内存中逐出,仅仅为了使用磁盘 I/O 就终止进程是没有意义的。

内核中相关的代码如下:

/*
 * This is the main entry point to direct page reclaim.
 *
 * If a full scan of the inactive list fails to free enough memory then we
 * are "out of memory" and something needs to be killed.
 *
 * If the caller is !__GFP_FS then the probability of a failure is reasonably
 * high - the zone may be full of dirty or under-writeback pages, which this
 * caller can't do much about.  We kick the writeback threads and take explicit
 * naps in the hope that some of these pages can be written.  But if the
 * allocating task holds filesystem locks which prevent writeout this might not
 * work, and the allocation attempt will fail.
 *
 * returns:	0, if no pages reclaimed
 * 		else, the number of pages reclaimed
 */
static unsigned long do_try_to_free_pages(struct zonelist *zonelist,
					  struct scan_control *sc)

Pod 之间能否共享 PageCache

PageCache 是由内核控制,所以当 Pod 读取宿主机上相同的文件时,产生的 PageCache 是可以共享的。

而产生的 PageCache 是算在第一个读取文件的 Pod 的 Memory limit 中,后续并不会重复记账,即使第一个 Pod 被删除了,后面的 Pod 仍然不会记录已经产生的 PageCache。

先后启动两个 Pod,读取同一个文件。分别查看其监控信息:

可以看到第二个 Pod 的 memory_cache 始终为 0。

Kill 哪个进程

毫无疑问,Kill 的是永远使用内存高的那个进程。只有当容器 1 号进程退出,容器才会退出,Pod 根据其重启策略决定需不需要重启。

我们启动一个 Pod,父进程启动子进程,且 wait 子进程,其退出时打印日志,主进程不退出。其中,父进程不断地写文件,子进程不断地申请内存。

$ kubectl logs pc-mem-test
run command
sub command memory
execute sub command [memory]
wait error signal: killed
$ kubectl describe po pc-mem-test
...
Status:           Running
IP:               10.233.89.212
Containers:
  pc-mem-test:
    ...
    State:          Running
      Started:      Sun, 08 Sep 2024 04:52:02 +0000
    Ready:          True
    Restart Count:  0
...

$ // 去对应的节点上
$ dmesg -T | grep oom
[Sun Sep  8 05:04:45 2024] page invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=975
[Sun Sep  8 05:04:45 2024]  oom_kill_process.cold+0xb/0x10
[Sun Sep  8 05:04:45 2024] [  pid  ]   uid  tgid total_vm      rss pgtables_bytes swapents oom_score_adj name
[Sun Sep  8 05:04:45 2024] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=cri-containerd-37a61e1f48e92a706660d5a4e147692c0630f28b9dd8ee4a84d6929f07c81cf3.scope,mems_allowed=0,oom_memcg=/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podceb06323_be34_45be_ae42_386a69731d8f.slice,task_memcg=/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podceb06323_be34_45be_ae42_386a69731d8f.slice/cri-containerd-37a61e1f48e92a706660d5a4e147692c0630f28b9dd8ee4a84d6929f07c81cf3.scope,task=page,pid=612054,uid=0
[Sun Sep  8 05:04:45 2024] Memory cgroup out of memory: Killed process 612054 (page) total-vm:1369624kB, anon-rss:97512kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:412kB oom_score_adj:975

在 Pod 日志中可以看到父进程捕获到了子进程的退出信息,其收到了 SIGKill 信号。再看 Pod 本身的状态,并未重启。而节点上 demsg 则显示触发了 OOM。

查看该 Pod 的监控信息可以发现,当子进程被 kill 后,父进程仍然在不停地写文件占用 Page Cache。所以 memory_cache 会慢慢上涨,而 working_set 则停留在一个比较低的水位:

OOM 相关的代码如下:

unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
	long points;
	long adj;

	if (oom_unkillable_task(p))
		return 0;

	p = find_lock_task_mm(p);
	if (!p)
		return 0;

	/*
	 * Do not even consider tasks which are explicitly marked oom
	 * unkillable or have been already oom reaped or the are in
	 * the middle of vfork
	 */
	adj = (long)p->signal->oom_score_adj;
	if (adj == OOM_SCORE_ADJ_MIN ||
			test_bit(MMF_OOM_SKIP, &p->mm->flags) ||
			in_vfork(p)) {
		task_unlock(p);
		return 0;
	}

	/*
	 * The baseline for the badness score is the proportion of RAM that each
	 * task's rss, pagetable and swap space use.
	 */
	points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
		mm_pgtables_bytes(p->mm) / PAGE_SIZE;
	task_unlock(p);

	/* Normalize to oom_score_adj units */
	adj *= totalpages / 1000;
	points += adj;

	/*
	 * Never return 0 for an eligible task regardless of the root bonus and
	 * oom_score_adj (oom_score_adj can't be OOM_SCORE_ADJ_MIN here).
	 */
	return points > 0 ? points : 1;
}


static inline unsigned long get_mm_rss(struct mm_struct *mm)
{
	return get_mm_counter(mm, MM_FILEPAGES) +
		get_mm_counter(mm, MM_ANONPAGES) +
		get_mm_counter(mm, MM_SHMEMPAGES);
}

缓存盘对 OOM 的影响

将宿主机的缓存盘 /dev/shm 挂载进 pod 中,pod 不断地向 /dev/shm 中的某个文件进行追加写,且一直不释放。

从监控中可以看出,缓存盘的使用是统计在 PageCache 的 active file 中,直到触发 OOM。

查看 pod 状态可以看出,进程被 oom 后,page cache 没有被释放,OOM Killer 会 kill Pod 的 init 进程。

$ kubectl describe po tmpfs
Name:             tmpfs
Namespace:        default
...
Containers:
  tmpfs:
    State:          Waiting
      Reason:       CrashLoopBackOff
    Last State:     Terminated
      Reason:       StartError
      Message:      failed to create containerd task: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: container init was OOM-killed (memory limit too low?): unknown
      Exit Code:    128
      Started:      Thu, 01 Jan 1970 00:00:00 +0000
      Finished:     Mon, 09 Sep 2024 03:14:13 +0000
    Ready:          False
    Restart Count:  153
...

参考

  1. 内存统计说明
  2. How much is too much? The Linux OOMKiller and “used” memory