探究 rootless containers 下通过 systemd 配置 cgroup v2

前言

笔者在上一篇博文《探索 Rootless Containers:应用于 OJ 的下一代技术》中提到,我们现在已经可以通过 umoci、runc 这样的工具以一个普通用户的权限创建并运行容器,这种技术被称为 Rootless Containers。我们也可以使用 seccomp 和 rlimit 对容器进程做出一定程度的限制,如限制部分系统调用、限制打开的文件数量等。

不过,我们还没有扫清 Rootless Containers 应用于 OJ 这一课题的最后一个障碍:cgroup。容器技术需要的许多重要的限制功能其实都由 cgroup 提供,例如 cpuset、io 限制等。并且,cgroup 能够提供 oom kill 功能,停止超出内存限制的进程;cgroup 还能统计进程占用的 cpu、内存等信息。这些对于 OJ 而言都是相当重要的功能。

本文将介绍如何在以一个普通用户的权限创建使用 Linux 容器的情况下,通过 systemd 配置 cgroup v2。

cgroup v1

截至 2023 年 5 月,大多数的新的 Linux 发行版已经不再默认开启 cgroup v1 了,笔者认为它已经是一个过时的技术了,现有的程序应当尽快迁移至 cgroup v2。以下是笔者总结的一些 cgroup v1 的缺陷。

需要 root 权限

cgroup v1 起初是提供给 root 使用的,普通用户使用 cgroup v1 需要经过某种委托机制,即由一个拥有 root 权限的服务去代替普通用户和 cgroup 进行交互,但这存在安全风险,所以在 Rootless Containers 的定义里不包含 cgroup v1,只能使用 cgroup v2

另外,如果跑在 Kubernetes 里的应用要使用 cgroup v1,则一般需要在 Pod 里挂载 cgroup 文件夹,还要给容器赋予足够的权限,这样来看是非常不安全的。

实现不统一

cgroup v1 因为各种历史原因,目前的实现很不统一,各种 controller 的开发工作没有很好地协调,导致 controller 之间存在许多不一致性。同时,也因为这种不统一的实现,用户使用 cgroup v1 也很麻烦,例如要限制某个进程的 cpu、内存、io 使用,需要分别到三个 controller 的目录下进行交互。

因此,cgroup v2 就被提了出来,拥有更统一、方便使用的 API。虽然因为兼容性考虑 cgroup v1 可能会一直留在内核里,但 cgroup v2 已经开始在各大发行版中取代了 cgroup v1 的地位。例如:Ubuntu 从最近的 21.10 版本开始就默认只开启 cgroup v2,禁用 cgroup v1。

复杂性

在 cgroup v1 中,一个进程的不同线程可以分别属于不同的 cgroup,很多人认为这个设计没什么用,还白白添加了复杂性。更糟糕的是,这给 cgroup v1 自身挖了许多坑,比如 memory controller(即管理内存限制的 controller)不能正常工作了,因为不同线程属于不同 cgroup,分属不同 memory controller 管辖,但是它们却又共享同一个进程的内存空间,这种情况下 memory controller 又该怎么工作呢?

在 cgroup v2 里人们去掉了这种设计。一个进程的所有线程只能属于同一个 cgroup(虽然后来又在 threaded mode 里加了回来,不过加上了很多限制)。在笔者看来,当前新开发的应用已经没有必要考虑 cgroup v1 了,cgroup v2 拥有更加统一、易用、符合直觉的 API 设计,并且已经开始取代 cgroup v1。

cgroup v2

cgroup v2 和 cgroup v1 的具体差别请读者自行查阅资料。根据 man7 文档,cgroup v2 清楚地定义了如何向普通用户提供 cgroup v2 交互能力,粗略来说就是:

  1. 创建一个子文件夹作为 sub-cgroup

  2. 在这个子文件夹的父文件夹(也是一个 cgroup)里,修改 cgroup.subtree_control 文件来控制 sub-cgroup 能使用的 controller 种类。

  3. 通过 chown 给普通用户来让他能够使用这个 sub-cgroup。

在目前的许多发行版中,/sys/fs/cgroup 已经自动被 systemd 挂载。但 systemd 做的时期可不仅仅是帮你 mount -t cgroup2 这么简单,它做的事情是让自己“拥有”这个 cgroup v2。换句话说,任何想要使用 cgroup v2 的进程都需要经过 systemd 的管理。

