使用 GoLang 从零开始写一个 Docker(容器篇)-- 《自己动手写 Docker》读书笔记

1,509 阅读12分钟
  1. 使用 GoLang 从零开始写一个 Docker(概念篇)-- 《自己动手写 Docker》读书笔记
  2. 使用 GoLang 从零开始写一个 Docker(容器篇)-- 《自己动手写 Docker》读书笔记
  3. 使用 GoLang 从零开始写一个 Docker(镜像篇)-- 《自己动手写 Docker》读书笔记
  4. 使用 GoLang 从零开始写一个 Docker(容器进阶篇/完结篇?)-- 《自己动手写 Docker》读书笔记 上文 的续篇。这一篇开始写代码了!

1. 简单介绍下 Linux 的 /proc 文件夹

这部分是 linux 的基础知识,知道可以跳过。
众所周知,linux 里一切皆文件,而 /proc 文件夹也是如此。/proc 文件夹下放置着系统运行时的信息。并不存在于磁盘上,而只是存在于内存中。只不过以文件系统的形式展现出来。
实际上,很多 command 都只是对访问 /proc 上的某个文件的封装。比如 lsmod 这个操作,其实也不过只是对 cat /proc/modules 的封装。

1.1 PID

在 /proc 文件夹下可以看到很多文件夹的名字是个数字,这其实就是 PID。是 linux 为每个进程创建的空间。

1.2 一些重要的目录

/proc/N // PID 为 N 的进程
/proc/N/cmdline // 进程的启动命令
/proc/N/cwd // 链接到进程的工作目录
/proc/N/environ // 进程的环境变量列表
/proc/N/exe // 链接到进程的执行命令
/proc/N/fd // 包含进程相关的所有文件描述符
/proc/N/maps // 与进程相关的内存映射信息
/proc/N/mem // 进程持有的内存,不可读
/proc/N/root // 链接到进程的根目录
/proc/N/stat // 进程的状态
/proc/N/statm // 进程的内存状态
/proc/N/status // 比上面两个更可读
/proc/self // 链接到当前正在运行的进程

2. 简单实现 docker run -it [command]

2.1 工具

  1. github.com/urfave/cli 可以帮助编写 command line app。

2.2 实现代码

image.png

2.2.1 runCommand

command.go 用于放置各种 command 命令,这里我们先只写一个 runCommand 命令。
首先使用 urfave/cli 创建一个 runCommand 命令:

// command.go
var runCommand = cli.Command{
	Name:  "run",
	Usage: "Create a container",
	Flags: []cli.Flag{
		// integrate -i and -t for convenience
		&cli.BoolFlag{
			Name:  "it",
			Usage: "open an interactive tty(pseudo terminal)",
		},
	},
	Action: func(context *cli.Context) error {
		args := context.Args()
		if args.Len() == 0 {
			return errors.New("Run what?")
		}
		containerCmd := args.Get(0)        // command

		// check whether type `-it`
		tty := context.Bool("it") // presudo terminal
                
                // 这个函数在下面定义
		dockerCommand.Run(tty, containerCmd)

		return nil
	},
}

2.2.2 run

上面的 Run 函数在 dockerCommand/run.go 下定义。 当运行 docker run 的时候,实际上主要是 Action 下的这个函数主要在工作

// dockerCommand/run.go
// This is the function what `docker run` will call
func Run(tty bool, containerCmd string) {

	// this is "docker init <containerCmd>"
	initProcess := container.NewProcess(tty, containerCmd)

	// start the init process
	if err := initProcess.Start(); err != nil{
		logrus.Error(err)
	}

	initProcess.Wait()
	os.Exit(-1)
}

但是其实这个函数做的也只是去跑一个 initProcess。 这个 command process 在另一个包里定义。

2.2.3 NewProcess

上面的 container.NewProcess 在 container/init.go 里定义:

// container/init.go
func NewProcess(tty bool, containerCmd string) *exec.Cmd {

	// create a new command which run itself
	// the first arguments is `init` which is the below exported function
	// so, the <cmd> will be interpret as "docker init <containerCmd>"
	args := []string{"init", containerCmd}
	cmd := exec.Command("/proc/self/exe", args...)

	// new namespaces, thanks to Linux
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET,
	}

	// this is what presudo terminal means
	// link the container's stdio to os
	if tty {
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
	}

	return cmd
}

