当我们说到 OOM 的时候,以 Java + Kubernetes 为例,其实包含三层:
Java OOM,这个和 JVM 有关,本文将不讨论该话题;
Linux OOM Kill,这又分为两种:
一种是 cgroup 级别的:容器内所有进程使用的总内存超过了容器设置的内存上限,此时会触发该 cgroup 范围内的 OOM Kill(即在容器的进程中挑选进程杀掉),如果杀掉一个进程就可以满足,同时杀掉的进程不影响容器的 1 号进程运行,则容器就会继续运行;
一种是节点级别的:没有出现 cgroup OOM,但是整个操作系统的内存不足了,此时会在所有用户态进程中挑选进程进行 OOM kill;
我们今天要讨论的是如何让我们的容器尽量不要被 OOM,基于 Linux OOM Kill 机制,也就是在计算进程 OOM 分数的时候,尽量排在后面。
相关文章
容器技术回顾 - Kubernetes memory limit 产生的 OOM
Memory Request and Limit
当我们在 Kubernetes 上部署 Pod 的时候,我们可以为 Pod 的每个容器设置 memory 的 request 和 limit。在操作系统上,会使用 cgroup 对容器的一组进程做严格的内存限制。
在 Cadvisor 中提供和内存使用量相关的指标主要有以下几种:
指标名称 |
含义 |
---|---|
container_memory_cache | 总的page cache使用量 |
container_memory_rss |
cgroup rss大小,含进程申请的内存(anno rss)和 swap cache,不含共享内存 进程rss = anno rss + file rss, 其中 file rss 包含了共享内存 |
container_memory_usage_bytes | 总内存使用量,包括所有容器使用的内存,如cache |
container_memory_working_set_bytes | 正在使用的内存 |
我们先说结论:
container_memory_usage_bytes 和 container_memory_working_set 基本是重合的,当container_memory_usage_bytes 到达 limit 后便不再增长;
container_memory_rss 一直保持增长直到触及 limit 后发生了 oomkill (cgroup oom kill);
container_memory_cache 先随着文件内容增加而增长,当总使用量到达 limit 后,便开始下降,下降到 0 后容器被 oomkill(不一定能降到0);
cgroup 很好限制了容器的总内存使用量,且在容器内存使用量到达 limit 时会强行终止容器。
container_memory_usage_bytes基本等于container_memory_rss + container_memory_cache 的和,在总的内存到达使用量限制后,cache会不断减少以让出内存空间给 rss,释放更多内存给到进程使用。
Linux OOM Killer
我们先来看一副漫画:
通常有这样的一种场景:若一台机器上部署多个应用服务,比如以 Spring Boot 微服务为例,在某些特殊的时刻,例如:业务促销、压力测试或当某一个联机负载节点或因网络抖动而挂掉时,此台服务器上的服务突然在毫无征兆的情况下,突然被“挂掉”。
那么,为什么会出现这种问题?它是如何产生的?OOM,全称为 “Out Of Memory”,即内存溢出。OOM Killer 是 Linux 自我保护的方式,防止内存不足时出现严重问题。
Linux 内核所采用的此种机制会时不时监控所运行中占用内存过大的进程,尤其针对在某一种瞬间场景下占用内存较快的进程,为了防止操作系统内存耗尽而不得不自动将此进程 Kill 掉。
通常,系统内核检测到系统内存不足时,筛选并终止某个进程的过程可以参考内核源代码:linux/mm/oom_kill.c,当系统内存不足的时候,out_of_memory()被触发,然后调用 select_bad_process() 选择一个 ”bad” 进程杀掉。
如何判断和选择一个”bad 进程呢?Linux 操作系统选择”bad”进程是通过调用 oom_badness(),挑选的算法和想法都很简单很朴实:最 bad 的那个进程就是那个最占用内存的进程。
OOM Killer 源码解析
OOM killer的核心函数是 out_of_memory(), 执行流程如下:
1、调用 check_panic_on_oom() 检查是否允许执行内核 panic,假如允许,需要重启系统。
2、若定义了 /proc/sys/vm/oom_kill_allocating_task 即允许 Kill 掉当前正在申请分配物理内存的进程,那么杀死当前进程。
3、调用 select_bad_process,选择 badness score 最高的进程。
4、调用 oom_kill_process, 杀死选择的进程。
我们通过分析 Badness Score 的计算函数来理解 OOM Killer 是如何选择需要被 Kill 掉的进程,具体源代码可参考如下所示:
unsigned long oom_badness(struct task_struct *p, struct mem_cgroup *memcg, const nodemask_t *nodemask, unsigned long totalpages){ long points; long adj; /* 假如该进程不能被kill, 则分数返回0. */ if (oom_unkillable_task(p, memcg, nodemask)) return 0; p = find_lock_task_mm(p); if (!p) return 0; /* 获取该进程的 oom_score_adj, 这个是用户为进程设置的 badness score * 调整值,假如这个值为-1000或者进程被标记为不可被kill,或者进程处于 * vfork()过程,badness score返回0. */ 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; } /* badness score分数 = 物理内存页数 + 交换区页数 + 页表Page Table数量. */ points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) + mm_pgtables_bytes(p->mm) / PAGE_SIZE; task_unlock(p); /* 利用以下公式对 badness score 值进行调整. */ adj *= totalpages / 1000; points += adj; /* 返回 badness score, 假如等于0, 则返回 1. */ return points > 0 ? points : 1;}
通过对 Badness Score 计算函数的分析,我们可以发现 OOM Killer 是基于 RSS 即常驻的物理内存来选择进程进行 Kill 操作, 从而释放相关内存以进行系统自我保护。
oom_adj 和 oom_score_adj 介绍
oom_adj 和 oom_score_adj 是与内核的 OOM(Out of Memory)机制相关的两个参数。
oom_adj: 在早期的 Linux 内核版本中,oom_adj 用于设置进程的OOM优先级,它是一个整数值。更低的优先级值意味着当系统内存不足时,该进程更容易成为OOM Killer的目标,因此更有可能被内核终止以释放内存。oom_adj 的值范围是 -17 到 15,其中 -17 表示最高优先级,15 表示最低优先级,而 0 表示默认优先级。从Linux内核版本2.6.36开始,oom_adj 被弃用了,取而代之的是 oom_score_adj。
oom_score_adj: oom_score_adj 是 Linux 内核版本2.6.36及更高版本中引入的参数,也用于设置进程的 OOM 优先级。与 oom_adj 类似,更低的优先级值表示进程更容易成为 OOM Killer 的目标。oom_score_adj 的值范围是 -1000 到 1000,其中 -1000 表示最高优先级,1000 表示最低优先级,而 0 表示默认优先级。如果 oom_score_adj 分配-1000,进程可以使用100%的内存,并且仍然避免被 OOM Killer 终止。另一方面,如果给 oom_score_adj 分配 1000,Linux 内核将继续杀死该进程,即使它使用的内存最少。
与 oom_adj 不同的是,oom_score_adj 的值是相对的,它表示相对于内核计算得出的进程OOM得分(OOM Score)的偏移。oom_score 是一个非负整数,表示内核计算的进程 OOM 得分,数值越小,表示进程越有可能成为 OOM Killer 的目标。
通过调整 oom_score_adj 的值,可以影响进程在内存紧张时被内核终止的概率。例如,将 oom_score_adj 设置为正值,可以增加进程成为OOM Killer 目标的概率,而将其设置为负值可以降低成为目标的概率。设置为0将使用默认的优先级。
总结起来,oom_adj 和 oom_score_adj 都是用于设置进程的OOM优先级,使得内核在内存不足时有选择性地终止进程来释放内存。
查看相关数值的方法:
# cat /proc/1/oom_score_adj0# cat /proc/1/oom_score0# cat /proc/1/oom_score_adj0
目标
通过 Kubernetes 现有机制调整 Pod 中容器的 OOM 分数,不行就用黑科技😄。
PriorityClass
https://kubernetes.io/zh-cn/docs/concepts/scheduling-eviction/pod-priority-preemption/
PriorityClass 是一个无命名空间对象,它定义了从优先级类名称到优先级整数值的映射。名称在 PriorityClass 对象元数据的 name 字段中指定。值在必填的 value 字段中指定。值越大,优先级越高。
要将 Pod 标记为关键性(critical),设置 Pod 的 priorityClassName 为 system-cluster-critical 或者 system-node-critical。system-node-critical 是最高级别的可用性优先级,甚至比 system-cluster-critical 更高。
可以利用的副作用
kubelet 还将具有 system-node-critical 优先级的任何 Pod 中的容器 oom_score_adj 值设为 -997。
PriorityClass 主要还是用于调度及抢占,但是设置为 system-node-critical 可以侧面调整 Pod 中容器的 oom_score_adj。
Pod QoS
https://kubernetes.io/zh-cn/docs/tasks/configure-pod-container/quality-service-pod/
如果节点在 kubelet 能够回收内存(驱逐 Pod)之前遇到内存不足(OOM)事件, 则节点依赖 oom_killer 来响应(释放内存)。kubelet 根据 Pod 的服务质量(QoS)为每个容器设置一个 oom_score_adj 值。
oom_score_adj |
|
---|---|
Guaranteed |
|
BestEffort |
|
Burstable |
如果 kubelet 在节点遇到 OOM 之前无法回收内存, 则 oom_killer 根据它在节点上使用的内存百分比以及加上 oom_score_adj 得到每个容器有效的 oom_score。 然后它会杀死得分最高的容器。
这意味着低 QoS Pod 中相对于其调度请求消耗内存较多的容器,将首先被杀死。与 Pod 驱逐不同,如果容器被 OOM 杀死, kubelet 可以根据其 restartPolicy 重新启动它。
黑科技
https://github.com/kubernetes/kubernetes/issues/90973
直接调整进程的 oom_score_adj。我们可以通过如下脚本,获取指定命名空间在当前节点上的所有容器进程以及其对应的 oom_score_adj.
#!/bin/bash# 打印使用说明print_usage() { echo "使用方法: $0 -n <namespace> [-d]" echo "" echo "选项说明:" echo " -n <namespace> 指定命名空间 (默认为 'default')" echo " -d 开启调试模式" echo ""}# 定义默认值namespace="default"debug=false# 解析命令行参数while getopts ":n:d" opt; do case $opt in n) namespace=$OPTARG ;; d) debug=true ;; \?) echo "无效的选项: -$OPTARG" >&2 print_usage exit 1 ;; :) echo "选项 -$OPTARG 需要一个参数" >&2 print_usage exit 1 ;; esacdone# 检查参数是否为空if [ -z "$namespace" ]; then echo "请提供命名空间" print_usage exit 1fi# 检查 Docker 是否可用if ! command -v docker &>/dev/null; then echo "错误: 未找到 Docker,请确保 Docker 已安装并在 PATH 中可用" exit 1fi# 检查是否开启调试模式if $debug; then set -xfi# 检查命名空间是否存在if ! kubectl get namespace $namespace >/dev/null 2>&1; then echo "命名空间 $namespace 不存在" exit 1fi# 获取当前节点的主机名CURRENT_NODE=$(hostname)# 获取当前节点上命名空间下的所有 Pod 的名称POD_NAMES=$(kubectl get pods -n $namespace -o=jsonpath='{.items[?(@.spec.nodeName=="'$CURRENT_NODE'")].metadata.name}')# 循环遍历每个 Podfor POD_NAME in $POD_NAMES; do echo "Pod: $POD_NAME" # 获取该 Pod 中所有容器的信息,并过滤出非 pause 容器 CONTAINER_INFO=$(kubectl get pod $POD_NAME -n $namespace -o=jsonpath='{.spec.containers[?(@.name!="pause")].name}') # 循环遍历每个容器 for CONTAINER_NAME in $CONTAINER_INFO; do echo " Container: $CONTAINER_NAME" # 获取容器的 PID container_id=$(docker ps --filter "label=io.kubernetes.pod.name=$POD_NAME" --filter "label=io.kubernetes.container.name=$CONTAINER_NAME" --format "{{.ID}}") if [ -z "$container_id" ]; then echo " 未找到容器 ID,可能容器未运行" continue fi pid=$(docker inspect -f '{{.State.Pid}}' $container_id) if [ -z "$pid" ]; then echo " 未找到容器 PID,可能容器未运行" continue fi # 打印 PID 列表 echo " Container PID: $pid" # 获取容器所在的 memory cgroup cgroup_path=$(grep -m 1 -E '^[[:digit:]]+:memory:' /proc/$pid/cgroup | cut -d: -f3) # 获取 cgroup 下的所有进程 if [ -n "$cgroup_path" ]; then cgroup_procs=$(cat /sys/fs/cgroup/memory/$cgroup_path/cgroup.procs) echo " Container Memory Cgroup Path: $cgroup_path" echo " Processes in Memory Cgroup: $cgroup_procs" # 循环遍历每个进程,打印 oom_score_adj for process_pid in $cgroup_procs; do oom_score_adj=$(cat /proc/$process_pid/oom_score_adj 2>/dev/null) echo " Process PID: $process_pid, oom_score_adj: $oom_score_adj" done else echo " 无法确定容器的内存 cgroup 路径" fi donedone
执行效果:
Pod: node1-etcd Container: etcd Container PID: 12386 Container Memory Cgroup Path: /kubepods/besteffort/podc979b4c1894367e4f49b882e5a4fa253/735ea31cb656658684b782b5bb2833352c25f0bfb075b670b6d0d030551ef02b Processes in Memory Cgroup: 123861257212573 Process PID: 12386, oom_score_adj: -998 Process PID: 12572, oom_score_adj: -998 Process PID: 12573, oom_score_adj: -998
好奇的读者可能会发现,这个 Pod 的 QoS 是 best-effort,但是因为它的 QoS priority 是 sysem-node-critial,因此它的 oom_score_adj 是-998.
当然了我们可以直接通过如下命令来进行修改:
echo 0 > /proc/12573/oom_score_adj
通常不建议这么做,除非是已经部署的 Pod,我们期望不重建的情况下调整 OOM 优先级。
至于为啥是 -998,原因如下;
v1.19.15 pkg/kubelet/qos/policy.go 定义如下:
const ( // KubeletOOMScoreAdj is the OOM score adjustment for Kubelet KubeletOOMScoreAdj int = -999 // KubeProxyOOMScoreAdj is the OOM score adjustment for kube-proxy KubeProxyOOMScoreAdj int = -999 guaranteedOOMScoreAdj int = -998 besteffortOOMScoreAdj int = 1000)
后续版本做了调整,例如 v1.22.17:
const ( // KubeletOOMScoreAdj is the OOM score adjustment for Kubelet KubeletOOMScoreAdj int = -999 // KubeProxyOOMScoreAdj is the OOM score adjustment for kube-proxy KubeProxyOOMScoreAdj int = -999 guaranteedOOMScoreAdj int = -997 besteffortOOMScoreAdj int = 1000)