自己动手写Docker学习笔记

科技资讯 投稿 8000 0 评论

自己动手写Docker学习笔记

零、前言

书中有摘录书中的一些知识点,不过限于篇幅,没有全部摘录 (主要也是懒)。项目仓库地址为:JaydenChang/simple-docker (github.com)

一、概念篇

1. 基础知识

1.1 kernel
1.2 namespace

namespace 是 Linux 自带的功能来隔离内核资源的机制。

1.2.1 UTS Namespace

UTS,UNIX Time Sharing,用于隔离 nodeName (主机名) 和 domainName (域名)。在该 Namespace 下修改 hostname 不会影相其他的 Namespace。

1.2.2 IPC Namespace

两种 IPC 本质上差不多,System V IPC 随内核持续,POSIX IPC 随进程持续。

1.2.3 PID Namespace
1.2.4 Mount Namespace

用于隔绝文件系统,挂载了某一目录,在这个 Namespace 下就会把这个目录当作根目录,我们看到的文件系统树就会以这个目录为根目录。

1.2.5 User Namespace

用于 隔离用户组 ID。

1.2.6 Network Namespace
1.3 Linux Cgroups

Cgroups 全称为 Control Groups,是 Linux 内核提供的物理资源隔离机制。

1.3.1 Cgroups 的三个组件
    cgroup:一个 cgroup 包含一组进程,且可以有 subsystem 的参数配置,以关联一组 subsystem。
  • subsystem:一组资源控制的模块。
  • hierarchy:把一组 cgroups 串成一个树状结构,以提供继承的功能。
1.3.2 这三个组件的关联
    首先,创建一个 hierarchy。这个 hierarchy 有一个 cgroup 根节点,所有的进程都会被加到这个根节点上,所有在这个 hierarchy 上创建的节点都是这个根节点的子节点。
  • 一个 subsystem 只能加到一个 hierarchy 上。
  • 但是一个 subsystem 可以加到同一个 hierarchy 的多个 cgroups 上。
  • 一个 hierarchy 可以有多个 subsystem。
  • 一个进程可以在多个 cgroups 中,但是这些 cgroup 必须在不同的 hierarchy 中。
  • 一个进程 fork 出子进程时,父进程和子进程属于同一个 cgroup。
1.3.3 cgroup 和 subsystem 和 hierarchy 之间的联系
    hierarchy 就是一颗 cgroups 树,由多个 cgroups 构成。每一个 hierarchy 建立时会包含 所有 的Linux 进程。这里的 “所有” 就是当前系统运行中的所有进程,每个 hierarchy 上的全部进程都是一样的,不同的 hierarchy 指的其实只是不同的分组方式,这也是为什么一个进程可以存在于多个 hierarchy 上;准确来说,一个进程一定会同时存在于所有的 hierarchy 上,区别在被放在的 cgroup 可能会有差异。
  • Linux 的 subsystem 只有一个的说法,没有一种的说法,也就是在一个 hierarchy 上使用了 memory subsystem,那么在其他 hierarchy 就不能使用 memory subsystem 了。
  • subsystem 是一种资源控制器,有很多个 subsystem,每个 subsystem 控制不同的资源。subsystem 和 cgroups 关联。新建一个 cgroups 文件夹时,里面会自动生成一堆配置文件,那个就是 subsystem 配置文件。但 subsystem 配置文件 不是 subsystem,就像 .git 不是 git 一样,就像没安装 git 也可以从别人那里获得 .git 文件夹,只是不能用罢了。subsystem 配置文件 也是如此,新建一个 cgroup 就会生成 cgroup 配置文件,但并不代表你关联了一个 subsystem。只有当改变了一个 cgroup 配置文件,里面要限制某种资源时,就会自动关联到这个被限制的资源所对应的 subsystem 上。
  • 假设我的 Linux 有 12 个 subsystem,也就是说我最多只能建 12 个 hierarchy (不加 subsystem 的情况下可以建更多 hierarchy,这样 cgroup 就变成纯分组使用)。每一个 hierarchy 上一个 subsystem。如果在某个 hierarchy 放多个 subsystem,能建立的 hierarchy就更少了。
  • subsystem 和 cgroup 是关联的,不是和 hierarchy 关联的,但经常看到有人说把某个 subsystem 和某个 hierarchy 关联。实质上一般指的是 hierarchy 中的某一个 cgroup 或多个 cgroup 关联。
1.3.4 cgroup 的 kernel 接口

