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

5,758 阅读10分钟

一直学 golang 有点无聊,发现有一本书叫做 《自己动手写 Docker》,书名吸引到我了。我决定立刻照着写一遍。

这里的零指的是:对容器有一定的了解,会一点 golang。

  1. 使用 GoLang 从零开始写一个 Docker(概念篇)-- 《自己动手写 Docker》读书笔记
  2. 使用 GoLang 从零开始写一个 Docker(容器篇)-- 《自己动手写 Docker》读书笔记
  3. 使用 GoLang 从零开始写一个 Docker(镜像篇)-- 《自己动手写 Docker》读书笔记
  4. 使用 GoLang 从零开始写一个 Docker(容器进阶篇/完结篇?)-- 《自己动手写 Docker》读书笔记

1. 基础概念

容器就不解释了,golang 也不解释了,这些都在我之前的文章里有写。这里着重解释一下一些 linux 的概念。

1.1 Linux Namespace

首先要了解一下 Namespace

1.1.1 Linux Kernel

了解 Namespace 前需要了解 kernel。在 Linux 中,kernel 用于四种工作:

  1. 管理内存
  2. 管理进程
  3. 管理驱动
  4. 管理系统调用和安全防护 可以理解为,kernel 就是在硬件和其他资源(上面的四个资源)之间进行管理调度的中间件。

1.1.2 Namespace

Namespace 是用于隔离各种资源的。在 Linux 中有 6 种不同类型的 Namespace,每一种隔离不同的资源。

1.1.2.1 UTS Namespace

UTS Namespace 是用于隔绝 nodeName 和 domainName 的。每个 UTS Namespace 下都可以有一个 hostname。在某个 UTS Namespace 下修改 hostname 不会影响到其他 Namespace。

1.1.2.2 IPC Namespace

IPC Namespace 用来隔离System V IPC 和 POSIX message queues。

1.1.2.3 PID Namespace

顾名思义,用于隔绝 PID 的。同样的进程,在不同的 Namespace 里看到的却是不同的 PID。新创建的 PID Namespace 里的第一个 PID 是 1,然后这个 PID 其实映射着主机上的一个很大的 PID。

1.1.2.4 Mount Namespace

Mount Namespace 用于隔绝文件系统。由于 Mount Namespace 是第一个出来的 Namespace,当时人们认为不会有别的 Namespace 了,因此在很多地方尤其是各种命令的命名中,Mount Namespace 会被简称为 NS。
Mount Namespace 会隔离挂载点视图,也就是说,当你挂载了某一个目录,在这个 Namespace 下就会把这个目录当做根目录。你看到的文件系统树就会以这个目录为根目录。
mount 操作本身不会影响到外部,Docker 中的 volume 也使用到了这个特性。

1.1.2.5 User Namespace

User Namespace 主要用于隔离用户组 ID。

1.1.2.6 Network Namespace

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

1.2 Linux Cgroups

Namespace 虽然可以让容器拥有自己的空间。但是怎么保证这些空间的大小,不会互相争抢呢?这就需要用到 Cgroups。

1.2.1 Cgroups 是什么

Cgroups 可以限制和监控一组进程及其子进程的资源。

1.2.1.1 Cgroups 中的 3 个组件

  1. cgroup:顾名思义是一个组,一个组包含一组进程。并且可以有 subsystem 的参数配置,以关联一组 subsystem。
  2. subsystem:一组资源控制的模块。
  3. hierarchy:把一组 cgroups 串成一个树状结构。以提供继承的功能。

1.2.1.2 这 3 个组件的关联

linux 有一些限制:

  1. 首先,创建一个 hierarchy。这个 hierarchy 有一个 cgroup 根节点,所有的进程都会被加到这个根节点上。所有在这个 hierarchy 上创建的节点都是这个根节点的子节点。
  2. 一个 subsystem 只能加到一个 hierarchy 上。
  3. 但是一个 subsystem 可以加到同一个 hierarchy 的多个 cgroups 上。
  4. 一个 hierarchy 可以有多个 subsystem。
  5. 一个进程可以在多个 cgroups 中,但是这些 cgroup 必须在不同的 hierarchy 中。
  6. 一个进程 fork 出子进程的时候,父进程和子进程属于同一个 cgroup。
1.2.1.3 猴子都能看懂的 cgroup 和 subsystem 和 hierarchy 之间的关系