你不能在 /sys/fs/cgroup 里直接使用上面 man7 提到的方法,因为 systemd 的文档这么警告你:

Specifically: 🔥 don’t create your own cgroups below the root cgroup 🔥. That’s owned by systemd, and you will step on systemd’s toes if you ignore that, and systemd will step on yours. Get your own delegated sub-tree, you may create as many cgroups there as you like. Seriously, if you create cgroups directly in the cgroup root, then all you do is ask for trouble.

我们可以通过下列方法让 systemd 给普通用户创建一个 sub-cgroup:

$ sudo mkdir -p /etc/systemd/system/user@.service.d
$ cat <<EOF | sudo tee /etc/systemd/system/user@.service.d/delegate.conf
[Service]
Delegate=cpu cpuset io memory pids
EOF
$ sudo systemctl daemon-reload

这样,systemd 就会创建一个类似于 /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service 的文件夹,这个文件夹的所有者是普通用户,对应着一个完全由普通用户控制的 cgroup。我们可以在这个文件夹里随意创建新的文件夹(也就是 sub-cgroup)来限制进程。可以通过读取 memory.statcpu.stat 文件来获得进程使用的内存和 cpu 信息。

不过,因为不知道 systemd 具体给当前用户分配了哪个文件夹,所以一般情况下应用应该调用 DBus 来创建一个 transient unit,告诉 systemd 在当前用户的 cgroup 里给应用开一个新的 sub-cgroup。在 transient unit 里我们需要指定 [Delegate] 参数才能告诉 systemd 这个 sub-cgroup 交给我们的应用自己管理,具体可见文档

runc 与 libcontainer

笔者已经在前一篇文章《探索 Rootless Containers:应用于 OJ 的下一代技术》中介绍了如何使用 runc 创建 rootless container。但没有介绍 cgroup v2 的配置。接下来笔者结合上面对 cgroup v2 的讨论,介绍如何让 runc 得以利用 cgroup。

有一点需要提前说明:在这种场景下,和 runc 交互的最好方法应该是在 go 程序里进行调用,而不是通过容器的 json 文件。因为 runc 和它内部的 libcontainer 有很多非常实用的 API 并没有通过 json 接口暴露。

runc 的 libcontainer 提供了一个 systemdManager,支持在 rootless 情景下利用 systemd 创建 cgroup v2 文件夹。但是它有一个缺陷:当容器进程退出之后,systemd 会自动清理创建出来的 cgroup,导致我们的应用无法收集 cpu、内存占用等信息。

runc 源码 中可以看到,它是通过 DBus 创建了一个 transient scope 来让 systemd 为创建的容器进程开一个 cgroup。问题的关键在于,依据 systemd 对 transient scope 的定义

The .scope unit type. This is very similar to .service. The main difference: the processes the units of this type encapsulate are forked off by some unrelated manager process, and that manager asked systemd to expose them as a unit.

Transient scope 代表的容器进程是一种由其他进程(也就是调用 runc 的我们的应用)创建的子进程,因此在子进程退出之后应该自动清理掉。对于 runc 本身来说这没有什么问题,但是对于我们的应用来说,自动清理就意味着无法知道容器运行结束之后它到底占用了多少 cpu 时间、占用了多少内存等信息。而且 runc 的 systemdManager 的参数非常不人性化。它会在配置 cgroup 失败之后默默地打印一行 warning 然后继续运行容器,忽略用户给定的 cgroup 配置参数。在 OJ 的场景下这个行为是无法接受的,不能出现有些用户的提交的代码可以受到限制而有些不受限制的情况。

因此,我们只好使用 runc 为 rootless 模式提供的另一个实现:fsManager。这个实现做的事情就是直接读写给定的 cgroup 文件夹来为容器初始化相关设定,而且它不会在容器停止之后就马上删掉文件夹。至于如何给 runc 提供 cgroup 文件夹则需要我们另辟蹊径了。

其实 runc 调用 systemd 的实现最终也是用到了这个 fsManager。它是先调用 systemd 申请到一个 cgroup,再输入给这个 fsManager 来初始化各种设定。