kernel 接口,就是在 Linux 上调用 api 来控制 cgroups。

    mkdir hierarchy-test
    
  1. 然后挂载:

    sudo mount -t cgroup -o none,name=hierarchy-test hierarchy-test ./hierarchy-test
    
  2. 可以在这个目录下看到一大堆文件,这些文件就是 cgroup 根节点的配置。

  3. .
    ├── cgroup.clone_children
    ├── cgroup.procs
    ├── cgroup.sane_behavior
    ├── notify_on_release
    ├── release_agent
    ├── tasks
    └── temp  # 这是新创建的文件夹
        ├── cgroup.clone_children
        ├── cgroup.procs
        ├── notify_on_release
        └── tasks
    
  4. 在 cgroup 中添加和移动进程:系统的所有进程都会被放到根节点中,可以根据需要移动进程:

      sudo sh -c "echo $$ >> tasks"
      

      该命令就是将当前终端的这个进程加到当前所在的 cgroup 的目录的 tasks 文件中。

      • 上面的方法有个问题,因为这个 hierarchy 没有关联到任何 subsystem,因此不能够控制资源。
    • 不过其实系统会自动给每个 subsystem 创建一个 hierarchy,所以通过控制这个 hierarchy 里的配置,可以达到控制进程的目的。
    1.3.5 docker 是怎么使用 Cgroups 的

    docker 会给每个容器创建一个 cgroup,再限制该 cgroup 的资源,从而达到限制容器的资源的作用。

    1.4 Demo
    package main
    
    import (
    	"fmt"
    	"io/ioutil"
    	"os"
    	"os/exec"
    	"path"
    	"strconv"
    	"syscall"
    )
    
    const cgroupMemoryHierarchyCount = "/sys/fs/cgroup/memory"
    
    func main() {
        // 第二次会运行这段代码
        // 这段代码运行的地方就可以看做是一个简易的容器
        // 这里只是对进程进行了隔离
        // 但是可以看到 pid 已经变成了 1,因为我们有 PID Namespace
        if os.Args[0] == "/proc/self/exe" {
            fmt.Printf("current pid %d\n", syscall.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
            if err := cmd.Run(); err != nil {
                fmt.Println(err)
                os.Exit(1)
            }
        }
        
        // 第一次运行这段
        // **command 设置为当前进程,也就是这个 go 程序本身,也就是说 cmd.Start() 会再次运行该程序
        cmd := exec.Command("/proc/self/exe")
        // 在 start 之前,修改 cmd 的各种配置,也就是第二次运行这个程序的时候的配置
    	// 创建 namespace
        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
        
        // 因为之后要打印 process 的 id,所以用 start
        // 如果这里用 run 的话,那么 else 里的代码永远不会执行,因为 stress 永远不会结束
        if err := cmd.Start(); err != nil {
            fmt.Println("Error", err)
            os.Exit(1)
        } else {
            // 打印 new process id
            fmt.Printf("%v\n", cmd.Process.Pid)
            
            // 接下来三段对 cgroup 操作
            // the hierarchy has been already created by linux on the memory subsystem
            // create a sub cgroup   
            os.Mkdir(path.Join(
                cgroupMemoryHierarchyCount,
                "testMemoryLimit",
            ), 0755)
            
            // place container process in this cgroup
            ioutil.WriteFile(path.Join(
                cgroupMemoryHierarchyCount,
                "testMemoryLimit",
                "tasks",
            ), []byte(strconv.Itoa(cmd.Process.Pid)), 0644)
            
            // restrict the stress process on this cgroup
            ioutil.WriteFile(path.Join(
            	cgroupMemoryHierarchyCount,
                "testMemoryLimit",
                "memory.limit_int_bytes",
            ), []byte("100m"), 0644)
            
            // cmd.Start() 不会等待进程结束,所以需要手动等待
            // 如果不加的话,由于主进程结束了,子进程也会被强行结束
            cmd.Process.Wait()
        }
    }
    
    1.5 UFS
    1.5.1 UFS 概念

    UFS,Union File System,联合文件系统。docker 在下载一个 image 文件时,会看到一次下载很多个文件,这就是 UFS。它是一种分层、轻量、高性能的文件系统。UFS 类似 git,每次修改文件时,都是一次提交,并有记录,修改都反映在一个新的文件上,而不是修改旧文件。

    1.5.2 AUFS

    AUFS,Advanced Union File System,是 UFS 的一个改动版本。

    1.5.3 docker 和 AUFS

    docker 在早期使用 AUFS,直到现在也可以选择作为一种存储驱动类型。

    1.5.4 image layer

    当启动一个 container 时,就会在 image 上再加一层 init layer,init layer 也是 read-only 的,用于储存容器的环境配置。此外,docker 还会创建一个 read-write 的 layer,用于执行所有的写操作。

    那么,怎么删除旧文件呢?

    .wh.<fileName> 文件来隐藏要删除的文件。

    1.5.5 实现一个 AUFS
    .
    ├── container-layer
    │   └── container.txt
    ├── image-layer
    │   └── image.txt
    └── mnt
    

    然后挂载到 mnt 文件夹上:

    sudo mount -t aufs -o dirs=./container-layer:./image-layer none ./mnt
    

    如果没有手动添加权限的话,默认 dirs 左边第一个文件夹有 write-read 权限,其他都是 read-only。

    .
    ├── container-layer
    │   └── container.txt
    ├── image-layer
    │   └── image.txt
    └── mnt
        ├── container.txt
        └── image.txt
    

    然后我们修改一下 image.txt 的内容,然后再看看整个目录,会发现,container-layer 目录下多了一个 image.txt,然后我们看看 container-layerimage.txt 的内容,有添加前后的的文字。

    二、容器篇

    2. Linux 的 /proc 文件夹

    2.1 PID

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

    2.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 # 链接到当前正在运行的进程
    

    3. 简单实现

    3.1 工具

    获取帮助编写 command line app 的工具:

    go get github.com/urfave/cli 
    
    3.2 实现代码

    代码结构:

    .
    ├── command.go
    ├── container
    │   └── init.go
    ├── dockerCommand
    │   └── run.go
    ├── go.mod
    ├── go.sum
    └── main.go
    
    3.2.1 runCommand
    command.go 用于放置各种 command 命令,这里先只写一个 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 len(args) == 0 {
                return errors.New("Run what?")
            }
            cmdArray := args.Get(0)        // command
    
            // check whether type `-it`
            tty := context.Bool("it") // presudo terminal
    
                    // 这个函数在下面定义
            dockerCommand.Run(tty, cmdArray)
    
            return nil
        },
    }
    
    3.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, cmdArray string) {
    
    	// this is "docker init <cmdArray>"
    	initProcess := container.NewProcess(tty, cmdArray)
    
    	// start the init process
    	if err := initProcess.Start(); err != nil{
    		logrus.Error(err)
    	}
    
    	initProcess.Wait()
    	os.Exit(-1)
    }
    

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

    3.2.3 NewProcess

    container.NewProcess 在 container/init.go 里定义:

    // container/init.go
    func NewProcess(tty bool, cmdArray 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 <cmdArray>"
    	args := []string{"init", cmdArray}
    	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 这个程序本身,也就是,我们最后生成的可执行文件,但这次我们不运行 docker run,而是 docker init,这个 init 命令在下面定义。

    3.2.4 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...")
    		cmdArray := context.Args().Get(0)
    		logrus.Infof("container command: %v", cmdArray)
    		return container.InitProcess(cmdArray, nil)
    	},
    }
    

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

    3.2.5 InitProcess

    注意 syscall.Exec 这里:

      就是 mount / 并指定 private,不然容器里的 proc 会使用外面的 proc,即使在不同 namespace 下。
    • 所以如果没有加这一段,其实退出容器后还需要在外面再次 mount proc 才能使用 ps 等命令
    // already in container
    // initiate the container
    func InitProcess(cmdArray 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{cmdArray}
    	if err := syscall.Exec(cmdArray, argv, os.Environ()); err != nil {
    		logrus.Errorf("mount /proc fails: %v", err)
    	}
    	
    	return nil
    }
    

    一般来说,我们都是想要这个 cmdArray 作为 PID=1 的进程。but,我们有 initProcess 本身的存在,所以 PID = 1 的其实是 initProcess,那如何让 cmdArray 作为 PID=1 的存在呢?

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

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

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

    4.1 subsystem.go
      根据 subsystem 的特性,和接口很搭。
    • 此外再定义一个 ResourceConfig 的类型,用于放置资源控制的配置。
    • subsystemInstance 里包括 3 个 subsystem,分别对 memory,cpu,cpushare 进行限制。因为我们只需要对整个容器进行限制,所以这一套 3 个够了。

    看到这里,有个 cpu,cpushare,cpuset 等等,有点晕,查了下,有关 CPU 的 cgroup subsystem,这里列举常见的 3 个:

      cpu:经常看到的 cpushares 在其麾下,share 即相对权重的 cpu 调度,用来限制 cgroup 的 cpu 的使用率
    • cpuacct:统计 cgroup 的 cpu 使用率
    • cpuset:在多核机器上设置 cgroups 可使用的 cpu 核心数和内存
    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{
    	&CPU{},
    	&CPUSet{},
    	&Memory{},
    }
    
    4.2 MemorySubsystem
    4.2.1 Name()

    很简单,返回 “memory” 字符串,表示这个 subsystem 是 memorySubsystem。

    func (ms *MemorySubsystem) Name() string {
        return "memory"
    }
    
    4.2.2 Set()

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

      其中 GetCgroupPath 后面会提及,作用是获取这个 subsystem 所挂载的 hierarchy 上的虚拟文件系统下的 从group 路径。
    1. 获取到 cgroupPath 在虚拟文件系统中的位置后,只需要写入 "memory.limit_in_bytes" 文件中即可。
    // set the memory limit to this cgroup with cgroupPath
    func (ms *Memory) 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
    }
    
    4.2.3 AddProcess()
      和上面基本一样,只不过是写到 tasks 里。
    1. pid 变成 byte slice 之前要用 Itoa 转化一下。
    func (ms *Memory) 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
    }
    
    4.2.4 RemoveCgroup()
      使用 os.Remove 可以移除参数所指定的文件或文件夹。
    1. 这里移除整个 cgroup 文件夹,就等于是删除 cgroup 了。
    func (ms *Memory) RemoveCgroup(cgroupPath string) error {
    	if subsystemCgroupPath, err := GetCgroupPath(ms.Name(), cgroupPath, false); err != nil {
    		return err
    	} else {
    		return os.Remove(subsystemCgroupPath)
    	}
    }
    
    4.3 CPUSubsystem

    这里的设计和上面没什么区别,直接贴参考代码

    // cpu.go
    func (c *CPU) Name() string {
    	return "CPUShare"
    }
    
    func (c *CPU) Set(cgroupPath string, res *ResourceConfig) error {
    	if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, true); err != nil {
    		return err
    	} else {
    		if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "cpu.shares"), []byte(res.CPUShare), 0644); err != nil {
    			return fmt.Errorf("set cpu share limit failed: %s", err)
    		}
    	}
    	return nil
    }
    
    func (c *CPU) AddProcess(cgroupPath string, pid int) error {
    	if subsystemCgroupPath, err := GetCgroupPath(c.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 cpu process failed: %v", err)
    		}
    	}
    	return nil
    }
    
    func (c *CPU) RemoveCgroup(cgroupPath string) error {
    	if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, false); err != nil {
    		return err
    	} else {
    		return os.Remove(subsystemCgroupPath)
    	}
    }
    
    4.4 CPUSetSubsystem
    // cpuset.go
    func (c *CPUSet) Name() string {
    	return "CPUSet"
    }
    
    func (c *CPUSet) Set(cgroupPath string, res *ResourceConfig) error {
    	if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, true); err != nil {
    		return err
    	} else {
    		if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "cpuset.cpus"), []byte(res.CPUSet), 0644); err != nil {
    			return fmt.Errorf("set cgroup cpuset failed: %v", err)
    		}
    	}
    	return nil
    }
    
    func (c *CPUSet) AddProcess(cgroupPath string, pid int) error {
    	if subsystemCgroupPath, err := GetCgroupPath(c.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 cpuset process failed: %v", err)
    		}
    	}
    	return nil
    }
    
    func (c *CPUSet) RemoveCgroup(cgroupPath string) error {
    	if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, false); err != nil {
    		return err
    	} else {
    		return os.Remove(path.Join(subsystemCgroupPath))
    	}
    }
    
    4.5 GetCgroupPath()
    GetCgroupPath() 用于获取某个 subsystem 所挂载的 hierarchy 上的虚拟文件系统 (挂载后的文件夹) 下的 cgroup 的路径。通过对这个目录的改写来改动 cgroup。
    

    /proc/self/mountinfo 中获取。

      首先定义一个 FindCgroupMountpoint() 来找到 cgroup 的根节点。
    1. 然后在 GetCgroupPath 将其和 cgroup 的相对路径拼接从而获取 cgroup 的路径。如果 autoCreate 为 true 且该路径不存在,那么就新建一个 cgroup。(在 hierarchy 环境下,mkdir 其实会隐式地创建一个 cgroup,其中包括很多配置文件)

    点击这里回顾

    // as the function name shows, find the root path of hierarchy
    func FindCgroupMountpoint(subsystemName string) string  {
    	f, err := os.Open("/proc/self/mountinfo")
        // get info about mount relate to current process
    	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 := FindCgroupMountpoint(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)
    	}
    }
    
    4.6 cgroupsManager.go
      定义 CgroupManager 类型,其中的 path 要注意是相对路径,相对于 hierarchy 的 root path。所以一个 CgroupManager 是有可能表示多个 cgroups 的,或准确说,和对应的 hierarchy root path 的相对路径一样的多个 cgroups。
    1. 因为上述原因,Set() 可能会创建多个 cgroups,如果 subsystems 们在不同的 hierarchy 就会这样。
    2. 这也是为什么 AddProcess()Remove() 要在每个 subsystem 上执行一遍。因为这些 subsystem 可能存在于不同的 hierarchies。
    3. 注意 Set()AddProcess() 都不是返回错误,而是发出警告,然后返回 nil。因为有些时候用户只指定某一个限制,例如 memory,那样的话修改 cpu 等其实会报错 (正常的报错),因此我们不 return err 来退出。
    package cgroups
    
    import "simple-docker/subsystem"
    
    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
    }
    
    4.7 管道处理多个容器参数

    限制容器运行的命令不再像是 /bin/sh 这种单个参数,而是多个参数,因此需要使用管道来对多个参数进行处理。那么需要修改以下文件:

    4.7.1 container/init.go
      管道原理和 channel 很像,read 端和 write 端会在另一边没响应时堵塞。
    1. 使用 os.Pipe() 获取管道。返回的 readPipe 和 writePipe 都是 *os.File 类型。
    2. 如何把管道传给子进程 (也就是容器进程) 变成了一个难题,这里用到了 ExtraFile 这个参数来解决。cmd 会带着参数里的文件来创建新的进程。(这里除了 ExtraFile,还会有类似 StandardFile,也就是 stdin,stdout,stderr)
    3. 这里把 read 端传给容器进程,然后 write 端保留在父进程上。
    func NewParentProcess(tty bool) (*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
    	cmd := exec.Command("/proc/self/exe", "init")
    
    	// new namespaces
    	cmd.SysProcAttr = &syscall.SysProcAttr{
    		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET,
    	}
    
    	// 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
    }
    

    除了 NewProcess()InitProcess() 也要改变下。

      使用 readCommand 来读取 pipe。
    1. 实际运行中,当进程运行到 readCommand() 时会堵塞,直到 write 端传数据进来。
    2. 因此不用担心我们在容器运行后再传输参数。因为再读取完参数之前,InitProcess() 也不会运行到 syscall.Exec() 这一步。
    3. 这里添加了 lookPath,这个是用于解决每次我们都要输入 /bin/ls 的麻烦,这个函数会帮我们找到参数命令的绝对路径。也就是说,只要输入 ls 即可,lookPath 会自动找到 /bin/ls。然后我们再把这个 path 作为 argv() 传给 syscall.Exec
    // already in container
    // initialize the container
    func InitProcess() error {
    	cmdArray := readCommand()
    	if len(cmdArray) == 0 {
    		return fmt.Errorf("init process fails, cmdArray is nil")
    	}
    
    	defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
    
    	// mount proc filesystem
    	syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
    	path, err := exec.LookPath(cmdArray[0])
    	if err != nil {
    		logrus.Errorf("initProcess look path failed: %v", err)
    		return err
    	}
    
    	// log path info
    	logrus.Infof("find path: %v", path)
    	if err := syscall.Exec(path, cmdArray, os.Environ()); err != nil {
    		logrus.Errorf(err.Error())
    	}
    	return nil
    }
    
    func readCommand() []string {
    	pipe := os.NewFile(uintptr(3), "pipe")
    	msg, err := ioutil.ReadAll(pipe)
    	if err != nil {
    		logrus.Errorf("read pipe failed: %v", err)
    		return nil
    	}
    	return strings.Split(string(msg), " ")
    }
    
    4.7.2 dockerCommand/run.go
      在 run.go 向 writePipe 写入参数,这样容器就会获取到参数。
    1. 关闭 pipe,使得 init 进程继续进行。
    func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig) {
    	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("simple-docker")
    	defer cm.Remove()
    	cm.Set(res)
    	cm.AddProcess(initProcess.Process.Pid)
    
    	// send command to write side
    	sendInitCommand(cmdArray, writePipe)
    
    	initProcess.Wait()
    	os.Exit(-1)
    }
    
    func sendInitCommand(cmdArray []string, writePipe *os.File) {
    	cmdString := strings.Join(cmdArray, " ")
    	logrus.Infof("whole init command is: %v", cmdString)
    	writePipe.WriteString(cmdString)
    	writePipe.Close()
    }
    
    4.7.3 command.go
    var RunCommand = cli.Command{
    	Name:  "run",
    	Usage: "Create a container",
    	Flags: []cli.Flag{
    		&cli.BoolFlag{
    			Name:  "it",
    			Usage: "open a interactive tty(pre sudo 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 len(args) == 0 {
    			return errors.New("run what?")
    		}
    		cmdArray := make([]string,len(args)) // command		
    		copy(cmdArray,args)
    
    		// checkout whether type `-it`
    		tty := context.Bool("it") // pre sudo terminal
    
    		// get the resource config
    		resourceConfig := subsystem.ResourceConfig {
    			MemoryLimit: context.String("m"),
    			CPUShare: context.String("cpushare"),
    			CPUSet: context.String("cpu"),
    		}
    
    		dockerCommand.Run(tty, cmdArray, &resourceConfig)
    		return nil
    	},
    }
    
    // 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 initializing...")
    		return container.InitProcess()
    	},
    }
    
    4.7.4 main.go

    除了上面的修改,我们还要定义一个程序的入口:

    package main
    
    import (
    	"os"
    	"github.com/sirupsen/logrus"
    	"github.com/urfave/cli"
    )
    
    const usage = `Usage`
    
    func main() {
    	app := cli.NewApp()
    	app.Name = "simple-docker"
    	app.Usage = usage
    	app.Commands = []cli.Command{
    		RunCommand,
    		InitCommand,
    	}
    	app.Before = func(context *cli.Context) error {
    		logrus.SetFormatter(&logrus.JSONFormatter{})
    		logrus.SetOutput(os.Stdout)
    		return nil
    	}
    	if err := app.Run(os.Args); err != nil {
    		logrus.Fatal(err)
    	}
    }
    
    4.8 运行 demo
    go run . run -it stress -m 100m --vm-bytes 200m --vm-keep -m 1
    

    效果如下:

    go run . run -it /bin/sh
    

    三、镜像篇

    5. 构造镜像

    5.1 编译 aufs 内核

    因为电脑硬盘空间不太够,就不使用虚拟机来做实验了,笔者这里使用 WSL2 来完成后续工作,然而,WSL2 Kernel 没有把 aufs 编译进去,那只能换内核了,查阅资料,有两种更换内核的方法:

      C:\System32\lxss\tools\kernel 文件

    • .wslconfig 文件:

      [wsl2]
      kernel="要替换kernel的路径"
      

    很明显,我是不会满足于使用别人编译好的内核的,那我也来动手做一个。

    5.1.1 准备代码库
    apt update #更新源
    apt install build-essential flex bison libssl-dev libelf-dev gcc make
    

    编译内核需要从 GitHub 上 clone 微软官方的 WSL 代码和 AUFS-Standalone 的代码库

    git clone https://github.com/microsoft/WSL2-Linux-Kernel kernel
    git clone https://github.com/sfjro/aufs-standalone aufs5
    

    然后查看 WSL 内核版本:在 wsl 下运行命令 uname -r

    cd aufs5
    git checkout aufs5.15.36
    

    然后退回到 kernel 文件夹给代码打补丁:

    cat ../aufs5/aufs5-mmap.patch | patch -p1
    cat ../aufs5/aufs5-base.patch | patch -p1
    cat ../aufs5/aufs5-kbuild.patch | patch -p1
    

    三个 Patch 的顺序无关。

    cp ../aufs5/Documentation . -r
    cp ../aufs5/fs/ . -r
    cp ../aufs5/include/uapi/linux/aufs_type.h ./include/uapi/linux
    

    接下来我们来修改一下编译配置,在 Microsoft/config-wsl 中任意位置增加一行:

    CONFIG_AUFS_FS=y
    

    最后,就可以开始编译了!

    make KCONFIG_CONFIG=Microsoft/config-wsl -j8
    

    过程中会问你一些问题,我除了 AUFS Debug 都选了 y。

    vmlinuz,在 arch/x86/boot 下生成 bzImage

    grep aufs /proc/filesystems验证结果,如果出现 aufs 的字样,说明操作成功。

    5.2 使用 busybox 创建容器
    5.2.1 busybox
    docker pull busybox
    docker run -d busybox top -b
    docker export -o busybox.tar <container_id>
    

    将其复制到 WSL 下并解压。

    5.2.2 pivot_root
    func pivotRoot(root string) error {
    	// remount the root dir, in order to make current root and old root in different file systems
    	if err := syscall.Mount(root, root, "bind", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
    		return fmt.Errorf("mount rootfs to itself error: %v", err)
    	}
    
    	// create 'rootfs/.pivot_root' to store old_root
    	pivotDir := filepath.Join(root, ".pivot_root")
    	if err := os.Mkdir(pivotDir, 0777); err != nil {
    		return err
    	}
    
    	// pivot_root mount on new rootfs, old_root mount on rootfs/.pivot_root
    	if err := syscall.PivotRoot(root, pivotDir); err != nil {
    		return fmt.Errorf("pivot_root %v", err)
    	}
    
    	// change current work dir to root dir
    	if err := syscall.Chdir("/"); err != nil {
    		return fmt.Errorf("chdir / %v", err)
    	}
    
    	pivotDir = filepath.Join("/", ".pivot_root")
    	// umount rootfs/.rootfs_root
    	if err := syscall.Unmount(pivotDir, syscall.MNT_DETACH); err != nil {
    		return fmt.Errorf("umount pivot_root dir %v", err)
    	}
    
    	// del the temporary dir
    	return os.Remove(pivotDir)
    }
    

    有了这个函数就可以在 init 容器进程时,进行一系列的 mount 操作:

    func setUpMount() error {
    	// get current path
    	pwd, err := os.Getwd()
    	if err != nil {
    		logrus.Errorf("get current location error: %v", err)
    		return err
    	}
    	logrus.Infof("current location: %v", pwd)
    	pivotRoot(pwd)
    
    	// mount proc
    	defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
    	if err := syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), ""); err != nil {
    		logrus.Errorf("mount /proc failed: %v", err)
    		return err
    	}
    
    	if err := syscall.Mount("tmpfs", "/dev", "tmpfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755"); err != nil {
    		logrus.Errorf("mount /dev failed: %v", err)
    		return err
    	}
    	return nil
    }
    

    tmpfs 是一种基于内存的文件系统,用 RAM 或 swap 分区来存储。

    NewParentProcess() 中加一句 cmd.Dir="/root/busybox"

    initProcess() 中调用一下:

    if err := setUpMount(); err != nil {
        logrus.Errorf("initProcess look path failed: %v", err)
    }
    

    然后来运行测试一下:

    root@Jayden: ~# go run . run -it sh
    ###### dividing live	
    {"level":"info","msg":"Start initiating...","time":"2023-05-04T11:27:04+08:00"}
    {"level":"info","msg":"whole init command is: sh","time":"2023-05-04T11:27:04+08:00"}
    {"level":"info","msg":"current location: /root/busybox","time":"2023-05-04T11:27:04+08:00"}
    {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-04T11:27:04+08:00"}
    / #
    

    可以看到,容器当前目录被虚拟定位到了根目录,其实是在宿主机上映射的 /root/busybox

    5.2.3 用 AUFS 包装 busybox
      CreateReadOnlyLayer() 新建 busybox 文件夹,解压 busybox.tarbusybox 目录下,作为容器只读层。
    • CreateWriteLayer() 新建一个 writeLayer 文件夹,作为容器唯一可写层。
    • CreateMountPoint() 先创建了 mnt 文件夹作为挂载点,再把 writeLayer 目录和 busybox 目录 mount 到 mnt 目录下。
    // extra tar to 'busybox', used as the read only layer for container
    func CreateReadOnlyLayer(rootURL string) {
    	busyboxURL := rootURL + "busybox/"
    	busyboxTarURL := rootURL + "busybox.tar"
    	exist, err := PathExists(busyboxURL)
    
    	if err != nil {
    		logrus.Infof("fail to judge whether dir %s exists. %v", busyboxURL, err)
    	}
    	if !exist {
    		if err := os.Mkdir(busyboxURL, 0777); err != nil {
    			logrus.Errorf("mkdir dir %s error. %v", busyboxURL, err)
    		}
    		if _, err := exec.Command("tar", "-xvf", busyboxTarURL, "-C", busyboxURL).CombinedOutput(); err != nil {
    			logrus.Errorf("unTar dir %s error %v", busyboxTarURL, err)
    		}
    	}
    }
    
    // create a unique folder as writeLayer
    func CreateWriteLayer(rootURL string) {
    	writeURL := rootURL + "writeLayer/"
    	if err := os.Mkdir(writeURL, 0777); err != nil {
    		logrus.Errorf("mkdir dir %s error %v", writeURL, err)
    	}
    }
    
    func CreateMountPoint(rootURL string, mntURL string) {
    	// create mnt folder as mount point
    	if err := os.Mkdir(mntURL, 0777); err != nil {
    		logrus.Errorf("mkdir dir %s error %v", mntURL, err)
    	}
    	// mount 'writeLayer' and 'busybox' to 'mnt'
    	dirs := "dirs=" + rootURL + "writeLayer:" + rootURL + "busybox"
    	cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", mntURL)
    	cmd.Stdout = os.Stdout
    	cmd.Stderr = os.Stderr
    	if err := cmd.Run(); err != nil {
    		logrus.Errorf("%v", err)
    	}
    }
    
    func NewWorkSpace(rootURL, mntURL string) {
    	CreateReadOnlyLayer(rootURL)
    	CreateWriteLayer(rootURL)
    	CreateMountPoint(rootURL, mntURL)
    }
    

    接下来在 NewParentProcess() 将容器使用的宿主机目录 /root/busybox 替换为 /root/mnt,这样使用 AUFS 系统启动容器的代码就完成了。

    cmd.ExtraFiles = []*os.File{readPipe}
    mntURL := "/root/mnt/"
    rootURL := "/root/"
    NewWorkSpace(rootURL, mntURL)
    cmd.Dir = mntURL
    return cmd, writePipe
    

    docker 会在删除容器时,把容器对应的 write layer 和 container-init-layer 删除,而保留镜像中所有的内容。

      DeleteMountPoint() 中 umount mnt 目录。
    • 删除 mnt 目录。
    • DeleteWriteLayer() 删除 writeLayer 文件夹。
    func DeleteMountPoint(rootURL string, mntURL string) {
    	cmd := exec.Command(rootURL, mntURL)
    	cmd.Stdout = os.Stdout
    	cmd.Stderr = os.Stderr
    	if err := cmd.Run(); err != nil {
    		logrus.Errorf("%v", err)
    	}
    	if err := os.RemoveAll(mntURL); err != nil {
    		logrus.Errorf("remove dir %s error %v", mntURL, err)
    	}
    }
    
    func DeleteWriteLayer(rootURL string) {
    	writeURL := rootURL + "writeLayer/"
    	if err := os.RemoveAll(writeURL); err != nil {
    		logrus.Errorf("remove dir %s error %v", writeURL, err)
    	}
    }
    
    func DeleteWorkSpace(rootURL, mntURL string) {
    	DeleteMountPoint(rootURL, mntURL)
    	DeleteWriteLayer(rootURL)
    }
    

    现在来启动一个容器测试:

    root@Jayden: ~# go run . run -it sh
    dirs=/root/writeLayer:/root/busybox
    {"level":"info","msg":"Start initiating...","time":"2023-05-04T15:16:43+08:00"}
    {"level":"info","msg":"whole init command is: sh","time":"2023-05-04T15:16:43+08:00"}
    {"level":"info","msg":"current location: /root/mnt","time":"2023-05-04T15:16:43+08:00"}
    {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-04T15:16:43+08:00"}
    / #
    

    测试在容器内创建文件:

    / # mkdir aaa
    / # touch aaa/test.txt
    

    此时我们可以在宿主机终端查看 /root/mnt/writeLayer,可以看到刚才新建的 aaa 文件夹和 test.txt,在我们退出容器后,/root/mnt 文件夹被删除,伴随着刚才创建的文件夹和文件都被删除,而作为镜像的 busybox 仍被保留,且内容未被修改。

    5.3 实现 volume 数据卷

    先在 command.go 里添加 -v 标签:

    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",
    		},
             // add `-v` tag
             &cli.StringFlag{
    			Name:  "v",
    			Usage: "volume",
    		},
    	},
    	Action: func(context *cli.Context) error {
    		args := context.Args()
    		if len(args) <= 0 {
    			return errors.New("run what?")
    		}
    
    		// 转化 cli.Args 为 []string
    		cmdArray := make([]string, len(args)) // command
    		copy(cmdArray, args)
    
    		// check whether type `-it`
    		tty := context.Bool("it") // presudo terminal
    
    		// get the resource config
    		resourceConfig := subsystem.ResourceConfig{
    			MemoryLimit: context.String("m"),
    			CPUShare:    context.String("cpushare"),
    			CPUSet:      context.String("cpu"),
    		}
             // send volume args to Run()
    		volume := context.String("v")
    		dockerCommand.Run(tty, cmdArray, &resourceConfig,volume)
    
    		return nil
    	},
    }
    

    Run() 中,把 volume 传给创建容器的 NewParentProcess() 和删除容器文件系统的 DeleteWorkSpace()

    func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume string) {
    
    	// this is "docker init <cmdArray>"
    	initProcess, writePipe := container.NewParentProcess(tty, volume)
    	if initProcess == nil {
    		logrus.Errorf("new parent process error")
    		return
    	}
    
    	// start the init process
    	if err := initProcess.Start(); err != nil {
    		logrus.Error(err)
    	}
    
    	// create container manager to control resource config on all hierarchies
    	cm := cgroup.NewCgroupManager("simple-docker-container")
    	defer cm.Remove()
    	cm.Set(res)
    	cm.AddProcess(initProcess.Process.Pid)
    
    	// send command to write side
    	// will close the plug
    	sendInitCommand(cmdArray, writePipe)
    
    	initProcess.Wait()
    	rootURL := "/root/"
    	mntURL := "/root/mnt/"
    	container.DeleteWorkSpace(rootURL, mntURL, volume)
    	os.Exit(0)
    }
    

    NewWorkSpace() 中,继续把 volume 传给创建容器文件系统的 NewWorkSapce()

      创建只读层。
    • 创建容器读写层。
    • 创建挂载点并把只读层和读写层挂载到挂载点上。
    • 判断 volume 是否为空,如果是,说明用户没有使用挂载标签,结束创建过程。
    • 不为空,就用 volumeURLExtract() 解析。
    • volumeURLExtract() 返回字符数组长度为 2,且数据元素均不为空时,则执行 MountVolume() 来挂载数据卷。
      • 否则提示用户创建数据卷输入值不对。
    func NewWorkSpace(rootURL, mntURL, volume string) {
    	CreateReadOnlyLayer(rootURL)
    	CreateWriteLayer(rootURL)
    	CreateMountPoint(rootURL, mntURL)
    	if volume != "" {
    		volumeURLs := volumeUrlExtract(volume)
    		length := len(volumeURLs)
    		if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" {
    			MountVolume(rootURL, mntURL, volumeURLs)
    			logrus.Infof("%q", volumeURLs)
    		} else {
    			logrus.Infof("volume parameter input is not correct")
    		}
    	}
    }
    
    func volumeUrlExtract(volume string) []string {
    	// divide volume by ":"
    	return strings.Split(volume, ":")
    }
    

    挂载数据卷过程如下:

      读取宿主机文件目录 URL,创建宿主机文件目录 (/root/${parentURL})
    • 读取容器挂载点 URL,在容器文件系统里创建挂载点 (/root/mnt/${containerURL})
    • 把宿主机文件目录挂载到容器挂载点,这样启动容器的过程,对数据卷的处理就完成了。
    func MountVolume(rootURL, mntURL string, volumeURLs []string) {
    	// create host file catalog
    	parentURL := volumeURLs[0]
    	if err := os.Mkdir(parentURL, 0777); err != nil {
    		logrus.Infof("mkdir parent dir %s error. %v", parentURL, err)
    	}
    	// create mount point in container file system
    	containerURL := volumeURLs[1]
    	containerVolumeURL := mntURL + containerURL
    	if err := os.Mkdir(containerVolumeURL, 0777); err != nil {
    		logrus.Infof("mkdir container dir %s error. %v", containerVolumeURL, err)
    	}
    	// mount host file catalog to mount point in container
    	dirs := "dirs=" + parentURL
    	cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", containerVolumeURL)
    	cmd.Stdout = os.Stdout
    	cmd.Stderr = os.Stderr
    	if err := cmd.Run(); err != nil {
    		logrus.Errorf("mount volume failed. %v", err)
    	}
    }
    

    删除容器文件系统过程如下:

      在 volume 不为空,且使用 volumeURLExtract() 解析 volume 字符串返回的字符数组长度为 2,数据元素均不为空时,才执行 DeleteMountPointWithVolume() 来处理。
    • 其余情况仍使用前面的 DeleteMountPoint()
    func DeleteWorkSpace(rootURL, mntURL, volume string) {
    	if volume != "" {
    		volumeURLs := volumeUrlExtract(volume)
    		length := len(volumeURLs)
    		if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" {
    			DeleteMountPointWithVolume(rootURL, mntURL, volumeURLs)
    		} else {
    			DeleteMountPoint(rootURL, mntURL)
    		}
    	} else {
    		DeleteMountPoint(rootURL, mntURL)
    	}
    	DeleteWriteLayer(rootURL)
    }
    
    DeleteMountPointWithVolume() 处理逻辑如下:
    
      卸载 volume 挂载点的文件系统 (/root/mnt/${containerURL}),保证整个容器挂载点没有再被使用。
    • 卸载整个容器文件系统挂载点 (/root/mnt)。
    • 删除容器文件系统挂载点。
    func DeleteMountPointWithVolume(rootURL, mntURL string, volumeURLs []string) {
    	// umount volume point in container
    	containerURL := mntURL + volumeURLs[1]
    	cmd := exec.Command("umount", containerURL)
    	cmd.Stdout = os.Stdout
    	cmd.Stderr = os.Stderr
    	if err := cmd.Run(); err != nil {
    		logrus.Errorf("umount volume failed. %v", err)
    	}
    	// umount the whole point of the container
    	cmd = exec.Command("umount", mntURL)
    	cmd.Stdout = os.Stdout
    	cmd.Stderr = os.Stderr
    	if err := cmd.Run(); err != nil {
    		logrus.Errorf("umount mountpoint failed. %v", err)
    	}
    	if err := os.RemoveAll(mntURL); err != nil {
    		logrus.Infof("remove mountpoint dir %s error %v", mntURL, err)
    	}
    }
    

    接下来启动容器测试:

    # go run . run -it -v /root/volume:/containerVolume sh
    {"level":"info","msg":"[\"/root/volume\" \"/containerVolume\"]","time":"2023-05-05T09:25:43+08:00"}
    {"level":"info","msg":"whole init command is: sh","time":"2023-05-05T09:25:43+08:00"}
    {"level":"info","msg":"Start initiating...","time":"2023-05-05T09:25:43+08:00"}
    {"level":"info","msg":"current location: /root/mnt","time":"2023-05-05T09:25:43+08:00"}
    {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-05T09:25:43+08:00"}
    / # ls
    bin              dev              home             lib64            root             tmp              var
    containerVolume  etc              lib              proc             sys              usr
    / #
    

    进入 containerVolume,创建一个 文本文件,并随便写点东西:

    cd containerVolume
    echo -e "test" >> test.txt
    

    此时我们能在宿主机的 /root/volume 找到我们刚才创建的文本文件。退出容器后,volume 文件夹也没有被删除。再次进入容器:

    r# go run . run -it -v /root/volume:/containerVolume sh
    {"level":"info","msg":"mkdir parent dir /root/volume error. mkdir /root/volume: file exists","time":"2023-05-05T09:29:24+08:00"}
    {"level":"info","msg":"[\"/root/volume\" \"/containerVolume\"]","time":"2023-05-05T09:29:24+08:00"}
    {"level":"info","msg":"whole init command is: sh","time":"2023-05-05T09:29:24+08:00"}
    {"level":"info","msg":"Start initiating...","time":"2023-05-05T09:29:24+08:00"}
    {"level":"info","msg":"current location: /root/mnt","time":"2023-05-05T09:29:24+08:00"}
    {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-05T09:29:24+08:00"}
    / # ls
    bin              dev              home             lib64            root             tmp              var
    containerVolume  etc              lib              proc             sys              usr
    / # ls containerVolume/
    test.txt
    

    此时这里会提示 volume 文件夹存在,我们在 test.txt 内追加内容:

    cd containerVolume
    echo -e "###" >> test.txt
    

    此时再次退出容器,能看到修改过后的文件内容,可以看到 volume 文件夹没有被删除。

    5.4 简单镜像打包

    main.go 里添加 commit 命令:

    app.Commands = []cli.Command{
        InitCommand,
        RunCommand,
        CommitCommand,
    }
    

    然后在 command.go 里实现 CommitCommand 命令:

    var CommitCommand = cli.Command{
    	Name:  "commit",
    	Usage: "commit a container into image",
    	Action: func(context *cli.Context) error {
    		if len(context.Args()) < 1 {
    			return fmt.Errorf("missing container name")
    		}
    		imageName := context.Args()[0]
    		// commitContainer(containerName)
    		commitContainer(imageName)
    		return nil
    	},
    }
    

    添加 commit.go,通过 commitContainer() 实现将容器文件系统打包成 ${imagename}.tar

    package main
    
    import (
    	"os/exec"
    
    	"github.com/sirupsen/logrus"
    )
    
    func commitContainer(imageName string) {
    	mntURL := "/root/mnt"
    	imageTar := "/root/" + imageName + ".tar"
    	if _, err := exec.Command("tar", "-czf", imageTar, "-C", mntURL, ".").CombinedOutput(); err != nil {
    		logrus.Errorf("tar folder %s error %v", mntURL, err)
    	}
    }
    

    运行测试:

    # go run . run -it sh
    

    然后在另一个终端运行:

    # go run . commit image
    

    这时候可以在 root 目录下看到多了一个 image.tar,解压后可以发现压缩包的内容和 /root/mnt 一致。

    /root/mnt 不存在。

    6. 构建容器进阶

    6.1 实现容器后台运行

    先在 command.go 添加 -d 标签,表示这个容器启动时在后台运行:

    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",
    		}, &cli.StringFlag{
    			Name:  "v",
    			Usage: "volume",
    		}, &cli.BoolFlag{
    			Name: "d",
    			Usage :"detach container",
    		}, &cli.StringFlag{
    			Name: "cpuset",
    			Usage: "limit the cpuset",
    		},
    	},
    	Action: func(context *cli.Context) error {
    		args := context.Args()
    		if len(args) <= 0 {
    			return errors.New("run what?")
    		}
    
    		// 转化 cli.Args 为 []string
    		cmdArray := make([]string, len(args)) // command
    		copy(cmdArray, args)
    
    		// check whether type `-it`
    		tty := context.Bool("it") // presudo terminal
    		detach := context.Bool("d") // detach container
    
             // tty cannot work with detach
    		if tty && detach {
    			return fmt.Errorf("it and d paramter cannot both privided")
    		}
    
    		// get the resource config
    		resourceConfig := subsystem.ResourceConfig{
    			MemoryLimit: context.String("m"),
    			CPUShare:    context.String("cpushare"),
    			CPUSet:      context.String("cpu"),
    		}
    		volume := context.String("v")
    		dockerCommand.Run(tty, cmdArray, &resourceConfig, volume)
    
    		return nil
    	},
    }
    

    然后也要修改一下 run.goRun()

    func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume string) {
    
    	// this is "docker init <cmdArray>"
    	initProcess, writePipe := container.NewParentProcess(tty, volume)
    	if initProcess == nil {
    		logrus.Errorf("new parent process error")
    		return
    	}
    
    	// start the init process
    	if err := initProcess.Start(); err != nil {
    		logrus.Error(err)
    	}
    
    	// create container manager to control resource config on all hierarchies
    	cm := cgroup.NewCgroupManager("simple-docker-container")
    	defer cm.Remove()
    	cm.Set(res)
    	cm.AddProcess(initProcess.Process.Pid)
    
    	// send command to write side
    	// will close the plug
    	sendInitCommand(cmdArray, writePipe)
    
        // if background process, parent process won't wait
    	if tty {
    		initProcess.Wait()
    	}
    	rootURL := "/root/"
    	mntURL := "/root/mnt/"
    	container.DeleteWorkSpace(rootURL, mntURL, volume)
    	os.Exit(0)
    }
    

    测试一下:

    # go run . run -d top
    {"level":"info","msg":"whole init command is: top","time":"2023-05-05T15:32:44+08:00"}
    

    根据书上的提示,ps -ef 用来查找 top 进程:

    # ps -ef | grep top
    root        3713     751  0 14:42 pts/2    00:00:00 top
    

    前面几次运行命令,都找不到 top 这个进程,于是我后面多跑了几次,终于看到了这个进程。

    ps -ef 查看一下,top 的父进程是一个 bash 进程,而 bash 进程的父进程是一个 init 进程,这样应该算过了吧 (偶尔的一两次不严谨)。

    6.2 实现查看运行中的容器
    6.2.1 name 标签

    command.go 里加一个 name 标签,方便用户指定容器的名字:

    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",
    		}, &cli.StringFlag{
    			Name:  "v",
    			Usage: "volume",
    		}, &cli.BoolFlag{
    			Name: "d",
    			Usage :"detach container",
    		}, &cli.StringFlag{
    			Name: "cpuset",
    			Usage: "limit the cpuset",
    		}, &cli.StringFlag {
    			Name: "name",
    			Usage: "container name",
    		},
    	},
    	Action: func(context *cli.Context) error {
    		args := context.Args()
    		if len(args) <= 0 {
    			return errors.New("run what?")
    		}
    
    		// 转化 cli.Args 为 []string
    		cmdArray := make([]string, len(args)) // command
    		copy(cmdArray, args)
    
    		// check whether type `-it`
    		tty := context.Bool("it") // presudo terminal
    		detach := context.Bool("d") // detach container
    
    		if tty && detach {
    			return fmt.Errorf("it and d paramter cannot both privided")
    		}
    
    		// get the resource config
    		resourceConfig := subsystem.ResourceConfig{
    			MemoryLimit: context.String("m"),
    			CPUShare:    context.String("cpushare"),
    			CPUSet:      context.String("cpu"),
    		}
    		volume := context.String("v")
    		containerName := context.String("name")
    		dockerCommand.Run(tty, cmdArray, &resourceConfig, volume, containerName)
    
    		return nil
    	},
    }
    

    添加一个方法来记录容器的相关信息,这里用先用一个 10 位的数字来表示容器的 id:

    func randStringBytes(n int) string {
    	letterBytes := "1234567890"
    	rand.Seed(time.Now().UnixNano())
    	b := make([]byte, n)
    	for i := range b {
    		b[i] = letterBytes[rand.Intn(len(letterBytes))]
    	}
    	return string(b)
    }
    

    这里用时间戳为种子,每次生成一个 10 以内的数字作为 letterBytes 数组的下标,最后拼成整个容器的 id。容器的信息默认保存在 /var/run/simple-docker/${containerName}/config.json,容器基本格式如下:

    type ContainerInfo struct {
    	Pid         string `json:"pid"`
    	Id          string `json:"id"`
    	Name        string `json:"name"`
    	Command     string `json:"command"` // the command that init process execute
    	CreatedTime string `json:"created_time"`
    	Status      string `json:"status"`
    }
    
    var (
    	RUNNING             string = "running"
    	STOP                string = "stopped"
    	Exit                string = "exited"
    	DefaultInfoLocation string = "/var/run/simple-docker/%s"
    	ConfigName          string = "config.json"
    )
    

    下面是记录容器信息:

    func recordContainerInfo(containerPID int, commandArray []string, containerName string) (string, error) {
    	// create an ID that length is 10
    	id := randStringBytes(10)
    	createTime := time.Now().Format("2006-01-02 15:04:05") // format must like this
    	command := strings.Join(commandArray, "")
    	// if containerName is nil, make containerID as name
    	if containerName == "" {
    		containerName = id
    	}
    	containerInfo := &container.ContainerInfo{
    		Id:          id,
    		Pid:         strconv.Itoa(containerPID),
    		Command:     command,
    		CreatedTime: createTime,
    		Status:      container.RUNNING,
    		Name:        containerName,
    	}
    	// trun containerInfo info string
    	jsonBytes, err := json.Marshal(containerInfo)
    	if err != nil {
    		logrus.Errorf("record container info error: %v", err)
    		return "", err
    	}
    	jsonStr := string(jsonBytes)
    
    	// container path
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName)
    	if err := os.MkdirAll(dirURL, 0622); err != nil {
    		logrus.Errorf("mkdir error %s error: %v", dirURL, err)
    		return "", err
    	}
    	fileName := dirURL + "/" + container.ConfigName
    	// create config.json
    	file, err := os.Create(fileName)
    	if err != nil {
    		logrus.Errorf("create %s error %v", fileName, err)
    		return "", err
    	}
    	defer file.Close()
    	// write jsonify data to file
    	if _, err := file.WriteString(jsonStr); err != nil {
    		logrus.Errorf("write %s error %v", fileName, err)
    		return "", err
    	}
    	return containerName, nil
    }
    

    这里格式化的时间必须是 2006-01-02 15:04:05,不然格式化后的时间会是几千年后 doge。

    在主函数加上调用:

    func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume, containerName string) {
    
    	// this is "docker init <cmdArray>"
    	initProcess, writePipe := container.NewParentProcess(tty, volume)
    	if initProcess == nil {
    		logrus.Errorf("new parent process error")
    		return
    	}
    
    	// start the init process
    	if err := initProcess.Start(); err != nil {
    		logrus.Error(err)
    	}
    	// container info
    	containerName, err := recordContainerInfo(initProcess.Process.Pid, cmdArray, containerName)
    	if err != nil {
    		logrus.Errorf("record container info error: %v", err)
    		return
    	}
    
    	// create container manager to control resource config on all hierarchies
    	cm := cgroup.NewCgroupManager("simple-docker-container")
    	defer cm.Remove()
    	cm.Set(res)
    	cm.AddProcess(initProcess.Process.Pid)
    
    	// send command to write side
    	// will close the plug
    	sendInitCommand(cmdArray, writePipe)
    
    	if tty {
    		initProcess.Wait()
    		deleteContainerInfo(containerName)
    	}
    	rootURL := "/root/"
    	mntURL := "/root/mnt/"
    	container.DeleteWorkSpace(rootURL, mntURL, volume)
    	os.Exit(0)
    }
    

    如果创建 tty 方式的容器,在容器退出后,就会删除相关信息:

    func deleteContainerInfo(containerID string) {
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerID)
    	if err := os.RemoveAll(dirURL); err != nil {
    		logrus.Errorf("remove dir %s error %v", dirURL, err)
    	}
    }
    

    测试一下:

    # go run . run -d top
    # go run . run -d --name jay top
    

    执行完成后,可以在 /var/run/simple-docker/ 找到两个文件夹,一个是随机 id,一个是 jay,文件夹下各有一个 config.json,记录了容器的相关信息。

    6.2.2 实现 docker ps

    main.go 加一个 listCommand

    app.Commands = []cli.Command{
        RunCommand,
        InitCommand,
        CommitCommand,
        ListCommand,
    }
    

    command.go 添加定义:

    var ListCommand = cli.Command{
    	Name: "ps",
    	Usage: "list all the containers",
    	Action: func(context *cli.Context) error {
    		ListContainers()
    		return nil
    	},
    }
    

    新建一个 list.go,实现记录列出容器信息:

    func ListContainers() {
    	// get the path that store the info of the container
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, "")
    	dirURL = dirURL[:len(dirURL)-1]
    	// read all the files in the directory
    	files, err := ioutil.ReadDir(dirURL)
    	if err != nil {
    		logrus.Errorf("read dir %s error %v", dirURL, err)
    		return
    	}
    	var containers []*container.ContainerInfo
    	for _, file := range files {
    		tmpContainer, err := getContainerInfo(file)
    		// .Println(tmpContainer)
    		if err != nil {
    			logrus.Errorf("get container info error %v", err)
    			continue
    		}
    		containers = append(containers, tmpContainer)
    	}
    	// use tabwriter.NewWriter to print the containerInfo
    	w := tabwriter.NewWriter(os.Stdout, 12, 1, 3, ' ', 0)
    	fmt.Fprintf(w, "ID\tNAME\tPID\tSTATUS\tCOMMAND\tCREATED\n")
    	for _, item := range containers {
    		fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
    			item.Id, item.Name, item.Pid, item.Status, item.Command, item.CreatedTime)
    	}
    	// refresh stdout 
    	if err := w.Flush(); err != nil {
    		logrus.Errorf("flush stdout error %v",err)
    		return
    	}
    }
    
    func getContainerInfo(file os.FileInfo) (*container.ContainerInfo, error) {
    	containerName := file.Name()
    	// create the absolute path
    	configFileDir := fmt.Sprintf(container.DefaultInfoLocation, containerName)
    	configFileDir = configFileDir + "/" + container.ConfigName
    	// read config.json
    	content, err := ioutil.ReadFile(configFileDir)
    	if err != nil {
    		logrus.Errorf("read file %s error %v", configFileDir, err)
    		return nil, err
    	}
    	var containerInfo container.ContainerInfo
    	// turn json to containerInfo
    	if err := json.Unmarshal(content, &containerInfo); err != nil {
    		logrus.Errorf("unmarshal json error %v", err)
    		return nil, err
    	}
    	return &containerInfo, nil
    }
    

    接上小节的测试,我们运行以下命令:

    # go run . run -d top
    {"level":"info","msg":"whole init command is: top","time":"2023-05-05T19:29:11+08:00"}
    # go run . run -d --name jay top
    {"level":"info","msg":"whole init command is: top","time":"2023-05-05T19:29:25+08:00"}
    # go run . ps
    ID           NAME         PID         STATUS      COMMAND     CREATED
    6675792962   6675792962   4317        running     top         2023-05-05 19:29:11
    5553437308   jay          4404        running     top         2023-05-05 19:29:25
    

    现在就可以通过 ps 来看到所有创建的容器状态和它们的 init 进程 id 了。

    6.3 查看容器日志

    main.go 加一个 logCommand

    app.Commands = []cli.Command{
        RunCommand,
        InitCommand,
        CommitCommand,
        ListCommand,
        LogCommand,
    }
    

    然后在 command.go 里添加 logCommand

    var LogCommand = cli.Command{
    	Name:  "logs",
    	Usage: "print logs of a container",
    	Action: func(context *cli.Context) error {
    		if len(context.Args()) < 1 {
    			return fmt.Errorf("missing container name")
    		}
    		contianerName := context.Args()[0]
    		logContainer(contianerName)
    		return nil
    	},
    }
    

    新建一个 log.go,定义 logContainer()

    func logContainer(containerName string) {
    	// get the log path
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName)
    	logFileLocation := dirURL + "/" + container.ContainerLogFile
    	// open log file
    	file, err := os.Open(logFileLocation)
    	if err != nil {
    		logrus.Errorf("log container open file %s error: %v", logFileLocation, err)
    		return
    	}
    	defer file.Close()
    	// read log file content
    	content, err := ioutil.ReadAll(file)
    	if err != nil {
    		logrus.Errorf("log container read file %s error: %v", logFileLocation, err)
    		return
    	}
    	// use Fprint to transfer content to stdout
    	fmt.Fprint(os.Stdout, string(content))
    }	
    

    测试一下,先用 detach 方式创建一个容器:

    # go run . run -d --name jay top
    {"level":"info","msg":"whole init command is: top","time":"2023-05-06T14:26:32+08:00"}
    # go run . ps
    ID           NAME        PID         STATUS      COMMAND     CREATED
    1837062451   jay         2065        running     top         2023-05-06 14:26:32
    # go run . logs jay
    Mem: 3265116K used, 4568420K free, 3256K shrd, 71432K buff, 1135692K cached
    CPU:  0.3% usr  0.2% sys  0.0% nic 99.3% idle  0.0% io  0.0% irq  0.0% sirq
    Load average: 0.03 0.09 0.08 1/521 5
    PID  PPID USER     STAT   VSZ %VSZ CPU %CPU COMMAND
    

    可以看到,logs 命令成功运行并输出容器的日志。(这里之前出现过前几次创建容器,而后台却没运行的情况,导致一开始运行 logs 时报错了,建议在运行 logs 前多检查下 top 是否后台运行中)

    6.4 进入容器 Namespace
    6.4.1 setns

    setns 是一个系统调用,可根据提供的 PID 再次进入到指定的 Namespace。它要先打开 /proc/${pid}/ns 文件夹下对应的文件,然后使当前进程进入到指定的 Namespace 中。对于 go 来说,一个有多线程的进程使无法使用 setns 调用进入到对应的命名空间的,go 没启动一个程序就会进入多线程状态,因此无法简单在 go 里直接调用系统调用,这里还需要借助 C 来实现这个功能。

    6.4.2 Cgo
    package rand
    /*
    #include <stdlib.h>
    */
    import "C"
    
    func Random() int {
        return int(C.random())
    }
    
    func Seed(i int) {
        C.srandom(C.uint(i))
    }
    
    6.4.3 实现

    先使用 C 根据 PID进入对应 Namespace:

    package nsenter
    
    /*
    #define _GNU_SOURCE
    #include <errno.h>
    #include <sched.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <fcntl.h>
    #include <unistd.h>
    
    // if this package is quoted, this function will run automatic
    __attribute__((constructor)) void enter_namespace(void)
    {
        char *simple_docker_pid;
        // get pid from system environment
        simple_docker_pid = getenv("simple_docker_pid");
        if (simple_docker_pid)
        {
            fprintf(stdout, "got simple docker pid=%s\n", simple_docker_pid);
        }
        else
        {
            fprintf(stdout, "missing simple docker pid env skip nsenter");
            // if no specified pid, the func will exit
            return;
        }
    
        char *simple_docker_cmd;
        simple_docker_cmd = getenv("simple_docker_cmd");
        if (simple_docker_cmd)
        {
            fprintf(stdout, "got simple docker cmd=%s\n", simple_docker_cmd);
        }
        else
        {
            fprintf(stdout, "missing simple docker cmd env skip nsenter");
            // if no specified cmd, the func will exit
            return;
        }
        int i;
        char nspath[1024];
    
        char *namespace[] = {"ipc", "uts", "net", "pid", "mnt"};
    
        for (i = 0; i < 5; i++)
        {
            // create the target path, like /proc/pid/ns/ipc
            sprintf(nspath, "/proc/%s/ns/%s", simple_docker_pid, namespace[i]);
            int fd = open(nspath, O_RDONLY);
    		printf("===== %d %s\n", fd, nspath);
            // call sentns and enter the target namespace
            if (setns(fd, 0) == -1)
            {
                fprintf(stderr, "setns on %s namespace failed: %s\n", namespace[i], strerror(errno));
            }
            else
            {
                fprintf(stdout, "setns on %s namespace succeeded\n", namespace[i]);
            }
            close(fd);
        }
        // run command in target namespace
        int res = system(simple_docker_cmd);
        exit(0);
        return;
    }
    */
    
    import "C"
    

    那如何使用这段代码呢,只需要在要加载的地方引用这个 package 即可,我这里是 nenster

    package nsenter
    
    import "C"
    

    下面增加 ExecCommand

    var ExecCommand = cli.Command{
    	Name:  "exec",
    	Usage: "exec a command into container",
    	Action: func(context *cli.Context) error {
    		if os.Getenv(ENV_EXEC_PID) != "" {
    			logrus.Infof("pid callback pid %v", os.Getgid())
    			return nil
    		}
    		if len(context.Args()) < 2 {
    			return fmt.Errorf("missing container name or command")
    		}
    		containerName := context.Args()[0]
    		cmdArray := make([]string, len(context.Args())-1)
    		for i, v := range context.Args().Tail() {
    			cmdArray[i] = v
    		}
    		ExecContainer(containerName, cmdArray)
    		return nil
    	},
    }
    

    新建一个 exec.go 下面实现获取容器名和需要的命令,并且在这里引用 nsenter

    const ENV_EXEC_PID = "simple_docker_pid"
    const ENV_EXEC_CMD = "simple_docker_cmd"
    
    func getContainerPidByName(containerName string) (string, error) {
    	// get the path that store container info
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName)
    	configFilePath := dirURL + "/" + container.ConfigName
    	// read files in target path
    	contentBytes, err := ioutil.ReadFile(configFilePath)
    	if err != nil {
    		return "", err
    	}
    	var containerInfo container.ContainerInfo
    	// unmarshal json to containerInfo
    	if err := json.Unmarshal(contentBytes, &containerInfo); err != nil {
    		return "", err
    	}
    	return containerInfo.Pid, nil
    }
    
    func ExecContainer(containerName string, comArray []string) {
    	// get the pid according the containerName
    	pid, err := getContainerPidByName(containerName)
    	if err != nil {
    		logrus.Errorf("exec container getContainerPidByName %s error %v", containerName, err)
    		return
    	}
    	// divide command by blank space and combine as a string
    	cmdStr := strings.Join(comArray, " ")
    	logrus.Infof("container pid %s", pid)
    	logrus.Infof("command %s", cmdStr)
    
    	cmd := exec.Command("/proc/self/exe", "exec")
    	cmd.Stdin = os.Stdin
    	cmd.Stdout = os.Stdout
    	cmd.Stderr = os.Stderr
    
    	err = os.Setenv(ENV_EXEC_PID, pid)
    	if err != nil {
    		logrus.Errorf("set env exec pid %s error %v", pid, err)
    	}
    	err = os.Setenv(ENV_EXEC_CMD, cmdStr)
    	if err != nil {
    		logrus.Errorf("set env exec command %s error %v", cmdStr, err)
    	}
    
    	if err := cmd.Run(); err != nil {
    		logrus.Errorf("exec container %s error %v", containerName, err)
    	}
    }
    

    测试一下:

    # go run . run --name jay -d top
    {"level":"info","msg":"whole init command is: top","time":"2023-05-07T13:23:09+08:00"}
    # go run . ps
    ID           NAME        PID         STATUS      COMMAND     CREATED
    6530018751   jay         146639      running     top         2023-05-07 13:23:09
    # go run . logs jay
    Mem: 4355160K used, 3478372K free, 3272K shrd, 208844K buff, 1581396K cached
    CPU:  1.2% usr  0.6% sys  0.0% nic 97.9% idle  0.0% io  0.0% irq  0.1% sirq
    Load average: 0.12 0.14 0.16 1/574 6
      PID  PPID USER     STAT   VSZ %VSZ CPU %CPU COMMAND
    # go run . exec jay sh
    / # ls
    bin    dev    etc    home   lib    lib64  proc   root   sys    tmp    usr    var
    / # ps -ef
    PID   USER     TIME  COMMAND
        1 root      0:00 top
       13 root      0:00 sh
       15 root      0:00 ps -ef
    / #
    

    可以看到,成功进入容器内部,且与宿主机隔离。

    Stale file handle,当时找了全网,也找不到答案,于是陷入了两天的痛苦 debug,在重新敲代码时,发现又不报错了,切换回那个有错误的分支,也不报错了。既然暂时找不到错误,先搁着吧,如果有看到这篇文章的朋友,也遇到了这个错误,可以留意下。(感觉会是一个雷)

    6.5 停止容器

    定义 StopCommand

    var StopCommand = cli.Command{
    	Name:  "stop",
    	Usage: "stop a container",
    	Action: func(context *cli.Context) error {
    		if len(context.Args()) < 1 {
    			return fmt.Errorf("missing container name")
    		}
    		containerName := context.Args()[0]
    		stopContainer(containerName)
    		return nil
    	},
    }
    

    然后声明一个函数,通过容器名来获取容器信息:

    func getContainerInfoByName(containerName string) (*container.ContainerInfo, error) {
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName)
    	configFilePath := dirURL + "/" + container.ConfigName
    	contentBytes, err := ioutil.ReadFile(configFilePath)
    	if err != nil {
    		logrus.Errorf("read config file %s error %v", configFilePath, err)
    		return nil, err
    	}
    	var containerInfo container.ContainerInfo
    	// unmarshal json to container info
    	if err := json.Unmarshal(contentBytes, &containerInfo); err != nil {
    		logrus.Errorf("unmarshal json to container info error %v", err)
    		return nil, err
    	}
    	return &containerInfo, nil
    }
    

    然后是停止容器:

    func stopContainer(containerName string) {
    	// get pid by containerName
    	pid, err := getContainerPidByName(containerName)
    	if err != nil {
    		logrus.Errorf("get container pid by name %s error %v", containerName, err)
    		return
    	}
    	// turn pid(string) to int
    	pidInt, err := strconv.Atoi(pid)
    	if err != nil {
    		logrus.Errorf("convert pid from string to int error %v", err)
    		return
    	}
    	// kill container main process
    	if err := syscall.Kill(pidInt, syscall.SIGTERM); err != nil {
    		logrus.Errorf("stop container %s error %v", containerName, err)
    		return
    	}
    	// get info of the container
    	containerInfo, err := getContainerInfoByName(containerName)
    	if err != nil {
    		logrus.Errorf("get container info by name %s error %v", containerName, err)
    		return
    	}
    	// process is killed, update process status
    	containerInfo.Status = container.STOP
    	containerInfo.Pid = " "
    	// update info to json
    	nweContentBytes, err := json.Marshal(containerInfo)
    	if err != nil {
    		logrus.Errorf("json marshal %s error %v", containerName, err)
    		return
    	}
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName)
    	configFilePath := dirURL + "/" + container.ConfigName
    	// overwrite containerInfo
    	if err := ioutil.WriteFile(configFilePath, nweContentBytes, 0622); err != nil {
    		logrus.Errorf("write config file %s error %v", configFilePath, err)
    	}
    }
    

    测试:

    # go run . stop jay
    # go run . ps
    ID           NAME        PID         STATUS      COMMAND     CREATED
    6883605813   jay                     stopped     top
    # ps -ef | grep top
    root       43588     761  0 20:00 pts/0    00:00:00 grep --color=auto top
    

    可以看到,jay 这个进程被停止了,且 pid 号设为空。

    6.6 删除容器

    RemoveCommand:

    var RemoveCommand = cli.Command{
    	Name:  "rm",
    	Usage: "remove a container",
    	Action: func(context *cli.Context) error {
    		if len(context.Args()) < 1 {
    			return fmt.Errorf("missing container name")
    		}
    		containerName := context.Args()[0]
    		removeContainer(containerName)
    		return nil
    	},
    }
    

    实现删除容器:

    func removeContainer(containerName string) {
    	containerInfo, err := getContainerInfoByName(containerName)
    	if err != nil {
    		logrus.Errorf("get container %s info failed: %v", containerName, err)
    		return
    	}
    	// only remove the stopped container
    	if containerInfo.Status != container.STOP {
    		logrus.Errorf("cannot remove running container %s", containerName)
    		return
    	}
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName)
    	// remove all the info including sub dir
    	if err := os.RemoveAll(dirURL); err != nil {
    		logrus.Errorf("cannot remove dir %s error: %v", dirURL, err)
    		return
    	}
    }
    

    测试一下:

    # go run . rm jay
    # go run . ps
    ID          NAME        PID         STATUS      COMMAND     CREATED
    

    可以看到,jay 这个容器被删除了。

    6.7 通过容器制作镜像

    之前的文件系统如下:

      只读层:busybox,只读,容器系统的基础
    • 可写层:writeLayer,容器内部的可写层
    • 挂载层:mnt,挂载外部的文件系统,类似虚拟机的文件共享
      只读层:不变
    • 可写层:再加容器名为目录进行隔离,也就是 writeLayer/${containerName}
    • 挂载层:再加容器名为目录进行隔离,也就是 mnt/${containerName}

    因此,本节要实现为每个容器分配单独的隔离文件系统,以及实现对不同容器打包镜像。

    修改 run.go

    imageName:

    func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume, containerName, imageName string) {
    	containerID := randStringBytes(10)
    	if containerName == "" {
    		containerName = containerID
    	}
    	// this is "docker init <cmdArray>"
    	initProcess, writePipe := container.NewParentProcess(tty, volume, containerName, imageName)
    	if initProcess == nil {
    		logrus.Errorf("new parent process error")
    		return
    	}
    
    	// start the init process
    	if err := initProcess.Start(); err != nil {
    		logrus.Error(err)
    	}
    	// container info
    	containerName, err := recordContainerInfo(initProcess.Process.Pid, cmdArray, containerName, volume)
    	if err != nil {
    		logrus.Errorf("record container info error: %v", err)
    		return
    	}
    
    	// create container manager to control resource config on all hierarchies
    	cm := cgroups.NewCgroupManager("simple-docker-container")
    	defer cm.Remove()
    	cm.Set(res)
    	cm.AddProcess(initProcess.Process.Pid)
    
    	// send command to write side
    	// will close the plug
    	sendInitCommand(cmdArray, writePipe)
    
    	if tty {
    		initProcess.Wait()
    		deleteContainerInfo(containerName)
    		container.DeleteWorkSpace(volume, containerName)
    	}
    	os.Exit(0)
    }
    

    同时也在 command.go 的 runCommand 里修改:

    Action: func(context *cli.Context) error {
    		args := context.Args()
    		if len(args) <= 0 {
    			return errors.New("run what?")
    		}
    
    		// 转化 cli.Args 为 []string
    		cmdArray := make([]string, len(args)) // command
    		copy(cmdArray, args)
    
    		// check whether type `-it`
    		tty := context.Bool("it")   // presudo terminal
    		detach := context.Bool("d") // detach container
    
    		if tty && detach {
    			return fmt.Errorf("it and d paramter cannot both privided")
    		}
    
    		// get the resource config
    		resourceConfig := subsystem.ResourceConfig{
    			MemoryLimit: context.String("m"),
    			CPUShare:    context.String("cpushare"),
    			CPUSet:      context.String("cpu"),
    		}
    		volume := context.String("v")
    		containerName := context.String("name")
    		imageName := cmdArray[0]
    		cmdArray = cmdArray[1:]
    		Run(tty, cmdArray, &resourceConfig, volume, containerName, imageName)
    
    		return nil
    	},
    

    recordContainerInfo 函数的参数列表添加 volume:

    func recordContainerInfo(containerPID int, commandArray []string, containerName, volume string) (string, error) {
    	// create an ID that length is 10
    	id := randStringBytes(10)
    	createTime := time.Now().Format("2006-01-02 15:04:05")
    	command := strings.Join(commandArray, "")
    	// if containerName is nil, make containerID as name
    	if containerName == "" {
    		containerName = id
    	}
    	containerInfo := &container.ContainerInfo{
    		Id:          id,
    		Pid:         strconv.Itoa(containerPID),
    		Command:     command,
    		CreatedTime: createTime,
    		Status:      container.RUNNING,
    		Name:        containerName,
    		Volume:      volume,
    	}
    	// trun containerInfo info string
    	jsonBytes, err := json.Marshal(containerInfo)
    	if err != nil {
    		logrus.Errorf("record container info error: %v", err)
    		return "", err
    	}
    	jsonStr := string(jsonBytes)
    
    	// container path
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName)
    	if err := os.MkdirAll(dirURL, 0622); err != nil {
    		logrus.Errorf("mkdir error %s error: %v", dirURL, err)
    		return "", err
    	}
    	fileName := dirURL + "/" + container.ConfigName
    	// create config.json
    	file, err := os.Create(fileName)
    	if err != nil {
    		logrus.Errorf("create %s error %v", fileName, err)
    		return "", err
    	}
    	defer file.Close()
    	// write jsonify data to file
    	if _, err := file.WriteString(jsonStr); err != nil {
    		logrus.Errorf("write %s error %v", fileName, err)
    		return "", err
    	}
    	return containerName, nil
    }
    

    给 ContainerInfo 添加 Volume 成员:

    type ContainerInfo struct {
    	Pid         string `json:"pid"`        //容器的init进程在宿主机上的 PID
    	Id          string `json:"id"`         //容器Id
    	Name        string `json:"name"`       //容器名
    	Command     string `json:"command"`    //容器内init运行命令
    	CreatedTime string `json:"createTime"` //创建时间
    	Status      string `json:"status"`     //容器的状态
    	Volume      string `json:"volume"`
    }
    

    然后将 RootURLMntURLWriteLayer 设为常量:

    var (
    	RUNNING             string = "running"
    	STOP                string = "stopped"
    	Exit                string = "exited"
    	DefaultInfoLocation string = "/var/run/simple-docker/%s/"
    	ConfigName          string = "config.json"
    	ContainerLogFile    string = "container.log"
    	RootURL             string = "/root/"
    	MntURL              string = "/root/mnt/%s/"
    	WriteLayerURL       string = "/root/writeLayer/%s"
    )
    

    相应地,NewParentProcess 函数也要修改:

    func NewParentProcess(tty bool, volume string, containerName, imageName 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 <cmdArray>"
    	cmd := exec.Command("/proc/self/exe", "init")
    
    	cmd.SysProcAttr = &syscall.SysProcAttr{
    		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
    			syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
    	}
    	cmd.Stdin = os.Stdin
    	if tty {
    		cmd.Stdout = os.Stdout
    		cmd.Stderr = os.Stderr
    	} else {
    		dirURL := fmt.Sprintf(DefaultInfoLocation, containerName)
    		if err := os.MkdirAll(dirURL, 0622); err != nil {
    			logrus.Errorf("NewParentProcess mkdir %s error %v", dirURL, err)
    			return nil, nil
    		}
    		stdLogFilePath := dirURL + ContainerLogFile
    		stdLogFile, err := os.Create(stdLogFilePath)
    		if err != nil {
    			logrus.Errorf("NewParentProcess create file %s error %v", stdLogFilePath, err)
    			return nil, nil
    		}
    		cmd.Stdout = stdLogFile
    	}
    	cmd.ExtraFiles = []*os.File{readPipe}
    	NewWorkSpace(volume, imageName, containerName)
    	cmd.Dir = fmt.Sprintf(MntURL, containerName)
    
    	return cmd, writePipe
    }
    
    NewWorkSpace 函数的三个参数分别改为:volumeimageNamecontainerName
    func NewWorkSpace(volume, imageName, containerName string) {
    	CreateReadOnlyLayer(imageName)
    	CreateWriteLayer(containerName)
    	CreateMountPoint(containerName, imageName)
    	if volume != "" {
    		volumeURLs := volumeUrlExtract(volume)
    		length := len(volumeURLs)
    		if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" {
    			MountVolume(volumeURLs, containerName)
    			logrus.Infof("%q", volumeURLs)
    		} else {
    			logrus.Infof("volume parameter input is not correct")
    		}
    	}
    }
    

    下面来修改 CreateReadOnlyLayerCreateWriteLayerCreateMountPoint 这三个函数:

    CreateReadOnlyLayer,参数名改为 imageName,镜像解压出来的只读层以 RootURL+imageName 命名:

    func CreateReadOnlyLayer(imageName string) error {
    	unTarFolderURL := RootURL + "/" + imageName + "/"
    	imageURL := RootURL + "/" + imageName + ".tar"
    	exist, err := PathExists(unTarFolderURL)
    
    	if err != nil {
    		logrus.Infof("fail to judge whether dir %s exists. %v", unTarFolderURL, err)
    		return err
    	}
    	if !exist {
    		if err := os.MkdirAll(unTarFolderURL, 0777); err != nil {
    			logrus.Errorf("mkdir dir %s error. %v", unTarFolderURL, err)
    			return err
    		}
    		if _, err := exec.Command("tar", "-xvf", imageURL, "-C", unTarFolderURL).CombinedOutput(); err != nil {
    			logrus.Errorf("unTar dir %s error %v", unTarFolderURL, err)
    			return err
    		}
    	}
    	return nil
    }
    
    CreateWriteLayer 为每个容器创建一个读写层,把参数改为 containerName,容器读写层修改为 WriteLayerURL+containerName 命名:
    
    func CreateWriteLayer(containerName string) {
    	writeUrl := fmt.Sprintf(WriteLayerURL, containerName)
    	if err := os.MkdirAll(writeUrl, 0777); err != nil {
    		logrus.Infof("Mkdir write layer dir %s error. %v", writeUrl, err)
    	}
    }
    
    CreateMountPoint 创建容器根目录,然后把镜像只读层和容器读写层挂载到容器根目录,成为容器文件系统,参数列表改为 containerNameimageName
    func CreateMountPoint(containerName, imageName string) error {
    	// create mnt folder as mount point
    	mntURL := fmt.Sprintf(MntURL, containerName)
    	if err := os.MkdirAll(mntURL, 0777); err != nil {
    		logrus.Errorf("mkdir dir %s error %v", mntURL, err)
    		return err
    	}
    	// mount 'writeLayer' and 'busybox' to 'mnt'
    	tmpWriteLayer := fmt.Sprintf(WriteLayerURL, containerName)
    	tmpImageLocation := RootURL + "/" + imageName
    	dirs := "dirs=" + tmpWriteLayer + ":" + tmpImageLocation
    	_, err := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", mntURL).CombinedOutput()
    	if err != nil {
    		logrus.Errorf("run command for creating mount point failed: %v", err)
    		return err
    	}
    	return nil
    }
    
    MountVolume 根据用户输入的 volume 参数获取相应挂载宿主机数据卷 URL 和容器的挂载点 URL,并挂载数据卷。参数列表改为 volumeURLscontainerName
    func MountVolume(volumeURLs []string, containerName string) error {
    	// create host file catalog
    	parentURL := volumeURLs[0]
    	if err := os.Mkdir(parentURL, 0777); err != nil {
    		logrus.Infof("mkdir parent dir %s error. %v", parentURL, err)
    	}
    	// create mount point in container file system
    	containerURL := volumeURLs[1]
    	mntURL := fmt.Sprintf(MntURL, containerName)
    	containerVolumeURL := mntURL + "/" + containerURL
    	if err := os.Mkdir(containerVolumeURL, 0777); err != nil {
    		logrus.Infof("mkdir container dir %s error. %v", containerVolumeURL, err)
    	}
    	// mount host file catalog to mount point in container
    	dirs := "dirs=" + parentURL
    	_, err := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", containerVolumeURL).CombinedOutput()
    	if err != nil {
    		logrus.Errorf("mount volume failed. %v", err)
    		return err
    	}
    	return nil
    }
    

    然后在删除容器的 removeContainer 函数最后加一行 DeleteWorkSpace

    func removeContainer(containerName string) {
    	containerInfo, err := getContainerInfoByName(containerName)
    	if err != nil {
    		logrus.Errorf("get container %s info failed: %v", containerName, err)
    		return
    	}
    	// only remove the stopped container
    	if containerInfo.Status != container.STOP {
    		logrus.Errorf("cannot remove running container %s", containerName)
    		return
    	}
    	dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName)
    	// remove all the info including sub dir
    	if err := os.RemoveAll(dirURL); err != nil {
    		logrus.Errorf("cannot remove dir %s error: %v", dirURL, err)
    		return
    	}
    	container.DeleteWorkSpace(containerInfo.Volume, containerName)
    }
    

    然后 DeleteWorkSpace 也要修改,DeleteWorkSpace 作用是当容器退出时,删除容器相关文件系统,参数列表改为 containerName 和 volume:

    func DeleteWorkSpace(volume, containerName string) {
    	if volume != "" {
    		volumeURLs := volumeUrlExtract(volume)
    		length := len(volumeURLs)
    		if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" {
    			DeleteMountPointWithVolume(volumeURLs, containerName)
    		} else {
    			DeleteMountPoint(containerName)
    		}
    	} else {
    		DeleteMountPoint(containerName)
    	}
    	DeleteWriteLayer(containerName)
    }
    
    DeleteMountPoint 函数作用是删除未挂载数据卷的容器文件系统,参数修改为 containerName
    func DeleteMountPoint(containerName string) error {
    	mntURL := fmt.Sprintf(MntURL, containerName)
    	_, err := exec.Command("umount", mntURL).CombinedOutput()
    	if err != nil {
    		logrus.Errorf("%v", err)
    		return err
    	}
    	if err := os.RemoveAll(mntURL); err != nil {
    		logrus.Errorf("remove dir %s error %v", mntURL, err)
    		return err
    	}
    	return nil
    }
    
    DeleteMountPointWithVolume 函数用来删除挂载数据卷容器的文件系统,参数列表改为 volumeURLscontainerName
    func DeleteMountPointWithVolume(volumeURLs []string, containerName string) error {
    	// umount volume point in container
    	mntURL := fmt.Sprintf(MntURL, containerName)
    	containerURL := mntURL + "/" + volumeURLs[1]
    	if _, err := exec.Command("umount", containerURL).CombinedOutput(); err != nil {
    		logrus.Errorf("umount volume failed. %v", err)
    		return err
    	}
    	// umount the whole point of the container
    	_, err := exec.Command("umount", mntURL).CombinedOutput()
    	if err != nil {
    		logrus.Errorf("umount mountpoint failed. %v", err)
    		return err
    	}
    	if err := os.RemoveAll(mntURL); err != nil {
    		logrus.Infof("remove mountpoint dir %s error %v", mntURL, err)
    	}
    	return nil
    }
    
    DeleteWriteLayer 函数用来删除容器读写层,参数改为 containerName
    func DeleteWriteLayer(containerName string) {
    	writeURL := fmt.Sprintf(WriteLayerURL, containerName)
    	if err := os.RemoveAll(writeURL); err != nil {
    		logrus.Errorf("remove dir %s error %v", writeURL, err)
    	}
    }
    

    然后修改 command.go 中的 commitCommand:输入参数名改为 containerNameimageName:·

    var CommitCommand = cli.Command{
    	Name:  "commit",
    	Usage: "commit a container into image",
    	Action: func(context *cli.Context) error {
    		if len(context.Args()) < 1 {
    			return fmt.Errorf("missing container name")
    		}
    		containerName := context.Args()[0]
    		imageName := context.Args()[1]
    		// commitContainer(containerName)
    		commitContainer(containerName, imageName)
    		return nil
    	},
    }
    

    修改 commit.gocommitContainer 函数,根据传入的 containerName 制作 imageName.tar 镜像:

    func commitContainer(containerName, imageName string) {
    	mntURL := fmt.Sprintf(container.MntURL, containerName)
    	mntURL += "/"
    	imageTar := container.RootURL + "/" + imageName + ".tar"
    	if _, err := exec.Command("tar", "-czf", imageTar, "-C", mntURL, ".").CombinedOutput(); err != nil {
    		logrus.Errorf("tar folder %s error %v", mntURL, err)
    	}
    }
    

    测试一下,用 busybox 启动两个容器 test1 和 test2,test1 把宿主机 /root/from1 挂载到容器 /to1,test2 把宿主机 /root/from2 挂载到 /to2 下:

    # go run . run -d --name test1 -v /root/from1:/to1 busybox top
    {"level":"info","msg":"[\"/root/from1\" \"/to1\"]","time":"2023-05-11T10:04:42+08:00"}
    {"level":"info","msg":"whole init command is: top","time":"2023-05-11T10:04:42+08:00"}
    # go run . run -d --name test2 -v /root/from2:/to2 busybox top
    {"level":"info","msg":"[\"/root/from2\" \"/to2\"]","time":"2023-05-11T10:04:51+08:00"}
    {"level":"info","msg":"whole init command is: top","time":"2023-05-11T10:04:51+08:00"}
    # go run . ps
    ID           NAME        PID         STATUS      COMMAND     CREATED
    4010011034   test1       11570       running     top         2023-05-11 10:04:42
    5746376093   test2       11684       running     top         2023-05-11 10:04:51
    

    打开另一个终端,可以看到 /root 目录下多了 from1from2 两个目录,我们看看 mntwriteLayermnt 下多了两个 busybox 的挂载层,writeLayer 下分别挂载了两个容器的目录:

    # tree writeLayer/
    writeLayer/
    ├── test1
    │   └── to1
    └── test2
        └── to2
    

    下面进入 test1 容器,创建 /to1/test1.txt

    # go run . exec test1 sh
    {"level":"info","msg":"container pid 11570","time":"2023-05-11T10:16:33+08:00"}
    {"level":"info","msg":"command sh","time":"2023-05-11T10:16:33+08:00"}
    / # echo -e "test1" >> /to1/test1.txt
    / # mkdir to1-1
    / # echo -e "test111111" >> /to1-1/test1111.txt
    

    这时候再来看看可写层:

    # tree writeLayer/
    writeLayer/
    ├── test1
    │   ├── root
    │   ├── to1
    │   └── to1-1
    │       └── test1111.txt
    └── test2
        └── to2
    # cat writeLayer/test1/to1-1/test1111.txt
    test111111
    

    多了 to1-1/test1111.txt,那刚刚创建的 test1.txt 去哪了呢?这时候我们看看 from1,在这里,新创建的文件写入了数据卷。

    # go run . commit test1 image1
    

    导出的镜像路径为 /root/image1.tar

    # go run . stop test1
    # go run . ps
    ID           NAME        PID         STATUS      COMMAND     CREATED
    4010011034   test1                   stopped     top         2023-05-11 10:04:42
    5746376093   test2       11684       running     top         2023-05-11 10:04:51
    # go run . rm test1
    # go run . ps
    ID           NAME        PID         STATUS      COMMAND     CREATED
    5746376093   test2       11684       running     top         2023-05-11 10:04:51
    

    我们看看容器根目录和可读写层:

    # ls mnt
    test2
    # tree writeLayer/
    writeLayer/
    └── test2
        └── to2
    

    test1 的容器根目录和可读写层被删除。

    # go run . run -d --name test3 -v /root/from3:/to3 image1 top
    {"level":"info","msg":"[\"/root/from3\" \"/to3\"]","time":"2023-05-11T10:32:44+08:00"}
    {"level":"info","msg":"whole init command is: top","time":"2023-05-11T10:32:44+08:00"}
    # go run . ps
    ID           NAME        PID         STATUS      COMMAND     CREATED
    5746376093   test2       11684       running     top         2023-05-11 10:04:51
    4713076733   test3       13056       running     top         2023-05-11 10:32:44
    

    这时我们可以看到 /root 多了一个 image1 目录:

    # ls image1
    bin  dev  etc  home  lib  lib64  proc  root  sys  tmp  to1  to1-1  usr  var
    

    在这里发现了刚才创建的 to1-1,用 image1.tar 启动的容器 test3,进入容器后发现我们刚刚写入的文件,至此,我们成功把容器 test1 的数据卷 to1 信息,重新写入了容器 test3 数据卷 to3。

    6.8 实现容器指定环境变量运行

    本节来实现让容器内运行的程序可以使用外部传递的环境变量。

    6.8.1 修改 runCommand

    -e 选项,允许用户指定环境变量,由于环境变量可以是多个,这里允许用户多次使用 -e 来传递,同时添加对环境变量的解析,整体修改如下:

    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",
    		}, &cli.StringFlag{
    			Name:  "v",
    			Usage: "volume",
    		}, &cli.BoolFlag{
    			Name:  "d",
    			Usage: "detach container",
    		}, &cli.StringFlag{
    			Name:  "cpuset",
    			Usage: "limit the cpuset",
    		}, &cli.StringFlag{
    			Name:  "name",
    			Usage: "container name",
    		}, &cli.StringSliceFlag{
    			Name:  "e",
    			Usage: "set environment",
    		},
    	},
    	Action: func(context *cli.Context) error {
    		args := context.Args()
    		if len(args) <= 0 {
    			return errors.New("run what?")
    		}
    
    		// 转化 cli.Args 为 []string
    		cmdArray := make([]string, len(args)) // command
    		copy(cmdArray, args)
    
    		// check whether type `-it`
    		tty := context.Bool("it")   // presudo terminal
    		detach := context.Bool("d") // detach container
    
    		if tty && detach {
    			return fmt.Errorf("it and d paramter cannot both privided")
    		}
    
    		// get the resource config
    		resourceConfig := subsystem.ResourceConfig{
    			MemoryLimit: context.String("m"),
    			CPUShare:    context.String("cpushare"),
    			CPUSet:      context.String("cpu"),
    		}
    		volume := context.String("v")
    		containerName := context.String("name")
    		envSlice := context.StringSlice("e")
    		imageName := cmdArray[0]
    		cmdArray = cmdArray[1:]
    		Run(tty, cmdArray, &resourceConfig, volume, containerName, imageName, envSlice)
    
    		return nil
    	},
    }
    
    6.8.2 修改 Run 函数

    参数里新增一个 envSlice,然后传递给 NewParentProcess 函数。

    func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume, containerName, imageName string, envSlice []string) {
    	containerID := randStringBytes(10)
    	if containerName == "" {
    		containerName = containerID
    	}
    	// this is "docker init <cmdArray>"
    	initProcess, writePipe := container.NewParentProcess(tty, volume, containerName, imageName, envSlice)
    	if initProcess == nil {
    		logrus.Errorf("new parent process error")
    		return
    	}
    
    	// start the init process
    	if err := initProcess.Start(); err != nil {
    		logrus.Error(err)
    	}
    	// container info
    	containerName, err := recordContainerInfo(initProcess.Process.Pid, cmdArray, containerName, volume)
    	if err != nil {
    		logrus.Errorf("record container info error: %v", err)
    		return
    	}
    
    	// create container manager to control resource config on all hierarchies
    	cm := cgroups.NewCgroupManager("simple-docker-container")
    	defer cm.Remove()
    	cm.Set(res)
    	cm.AddProcess(initProcess.Process.Pid)
    
    	// send command to write side
    	// will close the plug
    	sendInitCommand(cmdArray, writePipe)
    
    	if tty {
    		initProcess.Wait()
    		deleteContainerInfo(containerName)
    		container.DeleteWorkSpace(volume, containerName)
    	}
    	os.Exit(0)
    }
    
    6.8.3 修改 NewParentProcess 函数

    参数新增一个 envSlice,给 cmd 设置环境变量。

    func NewParentProcess(tty bool, volume string, containerName, imageName string, envSlice []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 <cmdArray>"
    	cmd := exec.Command("/proc/self/exe", "init")
    
    	cmd.SysProcAttr = &syscall.SysProcAttr{
    		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
    			syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
    	}
    	cmd.Stdin = os.Stdin
    	if tty {
    		cmd.Stdout = os.Stdout
    		cmd.Stderr = os.Stderr
    	} else {
    		dirURL := fmt.Sprintf(DefaultInfoLocation, containerName)
    		if err := os.MkdirAll(dirURL, 0622); err != nil {
    			logrus.Errorf("NewParentProcess mkdir %s error %v", dirURL, err)
    			return nil, nil
    		}
    		stdLogFilePath := dirURL + ContainerLogFile
    		stdLogFile, err := os.Create(stdLogFilePath)
    		if err != nil {
    			logrus.Errorf("NewParentProcess create file %s error %v", stdLogFilePath, err)
    			return nil, nil
    		}
    		cmd.Stdout = stdLogFile
    	}
    	cmd.ExtraFiles = []*os.File{readPipe}
    	cmd.Env = append(os.Environ(), envSlice...)
    	NewWorkSpace(volume, imageName, containerName)
    	cmd.Dir = fmt.Sprintf(MntURL, containerName)
    
    	return cmd, writePipe
    }
    

    测试一下:

    # go run . run -it --name test -e test=123 -e luck=test busybox sh
    {"level":"info","msg":"Start initiating...","time":"2023-05-11T14:14:52+08:00"}
    {"level":"info","msg":"whole init command is: sh","time":"2023-05-11T14:14:52+08:00"}
    {"level":"info","msg":"Current location is /root/mnt/test","time":"2023-05-11T14:14:52+08:00"}
    {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-11T14:14:52+08:00"}
    / #  env | grep test
    test=123
    luck=test
    

    可以看到,手动指定的环境变量在容器内可见。后面创建一个后台运行的容器:

    # go run . run -d --name test -e test=123 -e luck=test busybox top
    {"level":"info","msg":"whole init command is: top","time":"2023-05-11T14:19:31+08:00"}
    # go run . ps
    ID           NAME        PID         STATUS      COMMAND     CREATED
    9649354121   test        29524       running     top         2023-05-11 14:19:31
    # go run . exec test sh
    {"level":"info","msg":"container pid 29524","time":"2023-05-11T14:20:12+08:00"}
    {"level":"info","msg":"command sh","time":"2023-05-11T14:20:12+08:00"}
    / # ps -ef
    PID   USER     TIME  COMMAND
        1 root      0:00 top
        7 root      0:00 sh
        8 root      0:00 ps -ef
    / # env | grep test
    / #
    

    查看环境变量,没有我们设置的环境变量。

    但只要是容器内 pid 为 1 的进程,创造出来的进程都会继承它的环境变量,下面来修改 exec 命令来直接使用 env 命令来查看容器内环境变量的功能。

    6.8.4 修改 exec 命令
    func getEnvsByPid(pid string) []string {
    	path := fmt.Sprintf("/proc/%s/environ", pid)
    	contentBytes ,err := ioutil.ReadFile(path)
    	if err != nil {
    		logrus.Errorf("read file %s error %v", path, err)
    		return nil
    	}
    	// divide by '\u0000'
    	envs := strings.Split(string(contentBytes),"\u0000")
    	return envs
    }
    

    由于进程存放环境变量的位置是 /proc/${pid}/environ,因此根据给定的 pid 去读取这个文件,可以获取环境变量,在文件的描述中,每个环境变量之间通过 \u0000 分割,因此可以以此标记来获取环境变量数组。

    func ExecContainer(containerName string, comArray []string) {
    	// get the pid according the containerName
    	pid, err := getContainerPidByName(containerName)
    	if err != nil {
    		logrus.Errorf("exec container getContainerPidByName %s error %v", containerName, err)
    		return
    	}
    	// divide command by blank space and combine as a string
    	cmdStr := strings.Join(comArray, " ")
    	logrus.Infof("container pid %s", pid)
    	logrus.Infof("command %s", cmdStr)
    
    	cmd := exec.Command("/proc/self/exe", "exec")
    	cmd.Stdin = os.Stdin
    	cmd.Stdout = os.Stdout
    	cmd.Stderr = os.Stderr
    
    	err = os.Setenv(ENV_EXEC_PID, pid)
    	if err != nil {
    		logrus.Errorf("set env exec pid %s error %v", pid, err)
    	}
    	err = os.Setenv(ENV_EXEC_CMD, cmdStr)
    	if err != nil {
    		logrus.Errorf("set env exec command %s error %v", cmdStr, err)
    	}
    	// get target pid environ (container environ)
    	containerEnvs := getEnvsByPid(pid)
    	// set host environ and container environ to exec process
    	cmd.Env = append(os.Environ(), containerEnvs...)
    
    	if err := cmd.Run(); err != nil {
    		logrus.Errorf("exec container %s error %v", containerName, err)
    	}
    }
    

    这里由于 exec 命令依然要宿主机的一些环境变量,因此将宿主机环境变量和容器环境变量都一起放置到 exec 进程中:

    # go run . run -d --name test -e test=123 -e luck=test busybox top
    {"level":"info","msg":"whole init command is: top","time":"2023-05-11T14:30:03+08:00"}
    # go run . ps
    ID           NAME        PID         STATUS      COMMAND     CREATED
    9729397397   test        50040       running     top         2023-05-11 14:30:03
    # go run . exec test sh
    {"level":"info","msg":"container pid 50040","time":"2023-05-11T14:30:17+08:00"}
    {"level":"info","msg":"command sh","time":"2023-05-11T14:30:17+08:00"}
    / # env | grep test
    test=123
    luck=test
    / #
    

    现在可以看到 exec 进程可以获取前面 run 时设置的环境变量了。

    四、网络篇

    7. 容器网络

    7.1 网络虚拟化技术
    7.1.1 Linux 虚拟网络设备

    Linux Veth

    # ip netns add ns1
    # ip netns add ns2
    # ip link add veth0 type veth peer name veth2
    # ip link set veth0 netns ns1
    # ip link set veth2 netns ns2
    # ip netns exec ns1 ip link
    1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
        link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    4: veth0@if3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
        link/ether 02:bf:18:99:77:ed brd ff:ff:ff:ff:ff:ff link-netns ns2
    

    在 ns1 和 ns2 的namespace 中,除 loopback 的设备以外就只看到了一个网络设备。当请求发送到这个虚拟网络设备时,都会原封不动地从另一个网络 namespace 的网络接口中出来。例如,给两端分别配置不同地址后,向虚拟网络设备的一端发送请求,就能达到这个虚拟网络设备对应的另一端。

    # ip netns exec ns1 ifconfig veth0 172.18.0.2/24 up
    # ip netns exec ns2 ifconfig veth2 172.18.0.3/24 up
    # ip netns exec ns1 route add default dev veth0
    # ip netns exec ns2 route add default dev veth2
    # ip netns exec ns1 ping -c 1 172.18.0.3
    PING 172.18.0.3 (172.18.0.3) 56(84) bytes of data.
    64 bytes from 172.18.0.3: icmp_seq=1 ttl=64 time=0.395 ms
    
    --- 172.18.0.3 ping statistics ---
    1 packets transmitted, 1 received, 0% packet loss, time 0ms
    rtt min/avg/max/mdev = 0.395/0.395/0.395/0.000 ms
    

    Linux Bridge

    # ip netns del ns1
    # ip netns del ns2
    # ip netns list
    

    此时之前创建的两个 netns 被删除。

    # ip netns add ns1
    # ip link add veth0 type veth peer name veth2
    # ip link set veth2 netns ns1
    ########## 创建网桥
    # brctl addbr br0
    ########## 挂载网络设备
    # brctl addif br0 eth0
    # brctl addif bro veth0
    
    7.1.2 Linux 路由表
    # ip link set veth0 up
    # ip link set br0 up
    # ip netns exec ns1 ifconfig veth2 172.18.0.2/24 up
    # ip netns exec ns1 route add default dev veth2
    # route add -net 172.18.0.0/24 dev br0
    
    # ifconfig eth0
    eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
            inet 172.31.93.218  netmask 255.255.240.0  broadcast 172.31.95.255
            inet6 fe80::215:5dff:fe4e:a16a  prefixlen 64  scopeid 0x20<link>
            ether 00:15:5d:4e:a1:6a  txqueuelen 1000  (Ethernet)
            RX packets 829  bytes 394161 (394.1 KB)
            RX errors 0  dropped 0  overruns 0  frame 0
            TX packets 90  bytes 10335 (10.3 KB)
            TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
    ########## 在namespace访问宿主机
    # ip netns exec ns1 ping -c 1 172.31.93.218
    PING 172.31.93.218 (172.31.93.218) 56(84) bytes of data.
    64 bytes from 172.31.93.218: icmp_seq=1 ttl=64 time=0.556 ms
    
    --- 172.31.93.218 ping statistics ---
    1 packets transmitted, 1 received, 0% packet loss, time 0ms
    rtt min/avg/max/mdev = 0.556/0.556/0.556/0.000 ms
    ######### 从宿主机访问namespace的网络地址
    # ping -c 1 172.18.0.2
    PING 172.18.0.2 (172.18.0.2) 56(84) bytes of data.
    64 bytes from 172.18.0.2: icmp_seq=1 ttl=64 time=0.113 ms
    
    --- 172.18.0.2 ping statistics ---
    1 packets transmitted, 1 received, 0% packet loss, time 0ms
    rtt min/avg/max/mdev = 0.113/0.113/0.113/0.000 ms
    
    7.1.3 Linux iptables

    iptables 是对 Linux 内核的 netfilter 模块进行操作和展示的工具,用来管理包的流动和转送。iptables 定义了一套链式处理的结构,在网络包传输的各个阶段可以使用不同的策略和包进行加工、传送或丢弃。在容器虚拟化技术里,常会用到两种策略,MASQUERADE 和 DNAT,用于容器和宿主机外部的网络通信。

    MASQUERADE

    # sysctl -w net.ipv4.conf.all.forwarding=1
    net.ipv4.conf.all.forwarding = 1
    # iptables -t nat -A POSTROUTING -s 172.18.0.0/24 -o eth0 -j MASQUERADE
    

    在 namespace 中请求宿主机外部地址时,将 namespace 中源地址转换为宿主机的地址作为源地址,就可以在 namespace 中访问宿主机外的网络了。

    DAT

    # iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 172.18.0.2:80
    

    这样就可以把宿主机上的 80 端口的 TCP 请求转发到 namespace 的 172.18.0.2:80,从而实现外部应用的调用。

    7.2 构建容器网络模型
    7.2.1 基本模型
    网络
    type Network struct {
        Name    string // network name
        IpRange *net.IPNet // address
        Driver  string // network driver name
    }
    
    网络端点

    网络端点用于连接网络与容器,保证容器内部与网络的通信。

    type Endpoint struct {
    	ID          string           `json:"id"`
    	Device      netlink.Veth     `json:"dev"`
    	IPAddress   net.IP           `json:"ip"`
    	MacAddress  net.HardwareAddr `json:"mac"`
    	Network     *Network
    	PortMapping []string
    }
    

    网络端点的信息传输需要靠网络功能的两个组件配合完成,分别为网络驱动和 IPAM。

    网络驱动
    type NetworkDriver interface {
    	Name() string // driver name
    	Create(subnet string, name string) (*Network, error)
    	Delete(network Network) error
    	Connect(network *Network, endpoint *Endpoint) error
    	Disconnect(network Network, endpoint *Endpoint) error
    }
    
    IPAM

    IPAM 也是网络功能的一个组件,用于网络 IP 地址的分配和释放,包括容器的 IP 和网络网关的 IP。主要功能如下:

      ipam.Allocate(*net.IPNet) 从指定的 subnet 网段中分配 IP
    • ipam.Release(*net.IPNet, net.IP) 从指定的 subnet 网段中释放掉指定的 IP
    var (
    	defaultNetworkPath = "/var/run/simple-docker/network/network/" // 默认网络配置信息存储位置
    	drivers            = map[string]NetworkDriver{} // 驱动字典,存储驱动信息
    	networks           = map[string]*Network{} // 网络字段,存储网络信息
    )
    
    7.2.2 调用关系
    创建网络
    func CreateNetwork(driver, subnet, name string) error {
    	_, cidr, _ := net.ParseCIDR(subnet)
        // allocate gateway ip by IPAM
    	gatewayIP, err := ipAllocator.Allocate(cidr)
    	if err != nil {
    		return err
    	}
    	cidr.IP = gatewayIP
    
    	nw, err := drivers[driver].Create(cidr.String(), name)
    	if err != nil {
    		return err
    	}
        // save network info
    	return nw.dump(defaultNetworkPath)
    }
    

    其中,network.dump 和 network.load 方法是将这个网络的配置信息保存在文件系统中,或从网络的配置目录中的文件读取到网络的配置。

    func (nw *Network) dump(dumpPath string) error {
    	if _, err := os.Stat(dumpPath); err != nil {
    		if os.IsNotExist(err) {
    			os.MkdirAll(dumpPath, 0644)
    		} else {
    			return err
    		}
    	}
    
    	nwPath := path.Join(dumpPath, nw.Name)
        // create file while empty file, write only, no file
    	nwFile, err := os.OpenFile(nwPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
    	if err != nil {
    		logrus.Errorf("error: %v", err)
    		return err
    	}
    	defer nwFile.Close()
    
    	nwJson, err := json.Marshal(nw)
    	if err != nil {
    		logrus.Errorf("error: %v", err)
    		return err
    	}
    
    	_, err = nwFile.Write(nwJson)
    	if err != nil {
    		logrus.Errorf("error: %v", err)
    		return err
    	}
    	return nil
    }
    
    func (nw *Network) load(dumpPath string) error {
    	nwConfigFile, err := os.Open(dumpPath)
    	if err != nil {
    		return err
    	}
    	defer nwConfigFile.Close()
    	nwJson := make([]byte, 2000)
    	n, err := nwConfigFile.Read(nwJson)
    	if err != nil {
    		return err
    	}
    
    	err = json.Unmarshal(nwJson[:n], nw)
    	if err != nil {
    		logrus.Errorf("error load nw info: %v", err)
    		return err
    	}
    	return nil
    }
    
    创建容器并连接网络
    func Connect(networkName string, cinfo *container.ContainerInfo) error {
    	network, ok := networks[networkName]
    	if !ok {
    		return fmt.Errorf("no Such Network: %s", networkName)
    	}
    
    	ip, err := ipAllocator.Allocate(network.IpRange)
    	if err != nil {
    		return err
    	}
    
    	ep := &Endpoint{
    		ID:          fmt.Sprintf("%s-%s", cinfo.Id, networkName),
    		IPAddress:   ip,
    		Network:     network,
    		PortMapping: cinfo.PortMapping,
    	}
    	if err = drivers[network.Driver].Connect(network, ep); err != nil {
    		return err
    	}
    	if err = configEndpointIpAddressAndRoute(ep, cinfo); err != nil {
    		return err
    	}
    
    	return configPortMapping(ep, cinfo)
    }
    
    展示网络列表

    从网络配置的目录中加载所有的网络配置信息:

    func Init() error {
    	var bridgeDriver = BridgeNetworkDriver{}
    	drivers[bridgeDriver.Name()] = &bridgeDriver
    
    	if _, err := os.Stat(defaultNetworkPath); err != nil {
    		if os.IsNotExist(err) {
    			os.MkdirAll(defaultNetworkPath, 0644)
    		} else {
    			return err
    		}
    	}
    
    	filepath.Walk(defaultNetworkPath, func(nwPath string, info os.FileInfo, err error) error {
             // skip if dir
    		if info.IsDir() {
    			return nil
    		}
    
    		if strings.HasSuffix(nwPath, "/") {
    			return nil
    		}
             // load filename as network name
    		_, nwName := path.Split(nwPath)
    		nw := &Network{
    			Name: nwName,
    		}
    
    		if err := nw.load(nwPath); err != nil {
    			logrus.Errorf("error load network: %s", err)
    		}
    		// save network info to network dic
    		networks[nwName] = nw
    		return nil
    	})
    
    	return nil
    }
    

    遍历展示创建的网络:

    func ListNetwork() {
    	w := tabwriter.NewWriter(os.Stdout, 12, 1, 3, ' ', 0)
    	fmt.Fprint(w, "NAME\tIpRange\tDriver\n")
    	for _, nw := range networks {
    		fmt.Fprintf(w, "%s\t%s\t%s\n",
    			nw.Name,
    			nw.IpRange.String(),
    			nw.Driver,
    		)
    	}
    	if err := w.Flush(); err != nil {
    		logrus.Errorf("Flush error %v", err)
    		return
    	}
    }
    
    删除网络
    func DeleteNetwork(networkName string) error {
    	nw, ok := networks[networkName]
    	if !ok {
    		return fmt.Errorf("no Such Network: %s", networkName)
    	}
    
    	if err := ipAllocator.Release(nw.IpRange, &nw.IpRange.IP); err != nil {
    		return fmt.Errorf("error Remove Network gateway ip: %s", err)
    	}
    
    	if err := drivers[nw.Driver].Delete(*nw); err != nil {
    		return fmt.Errorf("error Remove Network DriverError: %s", err)
    	}
    
    	return nw.remove(defaultNetworkPath)
    }
    

    删除网络的同时也删除配置目录的网络配置文件:

    func (nw *Network) remove(dumpPath string) error {
    	if _, err := os.Stat(path.Join(dumpPath, nw.Name)); err != nil {
    		if os.IsNotExist(err) {
    			return nil
    		} else {
    			return err
    		}
    	} else {
    		return os.Remove(path.Join(dumpPath, nw.Name))
    	}
    }
    
    7.3 容器地址分配

    现在转到 ipam.go

    7.3.1 数据结构定义
    const ipamDefaultAllocatorPath = "/var/run/simple-docker/network/ipam/subnet.json"
    
    type IPAM struct {
    	SubnetAllocatorPath string
    	Subnets             *map[string]string
    }
    // 初始化一个IPAM对象,并指定默认分配信息存储位置
    var ipAllocator = &IPAM{
    	SubnetAllocatorPath: ipamDefaultAllocatorPath,
    }
    

    反序列化读取网段分配信息和序列化保存网段分配信息:

    func (ipam *IPAM) load() error {
    	if _, err := os.Stat(ipam.SubnetAllocatorPath); err != nil {
    		if os.IsNotExist(err) {
    			return nil
    		} else {
    			return err
    		}
    	}
    	subnetConfigFile, err := os.Open(ipam.SubnetAllocatorPath)
    	if err != nil {
    		return err
    	}
    	defer subnetConfigFile.Close()
    	subnetJson := make([]byte, 2000)
    	n, err := subnetConfigFile.Read(subnetJson)
    	if err != nil {
    		return err
    	}
    
    	err = json.Unmarshal(subnetJson[:n], ipam.Subnets)
    	if err != nil {
    		logrus.Errorf("Error dump allocation info, %v", err)
    		return err
    	}
    	return nil
    }
    
    func (ipam *IPAM) dump() error {
    	ipamConfigFileDir, _ := path.Split(ipam.SubnetAllocatorPath)
    	if _, err := os.Stat(ipamConfigFileDir); err != nil {
    		if os.IsNotExist(err) {
    			os.MkdirAll(ipamConfigFileDir, 0644)
    		} else {
    			return err
    		}
    	}
    	subnetConfigFile, err := os.OpenFile(ipam.SubnetAllocatorPath, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0644)
    	if err != nil {
    		return err
    	}
    	defer subnetConfigFile.Close()
    
    	ipamConfigJson, err := json.Marshal(ipam.Subnets)
    	if err != nil {
    		return err
    	}
    
    	_, err = subnetConfigFile.Write(ipamConfigJson)
    	if err != nil {
    		return err
    	}
    
    	return nil
    }
    
    7.3.2 地址分配
    func (ipam *IPAM) Allocate(subnet *net.IPNet) (ip net.IP, err error) {
    	ipam.Subnets = &map[string]string{}
    
    	err = ipam.load()
    	if err != nil {
    		logrus.Errorf("error dump allocation info, %v", err)
    	}
    
    	_, subnet, _ = net.ParseCIDR(subnet.String())
    
    	one, size := subnet.Mask.Size()
    
    	if _, exist := (*ipam.Subnets)[subnet.String()]; !exist {
            // 用0填满网段的配置,1<<uint8(size-one)表示这个网段中有多少个可用地址
            // size-one时子网掩码后面的网络位数,2^(size-one)表示网段中的可用IP数
            // 2^(size-one)等价于1<<uint8(size-one)
            (*ipam.Subnets)[subnet.String()] = strings.Repeat("0", 1<<uint8(size-one))
    	}
    	// 这里的原理建议大家看看原著
    	for c := range (*ipam.Subnets)[subnet.String()] {
    		if (*ipam.Subnets)[subnet.String()][c] == '0' {
                ipalloc := []byte((*ipam.Subnets)[subnet.String()])
                // go的字符串创建后不能修改,先用byte存储
                ipalloc[c] = '1'
                (*ipam.Subnets)[subnet.String()] = string(ipalloc)
                // 
                ip = subnet.IP
                
                // 通过网段的IP与上面的偏移相加得出分配的IP,由于IP是一个uint的一个数组,需要通过数组中的每一项加所需要的值,例			 // 如网段是172.16.0.0/12,数组序号是65555,那就要在[172,16,0,0]上依次加
                // [uint8(65555 >> 24), uint8(65555 >> 16), uint8(65555 >> 8), uint(65555 >> 4)],即[0,1,0,19],
                // 那么获得的IP就是172.17.0.19
                for t := uint(4); t > 0; t-- {
                    []byte(ip)[4-t] += uint8(c >> ((t - 1) * 8))
                }
                // 由于此处IP是从1开始分配的,所以最后再加1,最终得到分配的IP是172.16.0.20
                ip[3]++
                break
    		}
    	}
    
    	ipam.dump()
    	return
    }
    
    7.3.3 地址释放
    func (ipam *IPAM) Release(subnet *net.IPNet, ipaddr *net.IP) error {
        ipam.Subnets = &map[string]string{}
    
        _, subnet, _ = net.ParseCIDR(subnet.String())
    
        err := ipam.load()
        if err != nil {
            logrus.Errorf("Error dump allocation info, %v", err)
        }
    
        c := 0
        // 将IP转换为4个字节的表示方式
        releaseIP := ipaddr.To4()
        // 由于IP是从1开始分配的,所以转换成索引减1
        releaseIP[3] -= 1
        for t := uint(4); t > 0; t -= 1 {
            // 和分配IP相反,释放IP获得索引的方式是IP的每一位相减后分别左移将对应的数值加到索引上
            c += int(releaseIP[t-1]-subnet.IP[t-1]) << ((4 - t) * 8)
        }
    
        ipalloc := []byte((*ipam.Subnets)[subnet.String()])
        ipalloc[c] = '0'
        (*ipam.Subnets)[subnet.String()] = string(ipalloc)
    
        ipam.dump()
        return nil
    }
    

    根据书上,写到这里就开始测试了,但是我们看看 IDE,红海一片,所以我们接着实现。

    7.4 创建 bridge 网络
    7.4.1 实现 Bridge Driver Create
    func (d *BridgeNetworkDriver) Create(subnet string, name string) (*Network, error) {
    	ip, ipRange, _ := net.ParseCIDR(subnet)
    	ipRange.IP = ip
    	n := &Network{
    		Name:    name,
    		IpRange: ipRange,
    		Driver:  d.Name(),
    	}
    	err := d.initBridge(n)
    	if err != nil {
    		logrus.Errorf("error init bridge: %v", err)
    	}
    
    	return n, err
    }
    
    7.4.2 Bridge Driver 初始化 Linux Bridge
    func (d *BridgeNetworkDriver) initBridge(n *Network) error {
    	// 创建bridge虚拟设备
    	bridgeName := n.Name
    	if err := createBridgeInterface(bridgeName); err != nil {
    		return fmt.Errorf("eror add bridge: %s, error: %v", bridgeName, err)
    	}
    
    	// 设置bridge设备的地址和路由
    	gatewayIP := *n.IpRange
    	gatewayIP.IP = n.IpRange.IP
    	if err := setInterfaceIP(bridgeName, gatewayIP.String()); err != nil {
    		return fmt.Errorf("error assigning address: %s on bridge: %s with an error of: %v", gatewayIP, bridgeName, err)
    	}
    	// 启动bridge设备
    	if err := setInterfaceUP(bridgeName); err != nil {
    		return fmt.Errorf("error set bridge up: %s, error: %v", bridgeName, err)
    	}
    
    	// 设置iptables的SNAT规则
    	if err := setupIPTables(bridgeName, n.IpRange); err != nil {
    		return fmt.Errorf("error setting iptables for %s: %v", bridgeName, err)
    	}
    
    	return nil
    }
    
    创建 bridge 设备
    func createBridgeInterface(bridgeName string) error {
    	_, err := net.InterfaceByName(bridgeName)
    	if err == nil || !strings.Contains(err.Error(), "no such network interface") {
    		return err
    	}
    
    	// create *netlink.Bridge object
    	la := netlink.NewLinkAttrs()
    	la.Name = bridgeName
    
    	br := &netlink.Bridge{LinkAttrs: la}
    	if err := netlink.LinkAdd(br); err != nil {
    		return fmt.Errorf("bridge creation failed for bridge %s: %v", bridgeName, err)
    	}
    	return nil
    }
    
    设置 bridge 设备的地址和路由
    func setInterfaceIP(name string, rawIP string) error {
    	retries := 2
    	var iface netlink.Link
    	var err error
    	for i := 0; i < retries; i++ {
    		iface, err = netlink.LinkByName(name)
    		if err == nil {
    			break
    		}
    		logrus.Debugf("error retrieving new bridge netlink link [ %s ]... retrying", name)
    		time.Sleep(2 * time.Second)
    	}
    	if err != nil {
    		return fmt.Errorf("abandoning retrieving the new bridge link from netlink, Run [ ip link ] to troubleshoot the error: %v", err)
    	}
    	ipNet, err := netlink.ParseIPNet(rawIP)
    	if err != nil {
    		return err
    	}
    	addr := &netlink.Addr{
    		IPNet:     ipNet,
    		Peer:      ipNet,
    		Label:     "",
    		Flags:     0,
    		Scope:     0,
    		Broadcast: nil,
    	}
    	return netlink.AddrAdd(iface, addr)
    }
    
    启动 bridge 设备
    func setInterfaceUP(interfaceName string) error {
    	iface, err := netlink.LinkByName(interfaceName)
    	if err != nil {
    		return fmt.Errorf("error retrieving a link named [ %s ]: %v", iface.Attrs().Name, err)
    	}
    
    	if err := netlink.LinkSetUp(iface); err != nil {
    		return fmt.Errorf("error enabling interface for %s: %v", interfaceName, err)
    	}
    	return nil
    }
    
    设置 iptables Linux Bridge SNAT 规则
    func setupIPTables(bridgeName string, subnet *net.IPNet) error {
    	iptablesCmd := fmt.Sprintf("-t nat -A POSTROUTING -s %s ! -o %s -j MASQUERADE", subnet.String(), bridgeName)
    	cmd := exec.Command("iptables", strings.Split(iptablesCmd, " ")...)
    	//err := cmd.Run()
    	output, err := cmd.Output()
    	if err != nil {
    		logrus.Errorf("iptables Output, %v", output)
    	}
    	return err
    }
    
    7.4.3 Bridge Driver Delete 实现
    func (d *BridgeNetworkDriver) Delete(network Network) error {
    	bridgeName := network.Name
    	br, err := netlink.LinkByName(bridgeName)
    	if err != nil {
    		return err
    	}
    	return netlink.LinkDel(br)
    }
    
    7.5 在 bridge 网络创建容器
    7.5.1 挂载容器端点
    连接容器网络端点到 Linux Bridge
    func (d *BridgeNetworkDriver) Connect(network *Network, endpoint *Endpoint) error {
    	bridgeName := network.Name
    	br, err := netlink.LinkByName(bridgeName)
    	if err != nil {
    		return err
    	}
    
    	la := netlink.NewLinkAttrs()
    	la.Name = endpoint.ID[:5]
    	la.MasterIndex = br.Attrs().Index
    
    	endpoint.Device = netlink.Veth{
    		LinkAttrs: la,
    		PeerName:  "cif-" + endpoint.ID[:5],
    	}
    
    	if err = netlink.LinkAdd(&endpoint.Device); err != nil {
    		return fmt.Errorf("error Add Endpoint Device: %v", err)
    	}
    
    	if err = netlink.LinkSetUp(&endpoint.Device); err != nil {
    		return fmt.Errorf("error Add Endpoint Device: %v", err)
    	}
    	return nil
    }
    
    配置容器 Namespace 中网络设备及路由

    回到 network.go

    func configEndpointIpAddressAndRoute(ep *Endpoint, cinfo *container.ContainerInfo) error {
    	peerLink, err := netlink.LinkByName(ep.Device.PeerName)
    	if err != nil {
    		return fmt.Errorf("fail config endpoint: %v", err)
    	}
    
    	defer enterContainerNetns(&peerLink, cinfo)()
    
    	interfaceIP := *ep.Network.IpRange
    	interfaceIP.IP = ep.IPAddress
    
    	if err = setInterfaceIP(ep.Device.PeerName, interfaceIP.String()); err != nil {
    		return fmt.Errorf("%v,%s", ep.Network, err)
    	}
    
    	if err = setInterfaceUP(ep.Device.PeerName); err != nil {
    		return err
    	}
    
    	if err = setInterfaceUP("lo"); err != nil {
    		return err
    	}
    
    	_, cidr, _ := net.ParseCIDR("0.0.0.0/0")
    
    	defaultRoute := &netlink.Route{
    		LinkIndex: peerLink.Attrs().Index,
    		Gw:        ep.Network.IpRange.IP,
    		Dst:       cidr,
    	}
    
    	if err = netlink.RouteAdd(defaultRoute); err != nil {
    		return err
    	}
    
    	return nil
    }
    
    进入容器 Net Namespace
    func enterContainerNetns(enLink *netlink.Link, cinfo *container.ContainerInfo) func() {
    	f, err := os.OpenFile(fmt.Sprintf("/proc/%s/ns/net", cinfo.Pid), os.O_RDONLY, 0)
    	if err != nil {
    		logrus.Errorf("error get container net namespace, %v", err)
    	}
    
    	nsFD := f.Fd()
    	runtime.LockOSThread()
    
    	if err = netlink.LinkSetNsFd(*enLink, int(nsFD)); err != nil {
    		logrus.Errorf("error set link netns , %v", err)
    	}
    
    	origns, err := netns.Get()
    	if err != nil {
    		logrus.Errorf("error get current netns, %v", err)
    	}
    
    	if err = netns.Set(netns.NsHandle(nsFD)); err != nil {
    		logrus.Errorf("error set netns, %v", err)
    	}
    	return func() {
    		netns.Set(origns)
    		origns.Close()
    		runtime.UnlockOSThread()
    		f.Close()
    	}
    }
    
    配置宿主机到容器的端口映射
    func configPortMapping(ep *Endpoint, cinfo *container.ContainerInfo) error {
    	for _, pm := range ep.PortMapping {
    		portMapping := strings.Split(pm, ":")
    		if len(portMapping) != 2 {
    			logrus.Errorf("port mapping format error, %v", pm)
    			continue
    		}
    		iptablesCmd := fmt.Sprintf("-t nat -A PREROUTING -p tcp -m tcp --dport %s -j DNAT --to-destination %s:%s",
    			portMapping[0], ep.IPAddress.String(), portMapping[1])
    		cmd := exec.Command("iptables", strings.Split(iptablesCmd, " ")...)
    		//err := cmd.Run()
    		output, err := cmd.Output()
    		if err != nil {
    			logrus.Errorf("iptables Output, %v", output)
    			continue
    		}
    	}
    	return nil
    }
    
    7.5.2 修补 bug

    写到这里,代码还是有很多 bug 的,例如,BridgeNetworkDriver 未完全继承 NetworkDriver 的所有函数。

    func (d *BridgeNetworkDriver) Disconnect(network Network, endpoint *Endpoint) error {
    	return nil
    }
    
    7.5.3 测试

    现在终于可以测试了。

    # go run . network create --driver bridge --subnet 192.168.10.1/24 testbridge
    

    然后启动两个容器:

    # go run . run -it -net testbridge busybox sh
    {"level":"info","msg":"Start initiating...","time":"2023-05-20T19:24:53+08:00"}
    {"level":"info","msg":"whole init command is: sh","time":"2023-05-20T19:24:53+08:00"}
    {"level":"info","msg":"Current location is /root/mnt/8116248511","time":"2023-05-20T19:24:53+08:00"}
    {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-20T19:24:53+08:00"}
    / # ifconfig
    cif-81162 Link encap:Ethernet  HWaddr 16:62:68:81:E0:A9
              inet addr:192.168.10.2  Bcast:192.168.10.255  Mask:255.255.255.0
              inet6 addr: fe80::1462:68ff:fe81:e0a9/64 Scope:Link
              UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
              RX packets:14 errors:0 dropped:0 overruns:0 frame:0
              TX packets:6 errors:0 dropped:0 overruns:0 carrier:0
              collisions:0 txqueuelen:1000
              RX bytes:1820 (1.7 KiB)  TX bytes:516 (516.0 B)
    
    lo        Link encap:Local Loopback
              inet addr:127.0.0.1  Mask:255.0.0.0
              inet6 addr: ::1/128 Scope:Host
              UP LOOPBACK RUNNING  MTU:65536  Metric:1
              RX packets:0 errors:0 dropped:0 overruns:0 frame:0
              TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
              collisions:0 txqueuelen:1000
              RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)
    
    / #
    

    记住这个 IP:192.168.10.2,然后进入另一个容器:

    # go run . run -it -net testbridge busybox sh
    {"level":"info","msg":"Start initiating...","time":"2023-05-20T19:26:24+08:00"}
    {"level":"info","msg":"whole init command is: sh","time":"2023-05-20T19:26:24+08:00"}
    {"level":"info","msg":"Current location is /root/mnt/9558830402","time":"2023-05-20T19:26:24+08:00"}
    {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-20T19:26:24+08:00"}
    / # ifconfig
    cif-95588 Link encap:Ethernet  HWaddr 42:18:0A:73:33:CA
              inet addr:192.168.10.3  Bcast:192.168.10.255  Mask:255.255.255.0
              inet6 addr: fe80::4018:aff:fe73:33ca/64 Scope:Link
              UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
              RX packets:10 errors:0 dropped:0 overruns:0 frame:0
              TX packets:6 errors:0 dropped:0 overruns:0 carrier:0
              collisions:0 txqueuelen:1000
              RX bytes:1248 (1.2 KiB)  TX bytes:516 (516.0 B)
    
    lo        Link encap:Local Loopback
              inet addr:127.0.0.1  Mask:255.0.0.0
              inet6 addr: ::1/128 Scope:Host
              UP LOOPBACK RUNNING  MTU:65536  Metric:1
              RX packets:0 errors:0 dropped:0 overruns:0 frame:0
              TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
              collisions:0 txqueuelen:1000
              RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)
    
    / # ping 192.168.10.2
    PING 192.168.10.2 (192.168.10.2): 56 data bytes
    64 bytes from 192.168.10.2: seq=0 ttl=64 time=2.619 ms
    64 bytes from 192.168.10.2: seq=1 ttl=64 time=0.086 ms
    ^C
    --- 192.168.10.2 ping statistics ---
    2 packets transmitted, 2 packets received, 0% packet loss
    round-trip min/avg/max = 0.086/1.352/2.619 ms
    / #
    

    可以看到,两个容器网络互通。

    /proc/sys/net/ipv4/ip_forward内的值改为1(默认是0)。执行 sysctl -w net.ipv4.ip_forward=1 即可。

    容器默认是没有 DNS 服务器的,需要我们手动添加:

    / # ping cn.bing.com
    ping: bad address 'cn.bing.com'
    / # echo -e "nameserver 8.8.8.8" > /etc/resolv.conf
    / # ping cn.bing.com
    PING cn.bing.com (202.89.233.101): 56 data bytes
    64 bytes from 202.89.233.101: seq=0 ttl=113 time=38.419 ms
    64 bytes from 202.89.233.101: seq=1 ttl=113 time=39.011 ms
    ^C
    --- cn.bing.com ping statistics ---
    3 packets transmitted, 2 packets received, 33% packet loss
    round-trip min/avg/max = 38.419/38.715/39.011 ms
    / #
    

    然后再来测试容器映射端口到宿主机供外部访问:

    # go run . run -it -p 90:90 -net testbridge busybox sh
    {"level":"info","msg":"Start initiating...","time":"2023-05-20T19:39:07+08:00"}
    {"level":"info","msg":"whole init command is: sh","time":"2023-05-20T19:39:07+08:00"}
    {"level":"info","msg":"Current location is /root/mnt/3445154844","time":"2023-05-20T19:39:07+08:00"}
    {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-20T19:39:07+08:00"}
    / # nc -lp 90
    

    然后访问宿主机的 80 端口,看看能不能转发到容器里:

    # telnet 172.31.93.218 90
    Trying 172.31.93.218...
    telnet: Unable to connect to remote host: Connection refused
    

    开始我以为是我哪里码错了,然后拿作者的代码来跑,并放到虚拟机上跑,发现并不是自己的问题,那只能这样测试了:

    # telnet 192.168.10.3 90
    Trying 192.168.10.3...
    Connected to 192.168.10.3.
    Escape character is '^]'.
    

    出现这样的字眼后,容器和宿主机之间就可以通信了。

    参考链接

    使用 GoLang 从零开始写一个 Docker(概念篇)-- 《自己动手写 Docker》读书笔记 - 掘金 (juejin.cn)

    如何让WSL2使用自己编译的内核 - 知乎 (zhihu.com)

    自己动手写Docker系列 -- 5.7实现通过容器制作镜像 - 掘金 (juejin.cn)

    iptable端口重定向 MASQUERADE_tycoon1988的博客-CSDN博客

    编程笔记 » 自己动手写Docker学习笔记

    赞同 (48) or 分享 (0)
    游客 发表我的评论   换个身份
    取消评论

    表情
    (0)个小伙伴在吐槽