Docker容器

自己动手写Docker学习笔记

本文主要是介绍自己动手写Docker学习笔记,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

零、前言

本文为《自己动手写 Docker》的学习,对于各位学习 docker 的同学非常友好,非常建议买一本来学习。

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

一、概念篇

1. 基础知识

1.1 kernel

kernel (内核) 指大多数操作系统的核心部分,由操作系统中用于管理存储器、文件、外设和系统资源的部分组成。操作系统内核通常运行进程,并提供进程间通信。

1.2 namespace

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

Linux 中有 6 种 namespace

1.2.1 UTS Namespace

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

1.2.2 IPC Namespace

IPC,Inter-Process Communication (进程间通讯),用于隔离 System V IPC 和 POSIX message queues (一种消息队列,结构为链表)。

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

1.2.3 PID Namespace

PID,Process IDs,用于隔绝 PID。同样的进程,在不同 Namespace 里是不同的 PID。新建的 PID Namespace 里第一个 PID 是1。

1.2.4 Mount Namespace

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

mount 操作本身不会影响到外部,docker 中的 volume 也用到了这个特性。

1.2.5 User Namespace

用于 隔离用户组 ID。

1.2.6 Network Namespace

每个 Namespace 都有一套自己的网络设备,可以使用相同的端口号,映射到 host 的不同端口。

1.3 Linux Cgroups

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

1.3.1 Cgroups 的三个组件
  • cgroup:一个 cgroup 包含一组进程,且可以有 subsystem 的参数配置,以关联一组 subsystem。
  • subsystem:一组资源控制的模块。
  • hierarchy:把一组 cgroups 串成一个树状结构,以提供继承的功能。
1.3.2 这三个组件的关联

Linux 有一些限制:

  • 首先,创建一个 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。

  1. 首先创建一个 hierarchy,而 hierarchy 要挂载到一个目录上,这里创建一个目录:

    mkdir hierarchy-test
    
  2. 然后挂载:

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

  4. 然后在这个目录下创建新的空目录,会发现,新的目录里也会有很多 cgroup 配置文件,这些目录已成为 cgroup 根节点的子节点 cgroup。

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

    • 只需将进程 ID 写到对应的 cgroup 的 tasks 文件即可。

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

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

  6. 通过 subsystem 限制 cgroup 中进程的资源:

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

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

其实写了这么多,综合上面的前置知识,不难猜测,docker 的原理是:隔离主机。

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,每次修改文件时,都是一次提交,并有记录,修改都反映在一个新的文件上,而不是修改旧文件。

UFS 允许多个不同目录挂载到同一个虚拟文件系统下,这就是为什么 image 之间可以共享文件,以及继承镜像的原因。

1.5.2 AUFS

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

笔者本身使用的是 WSL 做日常开发,WSL 内核不支持 AUFS,后面会提到更换内核。

1.5.3 docker 和 AUFS

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

1.5.4 image layer

image 由多层 read-only layer 构成。

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

当停止容器时,这个 read-write layer 依然保留,只有删除 container 时才会被删除。

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

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。

我们可以发现,imageLayer1 和 writeLayer 的文件出现在 mnt 文件夹下:

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

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

也就是说,实际上,当修改某一个 layer 的时候,实际上不会改变这个 layer,而是将其复制到 container-layer 中,然后再修改这个新的文件。

二、容器篇

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 命令。

首先用 urfave/cli 创建一个 runCommand 命令:

// command.go
var runCommand = cli.Command{
    Name:  "run",
    Usage: "Create a container",
    Flags: []cli.Flag{
        // integrate -i and -t for convenience
        &cli.BoolFlag{
            Name:  "it",
            Usage: "open an interactive tty(pseudo terminal)",
        },
    },
    Action: func(context *cli.Context) error {
        args := context.Args()
        if 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.NewProcesscontainer/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

initCommand 和 runCommand 在同一个文件里定义,也是一个 command,但是注意这个 command 不面向用户,只用于协助 runCommand。

// 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

这里的是 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 的存在呢?

这里有一个 syscall.Exec 神器,Exec 内部会调用 kernel 的 execve 函数,这个函数会把当前进程上运行的程序替换为另一个程序,这正是我们想要的,在不改变 PID 的情况下,替换程序 (即使 kill PID 为 1 的进程,新创建的进程也会是 PID=2)。

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

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

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

这里要用到 subsystem 的知识。

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。

  1. 其中 GetCgroupPath 后面会提及,作用是获取这个 subsystem 所挂载的 hierarchy 上的虚拟文件系统下的 从group 路径。
  2. 获取到 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()
  1. 和上面基本一样,只不过是写到 tasks 里。
  2. 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()
  1. 使用 os.Remove 可以移除参数所指定的文件或文件夹。
  2. 这里移除整个 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。

首先我们抛开 cgroup,在此之前我们要知道 这个 hierarchy 的 cgroup 根节点的路径。那可以在 /proc/self/mountinfo 中获取。

下面是一些实现细节:

  1. 首先定义一个 FindCgroupMountpoint() 来找到 cgroup 的根节点。
  2. 然后在 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

  1. 定义 CgroupManager 类型,其中的 path 要注意是相对路径,相对于 hierarchy 的 root path。所以一个 CgroupManager 是有可能表示多个 cgroups 的,或准确说,和对应的 hierarchy root path 的相对路径一样的多个 cgroups。
  2. 因为上述原因,Set() 可能会创建多个 cgroups,如果 subsystems 们在不同的 hierarchy 就会这样。
  3. 这也是为什么 AddProcess()Remove() 要在每个 subsystem 上执行一遍。因为这些 subsystem 可能存在于不同的 hierarchies。
  4. 注意 Set()AddProcess() 都不是返回错误,而是发出警告,然后返回 nil。因为有些时候用户只指定某一个限制,例如 memory,那样的话修改 cpu 等其实会报错 (正常的报错),因此我们不 return err 来退出。
package cgroups

import "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
  1. 管道原理和 channel 很像,read 端和 write 端会在另一边没响应时堵塞。
  2. 使用 os.Pipe() 获取管道。返回的 readPipe 和 writePipe 都是 *os.File 类型。
  3. 如何把管道传给子进程 (也就是容器进程) 变成了一个难题,这里用到了 ExtraFile 这个参数来解决。cmd 会带着参数里的文件来创建新的进程。(这里除了 ExtraFile,还会有类似 StandardFile,也就是 stdin,stdout,stderr)
  4. 这里把 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() 也要改变下。

  1. 使用 readCommand 来读取 pipe。
  2. 实际运行中,当进程运行到 readCommand() 时会堵塞,直到 write 端传数据进来。
  3. 因此不用担心我们在容器运行后再传输参数。因为再读取完参数之前,InitProcess() 也不会运行到 syscall.Exec() 这一步。
  4. 这里添加了 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
  1. 在 run.go 向 writePipe 写入参数,这样容器就会获取到参数。
  2. 关闭 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

效果如下:

不过这个运行方式不能进行交互,我们可以使用这个命令来验证我们写的 docker 是否与宿主机隔离:

go run . run -it /bin/sh

可以看到,pid,ipc,network 方面都与宿主机进行了隔离。

三、镜像篇

5. 构造镜像

5.1 编译 aufs 内核

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

  • 直接替换 C:\System32\lxss\tools\kernel 文件

  • 在 users 目录下新建 .wslconfig 文件:

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

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

5.1.1 准备代码库

我们先在 WSL 上准备好相关软件包:

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

例如我的内核版本是 5.15.19,那 kernel 和 aufs 都要切换到相应的分支去 (kernel 默认就是 5.15.19,故不用切换)

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

关闭 WSL 后更换内核,重启 WSL 输入 grep aufs /proc/filesystems验证结果,如果出现 aufs 的字样,说明操作成功。

5.2 使用 busybox 创建容器

5.2.1 busybox

先在 docker 获取 busybox 镜像并打包成一个 tar 包:

docker pull busybox
docker run -d busybox top -b
docker export -o busybox.tar <container_id>

将其复制到 WSL 下并解压。

5.2.2 pivot_root

pivot_root 是一个系统调用,作用是改变当前 root 文件系统。pivot_root 可以将当前进程的 root 文件系统移动到 put_old 文件夹,然后使 new_root 成为新的 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

前面提到了,docker 使用 AUFS 存储镜像和容器。docker 在使用镜像启动一个容器时,会新建 2 个 layer:write layer 和 container-init-layer。write layer 是容器唯一的可读写层,container-init-layer 是为容器新建的只读层,用来存储容器启动时传入的系统信息。

  • 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 数据卷

上节实现了容器和镜像的分离,但是如果容器退出,容器可写层的所有内容就会被删除,这里使用 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 简单镜像打包

容器在退出时会删除所有可写层的内容,commit 命令可以把运行状态容器的内容存储为镜像保存下来。

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 一致。

tips:一定要先运行容器!如果不运行容器直接打包,会提示 /root/mnt 不存在。

6. 构建容器进阶

6.1 实现容器后台运行

容器,放在操作系统层面,就是一个进程,当前运行命令的 simple-docker 是主进程,容器是当前 simple-docker 进程 fork 出来的子进程。子进程的结束和父进程的运行是一个异步的过程,即父进程不会知道子进程在什么时候结束。如果创建子进程时,父进程退出,那这个子进程就是孤儿进程 (没人管),此时进程号为 1 的进程 init 就会接受这些孤儿进程。

先在 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 这个进程,于是我后面多跑了几次,终于看到了这个进程。。。

可以看到,top 命令的进程正在运行着,不过运行环境是 WSL,父进程 id 不是 1,然后 ps -ef 查看一下,top 的父进程是一个 bash 进程,而 bash 进程的父进程是一个 init 进程,这样应该算过了吧 (偶尔的一两次不严谨)。

6.2 实现查看运行中的容器

6.2.1 name 标签

前面创建的容器里,所有关于容器的信息,例如 PID、容器创建时间、容器运行命令等,都没有记录,这导致容器运行完后就在也不知道它的信息了,因此要把这部分信息保留。先在 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。

详细可以看这篇文章:goland时间格式化time.Now().Format_golang time.now().format_好狗不见的博客-CSDN博客

在主函数加上调用:

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.3 小节里,实现了查看后台运行的容器的日志,但是容器一旦创建后,就无法再次进入容器,这一次来实现进入容器内部的功能,也就是 exec。

6.4.1 setns

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

6.4.2 Cgo

在 go 里写 C:

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

其实也可以,单独放在一个 C 文件里,go 文件可以这样写:

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
/ #

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

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

(应该是容器的 mnt 没有 mount 上去,才会导致 stale file handle)

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 通过容器制作镜像

这一节,根据书上的内容,有许多函数需要改动。建议这里对着作者给出的源码 debug,书上有部分内容有明显错误。

之前的文件系统如下:

  • 只读层:busybox,只读,容器系统的基础
  • 可写层:writeLayer,容器内部的可写层
  • 挂载层:mnt,挂载外部的文件系统,类似虚拟机的文件共享

修改后的文件系统如下:

  • 只读层:不变
  • 可写层:再加容器名为目录进行隔离,也就是 writeLayer/${containerName}
  • 挂载层:再加容器名为目录进行隔离,也就是 mnt/${containerName}

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

修改 run.go

在 Run 函数参数列表添加一个 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,在这里,新创建的文件写入了数据卷。

下面来验证 commit 功能:

# 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
/ #

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

这里不能用 env 命令获取设置的环境变量,原因是 exec 可以说 go 发起的另一个进程,这个进程的父进程是宿主机的,这个,并不是容器内的。在 cgo 内使用了 setns 系统调用,才使得进程进入了容器内部的命名空间,但由于环境变量是继承自父进程的,因此这个 exec 进程的环境变量其实是继承自宿主机,所以在 exec 看到的环境变量其实是宿主机的环境变量。

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

6.8.4 修改 exec 命令

提供一个函数,可根据指定的 pid 来获取对应进程的环境变量。

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 是用网络设备去操作和使用网卡的,系统装了一个网卡后就会为其生成一个网络设备实例,例如 eth0。Linux 支持创建出虚拟化的设备,可通过组合实现多种多样的功能和网络拓扑,这里主要介绍 Veth 和 Bridge。

Linux Veth

Veth 时成对出现的虚拟网络设备,发送到 Veth 一端虚拟设备的请求会从另一端的虚拟设备中发出。容器的虚拟化场景中,常会使用 Veth 连接不同的网络 namespace:

# ip netns add ns1
# ip netns add ns2
# ip link add veth0 type veth peer name veth1
# ip link set veth0 netns ns1
# ip link set veth1 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 veth1 172.18.0.3/24 up
# ip netns exec ns1 route add default dev veth0
# ip netns exec ns2 route add default dev veth1
# 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

进行下一步之前,先删除上一小节创建的 netns:

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

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

Bridge 虚拟设备时用来桥接的网络设备,相当于现实世界的交换机,可以连接不同的网络设备,当请求达到 Bridge 设备时,可以通过报文中的 Mac 地址进行广播或转发。例如,创建一个 Bridge 设备,来连接 namespace 中的网络设备和宿主机上的网络:

# ip netns add ns1
# ip link add veth0 type veth peer name veth1
# ip link set veth1 netns ns1
########## 创建网桥
# brctl addbr br0
########## 挂载网络设备
# brctl addif br0 eth0
# brctl addif bro veth0

7.1.2 Linux 路由表

路由表是 Linux 内核的一个模块,通过定义路由表来决定在某个网络 namespace 中包的流向,从而定义请求会到哪个网络设备上:

# ip link set veth0 up
# ip link set br0 up
# ip netns exec ns1 ifconfig veth1 172.18.0.2/24 up
# ip netns exec ns1 route add default dev veth1
# route add -net 172.18.0.0/24 dev br0

通过设置路由,对 IP 地址的请求就能正确被路由到对应的网络设备上,从而实现通信:

# 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

MASQUERADE 策略可以将请求包中的源地址转换为一个网络设备的地址,例如 [7.1.2 Linux 路由表](#7.1.2 Linux 路由表) 这一小节里,namespace 中网络设备的地址是 172.18.0.2,这个地址虽然在宿主机可以路由到 br0 的网桥,但是到底宿主机外部后,是不知道如何路由到这个 IP 的,所以如果请求外部地址的话,要先通过 MASQUERADE 策略将这个 IP 转换为宿主机出口网卡的 IP:

# 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 中的 DNAT 策略也是做网络地址的转换,不过它是要更换目标地址,常用于将内部网络地址的端口映射出去。例如,上面例子的 namespace 如果要提供服务给宿主机之外的应用要怎么办呢?外部应用没办法直接路由到 172.18.0.2 这个地址,这时候可以用 DNAT 策略。

# 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
/ #

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

下面来试一下访问外部网络。我用的 WSL,默认的 nat 是关闭的,前期各种设置 iptables 规则什么的,都无法访问容器外部的网络,直到发现一篇帖子里说到,需要打开内核的 nat 功能,要将文件/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 '^]'.

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

参考链接

七天用 Go 写个 docker(第一天) | Go 技术论坛 (learnku.com)

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

编译带有 AUFS 支持的 WSL 内核 - 徐天乐 :: 个人博客 (xtlsoft.top)

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

goland时间格式化time.Now().Format_golang time.now().format_好狗不见的博客-CSDN博客

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

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

这篇关于自己动手写Docker学习笔记的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!