尝试

经过上面的研究,笔者马上想到了一个解决的办法:应用首先调用 DBus,给自己的进程创建一个 transient scope,这样就能开一个 sub-cgroup 文件夹给 runc 使用了。

调用 DBus 的相关代码可以参考 runc 的源码。对于 Debian 系发行版,需要安装 dbus-user-session 包来开启相关功能。

然而,笔者试了一下并不能成功,详细的原因可以见我发的 issue。简单来说,runc 为了尝试开启所有的 cgroup controller,会迭代地向输入给 runc 的 cgroup 的所有祖先 cgroup 的 cgroup.subtree_control 里写入所有类型的 controller。

但是 cgroup v2 里有一条坑爹的设定:

A domain cgroup is turned into a threaded domain when one of its child cgroup becomes threaded or threaded controllers are enabled in the "cgroup.subtree_control" file while there are processes in the cgroup. A threaded domain reverts to a normal domain when the conditions clear.

简单来说,cgroup v2 的树形结构中只有叶子节点才能包含进程,而内部节点不能包含进程。这有一个例外:threaded mode 的内部节点可以包含进程(或者说线程,见 cgroup.threads),但它的叶子节点也只能是 threaded mode。注意 threaded mode 仍可使用 cpu controller,但无法使用 memory controller 等。这个模式下的 cgroup 控制的最小单位从进程变成了线程,所以 memory controller 和 io controller 也无法使用了(原因比较明了:怎么可以限制线程的内存占用呢?)。因此,笔者的目的不能完成了,不能拿到内存占用数据的 OJ 还有什么意义呢。

如果一个 cgroup 已经包含了一个进程(比如上面笔者应用的进程),那么 runc 在开启 cpu controller 的时候会被 cgroup 以为是要开启 threaded mode,导致笔者应用自己的 cgroup 和传给 runc 的 sub-cgroup 全部变成 threaded mode。事实上这就是 manpage 中提到的 “The second way of creating a threaded subtree”。

解决方案

经过反复阅读 systemd 和 cgroup 的相关文档,我向 systemd 仓库发表了一个 issue 寻求帮助并提出了一些见解。在维护者 Lennart Poettering 的帮助下我得到了下面的解决办法:

  1. 应用调用 DBus,为当前的应用进程创建一个 transient scope。systemd 保证创建出来的 cgroup 可以被当前用户(普通用户)读写。这一步是为了确保 runc 创建的容器进程依然能够被当前用户的 cgroup 管辖。否则它默认会被 root cgroup(也就是 systemd 直接管理的那个顶层 cgroup)管辖,当前用户(普通用户)是没有权限的。
  2. 在创建的父 cgroup 中建立一个子 cgroup,称为 app.scope(此命名风格不是必要的,但符合 systemd 的语义)。向后者的 cgroup.procs 中写入当前进程的 ID,即将当前进程移到 app.scope 中。
  3. 在创建的父 cgroup 中建立一个子 cgroup,称为 container.slice(此命名风格不是必要的,但符合 systemd 的语义)。将这个 cgroup 路径传给 runc,并启用它的 fsManager。此时 runc 会尝试在 container.slice 中建立子 cgroup,并将创建的容器放置在其中进行管理。
  4. 我们已经可以在容器结束之后调用 runc 相关的 API 来获得 cgroup 的相关信息,比如 cpu 时间(包括用户态时间、内核态时间)、内存占用(包括 swap)等。

上面省略了在父 cgroup 中配置 cgroup.subtree_control 和在子 cgroup 中开启相关 controller。

经过测试,上述方法成功解决了问题。笔者的应用现在可以以一个普通用户的权限做到以下事情:

  • 创建并运行容器
  • 配置 cgroup 来限制容器的资源占用
  • 容器结束后读取 cgroup 文件获得各种占用数据

感兴趣的读者可以参考笔者的项目 Seele 中的实现,它做的事情和上面类似,同样是先将当前进程移动到 app.scope 中。不过,它还额外将进程的所有线程分配到了 app.scope 下的不同子 cgroup 中,用以配置 cpu controller 进行绑核。而进程创建的 container.slice 的路径则会被传给 runj(笔者包装的 runc),用来管理创建容器的各种资源。