这个函数的作用是生成一个新的 command process,但是注意看这个 command 是 /proc/self/exe 其实还是这个程序本身,也就是 oyishyi-docker,但是这次我们不是运行 docker run,而是 docker init,这个 init 命令在下面定义。

2.2.4 init

initCommand 和 runCommand 在同一个文件里定义。也是一个 command,但是注意这个 command 不给用户使用。只是用于协助 runCommand。 最重要的一点是,你记不记得这个命令的环境是什么,是新的 namespaces,也就是说已经是在容器里了。我们在容器里运行这个 docker init 命令进程,用于初始化容器。

// command.go
// docker init, but cannot be used by user
var initCommand = cli.Command{
	Name:  "init",
	Usage: "init a container",
	Action: func(context *cli.Context) error {
		logrus.Infof("Start initiating...")
		containerCmd := context.Args().Get(0)
		logrus.Infof("container command: %v", containerCmd)
		return container.InitProcess(containerCmd, nil)
	},
}

这里使用了 container.InitProcess 函数。这个函数是真正用于容器初始化的函数。

2.2.5 InitProcess

这里的是 InitProcess,也就是容器初始化的步骤。
注意 syscall.Exec 这句话。

  1. 注意书本上有个坑,就是没有 mount / 并指定 private。不然容器里的 proc 会使用外面的 proc。即使在不同的 namespace 下。
  2. 所以如果你没有加这一段,其实退出容器后你会发现你需要在外面再次 mount proc 才能使用 ps 等命令。
// already in container
// initiate the container
func InitProcess(containerCmd string, args []string) error {

	defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
        
        // mount
	if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil {
		logrus.Errorf("mount / fails: %v", err)
		return err
	}
        
	// mount proc filesystem
	syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
	argv := []string{containerCmd}
	if err := syscall.Exec(containerCmd, argv, os.Environ()); err != nil {
		logrus.Errorf("mount /proc fails: %v", err)
	}
	
	return nil
}

一般来说,我们都是想要这个 containerCmd 作为 PID=1 的进程。但是很可惜,由于我们有 initProcess 半身的存在,所以 PID 为 1 的其实是 initProcess。那么如何让 containerCmd 作为 PID=1 的存在呢?
这里就出现了 syscall.Exec 这个黑魔法,实际上 Exec 内部会调用 kernel 的 execve 函数,这个函数会把当前进程上运行的程序替换成另外一个程序,而这正是我们想要的,不改变 PID 的情况下,替换掉程序。(即使删除 PID 为 1 的进程,新创建的进程也会是 PID=2,所以必须要靠这个方法)

为什么需要第一个命令的 PID 为 1?

  • 因为这样,退出这个进程后,容器就会因为没有前台进程,而自动退出。这也是 docker 的特性。

3. 给 docker run 增加对容器的资源限制功能

当然,这里需要使用到 subsystem 的知识。

3.1 subsystem.go

  1. 根据 subsystem 的特性来看,简直和接口绝配。
  2. 此外再定义一个 ResourceConfig 的类型,用于放置资源控制的配置。
  3. subsystemInastance 里包括 3 个 subsystem,分别对 memory,cpu,cpushare 进行限制。因为我们只需要对整个容器进行限制,所以这一套 3 个就够了。
package subsystems

type ResourceConfig struct {
	MemoryLimit string
	CPUShare string
	CPUSet string
}

type Subsystem interface {
	// return the name of which type of subsystem
	Name() string
	// set a resource limit on a cgroup
	Set(cgroupPath string, res *ResourceConfig) error
	// add a processs with the pid to a group
	AddProcess(cgroupPath string, pid int) error
	// remove a cgroup
	RemoveCgroup(cgroupPath string) error
}

// instance of a subsystems
var SubsystemsInstance = []Subsystem{
	&CPUSubsystem{},
	&CPUSetSubsystem{},
	&MemorySubsystem{},
}

3.2 MemorySubsystem

上面使用了三个 subsystem,这里只写对其中的 memorySubsystem 进行的实现,其他两个照葫芦画瓢即可。memorySubsystem 的作用就是限制内存资源。

3.2.1 Name() 方法

没什么好说的,返回 "memory" 字符串。表示这个 subsystem 是一个 memorySubsystem。

// return the name of the subsystem
func (ms *MemorySubsystem) Name() string {
	return "memory"
}

3.2.2 Set() 方法

