Cgroups是什么?

查看linux man page: https://man7.org/linux/man-pages/man7/cgroups.7.html 其中对Cgrpups的描述:

  cgroups - Linux control groups

Control groups, usually referred to as cgroups, are a Linux
kernel feature which allow processes to be organized into
hierarchical groups whose usage of various types of resources can
then be limited and monitored. The kernel’s cgroup interface is
provided through a pseudo-filesystem called cgroupfs. Grouping
is implemented in the core cgroup kernel code, while resource
tracking and limits are implemented in a set of per-resource-type
subsystems (memory, CPU, and so on).

翻译过来大致是说:
cgroups是Linux内核的特性. 利用cgroups, 可以将多个进程分组, 这样这些进程使用的各种资源可以被限制及监控. 内核的cgroups接口是通过一个叫cgroupfs的伪文件系统来实现的.

小结: Cgroups可以用来限制及监控进程使用的资源.

Cgroups中的三个组件

cgroup 用于对进程分组, 分组后便于统一设置资源限制;
subsystem 用于对资源做限制及监控;

一般包含如下几项:

  • blkio 设置对块设备(比如硬盘)输入输出的访问控制
  • cpu 设置 cgroup 中进程的 CPU 被调度的策略。
  • cpuacct 可以统计 cgroup 中进程的 CPU 占用
  • cpuset 在多核机器上设置 cgroup 中进程可以使用的 CPU 和内存(此处内存仅使用于
    NUMA 架构)
  • devices 控制 cgroup 中进程对设备的访问
  • freezer 用于挂起( suspend )和恢复( resume) cgroup 中的进程
  • memory 用于控制 cgroup 中进程的内存占用
  • net_cls 用于将 cgroup 中进程产生的网络包分类,以便 Linux tc (traffic controller)可
    以根据分类区分出来自某个 cgroup 的包并做限流或监控
  • net_prio 设置 cgroup 中进程产生的网络流量的优先级
  • ns 这个 subsystem 比较特殊,它的作用是使 cgroup 中的进程在新的 Namespace fork
    新进程 (NEWNS)时,创建出一个新的 cgroup ,这个 cgroup 包含新的 Namespace中的进程

每个 subsystem 会关联到定义了相应限制的 cgroup 上,并对这个 cgroup 中的进程做相
应的限制和控制. 这些 subsystem 是逐步合并到内核中的. 可以通过lssubsys命令查看内核支持的subsystem:

$ lssubsys 
cpuset
cpu,cpuacct
memory
devices
freezer
net_cls
blkio
perf_event
hugetlb
pids
oom
hierarchy

hierarchy的功能是把cgroup 串成一个树状的结构, 一个这样的树便是一个hierarchy ,通过这树状结构, Cgroups 可以做到继承;

三个组件的相互关系

  1. 系统在创建了新的 hierarchy 之后,系统中所有的进程都会加入这个 hierarchy的cgroup根节点,这个 cgroup 根节点是 hierarchy 默认创建的;
  2. subsystem 只能附加到 hierarchy 上面;
  3. 一个hierarchy 可以附加多个 subsystem;
  4. 一个进程可以作为多个 cgroup 的成员,但是这些 cgroup 必须在不同的 hierarchy中;
  5. 一个进程 fork 出子进程时,子进程是和父进程在同一个 cgroup 中的,也可以根据需要
    将其移动到其他 cgroup 中;

下面用一张图来简单说明下(其中的Process表示进程)
Cgroups三个组件的关系

cgroup 文件系统(cgroupfs)介绍

通过前面关于Cgroups的介绍, 我们知道, Cgroups的接口, 是通过cgroupfs这个伪文件系统来实现的, 那来看看这个文件系统长啥样:

$ mkdir cgroup-test # 创建一个hierarchy挂载点
$ mount -t cgroup -o none,name=cg-root-1  cg-root-1  ./cgroup-test/ # 挂载一个hierarchy
$ mount # 查看, 发现我们的系统上多了一个名为cg-root-1, 类型为cgroup的文件系统(即cgroupfs), 挂载点为/data/test/cgroups/cgroup-test
# ...省略一些内容...
cg-root-1 on /data/test/cgroups/cgroup-test type cgroup (rw,relatime,name=cg-root-1)
$ ls ./cgroup-test/ # 挂载后我们就可以看到系统在这个目录下生成了一些默认文件
cgroup.clone_children  cgroup.procs          notify_on_release  tasks
cgroup.event_control   cgroup.sane_behavior  release_agent
# 创建子cgroup: cg-1和cg-2
$ mkdir cg-1 cg-2
# 注意cg-1和cg-2下的文件也是自动生成的, 相当于继承了父cgroup(cgroup-test)的一些设置
$ tree
.
├── cg-1
│   ├── cgroup.clone_children
│   ├── cgroup.event_control
│   ├── cgroup.procs
│   ├── notify_on_release
│   └── tasks
├── cg-2
│   ├── cgroup.clone_children
│   ├── cgroup.event_control
│   ├── cgroup.procs
│   ├── notify_on_release
│   └── tasks
├── cgroup.clone_children
├── cgroup.event_control
├── cgroup.procs
├── cgroup.sane_behavior
├── notify_on_release
├── release_agent
└── tasks

