最近工作中经常会被问到关于容器内存统计的问题:
- PageCache 的使用会不会算到 Memory limit 的计算中?
- 不同的 Pod 访问同一个文件时,产生的 PageCache 能否共享?如果能共享,PageCache 算谁的?
- 当容器的内存使用达到了 limit 值,会 Kill 哪个进程?会重启容器吗?
- 容器使用缓存盘时,会影响容器的内存统计吗?
本文将整理容器内存统计相关内容,并尝试回答并验证以上几个问题。
内存记账
进程消耗的内存主要包括以下两部分:
- 虚拟地址空间映射的物理内存。
- 通过读写磁盘生成的 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
...