Set() 方法用于对 cgroup 设置资源限制。因此参数为 cgroup 的 path 和 resourceConfig。

  1. 其中 GetCgroupPath 在之后会讲。作用是获取这个 subsystem 所挂载的 hieararchy 上的虚拟文件系统下的 cgroup 路径。
  2. 其实也很简单,获取到 cgrouppath 在虚拟文件系统中的位置之后。只需要写入 "memory.limit_in_bytes" 文件中即可。
// set the memory limit to this cgroup with cgroupPath
func (ms *MemorySubsystem) Set(cgroupPath string, res *ResourceConfig) error  {
	if subsystemCgroupPath, err := GetCgroupPath(ms.Name(), cgroupPath, true); err != nil {
		return err
	} else {
		if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "memory.limit_in_bytes"), []byte(res.MemoryLimit), 0644); err != nil {
			return fmt.Errorf("set cgroup memory fail: %v", err)
		}
	}
	return nil
}

3.2.3 AddProcess() 方法

  1. 和上面基本一样,只不过是写到 tasks 里。
  2. pid 变成 byte slice 之前需要用 itoa 转化一下。
func (ms *MemorySubsystem) AddProcess(cgroupPath string, pid int) error {
	if subsystemCgroupPath, err := GetCgroupPath(ms.Name(), cgroupPath, false); err != nil {
		return err
	} else {
		if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "tasks"), []byte(strconv.Itoa(pid)), 0644); err != nil {
			return fmt.Errorf("cgroup add process fail: %v", err)
		}
	}
	return nil
}

3.2.4 RemoveCgroup() 方法

  1. 使用 os.Remove 可以移除参数所指定路径的文件或者文件夹。
  2. 这里移除整个 cgroup 文件夹,就等于是删除 cgroup 了。
func (ms *MemorySubsystem) RemoveCgroup(cgroupPath string) error {
	if subsystemCgroupPath, err := GetCgroupPath(ms.Name(), cgroupPath, false); err != nil {
		return err
	} else {
		return os.Remove(subsystemCgroupPath)
	}
}

3.3 GetCgroupPath() 函数

GetCgroupPath() 用于获取某个 subsystem 所挂载的 hieararchy 上的虚拟文件系统(挂载后的文件夹)下的 cgroup 的路径。通过对这个目录的改写来改动 cgroup。

首先我们抛开 cgroup,在此之前我们需要知道这个 hierarchy 的 cgroup 根节点的路径。如何获取?

  • 答案可以在 /proc/self/mountinfo 里找到。

以下是实现细节:

  1. 首先定义一个 FindHierarchyMountRootPath 函数来找到 cgroup 的根节点。
  2. 然后再在 GetCgroupPath 将其和 cgroup 的相对路径拼接上从而获取 cgroup 的路径。如果 autoCreate 为 true 并且该路径不存在,那么就新建一个 cgroup。(之前讲过了,在 hierarchy 环境下,mkdir 其实会隐式地创建一个 cgroup,其中包含很多配置文件)
// as the function name shows, find the root path of hierarchy
func FindHierarchyMountRootPath(subsystemName string) string  {
	f, err := os.Open("/proc/self/mountinfo")
	if err != nil {
		return ""
	}

	defer f.Close()

	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		txt := scanner.Text()
		fields := strings.Split(txt, " ")
		// find whether "subsystemName" appear in the last field
		// if so, then the fifth field is the path
		for _, opt := range strings.Split(fields[len(fields)-1], "/") {
			if opt == subsystemName {
				return fields[4]
			}
		}
	}
	return ""
}

// get the absolute path of a cgroup
func GetCgroupPath(subsystemName string, cgroupPath string, autoCreate bool) (string, error)  {
	cgroupRootPath := FindHierarchyMountRootPath(subsystemName)
	expectedPath := path.Join(cgroupRootPath, cgroupPath)
	
	// find the cgroup or create a new cgroup
	if _, err := os.Stat(expectedPath); err == nil  || (autoCreate && os.IsNotExist(err)) {
		if os.IsNotExist(err) {
			if err := os.Mkdir(expectedPath, 0755); err != nil {
				return "", fmt.Errorf("error when create cgroup: %v", err)
			}
		}
		return expectedPath, nil
	} else {
		return "", fmt.Errorf("cgroup path error: %v", err)
	}
}

