Kubernetes 服务质量和 OOM 详解
用了这么久 Kubernetes 资源,你有没有真正花时间研究过它们在节点级别的真正含义?本文将深入探讨节点内存限制是如何影响容器的。
资源的不同使用方式
众所周知,在 Kubernetes 中,每个 Pod 都有 QoS 标记,即服务质量。QoS 有三个不同的类:
Guaranteed
Burstable
BestEffort
Guaranteed vs Burstable
根据官网文档, QoS 为 Guaranteed 的 Pod 需要满足以下条件:
Pod 中的每个容器必须指定内存限制和内存请求,且两者必须相等
Pod 中的每个容器必须指定 CPU 限制和 CPU 请求,且两者必须相等
下面是一个示例:
apiVersion: v1
kind: Pod
metadata:
name: limits-and-requests
namespace: test
spec:
containers:
- name: container
image: busybox
command: [ /bin/sleep, 33d ]
resources:
limits:
cpu: 100m
memory: 10Mi
requests:
cpu: 100m
memory: 10Mi
在以上配置中,我们把所有容器的请求和限制(内存和 CPU)都设置成了相等的值,所以这个 Pod 的 QoS 类为 Guaranteed。
kubectl describe pod limits-and-requests
Name: limits-and-requests
Namespace: test
Priority: 0
Status: Running
QoS Class: Guaranteed
对于 Burstable,文档的定义是:
Pod 不符合 Guaranteed QoS 类标准
Pod 中至少一个有容器具备内存或 CPU 请求
也就是说,如果我们取消之前示例中的内存请求(将其设置为 0-unlimited),那么 Kubernetes 就会把 Pod 归为 Burstable QoS 类:
apiVersion: v1
kind: Pod
metadata:
name: limits
namespace: test
spec:
containers:
- name: container
image: busybox
command: [ /bin/sleep, 33d ]
resources:
limits:
cpu: 100m
memory: 10Mi
requests:
cpu: 100m
memory: 0
这意味着 Kubernetes 在将该 Pod 调度到一个节点时,它不会考虑内存约束,因为根本没有内存保留。我们可以验证 Pod 目前的 QoS 是不是 Burstable:
kubectl describe pod limits
Name: limits
Namespace: test
Priority: 0
Status: Running
QoS Class: Burstable
那么 Guaranteed 和 Burstable 在容器运行时级别上有何不同?我们可以看一下为这些容器生成的 OCI 规范之间的差异。
示例使用了 microk8s(它用了 containerd 实现的容器运行时接口 CRI),所以我们可以通过 ctr(containerd 的 CLI 工具)来收集规范:
# use `kubectl` to get the id of the container
#
function container_id() {
local name=$1
kubectl get pod limits \
-o jsonpath={.status.containerStatuses[0].containerID} \
| cut -d '/' -f3
}
# use `ctr` to get the oci spec
#
function oci_spec () {
local id=$1
microk8s.ctr container info $id | jq '.Spec'
}
spec $(container_id "limits-and-requests") > /tmp/guaranteed
spec $(container_id "limits") > /tmp/burstable
git diff --no-index /tmp/guaranteed /tmp/burstable
可以看到,差异非常大:
diff --git a/guaranteed.json b/burstable.json
index 046d16f..da7596a 100644
--- a/guaranteed.json
+++ b/burstable.json
@@ -14,15 +14,15 @@
],
"cwd": "/",
"capabilities": {
@@ -92,7 +92,7 @@
]
},
- "oomScoreAdj": -998
+ "oomScoreAdj": 999
},
"linux": {
"resources": {
"memory": {
"limit": 10485760
},
@@ -247,25 +247,25 @@
"period": 100000
}
},
- "cgroupsPath": "/kubepods/pod477062c0-1c.../05bef2...",
+ "cgroupsPath": "/kubepods/burstable/podfbb122d5-ca/59...",
首先,cgroupsPath
完全不同:是 /kubepods/burstable
,而不是 kubepods
。
其次,初始进程的 oomScoreAdj
是根据执行的计算进行配置的,以便在 OOM 时降低优先级。
Guaranteed vs BestEffort
对于 QoS 类为 BestEffort 的 Pod,Pod 中的容器不得设置任何内存、CPU 限制或请求。
根据文档定义,我们可以将请求和限制都设置为 0:
apiVersion: v1
kind: Pod
metadata:
name: nothing
namespace: test
spec:
containers:
- name: container
image: busybox
command: [ /bin/sleep, 33d ]
resources:
limits:
cpu: 0
memory: 0
requests:
cpu: 0
memory: 0
上述设置意味着在进行调度时,不应对 Pod 设置任何内存和 CPU 约束,在运行容器时,也不对其施加任何限制。我们可以验证该 Pod 目前的 QoS 是不是 BestEffort:
kubectl describe pod nothing
Name: nothing
Namespace: test
Priority: 0
Status: Running
QoS Class: BestEffort
注:资源只是 Pod 是否能运行的一个检查项,QoS 类为 BestEffort 的 Pod 并不是始终可调度的。
那么 BestEffort 和 Guaranteed 又有什么不同呢?
diff --git a/guaranteed.json b/besteffort.json
index 046d16f..bd16d6b 100644
--- a/guaranteed.json
+++ b/besteffort.json
@@ -92,7 +92,7 @@
]
},
- "oomScoreAdj": -998
+ "oomScoreAdj": 1000
},
"linux": {
"resources": {
@@ -239,33 +239,33 @@
}
],
"memory": {
- "limit": 10485760
+ "limit": 0
},
"cpu": {
- "shares": 102,
- "quota": 10000,
+ "shares": 2,
+ "quota": 0,
"period": 100000
}
},
- "cgroupsPath": "/kubepods/pod477062c0-1c/05bef2cca07a...",
+ "cgroupsPath": "/kubepods/besteffort/pod31b936/1435...",
如上所示,CPU 资源调用和限制都是非零的,因为我们没有对它做任何设置(设置就成了 Burstable)。但这里我们要关注的并不是 CPU 资源被用了多少,而是在“unlimited CPU”的情况下,Pod 在使用 CPU 时几乎没有得到任何优先级。
此外,容器被放在了不同的 cgroup 路径中,oomScoreAdj
也被更改了。要了解这些细节背后的原因,我们得回顾一下 Linux 中的 OOM 是怎么发生的。
oom score
在遇到较高内存使用压力时,Linux 内核会杀掉一些不太重要的进程,腾出空间保障系统正常运行。它会给每个进程(/proc/$ pid / oom_score
)分配一个得分(oom_score
),分数越高,被 OOM 的概率就越大。
这个参数本身只反映该进程的可用资源在系统中所占的百分比,并没有“该进程有多重要”的概念。例如,假设有一个双进程系统,其中一个进程(PROC1)需要占用系统中 95% 的内存,其他进程占用剩余内存:
MEM
PROC1 95%
PROC2 1%
PROC3 1%
当我们检查分配给每个进程的 oom score 时,我们会发现 PROC1 的分数相比其他进程会非常高:
MEM OOM_SCORE
PROC1 95% 907
PROC2 1% 9
PROC3 1% 9
如果我们现在创建一个 PROC4,给它分配 5% 的内存,这时系统就会触发 OOM,杀死 PROC1(而不是 PROC4):
[951799.046579] Out of memory:
Killed process 18163 (mem-alloc)
total-vm:14850668kB,
anon-rss:14133628kB,
file-rss:4kB,
shmem-rss:0kB
[951799.441402] oom_reaper:
reaped process 18163 (mem-alloc),
now anon-rss:0kB,
file-rss:0kB,
shmem-rss:0kB
(that's the one we're calling PROC1 here)
在大多数情况下,PROC1 肯定是系统里最重要的,无论内存压力有多大,我们都不希望它被杀死,因此这时就需要对 OOM 的分数进行调整。
oomScoreAdj
手动调整 oom_score
可以通过 oom_score_adj
来实现,它允许开发者在内存不足的情况下杀死指定进程。
具体做法是把可调参数 /proc/pid/oom_score_adj
直接添加到 badness() 分数中,范围从 -1000(OOM_SCORE_ADJ_MIN
)到 +1000(OOM_SCORE_ADJ_MAX
),使某些任务总是会被考虑 OOM,某些任务则永远不会被 OOM。
如果我们调整了 PROC1 的 oom_score_adj
(echo "-1000" > /proc/$(pidof PROC1)/oom_score_adj
),系统在 OOM 时就会先杀死其他进程。
MEM OOM_SCORE OOM_SCORE_ADJ
PROC1 95% 0 -1000
PROC2 1% 9 0
PROC3 1% 9 0
放在 Kubernetes 的例子里,它决定的就是系统 OOM 时 Pod 被杀死的优先级:
Guaranteed 具有高优先级
BestEffort 具有极低优先级
不同的 cgroup trees
在之前提到的不同中,比较特别的是为 Guaranteed 和 BestEffort 形成的两个不同的 cgroup trees。
"cgroupsPath": "/kubepods/pod477062c0-1c.../05bef2...",
"cgroupsPath": "/kubepods/besteffort/pod31b936/1435...",
"cgroupsPath": "/kubepods/burstable/podfbb122d5-ca/59...",
事实证明,对于这些不同的 tree,kubelet 能动态调整 CPU 配额及内存等不可压缩资源,向 Guaranteed 类 Pod 提供所需资源,并允许开发者把资源倾向更重要的 Pod,让 BestEffort 类 Pod 使用 Guaranteed 类和 Burstable 类占用后的剩余资源。
这允许我们把资源细粒度分配给一组 Pod,同时能够“将其余部分”分配给整个类。
/kubepods
/pod123 (guaranteed) cpu and mem limits well specified
cpu.shares = sum(resources.requests.cpu)
cpu.cfs_quota_us = sum(resources.limits.cpu)
memory.limit_in_bytes = sum(resources.limits.memory)
/burstable all - (guaranteed + reserved)
cpu.shares = max(sum(burstable pods cpu requests, 2))
memory.limit_in_bytes = allocatable - sum(requests guaranteed)
/pod789
cpu.shares = sum(resources.requests.cpu)
if all containers set cpu:
cpu.cfs_quota_us
if all containers set mem:
memory.limit_in_bytes
/besteffort all - burstable
cpu.shares = 2
memory.limit_in_bytes = allocatable - (sum(requests guaranteed) + sum(requests burstable))
/pod999
cpu.shares = 2
per-cgroup 内存统计
为了根据 memory.limit_in_bytes
的设置强制执行内存限制,内核必须跟踪资源分配情况,并以此判断进程是否可以继续进行。
关于这一点,我们可以举一个小例子:
#include <signal.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static const ptrdiff_t len = 1 << 25; // 32 MiB
static const ptrdiff_t incr = 1 << 12; // 4KiB
void handle_sig(int sig) { }
void wait_a_bit(char* msg) {
if (signal(SIGINT, handle_sig) == SIG_ERR) {
perror("signal");
exit(1);
}
printf("wait: %s\n", msg);
pause();
}
int main(void) {
char *start, *end;
void* pb;
pb = sbrk(0);
if (pb == (void*)-1) {
perror("sbrk");
return 1;
}
start = (char*)pb;
end = start + len;
wait_a_bit("next: brk");
// "allocate" mem by increasing the program break.
//
if (!~brk(end)) {
perror("brk");
return 1;
}
wait_a_bit("next: memset");
// "touch" the memory so that we get it really utilized - at this point,
// we should see the faults taking place, and both RSS and active anon
// going up.
//
while (start < end) {
memset(start, 123, incr);
start += incr;
}
wait_a_bit("next: exit");
return 0;
}
将进程置于内存 cgroup 下,然后跟踪与 charging 相关的函数(mem_cgroup_try_charge
),可以看到,charging 只在我们试图访问刚刚映射的新区域时发生。
# leverage iovisor/bcc's `trace`
#
./trace 'mem_cgroup_try_charge' -U -K -p $(pidof sample)
PID TID COMM FUNC
18223 18223 sample mem_cgroup_try_charge
mem_cgroup_try_charge+0x1 [kernel]
do_anonymous_page+0x139 [kernel]
__handle_mm_fault+0x760 [kernel]
handle_mm_fault+0xca [kernel]
do_user_addr_fault+0x1f9 [kernel]
__do_page_fault+0x58 [kernel]
do_page_fault+0x2c [kernel]
page_fault+0x34 [kernel]
main+0x57 [sample]
per-cgroup oom
为了观察 cgroup 的 OOM,我们可以对创建的 cgroup 设置一个限制,在示例中,就是把 memory.limit_in_bytes
设置得比 32Mib 小。
echo "4M" > /sys/fs/cgroup/memory/test/memory.limit_in_bytes
跟踪 mem_cgroup_out_of_memory
函数,我们可以找出所有这些情况是如何发生的:
mem_cgroup_out_of_memory() {
out_of_memory() {
out_of_memory.part.0() {
mem_cgroup_get_max();
mem_cgroup_scan_tasks() {
mem_cgroup_iter() { }
css_task_iter_start() { }
css_task_iter_next() { }
oom_evaluate_task() {
oom_badness.part.0() { }
}
css_task_iter_next() { }
oom_evaluate_task() {
oom_badness.part.0() { }
}
css_task_iter_next() { }
css_task_iter_end() { }
}
oom_kill_process() { }
}
}
}
kubelet 的软驱逐
除了从内核角度发生的驱逐之外,kubelet 也可以强制执行 Pod 驱逐,这是基于 kubelet 级别的阈值配置的。
Kubelet 可以主动监视并防止计算资源匮乏。当资源不足时,kubelet 可以通过主动使一个或多个 Pod 发生故障来回收其占用的资源。
当内存消耗超过内部配置的阈值时,Kubernetes 会强制重新启动 Pod。
K8S 进阶训练营
扫描二维码获取
更多云原生知识
k8s 技术圈