自己动手写Docker系列 -- 3.2增加容器资源限制

394 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第2天,点击查看活动详情

简介

继上篇的Run命令容器的实现,本章节中将实现增加容器资源限制,以内存限制为例,展示如果现在容器的内存使用

源码说明

同时放到了Gitee和Github上,都可进行获取

本章节对应的版本标签是:3.2,防止后面代码过多,不好查看,可切换到标签版本进行查看

本次只实现了内存的限制,CPU的实现同理

结果演示

 进程号 USER      PR  NI    VIRT    RES    SHR    %CPU  %MEM     TIME+ COMMAND
  46805 lw        20   0  208668 204988    336 R 100.0   0.6   2:20.89 stress
  48004 root      20   0  208668  99520    272 D  37.9   0.3   0:04.68 stress

如上所示,第一个是没有使用资源限制100M的stress命令: stress --vm-bytes 200m --vm-keep -m 1

第二个是使用自己的docker限制了100M内存的stress命令:./main run -ti -mem 100m stress --vm-bytes 200m --vm-keep -m 1

后者是前者的一半,可以看出了资源限制起到了作用

编码实现

这边的Cgroup卡了很久才解决,照抄代码有一定的风险,还是理解原理后,自己一步一步的实现最终才完成了

参数接收改造

我们需要使用docker运行stress命令:

上面的参数比较多,原本的是不支持的,我们需要进行改造,代码如下:

var RunCommand = cli.Command{
	Name:  "run",
	Usage: `Create a container with namespace and cgroups limit mydocker run -ti [command]`,
	Flags: []cli.Flag{
		cli.BoolFlag{
			Name:  "ti",
			Usage: "enable tty",
		},
		// 增加内存等限制参数
		cli.StringFlag{
			Name:  "mem",
			Usage: "memory limit",
		},
		cli.StringFlag{
			Name:  "cpuset",
			Usage: "cpuset limit",
		},
		cli.StringFlag{
			Name:  "cpushare",
			Usage: "cpushare limit",
		},
	},
	/*
		这里是run命令执行的真正函数
		1.判断参数是否包含command
		2.获取用户指定的command
		3.调用Run function 去准备启动容器
	*/
	Action: func(context *cli.Context) error {
		if len(context.Args()) < 1 {
			return fmt.Errorf("missing container command")
		}
		// 这个是获取启动容器时的命令
		// 如果本次中的 stress --vm-bytes 200m --vm-keep -m 1,空格分隔后会存储在下面的cmdArray中
		var cmdArray []string
		for _, arg := range context.Args() {
			cmdArray = append(cmdArray, arg)
		}
		tty := context.Bool("ti")
		resConfig := &subsystem.ResourceConfig{
			MemoryLimit: context.String("mem"),
			CpuShare:    context.String("cpuShare"),
			CpuSet:      context.String("cpuSet"),
		}
		run.Run(tty, cmdArray, resConfig)
		return nil
	},
}

在init的时候也需要支持,在init命令执行的时候需要传入对应的参数

func NewParentProcess(tty bool, cmdArray []string) *exec.Cmd {
	commands := strings.Join(cmdArray, " ")
	log.Infof("conmand all is %s", commands)
	// 传入,cmdArray[0]是启动的程序,比如本篇中的是stress,command是完整的命令:stress --vm-bytes 200m --vm-keep -m 1
	cmd := exec.Command("/proc/self/exe", "init", cmdArray[0], commands)
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
	}
	if tty {
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
	}
	return cmd
}

init函数是具体执行,我们进行完善,修复了之前挂载/proc的bug,并采用新的方式运行命令:

func RunContainerInitProcess(command string, args []string) error {
	log.Infof("RunContainerInitProcess command %s, args %s", command, args)

	// 上篇中没启动一次程序,就需要重新挂载一次/proc,很麻烦,添加下面的这个private方式挂载,即可解决这个问题
	// private 方式挂载,不影响宿主机的挂载
	err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")
	if err != nil {
		log.Errorf("private 方式挂载 failed: %v", err)
		return err
	}

	defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
	err = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
	if err != nil {
		log.Errorf("proc挂载 failed: %v", err)
		return err
	}

	// 同时,我们使用lookPath的方式去查找命令进行执行
	path, err := exec.LookPath(command)
	if err != nil {
		log.Errorf("can't find exec path: %s %v", command, err)
		return err
	}
	log.Infof("find path: %s", path)
	if err := syscall.Exec(path, args, os.Environ()); err != nil {
		log.Errorf("syscall exec err: %v", err.Error())
	}
	return nil
}

