细节篇(4):限制容器的CPU

391 阅读8分钟

CPU Usage

如果你想查看 Linux 的 CPU 使用,最常用的肯定是运行 Top 。

image.png

%Cpu(s)开头的这一行,你会看到一串数值,也就是"0.0 us, 0.0 sy, 0.0 ni, 99.9 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st",那么这每一项值都是什么含义呢?

下图里最长的带箭头横轴,可以看成一个时间轴。上半部分代表 Linux 用户态(User space),下半部分代表内核态(Kernel space)。为了方便理解,先假设只有一个 CPU

image.png

假设一个用户程序开始运行了,对应着第一个"us"框,"us"是"user"的缩写,代表 Linux 的用户态 CPU Usage。普通用户程序代码中,只要不是调用系统调用,这些代码的指令消耗的 CPU 就都属于"us"。

当用户程序代码中调用了系统调用,比如说 read() 去读取一个文件,这时用户进程就会从用户态切换到内核态。

内核态 read() 系统调用在读到真正 disk 上的文件前,就会进行一些文件系统层的操作。这些代码指令的消耗就属于"sy",对应上图第二个框。"sy"是 "system"的缩写,代表内核态 CPU 使用。

接下来 read() 系统调用会向 Linux 的 Block Layer 发出一个 I/O Request,触发一个真正的磁盘读取操作。

这时该进程一般会被置为 TASK_UNINTERRUPTIBLE。而 Linux 把这段时间标示成"wa",是"iowait"的缩写,代表等待 I/O 的时间,这里的 I/O 指 Disk I/O。

当磁盘返回数据时,进程在内核态拿到数据,这里仍旧是内核态的 CPU 使用中 的"sy"。

进程再从内核态切换回用户态,在用户态得到文件数据,这里进程又回到用户态的 CPU 使用。

这里假设用户进程在读取数据后,没事可做就休眠了。并且进一步假设,这时 CPU 上也没有其他需要运行的进程,那么系统就会进入"id"这个步骤,也就是第六个框。"id"是"idle"的缩写,代表系统处于空闲状态。

如果这时这台机器在网络收到一个网络数据包,网卡就会发出一个中断。相应地,CPU 会响应中断,然后进入中断服务程序。

CPU 就会进入"hi"。"hi"是"hardware irq"的缩写,代表 CPU 处理硬中断的开销。由于我们的中断服务处理需要关闭中断,所以这个硬中断的时间不能太长。 但是,发生中断后的工作是必须要完成的,如果这些工作比较耗时那怎么办呢?Linux 中有一个软中断的概念(softirq),它可以完成这些耗时比较长的工作。

可以这样理解软中断,从网卡收到数据包的大部分工作,都是通过软中断来处理。那么,CPU 就会进入"si",是"softirq"的缩写,代表 CPU 处理软中断的开销。 无论是"hi"还是"si",它们的 CPU 时间都不会计入进程的 CPU 时间。因为本身它们在处理时就不属于任何一个进程。

还剩两个类型的 CPU 使用没讲到,一个是"ni",是"nice"的缩写,这里表示如果进程的 nice 值是正值(1-19),代表优先级比较低的进程运行时所占用的 CPU。 另外一个是"st","st"是"steal"的缩写,是在虚拟机里用的一个 CPU 使用类型,表示有多少时间是被同一个宿主机上的其他虚拟机抢走的。

image.png

CPU Cgroup

Cgroups 是对指定进程做计算机资源限制的,CPU Cgroup 是 Cgroups 其中的一个子系统,用来限制进程的 CPU 使用。

对于进程的 CPU 使用, 我们知道它只包含两部分: 一个是用户态,包含了 us 和 ni;还有一部分是内核态,也就是 sy。

至于 wa、hi、si,这些 I/O 或者中断相关的 CPU 使用,CPU Cgroup 不会去做限制,接下来我们就来看看 CPU Cgoup 是怎么工作的?

