Go Docker

Docker = NameSpace + Cgroup + FileSystem

一旦“程序”被执行起来,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。像这样一个程序运行起来后的计算机执行环境的总和,就是进程。所以,对于进程来说,它的静态表现就是程序,平常都安安静静地待在磁盘上;而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现。而容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。

对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace 技术则是用来修改进程视图的主要方法。

对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,比如 PID=1。可实际上,他们在宿主机的操作系统里,还是原来的第 100 号进程。

虽然容器内的第 1 号进程在“障眼法”的干扰下只能看到容器里的情况,但是宿主机上,它作为第 100 号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第 100 号进程表面上被隔离了起来,但是它所能够使用到的资源(比如 CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个 100 号进程自己也可能把所有资源吃光。这些情况,显然都不是一个“沙盒”应该表现出来的合理行为。

而 Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。

有意思的是,Google 的工程师在 2006 年发起这项特性的时候,曾将它命名为“进程容器”(process container)。实际上,在 Google 内部,“容器”这个术语长期以来都被用于形容被 Cgroups 限制过的进程组。后来 Google 的工程师们说,他们的 KVM 虚拟机也运行在 Borg 所管理的“容器”里,其实也是运行在 Cgroups“容器”当中。这和我们今天说的 Docker 容器差别很大。

Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。 此外,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。

分类 系统调用参数 隔离内容 相关内核版本
Mount namespaces CLONE_NEWNS Mount points Linux 2.4.19
UTS namespaces CLONE_NEWUTS Hostname and NIS domain name Linux 2.6.19
IPC namespaces CLONE_NEWIPC System V IPC, POSIX message queues Linux 2.6.19
PID namespaces CLONE_NEWPID Process IDs Linux 2.6.24
Network namespaces CLONE_NEWNET Network devices, stacks, ports, etc. 始于 Linux 2.6.24 完成于 Linux 2.6.29
User namespaces CLONE_NEWUSER User and group IDs 始于 Linux 2.6.23 完成于 Linux 3.8)
Cgroup namespace CLONE_NEWCGROUP Cgroup root directory Linux 4.6
Time namespace CLONE_NEWTIME Boot and monotonic Linux 5.6

还设计到三个系统调用(system call)的 API:

  • clone():用来创建新进程,与 fork 创建新进程不同的是,clone 创建进程时候运行传递如 CLONE_NEW* 的 namespace 隔离参数,来控制子进程所共享的内容,更多内容请查看 clone 手册
  • setns():让某个进程脱离某个 namespace
  • unshare():让某个进程加入某个 namespace 之中
  • ioctl(): 显示 namespace 的信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
        case "run":
        	run()
        default:
        	fmt.Printf("do nothing, exit!!!")
    }
}

func run() {
    fmt.Printf("running %v\n", os.Args[2:])
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

如果启动一个/bin/bash的话,进行修改会改动本机的内容,这里就没有实现前面提到的 UTS 隔离。 可以为 cmd 加上 SysProcAttr,利用 CLONE_NEWUTS 参数来实现其子进程的 UTS 隔离

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func run() {
    // ...
    cmd.Stderr = os.Stderr

    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS,
    }

    must(cmd.Run())
}

通过上面的 readlink /proc/[PID]/ns/uts 和 hostname 可以看出来,在新的进程里已经实现了 UTS 的隔离了。那么 CLONE_NEWUTS 这个参数 go 是如何在创建子进程时候传入的呢?答案是利用了 clone 系统调用来完成的,这里可以简单的利用 strace 命令追踪下系统调用:

1
2
3
4
5
6
7
8
9
# go run 系统调用有干扰项,这里编译下
$ go build docker-1.go
# 这里我们只关心 clone,利用 grep 过滤下
$ strace ./docker-1 run echo hi |& grep "clone\|execv"
execve("./docker-1", ["./docker-1", "run", "echo", "hi"], [/* 26 vars */]) = 0
clone(child_stack=0xc820035fc0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD) = 15932
clone(child_stack=0xc820031fc0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD) = 15933
clone(child_stack=0xc820033fc0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD) = 15934
clone(child_stack=0, flags=CLONE_NEWUTS|SIGCHLD) = -1 EPERM (Operation not permitted)

前面的三个 clone 其实是 go 创建的一些自己的进程(可能用 c 来实现会更干净些),可以在 root 用户下开两个终端一个 strace ./docker-1 run sleep 10s |& grep “clone|execv”, 另一个 watch pstree -pa [PID] (这里的 PID 是前面终端的 PID)观察验证。可以看到这三个 clone 的调用采用的是默认的参数:CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD,其含义可在上面提到的 clone 手册 里查阅到。 最后的一个 clone 系统调用参数就很明显的是在程序里自行设定的 CLONE_NEWUTS,SIGCHLD 参数默认要添加上的:共享信号,即子进程的生命周期发生变化时候会通过 SIGCHLD 信号告知父进程。 这一版本要要在上个版本实现了 UTS 隔离的情况下进而实现 PID 隔离,很容易会想到在调用时候加上 CLONE_NEWPID 即可实现。为了检验,就需要在代理生成的子进程下再生成一个子进程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 在 main 中加上 child 的 case
func main() {
    switch os.Args[1] {
    // ...
    case "child":
        child()
    // ...
    }
}
// run 修改为下面
func run() {
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
    }

    must(cmd.Run())
}
// 加一个函数 child
func child() {
    fmt.Printf("running %v as pid: %d\n", os.Args[2:], os.Getpid())
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(syscall.Sethostname([]byte("InNamespace")))

    must(cmd.Run())
}