3.4 cgroupsManager.go

  1. 定义 CgroupManager 类型,其中的 path 需要注意是相对路径,相对的是对应的 hierarchy 的 root path。所以一个 CgroupManagee 是有可能表示多个 cgroups 的,或者准确来说,和对应的 hierarchy root path 的相对路径一样的多个 cgroups。

  2. 因为上述原因,Set() 方法可能会创建多个 cgroups,如果 subsystems 们在不同的 hierarchy 上的话就会这样。

  3. 这也是为什么 AddProcess 和 Remove 都要在每个 subsystem 上执行一遍。因为这些 subsystem 可能存在于不同的 hierarchies 上。

  4. 注意 set 和 addProcess 都不是返回错误,而是发出警告,然后返回 nil。因为有些时候用户只指定某一个限制,比如 memory。那样的话修改 cpu 等其实会报错的,这是正常的报错,因此我们不 return err 来退出。

package cgroups

import "github.com/oyishyi/oyishyi-docker/cgroups/subsystems"

type CgroupManager struct {
	Path     string // relative path, relative to the root path of the hierarchy
					// so this may cause more than one cgroup in different hierarchies
	Resource *subsystems.ResourceConfig
}

func NewCgroupManager(path string) *CgroupManager {
	return &CgroupManager{
		Path: path,
	}
}

// set the three resource config subsystems to the cgroup(will create if the cgroup path is not existed)
// this may generate more than one cgroup, because those subsystem may appear in different hierarchies
func (cm CgroupManager) Set(res *subsystems.ResourceConfig) error {
	for _, subsystem := range subsystems.SubsystemsInstance {
		if err := subsystem.Set(cm.Path, res); err != nil {
			logrus.Warnf("set resource fail: %v", err)
		}
	}
	return nil
}

// add process to the cgroup path
// why should we iterate all the subsystems? we have only one cgroup
// because those subsystems may appear at different hierarchies, which will then cause more than one cgroup, 1-3 in this case.
func (cm *CgroupManager) AddProcess(pid int) error {
	for _, subsystem := range subsystems.SubsystemsInstance {
		if err := subsystem.AddProcess(cm.Path, pid); err != nil {
			logrus.Warn("app process fail: %v", err)
		}
	}
	return nil
}

// delete the cgroup(s)
func (cm *CgroupManager) Remove() error {
	for _, subsystem := range subsystems.SubsystemsInstance {
		if err:= subsystem.RemoveCgroup(cm.Path); err != nil {
			return err
		}
	}
	return nil
}

3.5 管道处理多个容器参数

由于现在我们容器运行的命令不再是像 /bin/sh 这种单个参数,而是多个参数,因此我们需要使用管道来对多个参数进行处理。那么我们需要更改如下文件:

3.5.1 containerProcess.go

  1. 管道原理和 channel 很像,read 端和 write 端会在另一边没有响应的时候堵塞。
  2. 使用 os.Pipe() 获取管道。返回的 readPipe 和 writePipe 都是 *os.File 类型。
  3. 如何把管道传给子进程(也就是容器进程)变成了一个难题,这里用到了 ExtraFile 这个参数来解决。cmd 会带着参数里的文件来创建新的进程。(这里为什么是 extraFile,难道还有 standard file 吗?有的,就是 stdin,stdout,stderr)
  4. 这里把 read 端传给容器进程,然后 write 端保留在父进程上。
func NewProcess(tty bool, containerCmd string) (*exec.Cmd, *os.File) {
	
	readPipe, writePipe, err := os.Pipe()
	
	if err != nil {
		logrus.Errorf("New Pipe Error: %v", err)
		return nil, nil
	}
	// create a new command which run itself
	// the first arguments is `init` which is in the "container/init.go" file
	// so, the <cmd> will be interpret as "docker init <containerCmd>"
	cmd := exec.Command("/proc/self/exe", "init")

	// new namespaces, thanks to Linux
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET,
	}

	// this is what presudo terminal means
	// link the container's stdio to os
	if tty {
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
	}

	cmd.ExtraFiles = []*os.File{readPipe}
	return cmd, writePipe
}

3.5.2 container/init.go

init 函数也要改变一下了。

  1. 使用 readCommand 来读取 pipe。
  2. 实际运行中,当进程运行到 readCommand() 的时候会堵塞,直到 write 端传数据进来。
  3. 因此不用担心我们在容器运行后再传输参数。因为在读取完参数之前,init 函数也不会运行到 syscall.Exec 这一步。
  4. 注意这里我们添加了 lookPath,这个是用于解决每次我们都要输入 /bin/ls 的麻烦的,这个函数会帮我们找到参数命令的绝对路径。也就是说,你只需要输入 ls 即可,lookPath 会自动找到 /bin/ls 的。然后我们再把这个 path 作为 argv0 传给 syscall.Exec。