上面我们使用private挂载方式解决之前的挂载/proc的bug,具体原来目前自己还没琢磨透。。。。。。

但LookPath的方式比以前更好,是一直查找可执行程序的方式:

比如这次的 stress --vm-bytes 200m --vm-keep -m 1,如果我们用之前的有运行方式,前面的stress应该输入:/usr/sbin/stress之类的完整路径。

而使用LookPath,自动查找可执行程序,很好用

进程启动后,资源限制

在上篇中,我们当前进程下fork启动了一个进程,并进行Namespace的隔离

在本篇中,需要在进程启动后,使用Cgroup操作的相关知识,写入相关的文件,进行资源限制

代码如下:

func Run(tty bool, cmdArray []string, config *subsystem.ResourceConfig) {
	// 这里进程已经启动完成
	parent := container.NewParentProcess(tty, cmdArray)
	if err := parent.Start(); err != nil {
		log.Error(err)
		return
	}

	// 下面是使用自定义的资源管理器进行资源限制
	cgroupManager := cgroup.NewCgroupManager("mydocker-cgroup")
	// 进程退出时,重置
	defer cgroupManager.Destroy()
	// 将当前的进程的PID加入资源限制列表,如果内存的就是将PID写入tasks文件中
	if err := cgroupManager.Apply(parent.Process.Pid); err != nil {
		log.Errorf("cgroup apply err: %v", err)
		return
	}
	// 将PID加入Cgroup
	if err := cgroupManager.Set(config); err != nil {
		log.Errorf("cgoup set err: %v", err)
		return
	}

	log.Infof("parent process run")
	_ = parent.Wait()
	os.Exit(-1)
}

资源抽象类和资源管理器类

下面基本都是书中的代码,定义了一个资源的接口类,后面的内存、CPU等资源限制实现这个接口即可(go的这个接口写法和Java还挺不一样)

package subsystem

// 资源配置:命令中对内存、CPU的具体限制
type ResourceConfig struct {
	MemoryLimit string
	CpuShare    string
	CpuSet      string
}

// 资源限制接口:
// Name: 名称,如memory、cpuset、cpushare
// Set: 写入配置文件,对资源进行限制
// Apply: 将PID加入当前Cgroup
// Remove: 将PID移出当前Cgroup
type Subsystem interface {
	Name() string
	Set(cgroupName string, res *ResourceConfig) error
	Apply(cgroupName string, pid int) error
	Remove(cgroupName string) error
}

// 具体实现类:内存和CPU
var SubsystemIns = []Subsystem{
	&CpuSetSubsystem{},
	&CpuShareSubsystem{},
	&MemorySubsystem{},
}

管理类的代码如下:遍历资源限制的具体实现类,进行资源的限制

// CgroupName 当前进程的Cgroup名称:在Cgroup下建立的子文件夹名称
type CgroupManager struct {
	CgroupName string
	Resouce    *subsystem.ResourceConfig
}

func NewCgroupManager(cgroupName string) *CgroupManager {
	return &CgroupManager{
		CgroupName: cgroupName,
	}
}

// Apply 将PID加入Cgroup
func (c *CgroupManager) Apply(pid int) error {
	for _, ins := range subsystem.SubsystemIns {
		err := ins.Apply(c.CgroupName, pid)
		if err != nil {
			return err
		}
	}
	return nil
}

// Set 设置限制
func (c *CgroupManager) Set(res *subsystem.ResourceConfig) error {
	for _, ins := range subsystem.SubsystemIns {
		err := ins.Set(c.CgroupName, res)
		if err != nil {
			return err
		}
	}
	return nil
}

// Destroy 释放 Cgroup
func (c *CgroupManager) Destroy() error {
	for _, ins := range subsystem.SubsystemIns {
		err := ins.Remove(c.CgroupName)
		if err != nil {
			return err
		}
	}
	return nil
}

