一文彻底搞懂Linux Cgroup如何限制容器资源

3,827 阅读10分钟

什么是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之后,该系统的所有任务都会加入这个hierarchycgroup---称之为root cgroup,此cgroup在创建hierarchy自动创建,后面在该hierarchy中创建都是cgroup根节点的子节点

  2. 一个subsystem只能附加到一个hierarchy上面

  3. 一个hierarchy可以附加多个subsystem

  4. 一个task可以是多个cgroup的成员,但这些cgroup必须在不同的hierarchy

  5. 一个进程fork出子进程时,该子进程默认自动成为父进程所在的cgroup的成员,也可以根据情况将其移动到到不同的cgroup中.

avatar

图 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

这些文件就是hierarchycgroup根节点的配置项,这些文件的含义是

  • cgroup.clone_children cpusetsubsystem会读取这个配置文件,如果这个值(默认值是0)是 1cgroup才会继承父cgroupcpuset的配置

  • cgroup.procs是树中当前节点cgroup中的进程组ID,现在的位置是根节点,这个文件中会有现在系统中所有进程组的ID (查看目前全部进程PID ps -ef | awk '{print $2}')

  • notify_on_releaserelease_agent 会一起使用

    • notify_on_release 标志当这个cgroup最后一个进程退出的时候是否执行了release_agent

    • release_agent 则是一个路径,通常用作进程退出后自动清理不再使用的cgroup

  • task 标识该cgroup下面进程ID,如果把一个进程ID写到task文件中,便会把相应的进程加入到这个cgroup

  1. 在刚刚创建好的hierarchycgroup根节点中扩展出两个子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 subsystemHierarchy上创建cgroup
  2. 限制该cgroup的最大物理内存值
  3. fork出来的进程加入到这个容器内
  4. 在容器内重新挂载/proc使其跟宿主机隔离PID Namespace
  5. 在容器内运行stress进程
  6. 另起一个进程开sh进程进入到容器内

参考: