零、前言

本文为《自己动手写 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学习笔记的更多相关文章

  1. Docker学习笔记之一,搭建一个JAVA Tomcat运行环境

    Docker学习笔记之一,搭建一个JAVA Tomcat运行环境 前言 Docker旨在提供一种应用程序的自动化部署解决方案,在 Linux 系统上迅速创建一个容器(轻量级虚拟机)并部署和运行应用程序 ...

  2. docker~学习笔记索引

    回到占占推荐博客索引 使用docker也有段时间了,写了不少文章与总结,下面把它整理个目录出来,方便大家去学习与检索! docker~学习笔记索引 docker~linux下的部署和基本命令(2017 ...

  3. Docker学习笔记总结

    Docker学习笔记 https://yeasy.gitbooks.io/docker_practice/content/   一 环境搭建 Ubuntu安装 .添加软件源的GPG密钥 curl -f ...

  4. Docker:学习笔记(1)——基础概念

    Docker:学习笔记(1)——基础概念 Docker是什么 软件开发后,我们需要在测试电脑.客户电脑.服务器安装运行,用户计算机的环境各不相同,所以需要进行各自的环境配置,耗时耗力.为了解决这个问题 ...

  5. Docker学习笔记 — Docker私有仓库搭建

    Docker学习笔记 — Docker私有仓库搭建   目录(?)[-] 环境准备 搭建私有仓库 测试 管理仓库中的镜像 查询 删除 Registry V2   和Mavan的管理一样,Dockers ...

  6. Docker学习笔记二(linux下安装Docker)

    Docker学习笔记二(linux下安装Docker) 1.在线安装linux Docker 这种方式首先要保证linux 环境下可以上网,当然,小编是在自己的电脑上安装了虚拟机,在虚拟机上安装了,l ...

  7. Docker 学习笔记一

    Docker 学习笔记一 1.Docker是什么?         Docker 是一个开源的应用容器引擎,基于 Go 语言 并遵从 Apache2.0 协议开源.让开发者打包他们的应用以及依赖包到一 ...

  8. docker学习笔记(3)- 镜像

    简介 在docker学习笔记(1)- 架构概述一节中可以看到镜像是docker三大组件之一,可以将Docker镜像类比为虚拟机的模版. 镜像由多个层组成,每层叠加之后从外部看就像一个独立的对象,镜像的 ...

  9. Docker学习笔记 — 配置国内免费registry mirror

    Docker学习笔记 — 配置国内免费registry mirror Docker学习笔记 — 配置国内免费registry mirror

  10. docker学习笔记1 -- 安装和配置

    技术资料 docker中文官网:http://www.docker.org.cn/ 中文入门课程:http://www.docker.org.cn/book/docker.html docker学习笔 ...

随机推荐

  1. Spring Boot笔记--Spring Boot相关介绍+快速入门

    相关介绍 简化了Spring开发,避免了Spring开发的繁琐过程 提供了自动配置.起步依赖.辅助功能 快速入门 结果呈现: 相关过程: helloController.java package or ...

  2. Javaweb学习笔记第五弹

    preparedStatement 防止SQL注入:将敏感字符进行转义 1.获取PreparedStatement对象 String sql="selct * from 表名 where 列 ...

  3. Spring--事务角色+事务属性

    事务管理员 发起事务方,在Spring中通常指代业务层开启事务的方法 也就是相当于这个: 事务协调员 加入事务方,在Spring中通常指代数据层方法,也可以是业务层方法 也就是相当于这个: 事务相关配 ...

  4. 基于Go/Grpc/kubernetes/Istio开发微服务的最佳实践尝试 - 1/3

    基于Go/Grpc/kubernetes/Istio开发微服务的最佳实践尝试 - 1/3 基于Go/Grpc/kubernetes/Istio开发微服务的最佳实践尝试 - 2/3 基于Go/Grpc/ ...

  5. 数据挖掘决策树—R实现

    决策树 决策树是一种树形结构,其中每个内部节点表示一个属性上的测试,每个分支代表一个测试输出,每个叶节点代表一种类别.分类树(决策树)是一种十分常用的分类方法.它是一种监督学习,所谓监督学习就是给定一 ...

  6. OpenWRT实现NAT64/DNS64

    OpenWRT实现NAT64/DNS64 连接到核心路由器 # 连接到核心路由器 [C:\~]$ ssh root@10.0.0.1 Connecting to 10.0.0.1:22... Conn ...

  7. window设置开启启动程序的几种方式比较

    一.设置开机启动项 进入启动文件夹,拷贝程序的快捷方式到这个文件夹即可. 可在任务管理器--启动,查看是否设置成功 启动时间:用户登陆之后. 二.使用计划任务设置自启 进入计划任务界面进行配置,按wi ...

  8. 系统建模之UML状态图[转载]

    1 状态图的简介(Instrduction) 状态图(Statechart Diagram)主要用于描述一个对象在其生存期间的动态行为,表现为一个对象所经历的状态序列,引起状态转移的事件(Event) ...

  9. Unity学习笔记01 —— 编辑器

    场景Scene 基本操作 按下鼠标滚轮拖动场景,滑动滚轮缩放场景. 鼠标右键旋转场景,点击""后,通过左键移动场景. 点击右键同时按下W/S/A/D/Q/E键可实现场景漫游. 在S ...

  10. Zabbix - 部署随笔

    部署Zabbix服务端 准备机器,初始化环境 #查看IP地址 [root@Minimal ~]# ifconfig ens33 | awk 'NR==2{print $2}' 10.0.0.243 # ...