内存限制具体实现

下面是内存限制的具体实现:建议在宿主机上尝试操作下如果手动写配置文件进行内存限制后,即可明白下面的代码

基本上就是:

1.将进程PID加入当前CPU 2.配置当前Cgroup 3.移出当前PID

最后两个函数是,查找各个资源限制根目录和自动创建文件夹的相关代码

type MemorySubsystem struct {
}

func (m MemorySubsystem) Name() string {
	return "memory"
}

func (m MemorySubsystem) Set(cgroupName string, res *ResourceConfig) error {
	// 获取自定义Cgroup的路径,没有则创建,如:/sys/fs/cgroup/memory/mydocker-cgroup
	cgroupPath, err := GetCgroupPath(m.Name(), cgroupName)
	if err != nil {
		return err
	}
	log.Infof("%s cgroup path: %s", m.Name(), cgroupPath)
	// 将资源限制写入
	limitFilePath := path.Join(cgroupPath, "memory.limit_in_bytes")
	if err := ioutil.WriteFile(limitFilePath, []byte(res.MemoryLimit), 0644); err != nil {
		return fmt.Errorf("set memory cgroup failed: %v", err)
	}
	return nil
}

func (m MemorySubsystem) Apply(cgroupName string, pid int) error {
	// 获取自定义Cgroup的路径,没有则创建,如:/sys/fs/cgroup/memory/mydocker-cgroup
	cgroupPath, err := GetCgroupPath(m.Name(), cgroupName)
	if err != nil {
		return err
	}
	log.Infof("%s cgroup path: %s", m.Name(), cgroupPath)
	// 将PID加入该cgroup
	limitFilePath := path.Join(cgroupPath, "tasks")
	if err := ioutil.WriteFile(limitFilePath, []byte(strconv.Itoa(pid)), 0644); err != nil {
		return fmt.Errorf("add pid to cgroup failed: %v", err)
	}
	return nil
}

func (m MemorySubsystem) Remove(cgroupName string) error {
	// 获取自定义Cgroup的路径,没有则创建,如:/sys/fs/cgroup/memory/mydocker-cgroup
	cgroupPath, err := GetCgroupPath(m.Name(), cgroupName)
	if err != nil {
		return err
	}
	log.Infof("%s cgroup path: %s", m.Name(), cgroupPath)

	return os.RemoveAll(cgroupPath)
}

func FindCgroupMountPoint(subSystem string) (string, error) {
	f, err := os.Open("/proc/self/mountinfo")
	if err != nil {
		return "", fmt.Errorf("open /proc/self/mountinfo err: %v", err)
	}
	defer f.Close()

	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		txt := scanner.Text()
		fields := strings.Split(txt, " ")
		log.Debugf("mount info txt fields: %s", fields)
		for _, opt := range strings.Split(fields[len(fields)-1], ",") {
			if opt == subSystem {
				return fields[4], nil
			}
		}
	}
	if err := scanner.Err(); err != nil {
		return "", fmt.Errorf("file scanner err: %v", err)
	}
	return "", fmt.Errorf("FindCgroupMountPoint is empty")
}

func GetCgroupPath(subsystemName, cgroupName string) (string, error) {
	// 找到Cgroup的根目录,如:/sys/fs/cgroup/memory
	cgroupRoot, err := FindCgroupMountPoint(subsystemName)
	if err != nil {
		return "", err
	}

	cgroupPath := path.Join(cgroupRoot, cgroupName)
	_, err = os.Stat(cgroupPath)
	if err != nil && !os.IsNotExist(err) {
		return "", fmt.Errorf("file stat err: %v", err)
	}
	if os.IsNotExist(err) {
		if err := os.Mkdir(cgroupPath, os.ModePerm); err != nil {
			return "", fmt.Errorf("mkdir err: %v", err)
		}
	}
	return cgroupPath, nil
}

总结

在原书中,只写出了具体的内存资源限制的相关代码,但如果是照着github抄的话,本人就出现了运行不起来的尴尬

还是需要把原理搞懂,才能跟着作者的思路写出来,本文代码和书中有些许差异,但如果完整照抄应该是能跑起来的