侧边栏壁纸
博主头像
汪洋

即使慢,驰而不息,纵会落后,纵会失败,但一定可以达到他所向的目标。 - 鲁迅

  • 累计撰写 190 篇文章
  • 累计创建 74 个标签
  • 累计收到 108 条评论

Kubernetes - 服务质量 QoS 和 OOM 详解

汪洋
2021-09-04 / 0 评论 / 0 点赞 / 536 阅读 / 9,514 字

Kubernetes Qos 意义

如果把整个云环境比作一片海洋,kubernetes 是管理成千上万艘船只的掌舵人,它管理的船员(容器)可能上千万,每个船员都不一样,总有几个调皮捣蛋的,那么 kubernetes 是怎么管理这些容器的,如果一台宿主机上某个容器突然资源占用过高,kubernetes 应该如何分配保证上面的核心应用可用,服务降级防止雪崩。带着这些问题,咱们来一起看一下kubernetes 是如何实现资源管理的

Kubernetes 中的 Node Allocatable

概述

一个 kubernetes 集群,默认情况下 pod 是使用节点中的全部资源,如果没有给 node 分配足够的资源,会出现这些 pod 与系统守护进程或 kubelet 进程等资源争抢的问题,导致整个 node 节点资源短缺或不可用的情况

在 kubernetes 中把资源分为 allocatable(宿主机上pods资源)、eviction-threshold(节点驱逐阈值)、system-reserved(节点资源预留值)、kube-reserved(kubernetes 守护进程如 kubelet 等),node allocatable 是 kubernetes API 中资源对象的一种,调度器会根据每个节点上 node allocatable 的使用情况分配 pod,调度器不会超额申请过多的资源。结构图如下所示:
截屏20210904 00.15.46.png

截屏20210904 00.20.44.png

在 centos7 中由之前的 init 系统过度到 systemd 系统,在系统的开机启动后,会默认把 systemd 挂载到 /sys/fs/cgroup 中,可以通过 systemd-cgls 来查看系统的 cgroup 层级结构

一个集群中某个节点的 pod 可分配量公式如下
Allocatable = Node Capacity - (kube-reserved) - (system-reserved) - (eviction-threshold)

可以看到一个节点的 pods 可用资源需要排除 kubernetes 为系统预留资源、kubelet 守护进程、驱逐阈值这三部分的资源,剩下的就是这个节点真正可以为 pod 所分配的资源

资源的不同使用方式

pod 的 QoS

Kubernetes 为每个节点分配了可用资源,那么每个 pod 的级别是相同的吗?答案是否定的,kubernetes 为 pod会分配不同级别的角色,像一个国王会分一等公民、二等公民、自由人,如果发生饥荒,比如资源短缺, kubernetes 会先把资源分配给最优先的公民,保证它可用。Kubernetes 中 pod 的级别具体划分为:Guaranteed、Burstable、BestEffort 三种

截屏20210904 00.17.44.png

Guaranteed(一等公民):这类 pod 是有保证的,也是最优先的,在资源不足的情况下,kubernetes 优先保证guaranteed 格式的 pod,驱逐低优先格式的 pod 保证高优先级的 pod

众所周知,在 Kubernetes 中,每个 Pod 都有 QoS 标记,即服务质量。QoS 有三个不同的类:

  • Guaranteed
  • Burstable
  • BestEffort

它们之间的区别主要体现在两个指标上:一是 CPU,二是内存。在实际运行过程中,Kubernetes 会根据 Pod 的不同 QoS 标记采取不同的资源调度、驱逐策略。尤其是在资源稀缺时,QoS 差异会直接影响驱逐 Pod 的优先级

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

0

评论区