// already in container
// initiate the container
func InitProcess() error {
	
	// read command from pipe, will plug if write side is not ready
	containerCmd := readCommand()
	if containerCmd == nil || len(containerCmd) == 0 {
		return fmt.Errorf("Init process fails, containerCmd is nil")
	}
	defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV

	// mount proc filesystem
	syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")

	// look for the path of container command
	// so we don't need to type "/bin/ls", but "ls"
	path, err := exec.LookPath(containerCmd[0])
	if err != nil {
		logrus.Errorf("initProcess look path fails: %v", err)
		return err
	}
	
	// log path info
	// if you type "ls", it will be "/bin/ls"
	logrus.Infof("Find path: %v", path)
	if err := syscall.Exec(path, containerCmd, os.Environ()); err != nil {
		logrus.Errorf(err.Error())
	}

	return nil
}

func readCommand() []string {
	// 3 is the index of readPipe
	pipe := os.NewFile(uintptr(3), "pipe")
	msg, err := ioutil.ReadAll(pipe)
	if err != nil {
		logrus.Errorf("read pipe fails: %v", err)
		return nil
	}
	return strings.Split(string(msg), " ")
}

3.5.3 run.go

  1. 在 run.go 里向 writePipe 写入参数,这样容器就会获取到参数。
  2. 关闭 pipe,使得 init 进程继续运行。
// This is the function what `docker run` will call
func Run(tty bool, containerCmd []string, res *subsystems.ResourceConfig) {

	// this is "docker init <containerCmd>"
	initProcess, writePipe := container.NewProcess(tty)

	// start the init process
	if err := initProcess.Start(); err != nil{
		logrus.Error(err)
	}

	// create container manager to control resource config on all hierarchies
	cm := cgroups.NewCgroupManager("oyishyi-docker-first-cgroup")
	defer cm.Remove()
	cm.Set(res)
	cm.AddProcess(initProcess.Process.Pid)

	// send command to write side
	// will close the plug
	sendInitCommand(containerCmd, writePipe)
	
	initProcess.Wait()
	os.Exit(-1)
}

func sendInitCommand(containerCmd []string, writePipe *os.File) {
	cmdString := strings.Join(containerCmd, " ")
	logrus.Infof("whole init command is: %v", cmdString)
	writePipe.WriteString(cmdString)
	writePipe.Close()
}

3.5.4 commands.go

当然,这个也要改,不过很简单:

// docker init, but cannot be used by user
var initCommand = cli.Command{
	Name:  "init",
	Usage: "init a container",
	Action: func(context *cli.Context) error {
		logrus.Infof("Start initiating...")
		return container.InitProcess()
	},
}

// docker run
var runCommand = cli.Command{
	Name:  "run",
	Usage: "Create a container",
	Flags: []cli.Flag{
		// integrate -i and -t for convenience
		&cli.BoolFlag{
			Name:  "it",
			Usage: "open an interactive tty(pseudo terminal)",
		},
		&cli.StringFlag{
			Name: "m",
			Usage: "limit the memory",
		},&cli.StringFlag{
			Name: "cpu",
			Usage: "limit the cpu amount",
		},&cli.StringFlag{
			Name: "cpushare",
			Usage: "limit the cpu share",
		},
	},
	Action: func(context *cli.Context) error {
		args := context.Args()
		if args.Len() == 0 {
			return errors.New("Run what?")
		}
                
                // 转化 cli.Args 为 []string
		containerCmd := make([]string, args.Len())        // command
		for index, cmd := range args.Slice() {
			containerCmd[index] = cmd
		}

		// check whether type `-it`
		tty := context.Bool("it") // presudo terminal

		// get the resource config
		resourceConfig := subsystems.ResourceConfig{
			MemoryLimit: context.String("m"),
			CPUShare:    context.String("cpushare"),
			CPUAmount:   context.String("cpu"),
		}
		dockerCommands.Run(tty, containerCmd, &resourceConfig)

		return nil
	},
}

3.6 试用一下试试

运行下面的命令:

go run . run -it -m 100m stress --vm-bytes 200m --vm-keep -m 1

效果如下: image.png

  • cpuset 无法添加 pid 先忽略掉。