每个 Cgroups 子系统都是通过一个虚拟文件系统挂载点的方式,挂到一个缺省的目录下, CPU Cgroup 一般在 Linux 发行版里会放在 /sys/fs/cgroup/cpu 目录下。

在这个子系统的目录下,每个控制组(Control Group) 都是一个子目录,各个控制组之间的关系就是一个树状的层级关系。

比如在子系统的最顶层开始建立两个控制组 group1 和 group2,再在 group2 下面再建立两个控制组 group3 和 group4。 这样操作以后,我们就建立了一个树状的控制组层级

image.png

每个控制组里都有哪些 CPU Cgroup 相关的控制信息呢?(下图是未根据图片创建,读者实验时根据图片创建目录)

image.png

考虑到在云平台里大部分程序都不是实时调度的进程,而是普通调度 (SCHED_NORMAL)类型进程。普通调度的算法在 Linux 中目前是 CFS (Completely Fair Scheduler,完全公平调度器)。为了方便理解,我们直接来看 CPU Cgroup 和 CFS 相关的参数,一共有三个。

  1. cpu.cfs_period_us,是 CFS 算法的一个调度周期,一般为 100000,以 microseconds 为单位,也就 100ms。
  2. cpu.cfs_quota_us,表示 CFS 算法中,在一个调度周期里这个控制组被允许的运行时间,比如这个值为 50000 时,就是 50ms。 如果用这个值去除以调度周期(cpu.cfs_period_us),50ms/100ms = 0.5,这个控制组被允许使用的 CPU 最大配额就是 0.5 个 CPU。
  3. cpu.shares。这个值是 CPU Cgroup 对于控制组之间的 CPU 分配比例,它的缺省值是 1024。假设前面创建的 group3 中的 cpu.shares 是 1024,而 group4 中的 cpu.shares 是 3072,那么 group3:group4=1:3。在一台 4 个 CPU 的机器上,当 group3 和 group4 都需要 4 个 CPU 时,它们实际分配到的 CPU 是 1:3。

接下来通过几个例子来进一步理解。 第一个例子,启动一个消耗 2 个 CPU(200%)的程序 threads-cpu,然后把这个程序的 pid 加入到 group3 的控制组里:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>

void *doSomeThing(void *arg)
{
	static unsigned long i = 0;
	pthread_t id = pthread_self();

	printf("Thread%d, %x\n", i++, id);
	while (1) {
		int sum;
		sum += 1;
	}

	return NULL;
}

int main(int argc, char *argv[])
{
	int i;
	int total;
	pthread_attr_t tattr;
	int err;
	int stack_size = (20 * 1024 * 1024);

	if (argc < 2) {
		total = 1;
	} else {
		total = atoi(argv[1]);
	}

	err = pthread_attr_init(&tattr);
	if (err != 0) {
		printf("pthread_attr_init err\n");
	}

	err = pthread_attr_setstacksize(&tattr, stack_size);
	if (err != 0) {
		printf("Set stack to %d\n", stack_size);
	}

	printf("To create %d threads\n", total);

	for (i = 0; i < total; i++) {
		pthread_t tid;
		err = pthread_create(&tid, &tattr, &doSomeThing, NULL);
		if (err != 0)
			printf("\ncan't create thread :[%s]", strerror(err));
		else
			printf("\nThread %d created successfully\n", i);

	}

	usleep(1000000);
	printf("All threads are created\n");
	usleep(1000000000);

	return EXIT_SUCCESS;
}

image.png

在没有修改 cpu.cfs_quota_us 前,用 top 命令可以看到 threads-cpu 这个进程的 CPU 使用率近似 2 个 CPU。

image.png

更新这个控制组里的 cpu.cfs_quota_us,把它设置为 150000(150ms)。把这个值除以 cpu.cfs_period_us,计算过程是 150ms/100ms=1.5, 也就是 1.5 个 CPU,同时把 cpu.shares 设置为 1024。