注意, cgroup-test, cg-1, cg-2下面里边的文件都是自动生成的! 这些文件分别表示:

  1. cgroup.clone_children, cpuset subsystem 会读取这个配置文件,如果这个值是1(默认是0),子cgroup 才会继承父 cgroup cpuset 的配置;
  2. cgroup.procs 是hierarchy树中当前cgroup节点中的进程组 ID ,当前cgroup的位置是在hierarchy树的根节点,这个文件中会有现在系统中所有进程组的 ID
  3. notify_on _releaserelease agent 会一起使用。 notify_on_release 标识当这个 cgroup最后一个进程退出的时候是否执行了 release_agent; release_ agent 则是 个路径,通常用作进程退出之后自动清理掉不再使用的 cgroup;
  4. tasks 标识 cgroup 下面的进程 ID ,如果把 个进程 ID 写到 tasks 文件中,便会将相应的进程加入到这个 cgroup; 注意: tasks有时也区分线程还是进程id, 具体看这里: https://man7.org/linux/man-pages/man7/cgroups.7.html#CGROUPS_VERSION_2_THREAD_MODE

Cgroups使用示例

如何使用Cgroups来限制进程使用的资源呢? 一起来看看

在cgroup中添加和移动进程

使用上面创建好的hierarchy(cgroup-test/{cg-1, cg-2}).
我们知道, tasks文件中标识cgroup下面的进程ID, 那么将进程加入到cgroup中也很简单, 就是将进程的id写入到tasks文件中就行了.

$ cd cg-1 # 进入cgroup: cg-1
$ echo $$ # 查看当前shell进程id
1572
$ echo $$ > tasks # 将当前shell进程加入到cgroup: cg-1
$ cat /proc/$$/cgroup # 查看当前进程加入的cgroup
21:name=cg-root-1:/
# ... 省略一些内容

$ cd ../cg-2/
$ echo $$ > tasks # 将当前shell进程移动到cgroup: cg-2中
$ cat /proc/$$/cgroup 
21:name=cg-root-1:/cg-2 # 注意这里, 只有cg-2

上述示例: 将当前shell进程加入到cg-2时, 会自动从cg-1中移除, 也说明了: 一个进程可以作为多个 cgroup 的成员,但是这些 cgroup 必须在不同的 hierarchy中;

通过subsystem限制cgroup中进程的资源

在上面创建 hierarchy 的时候,这个 hierarchy 并没有关联到任何的 subsystem ,所以没办法通过那个 hierarchy 中的 cgroup 节点限制进程的资源占用,其实系统默认已经为每个subsystem 创建了一个默认的 hierarchy ,比如 memory hierarchy:

$ mount | grep memory
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)

可以看到, memory subsystem的hierarchy 挂载到了 /sys/fs/cgroup/memory , 我们就在这个hierarchy下创建cgroup, 限制进程占用的内存.

$ cd /sys/fs/cgroup/memory
$ mkdir test-memory-limit
$ cd test-memory-limit/
$ ls
cgroup.clone_children           memory.kmem.tcp.max_usage_in_bytes  
...... 省略一些内容
memory.kmem.tcp.limit_in_bytes  memory.move_charge_at_immigrate     tasks

其实还可以通过 cgcreate -g memory:test-memory-limit来创建cgroup, 效果和上面操作后是一样的.
可以通过cgdelete -g memory:test-memory-limit来删除cgroup;

创建好cgroup后, 我们来通过cgroup来限制下进程占用的内存(在test-memory-limit目录):

$ echo '100m' > memory.limit_in_bytes 
$ cat memory.limit_in_bytes 
104857600 # 100m=100 * 1024 * 1024
$ echo $$
16570
$ echo 16570 > tasks 
$ stress --vm-bytes 128m --vm-keep -m 1 # 启动一个占用128m的进程报错了
stress: info: [335] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
stress: FAIL: [335] (415) <-- worker 336 got signal 9
stress: WARN: [335] (417) now reaping child worker processes
stress: FAIL: [335] (451) failed run completed in 0s
$ stress --vm-bytes 99m --vm-keep -m 1 # 启动一个占用99m的进程成功了
stress: info: [351] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd

Docker是如何使用Cgroups的

我们知道Docker的底层技术有使用到Cgroups, 结合一个实例来看下:

$ docker run -itd -m 128m ubuntu # -m 128m 限制容器使用的内存最大为128m
64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
$ docker ps # 确保容器正常运行
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
64b8f2e16f30        ubuntu              "bash"              2 seconds ago       Up 1 second                             stupefied_bhaskara
$ cd /sys/fs/cgroup/memory/docker
$ cd 64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
$ ls
... 省略一些内容
memory.failcnt                  memory.limit_in_bytes               
memory.kmem.tcp.limit_in_bytes  memory.move_charge_at_immigrate     tasks
$ cat memory.limit_in_bytes # 查看容器内存限制
134217728 # 128m=134217728=128 * 1024 * 1024
$ cat memory.usage_in_bytes # 查看容器已经使用的内存
778240

