目录

容器 | Cgroup-CPU Cgroup

1. CPU Cgroup

CPU 控制器(子系统)主要用于控制一个控制组中所有任务可以使用的 CPU 时间片,读取与 CPU 相关的数据则依赖于另外一个子系统——cpuacct 子系统,在 cgroups 中这两个子系统通常会一起出现。CPU 控制器有基于 Linux CFS 调度器和实时调度器的两种实现,这里主要介绍前者的实现。

每个进程的 CPU 使用,它只包含两部分:一个是用户态,这里包括了 us、ni;另一部分是内核态的 sy。而 wa、hi、si,这些 IO 或者中断相关的 CPU 使用,不包含在进程的 CPU 使用中。CPU Cgroup 只对进程的 CPU 使用做限制。

CFS 中 CPU Cgroup(CFS 是 Linux 中大部分进程使用的调度算法)用到的参数,有以下这三个:

  • cpu.cfs_period_us,它是 CFS 算法的一个调度周期,一般它的值是 100000,以 microseconds 为单位,也就 100ms。

  • cpu.cfs_quota_us,它表示 CFS 算法中,在一个调度周期里这个控制组被允许的运行时间,它除以 cpu.cfs_period_us(用于设置调度周期)**得到的这个值决定了 CPU Cgroup 每个控制组中 CPU 使用的上限值。**这里的单位为微秒。另外,在多核系统中,配额最大值是可以大于周期长度的,这是由于一个控制组内的所有任务共享同一个配额,而一个控制组内的不同任务可以同时运行在不同的 CPU 上

    比如 cpu.cfs_quota_us 值为 50000 时,就是 50ms。如果用这个值去除以调度周期(也就是 cpu.cfs_period_us),50ms/100ms = 0.5,这样这个控制组被允许使用的 CPU 最大配额就是 0.5 个 CPU。如果这个值是 200000,也就是 200ms,那么它除以 period,也就是 200ms/100ms=2,这就意味着这时控制组需要 2 个 CPU 的资源配额。

    在每个周期内,一个控制组被分配了一定的 CPU 时间配额,当这个控制组的任何任务变为运行状态时,这个控制组的配额会随之减少。一旦该控制组用完了配额之后,其组内的任务都无法再被调度,直到下一个周期开始,所有控制组的配额被重置之后,这个控制组的任务才能继续运行。

    这个值对应了 Pod 中的 Limit CPU。kubernetes 不会去修改 cpu.cfs_period_us,它一般是一个固定值,所以改变 cpu.cfs_quota_us 的值即可。

  • cpu.shares。这个值决定了当系统上 CPU 完全被占满的时候,CPU Cgroup 子系统下控制组可用 CPU 的相对比例。需要注意的是:只有在整个节点中所有的 CPU 都跑满的时候,它才能发挥作用。它的缺省值是 1024,而一般 1024 表示一个 CPU。在容器中,这个值设置为了 1024,理论上应该可以得到 1 个 CPU,但是实际需要综合其他 Cgroup 的设置,可能达不到 1 个 CPU。比如,有两个控制组 A 和 B,A 的 shares 值为 300,B 的 shares 值为 100,那么当 A 和 B 都在满负载运行时,A 能够获得 75% 的 CPU 时间,B 能够获得 25% 的 CPU 时间。当控制组 A 没有任务需要 CPU 时,控制组 B 能够使用超过 25% 的 CPU 时间,但是不会超过 cfs_quota_us 设置的最大值。

    这个值对应 Pod 中的 Request CPU,它在 Pod 中相当于下限值。因为对于 Pod 来说,设置了 Request CPU 之后,如果节点上的 CPU 不满足 Pod 的 Request CPU 的要求,那么将不会被调度到该节点。比如节点上的 CPU 是 4 个,那么 kubernetes 会记录节点上 pod 的 request cpu 总数,如果为 3.5 个(这些 pod 都规范设置,遵循 1024 表示一个 cpu),而一个 pod 的 request cpu 为 1,那么它不会被调度到该节点上,从而确保了使用的下限。

  • cpu.stat。该文件为只读文件,保存了 CPU 时间相关的统计数据。