echo 150000 > /sys/fs/cgroup/cpu/group2/group3/cpu.cfs_quota_us 
echo 1024 > /sys/fs/cgroup/cpu/group2/group3/cpu.shares

image.png

threads-cpu 进程的 CPU 使用减小到了 150%.但 cpu.shares 的作用还没有发挥,因为 cpu.shares 是几个控制组之间的 CPU 分配比例,而且一定要到整个节点中所有的 CPU 都跑满时才能发挥作用。

再来运行第二个例子来理解 cpu.shares。先把第一个例子的程序启动,按前面的内容设置好 group3 里 cpu.cfs_quota_us 和 cpu.shares。设置完成后,启动第二个程序,并且设置好 group4 里的 cpu.cfs_quota_us 和 cpu.shares。

./threads-cpu 4 & echo $! > /sys/fs/cgroup/cpu/group2/group4/cgroup.procs 
echo 350000 > /sys/fs/cgroup/cpu/group2/group4/cpu.cfs_quota_us 
echo 3072 > /sys/fs/cgroup/cpu/group2/group4/cpu.shares 

现在节点上总共有 4 个 CPU,而 group3 的程序需要消耗 2 个 CPU, group4 的程序要消耗 4 个 CPU。 即使 cpu.cfs_quota_us 已经限制了进程 CPU 使用的绝对值,group3 的限制是 1.5CPU,group4 是 3.5CPU,1.5+3.5=5,结果还是超过了节点上的 4 个 CPU。

在这种情况下,cpu.shares 终于开始起作用了。在这里 shares 比例是 group4:group3=3:1,在总共 4 个 CPU 的节点上,按照比例, group4 里的进程应该分配到 3 个 CPU,而 group3 里的进程会分配到 1 个 CPU。 我们用 top 可以看一下,结果和我们预期的一样。

image.png

cpu.cfs_quota_us 和 cpu.cfs_period_us 决定了每个控制组中所有进程的可使用 CPU 资源的最大值。 cpu.shares 决定了 CPU Cgroup 子系统下控制组可用 CPU 的相对比例,只有当系统上 CPU 完全被占满时才起作用。

现象解释

K8s 为每个容器都在 CPU Cgroup 的子系统中建立一个控制组,把容器中进程写入控制组。

这时"Limit CPU"就需要为容器设置可用 CPU 的上限。上限由 cpu.cfs_quota_us 除以 cpu.cfs_period_us 得出的值来决定的。在操作系统里,cpu.cfs_period_us 的值一般固定,K8s 不会去修改,所以只修改 cpu.cfs_quota_us。

而"Request CPU"就是无论其他容器申请多少 CPU 资源,即使运行时整个节点的 CPU 都被占满的情况下,我的这个容器还是可以保证获得需要的 CPU 数目,那么这个设置具体要怎么实现呢?

在 CPU Cgroup 中 cpu.shares == 1024 表示 1 个 CPU 的比例,那么 Request CPU 的值就是 n,给 cpu.shares 的赋值对应就是 n*1024。

总结

进程的 CPU Usage 只包含用户态(us 或 ni)和内核态(sy)两部分,其他的系统 CPU 开销并不包含在进程的 CPU 使用中,而 CPU Cgroup 只是对进程的 CPU 使用做了限制。

“怎么限制容器的 CPU 使用”,这个问题背后隐藏了另一个问题,也就是容器是如何设置它的 CPU Cgroup 中参数值的?想

我们了解了 CPU Cgroup 中的主要参数: cpu.cfs_quota_us,cpu.cfs_period_us 还有 cpu.shares。

  • cpu.cfs_quota_us(一个调度周期里这个控制组被允许的运行时间)除以 cpu.cfs_period_us(用于设置调度周期)得到的这个值决定了 CPU Cgroup 每个控制组中 CPU 使用的上限值。
  • cpu.shares 决定了 CPU Cgroup 子系统下控制组可用 CPU 的相对比例,当系统 CPU 完全被占满时,这个比例才会在各个控制组间起效。