可以看到, Docker 通过为每个容器创建 cgroup 并通过 cgroup 去配置资源限制和资源监控。

事实上, 每一个Docker容器, 在操作系统上是对应到进程, 可以这样看:

# 通过容器id查找相应的容器进程
$ ps -ef|grep -v grep | grep 64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
root     11313  6086  0 14:17 ?        00:00:00 containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29 -address /run/containerd/containerd.sock -containerd-binary /usr/bin/containerd -runtime-root /var/run/docker/runtime-runc
$ pstree -pl | grep 11313 # 这里11313其实是containerd-shim进程, 真正运行的是bash(ubuntu镜像的入口是 CMD ["bash"])
           |-containerd(6086)-+-containerd-shim(11313)-+-bash(11330)
$ ps -ef|grep -v grep|grep 11330
root     11330 11313  0 14:17 ?        00:00:00 bash
# 通过docker inspect <容器id> 也可以看到其进程id是11300(bash进程)
$ docker inspect 64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29 | grep -C 2 '"Pid"'
            "OOMKilled": false,
            "Dead": false,
            "Pid": 11330,
            "ExitCode": 0,
            "Error": "",
$ 
$ cat /proc/11330/cgroup # 顺便看下容器进程关联的cgroup, 很快也就明白了docker容器是怎么使用cgroup的
12:hugetlb:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
11:cpuacct,cpu:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
10:cpuset:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
9:devices:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
8:freezer:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
7:perf_event:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
6:oom:/
5:blkio:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
4:memory:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
3:net_cls:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
2:pids:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
1:name=systemd:/docker/64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29

使用Go语言实现通过cgroup 限制容器的资源

memory-limit.go:

package main

import (
	"fmt"
	"io/ioutil"
	"os"
	"os/exec"
	"path"
	"strconv"
	"syscall"
)

// 挂载了 memory subsystem hierarchy 的根目录位置
const cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory"

func main() {
	fmt.Println("os args:", os.Args)
	if os.Args[0] == "/proc/self/exe" {
		// 容器进程
		fmt.Printf("current pid:%v\n", syscall.Getpid())
		cmd := exec.Command("sh", "-c", "stress --vm-bytes 200m --vm-keep -m 1")
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		err := cmd.Run()
		checkErr(err, "/proc/self/exe run")
	}
	cmd := exec.Command("/proc/self/exe")
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	err := cmd.Start()
	checkErr(err, "/proc/self/exe start")
	// 得到 fork 出来进程映射在外部命名空间的 pid
	fmt.Printf("%v\n", cmd.Process.Pid)
	// 在系统默认创建挂载了 memory subsystem Hierarchy 上创建 cgroup
	err = os.Mkdir(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit"), 0755)
	checkErr(err, "Mkdir")
	// 将容器进程加入到这个 cgroup
	err = ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "tasks"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644)
	checkErr(err, "WriteFile tasks")
	//g限制 cgroup 进程使用
	err = ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "memory.limit_in_bytes"), []byte("100m"), 0644)
	checkErr(err, "WriteFile limit_in_bytes")

	_, err = cmd.Process.Wait()
	checkErr(err, "cmd.Process.Wait")
}

func checkErr(err error, reason string) {
	if err != nil {
		panic(fmt.Sprintf("err:%v, reason:%s", err, reason))
	}
}

运行, 发现因内存限制而无法执行stress …:

$ rmdir /sys/fs/cgroup/memory/testmemorylimit
rmdir: failed to remove ‘/sys/fs/cgroup/memory/testmemorylimit’: No such file or directory
$ go run memory-limit.go 
os args: [/tmp/go-build3880927588/b001/exe/memory-limit]
9238
os args: [/proc/self/exe]
current pid:1
stress: info: [6] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
stress: FAIL: [6] (415) <-- worker 7 got signal 9
stress: WARN: [6] (417) now reaping child worker processes
stress: FAIL: [6] (421) kill error: No such process
stress: FAIL: [6] (451) failed run completed in 0s
panic: err:exit status 1, reason:/proc/self/exe run
...省略一些内容

上述 exec.Command("sh", "-c", "stress --vm-bytes 200m --vm-keep -m 1")改为exec.Command("sh", "-c", "stress --vm-bytes 99m --vm-keep -m 1"), 再运行发现可以成功:

$ rmdir /sys/fs/cgroup/memory/testmemorylimit
$ go run memory-limit.go 
os args: [/tmp/go-build1141603810/b001/exe/memory-limit]
10400
os args: [/proc/self/exe]
current pid:1
stress: info: [6] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
^Csignal: interrupt
$ 

说明memory限制生效了

总结

  1. Cgroups的三大组件为: cgroup, hierarchy, subsystem;
  2. Docker容器其实是操作系统上的一个进程;

参考

(完)

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