综上我们来举个例子,假如 CPU 的周期长度为 1s,控制组2 分配到的 CPU 时间比例是 716.8/(307.2+716.8+1024)=35%,也就是 0.35s,使用最大值是 0.4s。因此,在 CPU 空闲的情况下,控制组2 即使 CPU 时间比例用完了仍然可以继续运行。整个过程需要使用 CFS 调度器对控制组内的任务进行组级别层面的调度,在 CPU 资源紧张的时候 CFS 会调度 CPU 时间比例的控制组中的任务。如下图所示,控制组1 因为 CPU 时间比例已经耗尽,所以在下个周期来之前所有任务都将不会被调度,而控制组2 中的任务将会被调度执行。

https://img.dawnguo.cn/Container/image-20210224004341085.png

可以使用下面的代码,结合 cgroup 来进行实验。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#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;
}

可以参考:极客专栏《容器实战高手课》中,关于这个 cpu cgroup 的实验。

1.1. 内核实现

通过向 cpu.cfs_quota_us 文件写入一个值可以修改一个控制组的 CPU 时间配额,那么具体而言,设置的配额该如何生效呢?在“/kernel/sched/core.c’’ 中有如下这样的一段代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
static struct cftype cpu_files[] = {
#ifdef CONFIG_FAIR_GROUP_SCHED
    {   
        .name = "shares",
        .read_u64 = cpu_shares_read_u64,
        .write_u64 = cpu_shares_write_u64,
    },  
#endif
#ifdef CONFIG_CFS_BANDWIDTH
    {   
        .name = "cfs_quota_us",
        .read_s64 = cpu_cfs_quota_read_s64,
        .write_s64 = cpu_cfs_quota_write_s64,
    },  
    {   
        .name = "cfs_period_us",
        .read_u64 = cpu_cfs_period_read_u64,
        .write_u64 = cpu_cfs_period_write_u64,
    },  
}

当我们向 cpu.cfs_quota_us 文件中写入一个值时,cgroups 会调用 write_s64,也就是会调用 cpu_cfs_quota_write_s64 函数。而这个函数最后会调用到函数tg_set_cfs_banwidth,这个函数最终会修改 task_group 中的 cfs_bandwidth 结构体中的 period 和 quota。

CFS 调度器中除了按照任务为调度实体进行调度外,CFS 调度器还提供了按组调度的功能,也就是以task_group(任务组)为调度实体。cfs_bandwidth是task_group结构体中的一个域,其中包含了这个调度实体(也就是任务组)所剩下的运行时间runtime。系统会启动一个高精度定时器,每隔 period 将cfs_bandwidth中的runtime重置为 quota(其中quotaperiod 是我们自己配置的,配置过程就是下面这样的代码,过程前所述)。

这也是为什么 Linux 调度是针对调度实体进行调度的原因,因为 Linux 调度的对象有两类:任务(task)和任务组(task_group)。调度实体相当于对任务和任务组的一个抽象。

1
2
3
4
5
6
7
8
static int cpu_cfs_quota_write_s64(struct cgroup_subsys_state *css, struct cftype *cftype, s64 cfs_quota_us) { 		return tg_set_cfs_quota(css_tg(css), cfs_quota_us);
}

static int tg_set_cfs_quota(struct task_group *tg, long cfs_quota_us) {
	u64 quota, period;
  ...// 省略给 quota 和 period 赋值的代码
	return tg_set_cfs_bandwidth(tg, period, quota);
}

当调度组中的任务需要进行调度时,它会先以调度组的运行队列 cfs_rq 的身份向调度组申请一个时间片,调度组会从 runtime 中给它分配一段时间。如果runtime 不够用了,调度组会将这个调度实体从调度组的运行队列中移除(dequeue_entity)。在下一个周期 runtime 被重置之后,也就是有了足够的时间片之后,调度组会将之前出列的调度实体重新加入原本的运行队列(enqueue_entity)。通过这种办法,我们就实现了精确控制一个控制组内所有任务使用的 CPU 时间。

