查看原文
其他

Linux内核:容器底层cgroup如何使用

姜亚华 开源中国 2020-09-02

在前面的文章中,我们探讨了容器底层 cgroup 的数据结构与代码实现,本期是 cgroup 系列的最后一篇文章,我们将继续探讨在 mount 成功后,我们如何使用 cgroup 来实现进程限制。

在 mount 成功后,cgroup_root 已经存在了,也就是说 cgroup 层级结构已经搭建好了,接下来我们就可以使用 cgroup 了。

测试环境版本与之前一致:


1. cgroup 的 mkdir

mkdir 比 mount 的过程稍简单,由 cgroup_mkdir 函数实现,主要逻辑如下:

int cgroup_mkdir(struct kernfs_node *parent_kn, const char *name, umode_t mode)
{
struct cgroup *parent, *cgrp;


parent = cgroup_kn_lock_live(parent_kn, false); //1


cgrp = cgroup_create(parent, name, mode); //2


ret = cgroup_kn_set_ugid(cgrp->kn);


ret = css_populate_dir(&cgrp->self); //3
ret = cgroup_apply_control_enable(cgrp);
kernfs_activate(cgrp->kn);


ret = 0;
return ret;

第 1 步,获得父目录对应的 cgroup。无论是 cgroup_setup_root 还是接下来要说的cgroup_create,在创建文件的时候都将 cgroup 赋值给了 kernfs_node 的 priv。所以这里其实就是返回 parent_kn->priv 字段,不过要经过参数检查。

第 2 步,调用 cgroup_create:创建 cgroup,调用 kernfs_create_dir 创建目录,建立新cgroup 和父 cgroup 的父子关系。

第 3 步,和 mount 的时候一样,css_populate_dir 和 cgroup_apply_control_enable 会为我们创建 cftype 对应的文件,不过有两点区别:

首先,带 CFTYPE_ONLY_ON_ROOT 标志的 cftype 不会出现在这里,比如cgroup.sane_behavior 和 release_agent。

其次,mount 的时候,新 cgroup_root.cgrp 复用了原 cgroup_root.cgrp 相关的css(rebind_subsystems,第二篇),这里新建了一个 cgroup,cgroup_apply_control_enable 需要为我们创建新的 css(ss->css_alloc(parent_css))并建立 cgroup 和 ss 的多对多关系(init_and_link_css和online_css)。 

mount 的时候,cpuset 的 css_alloc 返回的是全局的 top_cpuset.css,这里创建一个新的 cpuset 对象并初始化,如下:

struct cgroup_subsys_state *
cpuset_css_alloc(struct cgroup_subsys_state *parent_css)
{
struct cpuset *cs;

 if (!parent_css)    //mount的时候,返回top_cpuset.css
  return &top_cpuset.css;

 cs = kzalloc(sizeof(*cs), GFP_KERNEL);
alloc_cpumasks(cs, NULL);    //#1

 set_bit(CS_SCHED_LOAD_BALANCE, &cs->flags);
nodes_clear(cs->mems_allowed);
nodes_clear(cs->effective_mems);    //#2
fmeter_init(&cs->fmeter);
cs->relax_domain_level = -1;

 return &cs->css;

注意标号 #1 和 #2,新 cs 的 cpus_allowed 和 mems_allowed 都被清零,此时读取cpuset.cpus 和 cpuset.mems 也是没有内容的,也就是说对 cpu 和 memory 的限制并不能从父目录继承,在使用前必须正确设置它们。

2. 限制资源

我们在第一篇的例子中通过 echo 0-2 > cpuset.cpus 和 echo 0 > cpuset.mems 限制 /cpuset0 管理的进程使用的 cpu 和 memory node,以 cpuset.cpus 为例,它的 cftype 如下:

{
  .name = "cpus",
  .seq_show = cpuset_common_seq_show,
  .write = cpuset_write_resmask,
  .max_write_len = (100U + 6 * NR_CPUS),
  .private = FILE_CPULIST,
},

最终调用的是 cpuset_write_resmask,后者调用 update_cpumask。

update_cpumask 的目的是更新我们在 mkdir 时创建的 cpuset(cpuset_css_alloc),当然了,之前已经配置过的 cpuset 重新配置也可以。我们关心以下几点:

  1.  不能更改top_cpuset的设置,这就是第一篇的课堂作业第一题的答案。

  2. 目标cpuset的资源必须是父目录cpuset的子集,而且是子目录cpuset的超集(由validate_change函数实现),这是课堂作业第二题的答案。

  3. 配置的资源最终更新cpuset的cpus_allowed字段。

可以看到,类似课堂作业中描述的类似限制,是需要 ss 自行实现的,cgroup 本身并不保证这点,尝试开发新的 ss 的时候需要注意这点。

3. 管理进程

我们在例子中将进程号写到 tasks 文件(echo $$ > tasks),以限制进程只能使用 /cpuset0 配置的 cpu 和 memory node。实际上,写 cgroup.procs 文件也是可以的。它们的 cftype 文件定义如下:

{
  .name = "tasks",
  .seq_start = cgroup_pidlist_start,
  .seq_next = cgroup_pidlist_next,
  .seq_stop = cgroup_pidlist_stop,
  .seq_show = cgroup_pidlist_show,
  .private = CGROUP_FILE_TASKS,
  .write = cgroup1_tasks_write,
},
{
  .name = "cgroup.procs",
  .seq_start = cgroup_pidlist_start,
  .seq_next = cgroup_pidlist_next,
  .seq_stop = cgroup_pidlist_stop,
  .seq_show = cgroup_pidlist_show,
  .private = CGROUP_FILE_PROCS,
  .write = cgroup1_procs_write,
},

两个文件的 write 分别是 cgroup1_tasks_write 和 cgroup1_procs_write,它们都是调用__cgroup1_procs_write 实现的,区别仅在于最后一个参数 threadgroup 不同,前者为false,后者为 true。看名字就知道,为 false 的情况下,仅作用于目标进程(线程),为 true 的情况下,作用于线程组。

这里对线程组稍作说明。线程组是属于同一个进程的线程的集合,同一个线程组的线程,它们的 task_struct 都通过 thread_group 字段链接到同一个链表中,链表的头为线程组领导进程的 task_struct 的 thread_group 字段,可以据此来遍历线程组。

__cgroup1_procs_write 可以分成以下 3 步:

第1步,调用 cgroup_kn_lock_live 获得文件所在的目录的 cgroup,实际上就是kernfs_node->parent->priv,kernfs_node->parent 是文件所在目录的 kernfs_node,priv 就是目标 cgroup。

第2步,调用 cgroup_procs_write_start 根据用户空间传递的进程 id 参数获得目标进程的 task_struct,threadgroup 为 true 的情况下,获得的是线程组领导进程的task_struct。

第3步,调用 cgroup_attach_task 将进程 attach 到(依附于或者连接)cgroup。

cgroup 和 ss 之间是对等的关系,使用的是 bind,称之为绑定;进程和 cgroup 之间并不是对等的关系,使用的是 attach,称之为依附。 

请注意,我们举例中仅涉及 cpuset,并不意味着某个进程只与 cpuset 有关,进程和cgroup 的关系是通过 css_set 实现的,也就是说是一组 cgroup。我们没有更改其他cgroup 层级结构的配置,这意味着进程关联的是它们的 cgroup_root,并不是没有关联。

先不论进程被创建后,“辗转”了几组cgroup,进程被创建时就已经attach cgroup了。

进程创建的过程在书里已经详细地分析过了,这里仅讨论与cgroup相关的部分。

首先被调用的是cgroup_fork,如下:

void cgroup_fork(struct task_struct *child)
{
RCU_INIT_POINTER(child->cgroups, &init_css_set);
INIT_LIST_HEAD(&child->cg_list);
}

直接指向了 init_css_set,不过这有可能是暂时的。child->cg_list 是空的,说明新进程还没有 attach 到任何 cgroup。

其次是 cgroup_can_fork,它调用 ss->can_fork,由 ss 判断是否可以创建新进程,如果答案是否,整个 fork 会失败。

最后是 cgroup_post_fork,做最后的调整,主要逻辑如下:

void cgroup_post_fork(struct task_struct *child)
{
struct cgroup_subsys *ss;
struct css_set *cset;

 if (likely(child->pid)) {
  WARN_ON_ONCE(!list_empty(&child->cg_list));
  cset = task_css_set(current); /* current is @child's parent */
  get_css_set(cset);
  cset->nr_tasks++;
  css_set_move_task(child, NULL, cset, false);
}

 do_each_subsys_mask(ss, i, have_fork_callback) {
  ss->fork(child);
} while_each_subsys_mask();
}

首先,current 是新进程 child 的父进程,先获得父进程的 css,然后调用css_set_move_task 将新进程转移到该 css 上。css_set_move_task 的第二个参数是原css,这里是 NULL 是因为还没有 attach 到任何 cgroup(css_set)上。css_set_move_task 会将 child->cg_list 插入 css->tasks 链表上,child->cg_list 不再为空。

也就是说,新进程在创建时会被 attach 到与父进程同一组 cgroup 上。

其次,如果 ss 定义了 fork,调用 ss->fork,以 cpuset 为例,它会为新进程复制父进程的设置,如下:

void cpuset_fork(struct task_struct *task)
{
if (task_css_is_root(task, cpuset_cgrp_id))
  return;

 set_cpus_allowed_ptr(task, current->cpus_ptr);
task->mems_allowed = current->mems_allowed;
}

回顾下第一篇的例子,我们在 cpuset 下创建的 cpuset0 目录,配置资源,管理进程。修改下,在 cpuset 下再创建一个 cpuset1 目录,进程先 attach 到 /cpuset0,然后migrate 到 /cpuset1上,以此为例分析 migrate 的过程:

love_cc@yahua:/sys/fs/cgroup/cpuset$ sudo mkdir cpuset0
love_cc@yahua:/sys/fs/cgroup/cpuset$ sudo mkdir cpuset1
love_cc@yahua:/sys/fs/cgroup/cpuset$ cd cpuset0/
root@yahua:/sys/fs/cgroup/cpuset/cpuset0# echo 0-2 > cpuset.cpus
root@yahua:/sys/fs/cgroup/cpuset/cpuset0# echo 0 > cpuset.mems
root@yahua:/sys/fs/cgroup/cpuset/cpuset0# echo $$ > tasks
root@yahua:/sys/fs/cgroup/cpuset/cpuset0# cat tasks
2682
2690
root@yahua:/sys/fs/cgroup/cpuset/cpuset0# cd ../cpuset1/
root@yahua:/sys/fs/cgroup/cpuset/cpuset1# echo 0-1 > cpuset.cpus
root@yahua:/sys/fs/cgroup/cpuset/cpuset1# echo 0 > cpuset.mems
root@yahua:/sys/fs/cgroup/cpuset/cpuset1# echo $$ > tasks
root@yahua:/sys/fs/cgroup/cpuset/cpuset1# cat tasks
2682
2713
root@yahua:/sys/fs/cgroup/cpuset/cpuset1# cat ../cpuset0/tasks
#没有省略内容,空的

继续讨论之前,先理一下目前的状况,我们在 __cgroup1_procs_write 函数的第 3 步cgroup_attach_task,之前的两步我们已经获得了目标 cgroup(也就是 /cpuset1)和进程的 task_struct。

cgroup_attach_task 的目的是将进程 attach 到目标 cgroup,逻辑上至少包括进程和原group detach 和进程和目标 cgroup attach 两部分。三个要素,src、dst 和 migrate,正好对应三个函数 cgroup_migrate_add_src、cgroup_migrate_prepare_dst 和cgroup_migrate。

首先被调用的是 cgroup_migrate_add_src,threadgroup 为 true 的情况下,对线程组的每个线程调用一次,否则调用一次即可,它的主要逻辑如下:

void cgroup_migrate_add_src(struct css_set *src_cset,
       struct cgroup *dst_cgrp,
       struct cgroup_mgctx *mgctx)
{
struct cgroup *src_cgrp;

 src_cgrp = cset_cgroup_from_root(src_cset, dst_cgrp->root);

 src_cset->mg_src_cgrp = src_cgrp;
src_cset->mg_dst_cgrp = dst_cgrp;
get_css_set(src_cset);
list_add_tail(&src_cset->mg_preload_node, &mgctx->preloaded_src_csets);

第一个参数 src_cset 表示进程原来的 css_set,也就是 task_struct 的 cgroups 字段。

首先要做的就是在目标 cgroup(dst_cgrp,也就是 /cpuset1)所属的 cgroup_root 中找到原 cgroup(src_cgrp,也就是 /cpuset0),它跟目标 cgroup 属于同一个cgroup_root,查找的过程就变成找到 src_cset 对应的某个 cgroup,它的 root 字段与dst_cgrp->root 相等,如下:

list_for_each_entry(link, &cset->cgrp_links, cgrp_link) {
  struct cgroup *c = link->cgrp;

  if (c->root == root) {
   res = c;    //res就是我们要找的
   break;
  }
}

提醒下,任何一个 cgroup 层级结构中,进程只能关联其中一个 cgroup,所以与 /cpuset1 属于同一个 cgroup_root 的只能是 /cpuset0 。

另外,我们分析的只是一种情况,前面说的 Ubuntu mount cpuset 的时候,进程从默认的层级结构迁移到 cpuset 上,原 cgroup 和目标 cgroup 实际上属于不同的cgroup_root,返回的是目标 cgroup_root 的 cgrp。

cgroup_migrate_add_src 的第三个参数 mgctx 是 cgroup_attach_task 的局部变量,函数结束前将 src_cset 插入到 mgctx->preloaded_src_csets 等待后续处理。

cgroup_migrate_prepare_dst 遍历 mgctx->preloaded_src_csets上的 src_cset,根据src_cset 和 src_cset->mg_dst_cgrp 查找当前已经存在的 css_set 是否有某个 css_set与期望一致,没有则创建新的 css_set 并赋以期望值。

期望,一致,两方面。

怎么描述我们的期望呢,进程只是从 /cpuset0 移到 /cpuset1 上,关联的其他 cgroup 层级结构的 cgroup 并没有变化,所以以原 css_set 作为模板,调整 cpuset 层级结构上的css 即可,实际的代码也大致如此,如下:

for_each_subsys(ss, i) {
  if (root->subsys_mask & (1UL << i)) {
   template[i] = cgroup_e_css_by_mask(cgrp, ss);
  } else {
   template[i] = old_cset->subsys[i];
  }
}

root 就是发生变动的层级结构的 cgroup_root,在我们的例子中就是 cpuset,至于cgroup_e_css_by_mask,这里的 e 是 effective,不考虑 cgroup v2 的情况下,也可以理解为 cgroup_css(cgrp, ss),也就是 /cpuset1 和 cpuset ss 对应的 css 。

某个 css_set(简称 cset)与我们的期望一致,需要满足以下两点。

首先,cset->subsys 与 template 一致,其实还是与 v2 有关。

其次,cset 的 css(cgrp_links字段)中,属于当前 cgroup_root 的,关联的 cgroup 是目标值,也就是 /cpuset1;不属于当前 cgroup_root 的,与 old_cset 关联的 cgroup 相等。

css_set 的 subsys 和 cgrp_links 都表示它关联的 css,二者有什么区别?subsys 在css_set 被创建后不会改变,cgrp_links 可以动态调整。比如 cgroup_setup_root 中调用的 link_css_set,修改的只是 cgrp_links。

如果找不到一致的 css_set,创建一个新的,按照要求的两点给它赋值。

接下来就是 cgroup_migrate 了,它的实现代码较多,但逻辑都是直来直去,我们就不直接分析代码了,主要分以下几步:

  1. 调用 cgroup_migrate_add_task 将需要迁移的进程放入 mgctx->tset,然后调用cgroup_migrate_execute 函数,实际的 migrate 过程由它完成。

  2. 回调有变动的 ss 的 ss->can_attach 函数,判断是否合法。

  3. 遍历需要 migrate 的进程,调用 css_set_move_task(task, from_cset, to_cset, true),进程的 css_set 得到更新。

  4. 回调有变动的 ss 的 ss->attach,migrate 正式生效。

  5. cpuset 的 attach 由 cpuset_attach 函数实现,核心逻辑如下:

cgroup_taskset_for_each(task, css, tset) {
  WARN_ON_ONCE(set_cpus_allowed_ptr(task, cpus_attach));

  cpuset_change_task_nodemask(task, &cpuset_attach_nodemask_to);
  cpuset_update_task_spread_flag(cs, task);
}

遍历进程,使 cpu 和 memory node 的限制生效。

我们分析的限制进程使用 cpu 由 set_cpus_allowed_ptr 调用 __set_cpus_allowed_ptr 实现,主要逻辑如下:

int __set_cpus_allowed_ptr(struct task_struct *p,
      const struct cpumask *new_mask, bool check)
{
const struct cpumask *cpu_valid_mask = cpu_active_mask;
unsigned int dest_cpu;
struct rq_flags rf;
struct rq *rq;

 rq = task_rq_lock(p, &rf);
update_rq_clock(rq);

 if (cpumask_equal(p->cpus_ptr, new_mask))    //1
  goto out;

 dest_cpu = cpumask_any_and(cpu_valid_mask, new_mask);    //2
if (dest_cpu >= nr_cpu_ids) {
  ret = -EINVAL;
  goto out;
}

 do_set_cpus_allowed(p, new_mask);    //3

 if (cpumask_test_cpu(task_cpu(p), new_mask))    //4
  goto out;

 if (task_running(rq, p) || p->state == TASK_WAKING) {    //5
  struct migration_arg arg = { p, dest_cpu };
  task_rq_unlock(rq, p, &rf);
  stop_one_cpu(cpu_of(rq), migration_cpu_stop, &arg);
  return 0;
} else if (task_on_rq_queued(p)) {
  rq = move_queued_task(rq, &rf, p, dest_cpu);
}
out:
task_rq_unlock(rq, p, &rf);
return ret;

}

满屏都是进程调度章节的内容,在此解释如下:

第1步,如果没有改变,直接退出。

第2步,指定的资源是否合法,如果不合法,返回错误。

第3步,do_set_cpus_allowed 会调用 p->sched_class->set_cpus_allowed 由具体的调度类实现,调度类一般会更新 task_struct 的 cpus_mask 字段。

第4步,进程当前所在的 cpu 是否在限制范围内,如果在,不需要额外处理。

第5步,进程被限制,不能使用当前所在的 cpu,如果正在运行则停止并 migrate,如果正在等待执行,移到其他 cpu 上。

cgroup v1 的讨论差不多了,绝大部分篇幅集中讨论最常用的操作,但实际上还不完整,其余操作大家可以自行继续当前的思路阅读。

作者介绍

姜亚华,《精通 Linux 内核——智能设备开发核心技术》的作者,一直从事与 Linux 内核和 Linux 编程相关的工作,研究内核代码十多年,对多数模块的细节如数家珍。曾负责华为手机 Touch、Sensor 的驱动和软件优化(包括 Mate、荣耀等系列),以及 Intel 安卓平台 Camera 和 Sensor 的驱动开发(包括 Baytrail、Cherrytrail、Cherrytrail CR、Sofia 等)。现负责 DMA、Interrupt、Semaphore 等模块的优化与验证(包括 Vega、Navi 系列和多款 APU 产品)。

推荐阅读

社区抗议LibreOffice商业化,开源就不能赚钱?

MySQL也替换了master、slave

开撕,“谷歌违反协议”

一个方案搞定模型量化到端侧部署全流程

GitHub上持续冲榜,ElasticJob重启

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存