上面的程序需要解释下的是 linux 系统中有个符号链接:/proc/self/exe 它代表当前程序,所以在 run 函数里面调用程序本身并加上 child 参数,以实现 隔一层 进程完成预设命令的指向

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 进入到子进程所创建的 shell 中,输出当前 PID,可以看到已经实现隔离
$ sudo go run docker-2.go run /bin/bash
running [/bin/bash] as pid: 1
root@InNamespace:~/share$ echo $$
5
root@InNamespace:~/share$ ps
   PID TTY          TIME CMD
 18868 pts/1    00:00:00 sudo
 18869 pts/1    00:00:00 go
 18886 pts/1    00:00:00 docker-2
 18890 pts/1    00:00:00 exe
 18894 pts/1    00:00:00 bash
 18973 pts/1    00:00:00 ps

上面出现了两个矛盾的结果: 运行输出了 running [/bin/bash] as pid: 1 和 echo $$ 的 PID 明显是隔离出来的(用户空间的进程不可能小于 1000)而 ps 显示的进程 PID 明显是没有隔离出来的。 其实这时候是已经实现了隔离,而 ps 命令显示的 PID 不对,甚至 ps -ef 还可以查看到整个系统的所有进程,这是因为 ps 命令只是简单的查看了文件系统里的 /proc 目录而给出内容信息,这时候进程的文件系统是继承于父进程的,所以虽然已经位于新的 PID 命名空间了,但是 ps 还无法正常工作。 自然的想到为 clone 进程时候加上 CLONE_NEWNS 即可达到挂载点隔离的效果,使用该参数之后创建子进程会复制一份父进程的挂载挂载点,之后子进程里的挂载操作不会影响到父进程的挂载点。但是同时要处理挂载 /proc 目录的问题,除了挂载点能不能直接更换所继承的文件系统? 从下面 Docker 分层文件系统中示意图可以看到,用户空间的文件系统(rootfs)是可以更换的,通过 chroot 系统调用可以更改(jail)当前正在运行的进程及其子进程的根目录。 https://raw.githubusercontent.com/lavaicer/Img/main/202302241129090.png 所以这里找来了一个非常精简的 alpine rootfs, 解压到 /var/lib/alpine 目录下以备后用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func run() {
    // ..
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    }
    // ..
}

func child() {
    // ..
    must(syscall.Sethostname([]byte("InNamespace")))

    must(syscall.Chroot("/var/lib/alpine"))
    must(os.Chdir("/"))
    must(syscall.Mount("proc", "proc", "proc", 0, ""))

    must(cmd.Run())
}

自制容器里 ps 已经能够正常工作了,但退出退出容器后,却发现容器内的挂载是会传播到父进程的,这是因为 systemd 将默认的 mount namespace 的事件传播机制定义成了 MS_SHARED,可以使用 findmnt -o TARGET,PROPAGATION 命令查看目录的 propagation。总体的有:共享挂载(shared mount)、从属挂载(slave mount)和私有挂载(private mount) 在 sudo unshare –mount –uts /bin/bash 里是可以的隔离挂载的,这是因为改变了 mount 的 propagation 为 private。如何改变呢,只需要利用 mount 系统调用更改下父目录,其下的子目录就会更变传播方式,如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 利用 root 用户探究下为什么可以实现挂载的隔离
$ strace unshare --mount --uts /bin/echo hi |& grep mount
execve("/usr/bin/unshare", ["unshare", "--mount", "--uts", "/bin/echo", "hi"], [/* 26 vars */]) = 0
mount("none", "/", NULL, MS_REC|MS_PRIVATE, NULL) = 0

# 照葫芦画瓢
$ strace mount --make-rshared / |& grep mount
execve("/bin/mount", ["mount", "--make-rshared", "/"], [/* 21 vars */]) = 0
open("/lib/x86_64-linux-gnu/libmount.so.1", O_RDONLY|O_CLOEXEC) = 3
mount("none", "/", NULL, MS_REC|MS_SHARED, NULL) = 0

但是在 syscall 当中就需要手动的以 private 的方式 mount 一遍根目录以达到效果(要在 chroot 之前):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func child() {
    // ..
    must(syscall.Sethostname([]byte("InNamespace")))

    must(syscall.Mount("", "/", "", uintptr(syscall.MS_PRIVATE|syscall.MS_REC), ""))
    must(syscall.Chroot("/var/lib/alpine"))
    must(syscall.Mount("proc", "/proc", "proc", 0, ""))
    must(os.Chdir("/"))

    must(cmd.Run())
}

最后运行一下是可以发现,隔离有效的,可以在其内使用 mount –bind a b 试试。处理 chroot 更换更目录还可以使用 PivotRoot + mount MS_BIND 的方式,参考NEXT 其实到最后会发现,容器就是一些按一定规则被限制继承父进程的某些资源的子进程。 如果后续继续完善其他的 namespace 然后再加以 cgroups 限制 CPU、内存、磁盘、网络等,然后在加上分层存储 Union FS,可能就是完成了一个真正意义上的简化的 Docker。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
        case "run":
        run()
        case "child":
        child()
        default:
        fmt.Printf("do nothing, exit!!!")
    }
}

func run() {
    // fmt.Printf("running %v\n", os.Args[2:])
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    }

    must(cmd.Run())
}

func child() {
    fmt.Printf("running %v as pid: %d\n", os.Args[2:], os.Getpid())
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(syscall.Sethostname([]byte("InNamespace")))
    must(syscall.Mount("", "/", "", uintptr(syscall.MS_PRIVATE|syscall.MS_REC), ""))
    must(syscall.Chroot("/home/o/Desktop/alpine"))
    must(syscall.Mount("proc", "/proc", "proc", 0, ""))
    must(os.Chdir("/"))

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

Linux Namespace 和 Cgroup

Docker Namespace 详细介绍

Linux Namespace 技术与 Docker 原理浅析

相关内容