这里的调度组个人觉得就是任务组,也就是控制组。

另外,关于 CPU 子系统的实现,可以搜关键字“组调度”、“带宽控制”。

修改 cpu.shares 的原理与前面修改 cpu.cfs_quota_us 的原理相同,最后将这个值更新到了该 task_group 中每一个调度实体的 load.weight 中(即调度的权重)。根据 CFS 调度原理,load.weight 越大,vruntime 就越小,也就意味着这个调度实体能够使用的 CPU 时间越多。

2. CPU Cgroup 相关问题

2.1. 为什么在容器中运行 top 命令不能得到容器中总的 CPU 使用率

对于系统总的 CPU 使用率,需要读取 /proc/stat 文件,但是这个文件中的各项 CPU ticks 是反映整个节点的,并且这个 /proc/stat 文件也不包含在任意一个 Namespace 里,所以每个容器中拿到的都是一样。因此,对于在容器中使用 top 命令来说,它只能显示整个节点中各项 CPU 的使用率,不能显示单个容器的各项 CPU 的使用率。

那么怎么得到呢?

每个容器都会有一个 CPU Cgroup 的控制组。在这个控制组目录下面有很多参数文件,有的参数可以决定这个控制组里最大的 CPU 可使用率外,除了它们之外,目录下面还有一个可读项 cpuacct.stat。这里包含了两个统计值,这两个值分别是这个控制组里所有进程的内核态 ticks 和用户态的 ticks。

那么,可以使用计算进程 CPU 使用率的方式去计算整个容器中总的 CPU 使用率,CPU 使用率 =((utime_2 – utime_1) + (stime_2 – stime_1)) * 100.0 / (HZ * et * 1 )。

比如,下面这个容器中它的 CPU 使用率就是 ( (174021 - 173820) + (4 – 4)) * 100.0 / (100 * 1 * 1) = 201, 也就是 201%。

https://img.dawnguo.cn/Container/fdcfd5yy6e593cccea1bed7bfe4e5095.png

为容器构造出一个 /proc 文件系统

https://github.com/lxc/lxcfs, lxcfs可以为每个容器虚拟一些/proc下的文件,比如/proc/stat

2.2. 为什么对容器已经用 CPU Cgroup 限制了它的 CPU Usage,容器里的进程还是可以造成整个系统很高的 Load Average

CPU Cgroup 可以限制进程的 CPU 资源使用,但是 CPU Cgroup 对容器的资源限制是存在盲点的。就是无法通过 CPU Cgroup 来控制 Load Average 的平均负载。而没有这个限制,就会影响我们系统资源的合理调度,很可能导致我们的系统变得很慢。

这是因为 Linux 下的 Load Averge 不仅仅计算了 CPU Usage 的部分,但是它计算了系统中 TASK_UNINTERRUPTIBLE 状态的进程数目。因为容器里的进程总的 CPU Usage 受到了限制,但是它还是要有那么多进程要执行,也就是处于可运行队列中,所以可能会导致整个系统很高的 Load Average。

目前 D 状态进程引起的容器中进程性能下降问题,Cgroups 还不能解决。而 CPU Cgroups 不能解决这个问题,是因为 Cgroups 更多的是以进程为单位进行隔离,而 D 状态进程是内核中系统全局资源引入的,所以 Cgroups 影响不了它。那么我们可以做的是,在生产环境中监控容器的宿主机节点里 D 状态的进程数量,然后对 D 状态进程数目异常的节点进行分析,比如磁盘硬件出现问题引起 D 状态进程数目增加,这时就需要更换硬盘。(对 D 状态进程进行监控很重要,因为这是通用系统性能的监控方法)

https://github.com/chengyli/training/tree/main/cpu/load_average/uninterruptable/kmod kernel module 的一个例子。

巨人的肩膀

  1. 极客时间.《容器实战》