其实这样理解起来方便一些:

  1. hierarchy 就是一颗 cgroups 树,由多个 cgroups 构成。每一个 hierarchy 建立的时候会包含 所有 的linux 进程。这里的 “所有” 指的就是你想的那个 “所有” ,每个 hierarchy 上的全部进程都是一样的。不同的 hierarchy 指的其实只是不同的分组方式,这也是为什么一个进程可以存在于多个 hierarchy 上;或者说准确点,一个进程一定会同时存在于所有的 hierarchy 上,只不过被放在哪个 cgroup 上是不一样的。

  2. linux 里 subsystem 只有 1 个的说法,没有 1 种的说法。也就是说你在一个 hierarchy 上使用了 memory subsystem,那么其他 hierarchy 上就不能使用 memory subsystem 了。

  3. subsystem 是一种资源控制器,有很多个 subsystem,每一个 subsystem 控制不同的资源。subsystem 和 cgroups 关联。新建一个 cgroups 文件夹的时候,里面会自动生成一堆配置文件,那个就是 subsystem 配置文件。但 subsystem 配置文件不是 subsystem,就好像 .git 不是 git 一样,就算你没有安装 git 也可以从别人那里下载 .git 文件夹,只是不能用罢了。subsystem 配置文件 也是如此,新建一个 cgroup 就会生成 cgroup 配置文件,但这并不代表你关联了一个 subsystem。只有当你改变了一个 cgroup 配置文件,里面要限制某种资源的时候,就会自动关联到这个被限制的资源所对应的 subsystem 上。

  4. 假设你的 linux 上有 12 个 subsystem,也就是说你最多只能建 12 个 hierarchy(当然你可以不加 subsystem,这样就可以建更多的 hierarchy,这样 cgroup 就变成纯分组用了。),每一个 hierarchy 上一个 subsystem。当然,如果你在某一个 hierarchy 上放多个 subsystem,那能建立的 hierarchy 就更少了。

  5. subsystem 是和 cgroup 关联的,不是和 hierarchy 关联的,但你经常会看到有人说把某个 subsystem 和某个 hierarchy 关联。这些人一般指的是和 hierarchy 中的某一个 cgroup 或多个 cgroups 关联。

1.2.2 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. 然后在这个目录下使用 mkdir 创建一个新的空目录。这个时候会发现,新的目录里会自动也有很多 cgroup 配置文件。其实这些目录已经成为了 cgroup 根节点的子节点 cgroup。
  5. 在 cgroup 中添加和移动进程:系统的所有进程都会被放入根节点中。可以根据需要移动进程:
    • 只需要将进程 ID 写到对应的 cgroup 的 tasks 文件中即可。
      sudo sh -c "echo $$ >> tasks"
      
      以上命令就是将当前终端这个进程加到当前所在的 cgroup 的目录的 tasks 文件中。
  6. 通过 subsystem 限制 cgroup 中进程的资源:
    • 上面的方法其实有个问题,因为这个 hierarchy 没有关联到任何 subsystem,因此不能够控制资源。
    • 不过其实系统会自动给每个 subsystem 创建一个 hierarchy,所以通过控制这个 hierarchy 里的配置,就可以达到控制进程的目的。

1.2.3 Docker 是怎么使用 Cgroups 的

重头戏来了。
Docker 会给每个容器创建一个 cgroup,再限制该 cgroup 的资源。从而达到限制容器的资源的作用。

1.3 Demo

这里提供一个 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.4 UFS

其实在之前的文章里就已经介绍过了,这里再次回顾一下。
顺便还解决了一个疑问就是 UFS 怎么实现删除文件的改动。请参考本文章的 # 1.4.5

1.4.1 UFS 概念

UFS 的概念很像 git,本身也用于 linux,FreeBSD,NetBSD。就是对一个文件的修改反映在一个新的文件上,而不是修改旧文件。

1.4.2 AUFS

AUFS 是对 UFS 的一个改动的版本。实际上 AUFS 本身的一些特性后来也被纳入 UFS 2.x。

1.4.3 Docker 和 AUFS

Docker 在早期其实使用的就是 AUFS。直到现在也可以选择作为一种存储驱动类型。

1.4.4 image layer

image 由多层 read-only layer 构成。
当我写这篇文章的时候,默认存储驱动已经变成了 overlay2。你可以在 /var/lib/docker 找到 overlay2 文件夹。
这个文件夹里的每个文件就是一层 layer。
当启动一个 container 的时候,就会在 image 上再加一层 init layer,init layer 也是 read-only 的,用于储存容器的环境配置。此外,docker 还会创建一个 read-write 的 layer,用于执行所有的写操作。

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

1.4.5 在 AUFS 中,如果不改变旧文件,那么怎么删除旧文件?

看到上面的说法后,很容易就想到这个问题,终于有人解答了。
答案是 docker 会在 read-write layer 生成一个 .wh.<fileName> 文件来隐藏要删除的文件。

1.4.6 实现一个 AUFS(全程 Linux 操作)

  1. 首先创建一个如下的文件夹结构:

    aufs
    |
    | -- mnt
    |
    | -- container-layer
    |                  | -- container-layer.txt << "I am container layer"
    |
    | -- image-layer1
    |               | -- image-layer1.txt << "I am image layer1"
    |
    | -- image-layer2
    |               | -- image-layer2.txt << "I am image layer2"
    |
    | -- image-layer3
    |               | -- image-layer3.txt << "I am image layer3"
    |
    | -- image-layer4
    |               | -- image-layer4.txt << "I am image layer4"
    
  2. 然后挂载到 mnt 文件夹上,结果如下:

    image.png image.png 这里如果没有自己手动添加权限的话,默认是 dirs 指定的左边第一个文件夹拥有 read-write 权限,其他都是 read-only。

  3. 可以通过 cat /sys/fs/aufs/si_xxxxxxxx/* 来查看 aufs 文件夹下的文件夹的权限。

  4. 然后修改 mnt 文件夹下的 image-layer1.txt 文件,在最后加一行文字。

  5. 此时再去看 image-layer1/image-layer1.txt,发现并没有改变。

    • 但是查看 container-layer 文件夹,发现这里多了一个 image-layer1.txt 文件 image.png
    • 而这个文件夹里的内容,不仅有添加后的文字,还有添加前的文字。
      image.png
  6. 也就是说,实际上。当修改某一个 layer 的时候,实际上并不会改变这个 layer,而是将其复制到 container-layer 中,然后再修改这个新的文件。