什么是Lunux Cgroups
Lunux Cgroups 提供了对一组进程及子进程的资源限制.控制和统计的能力,这些资源包括硬件资源CPU,Memory,DIsk,Network等等
Cgroups中的四个组件
Cgroup((控制组) 是对进程分组管理的一种机制,一个Cgroup包含一组进程,并可以在上面添加添加Linux Subsystem的各种参数配置,将一组进程和一组Subsystem的系统参数关联起来Subsystem(子系统)是一个资源调度控制器不同版本的Kernel所支持的有所偏差,可以通过cat /proc/cgroups查看blkio对块设备(比如硬盘)的IO进行访问限制cpu设置进程的CPU调度的策略,比如CPU时间片的分配cpuacct统计/生成cgroup中的任务占用CPU资源报告cpuset在多核机器上分配给任务(task)独立的CPU和内存节点(内存仅使用于NUMA架构)devices控制cgroup中对设备的访问freezer挂起(suspend) / 恢复 (resume)cgroup中的进程memory用于控制cgroup中进程的占用以及生成内存占用报告net_cls使用等级识别符(classid)标记网络数据包,这让 Linux 流量控制器 tc (traffic controller) 可以识别来自特定 cgroup 的包并做限流或监控net_prio设置cgroup中进程产生的网络流量的优先级hugetlb限制使用的内存页数量pids限制任务的数量ns可以使不同cgroups下面的进程使用不同的namespace. 每个subsystem会关联到定义的cgroup上,并对这个cgoup中的进程做相应的限制和控制.
hierarchy树形结构的CGroup层级,每个子CGroup节点会继承父CGroup节点的子系统配置,每个Hierarchy在初始化时会有默认的CGroup(Root CGroup)
比如一组task进程通过cgroup1限制了CPU使用率,然后其中一个日志进程还需要限制磁盘IO,为了避免限制磁盘IO影响到其他进程,就可以创建cgroup2,使其继承cgroup1并限制磁盘IO,这样这样cgroup2便继承了cgroup1中对CPU使用率的限制并且添加了磁盘IO的限制而不影响到cgroup1中的其他进程
Task(任务) 在cgroups中,任务就是系统的一个进程
四个组件的相互关系
1、  系统在创建新的hierarchy之后,该系统的所有任务都会加入这个hierarchy的cgroup-–称之为root cgroup,此cgroup在创建hierarchy自动创建,后面在该hierarchy中创建都是cgroup根节点的子节点
2、  一个subsystem只能附加到一个hierarchy上面
3、  一个hierarchy可以附加多个subsystem
4、  一个task可以是多个cgroup的成员,但这些cgroup必须在不同的hierarchy
5、  一个进程fork出子进程时,该子进程默认自动成为父进程所在的cgroup的成员,也可以根据情况将其移动到到不同的cgroup中.

图 1、 CGroup 层级图 (来源:www.ibm.com/developerwo…)
Kernel接口
Cgroups中的hierarchy是一种树状组织结构,Kernel为了使对Cgroups的配置更加直观,是通过一个虚拟文件系统来配置Cgroups的,通过层级虚拟出cgroup树,例子操作如下
1、  创建并挂载一个hierarchy(cgroup)树
# 创建一个`hierarchy`
root@DESKTOP-UMENNVI:~# mkdir -p cgroup-test/
# 挂载一个hierarchy
root@DESKTOP-UMENNVI:~# sudo mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test/
# 挂载后我们就可以看到系统在这个目录下生成了一些默认文件
root@DESKTOP-UMENNVI:~# ls ./cgroup-test/
cgroup.clone_children  cgroup.procs  cgroup.sane_behavior  notify_on_release  release_agent  tasks
这些文件就是hierarchy中cgroup根节点的配置项,这些文件的含义是
cgroup.clone_childrencpuset的subsystem会读取这个配置文件,如果这个值(默认值是0)是 1 子cgroup才会继承父cgroup的cpuset的配置cgroup.procs是树中当前节点cgroup中的进程组ID,现在的位置是根节点,这个文件中会有现在系统中所有进程组的ID (查看目前全部进程PIDps -ef | awk '{print $2}')notify_on_release和release_agent会一起使用notify_on_release标志当这个cgroup最后一个进程退出的时候是否执行了release_agentrelease_agent则是一个路径,通常用作进程退出后自动清理不再使用的cgroup
task标识该cgroup下面进程ID,如果把一个进程ID写到task文件中,便会把相应的进程加入到这个cgroup中
1、  在刚刚创建好的hierarchy上cgroup根节点中扩展出两个子cgroup
## 进入到刚刚创建的 hierarchy 内
root@DESKTOP-UMENNVI:~# cd cgroup-test/
root@DESKTOP-UMENNVI:~/cgroup-test# mkdir  -p cgroup-1 cgroup-2
## 查看目录树
root@DESKTOP-UMENNVI:~/cgroup-test# tree
.
├── cgroup-1
│   ├── cgroup.clone_children
│   ├── cgroup.procs
│   ├── notify_on_release
│   └── tasks
├── cgroup-2
│   ├── cgroup.clone_children
│   ├── cgroup.procs
│   ├── notify_on_release
│   └── tasks
├── cgroup.clone_children
├── cgroup.procs
├── cgroup.sane_behavior
├── notify_on_release
├── release_agent
└── tasks
2 directories, 14 files
可以看到,在一个cgroup的目录下创建文件夹时,Kernel会把文件夹标记记为子cgroup,它们会继承父cgroup的属性
1、  在cgroup中添加和移动进程 一个进程在一个hierarchy中,只能在一个cgroup节点上存在,系统的所有进程都默认在root cgroups上,我们可以将进程移动到其他的cgroup节点,只需要将进程ID移动到其他cgroup节点的tasks文件中即可
## 进入到 cgroup-1
root@DESKTOP-UMENNVI:~/cgroup-test# cd cgroup-1/
## 显示当前终端PID
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# echo ?
1945
## 将本终端移动到 cgroup-1
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# sudo sh -c "echo ? >> tasks"
## 检查进程所处 cgroup
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# cat /proc/1945/cgroup 
14:name=cgroup-test:/cgroup-1
13:rdma:/
12:pids:/
11:hugetlb:/
10:net_prio:/
9:perf_event:/
8:net_cls:/
7:freezer:/
6:devices:/
5:memory:/
4:blkio:/
3:cpuacct:/
2:cpu:/
1:cpuset:/
0::/
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# 
可以看到当前的1945进程已经被加到cgroup-test:/cgroup-1中了
1、  通过subsystem限制cgroup中进程的资源
在上面创建hierarchy的时候,这个hierarchy并没有关联到任何的subsystem,因此我们需要手动创建subsystem挂载到这个cgroup-1中
## 挂载 memory subsystem 到 cgroup-test/cgroup-1/memory
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# mkdir -p memory
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# mount -t cgroup -o memory cgoup-1-mem ./memory
## 创建限制内存的 cgroup (limit-mem 可以替换成任意字符串)
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1#  mkdir -p memory/limit-mem
## 将当前进程移动到这个 cgroup 中
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# echo ? > memory/limit-mem/tasks
## 运行 stress 进程占用 200M 内存
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# stress --vm-bytes 200m --vm-keep -m 1
stress: info: [308] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
## 结束进程
Ctrl+c / Command + c
开始限制 cgroup 内进程的内存使用量
## 设置最大 cgroup 的最大内存占用为100MB
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# sudo echo 100M > memory/limit-mem/memory.limit_in_bytes
## 运行 stress 进程占用 200M 内存
stress --vm-bytes 200m --vm-keep -m 1
另起终端,查看stress进程占用内存情况
## 此时查看进程ID号
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# ps -ef | grep stress | grep -v grep
root       496   258  0 11:14 pts/0    00:00:00 stress --vm-bytes 200m --vm-keep -m 1
root       497   496 23 11:14 pts/0    00:00:14 stress --vm-bytes 200m --vm-keep -m 1
## 可以看到有两个 stress 进程,其中有一个是 497 的子进程,我们需要查看的进程PID就是那个子进程
## 查看进程占用情况
root@DESKTOP-UMENNVI:~/cgroup-test/cgroup-1# cat /proc/497/status  | grep Vm
VmPeak:   213044 kB     # 进程所使用虚拟内存的峰值
VmSize:   213044 kB     # 进程当前使用的虚拟内存大小
VmLck:         0 kB     # 已经锁住的物理内存的大小
VmPin:         0 kB     # 进程所使用的物理内存峰值
VmHWM:    100292 kB     # 进程当前使用的物理内存的峰值
VmRSS:     99956 kB     # 进程当前使用的物理内存大小
VmData:   204996 kB     # 进程占用的数据段大小
VmStk:       132 kB     # 进程占用的栈大小
VmExe:        20 kB     # 进程占用的代码段大小(不包含链接库)
VmLib:      3764 kB     # 进程所加载的动态库所占用的内存大小(可能与其他进程共享)
VmPTE:       452 kB     # 进程占用的页表大小 (交换表项数量)
VmSwap:   105200 kB     # 进程所使用的交换区大小
可以看到stress进程实际物理内存占用只有 99956kB ,其余占用内存分配给了swap分区了,说明已经成功将进程最大(物理内存)占用限制到了 100M
用Go实现通过cgroup限制容器资源
下面在Namespace的基础上,加上cgroup限制实现一个demo,使其能够具有限制容器内存的功能
// Cgroup/limitMem/demo.go
package main
import (
    "fmt"
    "io/ioutil"
    "log"
    "os"
    "os/exec"
    "path"
    "strconv"
    "syscall"
)
const cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory"
const limitMemory = "100M"
func main() {
    //-----------------------------------------------------
    // 5.运行 stress 进程测试内存占用
    if os.Args[0] == "/proc/self/exe" {
        //-----------------------------------------------------
        // 6. 挂载容器内的 /proc 的文件系统
        //Mount /proc to new root's  proc directory using MNT namespace
        if err := syscall.Mount("proc", "/proc", "proc", uintptr(syscall.MS_NOEXEC|syscall.MS_NOSUID|syscall.MS_NODEV), ""); err != nil {
            fmt.Println("Proc mount failed,Error : ", err)
        }
        // 7. 异步执行一个 sh 进程进入到容器内
        go func() {
            cmd := exec.Command("/bin/sh")
            cmd.SysProcAttr = &syscall.SysProcAttr{}
            cmd.Stdin = os.Stdin
            cmd.Stdout = os.Stdout
            cmd.Stderr = os.Stderr
            cmd.Run()
            os.Exit(1)
        }()
        // 8. 运行 stress 进程
        log.Printf("Current pid %d \n", syscall.SYS_GETPID)
        cmd := exec.Command("sh", "-c", `stress --vm-bytes 200m --vm-keep -m 1`)
        cmd.SysProcAttr = &syscall.SysProcAttr{}
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
        log.Print("Close the program, press input `exit` \n")
        if err := cmd.Run(); err != nil {
            log.Fatal(err)
        } else {
            log.Printf("Stress process pid : %d \n", cmd.Process.Pid)
        }
        os.Exit(1)
    }
    //-----------------------------------------------------
    // 1, 先创建一个外部进程
    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
    if err := cmd.Start(); err != nil {
        log.Fatal(err)
        os.Exit(1)
    }
    //-----------------------------------------------------
    // 2. 在挂载了memory subsysyem 下创建限制内存的cgroup
    memory_limit_path := path.Join(cgroupMemoryHierarchyMount, "memorylimit")
    if f, err := os.Stat(memory_limit_path); err == nil {
        if !f.IsDir() {
            if err = os.Mkdir(memory_limit_path, 0755); err != nil {
                log.Fatal(err)
            } else {
                log.Printf("Mkdir memory cgroup %s \n", path.Join(cgroupMemoryHierarchyMount, "memorylimit"))
            }
        }
    } else {
        if err = os.Mkdir(memory_limit_path, 0755); err != nil {
            log.Fatal(err)
        } else {
            log.Printf("Mkdir memory cgroup %s \n", path.Join(cgroupMemoryHierarchyMount, "memorylimit"))
        }
    }
    //-----------------------------------------------------
    // 3. 限制 cgroup 内进程最大物理内存<limitMemory>
    if err := ioutil.WriteFile(path.Join(memory_limit_path, "memory.limit_in_bytes"), []byte(limitMemory), 0644); err != nil {
        log.Fatal("Litmit memory error,", err)
    } else {
        log.Printf("Litmit memory %v sucessed\n", limitMemory)
    }
    log.Printf("Self process pid : %d \n", cmd.Process.Pid)
    //-----------------------------------------------------
    // 4. 将进程加入到 cgroup 中
    if err := ioutil.WriteFile(path.Join(memory_limit_path, "tasks"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil {
        log.Fatal("Move process to task error,", err)
    } else {
        log.Printf("Move process %d to task sucessed \n", cmd.Process.Pid)
    }
    cmd.Process.Wait()
}
运行程序
root@DESKTOP-UMENNVI:# go run Cgroup/limitMem/demo.go 
2020/03/01 23:05:33 Litmit memory 100M sucessed
2020/03/01 23:05:33 Self process pid : 22761 
2020/03/01 23:05:33 Move process 22761 to task sucessed 
2020/03/01 23:05:33 Current pid 39 
2020/03/01 23:05:33 Close the program, press input `exit` 
# stress: info: [8] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
# 此时再按一下回车
# ps -ef      
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 23:05 pts/1    00:00:00 /proc/self/exe
root         6     1  0 23:05 pts/1    00:00:00 /bin/sh
root         7     1  0 23:05 pts/1    00:00:00 sh -c stress --vm-bytes 200m --vm-keep -m 1
root         8     7  0 23:05 pts/1    00:00:00 stress --vm-bytes 200m --vm-keep -m 1
root         9     8 36 23:05 pts/1    00:00:07 stress --vm-bytes 200m --vm-keep -m 1
root        10     6  0 23:05 pts/1    00:00:00 ps -ef
## 可以看到PID Namespace已经被隔离了,这里我们直接查看 stree 进程的内存占用
# cat /proc/9/status | grep Vm
VmPeak:   213044 kB
VmSize:   213044 kB
VmLck:         0 kB
VmPin:         0 kB
VmHWM:     98440 kB
VmRSS:     87220 kB
VmData:   204996 kB
VmStk:       132 kB
VmExe:        20 kB
VmLib:      3764 kB
VmPTE:       460 kB
VmSwap:   117936 kB
通过对Cgroup的配置,已经将容器中的stress进程的物理内存占用限制到了100MB
技术总结
1、  在挂载了memory subsystem的Hierarchy上创建cgroup
2、  限制该cgroup的最大物理内存值
3、  将fork出来的进程加入到这个容器内
4、  在容器内重新挂载/proc使其跟宿主机隔离PID Namespace
5、  在容器内运行stress进程
6、  另起一个进程开sh进程进入到容器内