Asher Wang

理解 Kubernetes 中的 CPU 资源

在 Kubernetes 中相比于 memory、storage 这些不可压缩(in-compressible)资源,CPU 资源属于可压缩(compressible)资源,简单来说就是当 cpu 这种资源紧张时,没有得到足够资源的服务并不会被驱逐(Evicted)或者服务本身异常结束,而是其分配的运行时间变少,表现往往就是服务变慢(接口耗时增加,job 运行时间变长等)。本文通过 kubernetes 中 pod 的 CPU 资源配置,追踪其实现,并做一个有趣的实验,来帮助我们更好地理解 CPU 这个资源。

Namespace 与 CGroup

熟悉容器原理的同学大概知道,容器本质上就是一组 linux namespaces。linux namespaces 是 linux 内核提供的用来隔离 process 的功能,经过隔离 process 只能看到自己视角的 pid,net,ipc 等资源,就仿佛自己独立运行在一个单独的机器上。

除隔离一个 process 外,还需要使用 linux cgroup 来为这个 process 进行资源限制。这样通过 linux 的 namespace 和 cgroup 功能,我们就构造了一个简单的容器。

CPU Request 与 Limit

当你配置了 CPU Request,kubernetes scheduler 会将其调度到剩余待分配资源足够的 node 上。

node 总资源 - node 上所有已分配服务 request 之和 >= 待调度服务

而 CPU Limit 配置的参数则在实际运行时起作用,当 CPU 资源消耗触发到 limit,该服务就会被限流(throttled),停止运行等待下个周期(period)再被调度运行。

202502142115

由上图可以看到,CPU Request 实际最终对应到 linux 的 cpu.shares 配置,而 CPU Limit 最终对应到 linux 的 cpu.cfs_quota_us 和 cpu.cfs_period_us。而这些参数的功用,就需要了解下 CFS Scheduler。

CFS Scheduler

CFS Scheduler 是一种 CPU 调度器,其功能简单来说就是调度哪一个 task 何时运行在哪个 CPU 核(如果是多核的话)运行多久的程序。

CFS Scheduler(Completely Fair Scheduler,完全公平调度器)从 Linux 2.6.23 被引入,是 Linux 目前默认的 CPU Scheduler,它的目标是

CFS basically models an “ideal, precise multi-tasking CPU” on real hardware.

术语

process:进程,一个 process 有属于自己的地址空间(text,data,bss,heap and stack),一个 process 可以包含多个线程(thread),属于同一个 process 的 thread 共享地址空间(单维护自己的 stack)。

task:从内核角度,一个 thread 通常被叫做 task。有时单线程的 process 也被称为一个 task。CPU 的调度都是以 thread 为维度的

原理介绍

我以单 CPU,线程调度这种简单模型来介绍下 CFS,CFS 的规则很简单:

在每次调度时选择运行时间(vruntime)最短的 task 运行

这也就是 Completely Fair 的含义,如果每次选择运行时间最短的 task 运行,那么随着时间运行的增加,所有的 task 的运行时间都能保证在相对公平(差不多运行时间一样)的状态。

如果要选择 vruntime 最小的 task,那就意味着要维护每个 task 的信息,CFS 使用了红黑树来记录这个信息。当选取了最小的 vruntime 时,运行多久呢?CFS 有个配置项 sched_latency,默认是 48 milliseconds,该配置决定了一个 task 调度后的运行时间,计算方式为 sched_latency/n,其中 n 为该 CPU 上运行的 task 数,比如当前有 3 个 task,那么当调度的时候,就会分配给这个 task 16 ms 的运行时间。

当然如果 task 数过多,会导致每个 task 分配到的时间过少,频繁地进行 context switch 影响效率,min_granularity 配置项来保证最小分配时间时间(默认 6ms),来防止这种情况,当然牺牲了一点公平性(fairness)。

CFS 还有很多需要讨论的地方,比如 Niceness,多核等,感兴趣的同学可以自己了解。

cpu.shares

CFS 除了支持对单个 task 的调度支持外,也支持了 group task(多个 task 属于同一个组),对每个组之间按照 cpu.shares 分配 CPU 份额然后在组内实现公平调度。

需要注意的是 cpu.share 是相对值,CFS 会根据各个 group 的 cpu.share 计算比例来分配,如 A group 值为 1000,B group 值为 3000,C group 值为 4000,那么各自分配到的时间为可分配时间的 1/8,3/8,4/8。

CFS Bandwidth Control

根据 CFS Scheduler 的设计,其默认是尽可能使用 cpu 资源,然后尽可能保证各 task 或 group task 之间的公平性(fairness)。而 CFS Bandwidth Control 则为每个 group 规定了最大运行时间,超过这个时间就算还有 CPU 资源也不会继续分配运行。

而具体就是上面所说的 cpu.cfs_period_us(默认是 100ms) 与 cpu.cfs_quota_us 来配置(kubernetes 假定 1 核对应 100ms)。假设配置 period 是 100 ms,而 quota 是 10 ms,那么含义就是在 100 ms 的时间里,这个 group 下的 task 最多只允许运行 10 ms。而剩下的 90ms 就是被 throttled,也就说这个 group 在 90% 的时间内都是被限流的。

单线程单核的情况容易理解,那么多线程多核的情况呢?(这里先说个前提,一个线程最多也只能用 1 核,不可能在多个核上运行一个线程)

case1:服务单线程,limit 1 核(100ms),此时没有限流;

case2:服务 2 个线程,limit 1 核(100ms),这两个线程在 100ms(period)的时间分别只能运行 50ms,此时 50% 时间被限流;

case3:服务 4 个线程,limit 1 核(100ms),这两个线程在 100ms(period)的时间分别只能运行 25ms,此时 75% 时间被限流。

通过上面的例子大家应该对 cpu limit 和经常在监控中看到的 CPU throttle 有个理解了。

实验

通过上面的介绍大家应该有一个基本认识了,那么我想提出一个问题?如果一个 node 上所有服务都没有配置 cpu limit,会出现什么情况?

我的答案是一切如常甚至更好。有点反直觉,直觉是配置了 limit 有助于防止个别服务突增的 CPU 需求导致其他服务受到影响(通过 CFS bandwidth 给这个 group task 强制设置了一个运行时间上限),但是实际情况是如果你配置的 cpu request 是正确的,个别服务 CPU 的突增并不会挤占正常运行的服务,解释如下:

首先,当我们配置了 cpu request,kubernetes 的调度器(Kubernetes Scheduler)会根据我们配置的 request 值寻找满足条件的 Node(Node 资源总量 - 已分配服务 Request 之和)分配上去,通过这个机制保证了调度到该机器上的服务资源需求量不会超过该 Node 的资源总量 。这个前提很重要,因为如果没有 Kubernetes 来调度,CFS 本身永远是根据比例来分配运行时间,cpu.share 本身的值是没有绝对意义(对应多少核),只有相对意义(能够运行多少比例时间),但是通过 kubernetes 的调度,这个值有了绝对意义,它保证了你一定能够拿到自己想要的资源

那这与去掉 cpu limit 有什么关系呢?我们现在假设所有服务都没有设置 limit,有一个 16C 的 Node,我们逐渐调度服务到这个 Node 上:

202502142121

首先调度服务 A request 3C,但实际只使用 2C 的服务,此时因为本身只消耗 2C,所以实际 CPU 消耗也就 2C(Node 可分配 15C,实际剩余 16C);

然后调度服务 B request 3C,实际也使用 3C 的服务(Node 可分配 12C,实际剩余 11C);

202502142122

调度服务 C reqeuest 3C,实际使用 8C 的服务,能够得到 8C 的资源(Node 可分配 9C,实际剩余 2C);

此时问题来了,为什么服务 C 可以得到超过它 request 的资源(3C)?按照直觉来说它只能分配到 16/3 C 的 CPU 资源(按照 cpu.shares 按比例分配),这里需要说明下 CFS Scheduler 在 CPU 资源充足时会尽量满足 task 的资源需求,只有当资源紧张时才会按照 cpu.shares 来分配剩余资源。

202502142123

再调度服务 D reqeust 3C,实际使用 8C 的服务。此时有趣的地方来了,两个使用 8C 的服务平分了 11C 的资源,它们正是根据 cpu.share 的比例来平分的(因为两个 task 都是 3C)。

202502142124

再调度服务 E request 2C,实际想要使用 16C 的服务,继续按照 cpu.shares 来分配实际剩余资源,可以看出虽然 E 想用 16C,但是它 request 比较小,所以也就只能得到比较少的 CPU 资源。

从上面的实验中,我们可以得到以下结论:

总结

我们总容易以空间的角度来理解 CPU 这种时间资源,平时所说的某某服务占用了几核的资源,实际可以理解为某某服务在过去的某段时间运行了多长时间。凭直觉一个服务一直在运行也只是一种幻觉,其实也是运行一会(running state)停一会等待调度(ready state),有时还会因为磁盘、网络 IO 或锁等原因进入阻塞(block state)暂停调度。

通过理解 CPU 这种时间资源,可以帮助我们了解一些现象。比如为什么 kubernetes 中的服务不会因为 CPU 资源的原因被驱逐(Evicted)?因为根本没有必要,当某些服务要求更多的 CPU 时间来运行的时候,有就调度运行,没有就不调度运行,并不会造成类似 memory 资源那种 OOM 系统崩溃的情况,这也就是为什么 CPU 被称作可压缩(Compressible)资源的原因。

拓展阅读

#kubernetes #linux #docker