用 Go 从头开始构建容器(第1部分:命名空间)

概述

在过去几年中,容器的使用显著增加。容器的概念已经出现好几年了,但是 Docker 易于使用的命令行才从 2013 年开始在开发人员中普及容器。

在这个系列中,我试图演示容器是如何在下面工作的,以及我是如何开发容器的。

什么是 vessel?

vessel 是我的一个教学目的的项目,它实现了一个小版本的 Docker 来管理容器。它既不使用 containerd 也不使用 runc,而是使用一组 Linux 特性来创建容器。

vessel 既不是生产就绪的,也没有经过良好测试的软件。这只是一个简单的项目来了解更多关于容器的知识。

让我们开始:阅读 Docker!

我发现,在开始编写代码之前,先看一下 Docker 文档,了解一下容器是很有用的。

Docker 就其文档而言,利用了 linux 内核的几个特性,并将它们组合成一个称为容器格式的包装器。这些特性是:

  • Namespaces
  • Control groups
  • Union file systems

现在让我们浏览一下上面的列表,并简要地了解一下它们是什么。

什么是命名空间(Namespace!)?

Linux 命名空间是最现代容器实现背后的基础技术。名称空间是进程对周围运行的其他事物的感知。命名空间允许隔离一组进程中的全局系统资源。例如,网络命名空间隔离网络堆栈,这意味着该网络命名空间中的进程可以拥有自己的独立路由、防火墙规则和网络设备。

因此,如果没有命名空间,容器中的进程可能(例如)卸载文件系统,或在另一个容器中设置网络接口。

哪些资源可以使用命名空间进行隔离?

在当前的 linux 内核 (5.9) 中,有 8 种类型的不同命名空间。每个命名空间可以隔离某个全局系统资源。

  • Cgroup: 此命名空间隔离控制组根目录。我将在第 2 部分中解释什么是 cgroups。但简而言之,cgroup 允许系统为一组进程定义资源限制。但要注意的是,“cgroup namespce” 仅控制在命名空间中哪些 cgroup 可见。命名空间无法分配资源限制。我们稍后将对此进行深入解释。
  • IPC: 此命名空间隔离进程间通信机制,如 System V 和 POSIX 消息队列。理解IPC 并不难,但这篇文章不会讨论这个主题。
  • Network: 此名称空间隔离路由、防火墙规则和名称空间内的一组进程可以看到的网络设备。
  • Mount:此名称空间隔离每个名称空间中的挂载点列表。在单独的挂载名称空间中运行的进程可以挂载和卸载,而不会影响其他名称空间。
  • PID:这个命名空间隔离进程 ID 号空间。它支持在名称空间内挂起/恢复进程之类的函数。
  • Time:这个命名空间隔离了 CLOCK_MONOTONICCLOCK_BOOTTIME 系统时钟,它们影响了针对这些时钟(如系统正常运行时间)测量的 API。
  • User:此名称空间隔离用户 id、组 id、根目录、密钥和功能。这允许进程在名称空间内是根,但不在命名空间外(如在主机中)。
  • UTS:这个命名空间隔离主机名和域名

关于命名空间的重要注意事项

命名空间除了隔离之外什么也没做,这意味着,例如,加入一个新的网络名称空间不会给您提供一组隔离的网络设备,您必须自己创建它们。UTS 命名空间也是如此,它不会改变您的主机名。它所做的唯一事情就是隔离与主机名相关的系统调用。我们将在这个系列中一起做这些事情。

命名空间生命周期

当命名空间中的最后一个进程离开命名空间时,命名空间将自动删除。然而,有许多例外情况使名称空间在没有任何成员进程的情况下保持活动。我们将在为 vessel 创建网络名称空间时解释其中一个例外。

命名空间的系统调用

现在我们已经简要了解了命名空间是什么,接下来看看如何与命名空间交互。在 Linux 中,有一组允许创建、加入和发现命名空间的系统调用。

  • clone:此系统调用实际上创建了一个新进程。但是借助 flags 参数,新进程将创建自己的新命名空间。
  • setns:此系统调用允许正在运行的进程加入现有命名空间。
  • unshare:此系统调用实际上与克隆相同,但不同之处在于此系统调用将创建当前进程并将其移动到新的命名空间,而 clone 将创建具有新的命名空间的新进程。

额外提示:forkvfork 内部系统调用只是使用不同的参数调用 clone()

命名空间 Flags

上面提到的系统调用需要一个能够指定所需命名空间的 flag。

1
2
3
4
5
6
7
8
CLONE_NEWCGROUP Cgroup namespaces
CLONE_NEWIPC IPC namespaces
CLONE_NEWNET Network namespaces
CLONE_NEWNS Mount namespaces$$
CLONE_NEWPID PID namespaces
CLONE_NEWTIME Time namespaces
CLONE_NEWUSER User namespaces
CLONE_NEWUTS UTS namespaces

例如,如果你想为当前进程创建一个新的网络命名空间,你应该用 CLONE_NEWNET 标记调用unshare,如果您想使用新用户和 UTS 命名空间创建新进程,你应该用CLONE_NEWUSER|CLONE_NEWUTS 调用 clone。竖线表示或按位组合两个标记。

命名空间文件

在上面我提到过 setns 系统调用将在名称空间之间移动一个正在运行的进程。但是,如何指定要移动到哪个名称空间呢?好的,在创建名称空间之后,成员进程将具有指向命名空间文件的符号链接。

在 Unix 中,所有内容都是文件。

例如,在您的 shell 中,通过列出 /proc/[pid]/ns 目录下的文件,您可以看到进程命名空间。在这里你可以看到正在运行的 shell 的当前命名空间(self 代表当前 shell pid):

1
2
3
4
5
6
7
8
9
10
11
$ ls -l /proc/self/ns | cut -d ' ' -f 10-12
cgroup -> cgroup:[4026531835]
ipc -> ipc:[4026531839]
mnt -> mnt:[4026531840]
net -> net:[4026532008]
pid -> pid:[4026531836]
pid_for_children -> pid:[4026531836]
time -> time:[4026531834]
time_for_children -> time:[4026531834]
user -> user:[4026531837]
uts -> uts:[4026531838]

同样使用 lsns 命令,您也可以看到进程命名空间列表:

1
2
3
4
5
6
7
8
9
# lsns
NS TYPE NPROCS PID USER COMMAND
4026531834 time 244 1 root /sbin/init
4026531835 cgroup 244 1 root /sbin/init
4026531836 pid 199 1 root /sbin/init
4026531837 user 198 1 root /sbin/init
4026531838 uts 241 1 root /sbin/init
4026531839 ipc 244 1 root /sbin/init
4026531840 mnt 234 1 root /sbin/init

实际上 setns syscall 所做的是更改 /proc/[pid]/ns 目录下文件的链接。

废话少说,让我们编码吧!

现在我们知道我们想要的一切。是时候编写第一个在单独命名空间上运行的代码了。首先让我们看看 unshare 是如何工作的。下面的代码,在第 1 行使用 syscall 包和 Unshare 方法为当前运行的 Go 程序创建一个新的名称空间,然后在第 5 行将主机名设置为“container”,然后在第 9 行,它创建一个新命令并运行它。Run 启动命令并等待其完成。

除用户命名空间外,创建命名空间需要 CAP_SYS_ADMIN 功能。因此,您需要以 root 用户来运行该程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
err := syscall.Unshare(syscall.CLONE_NEWPID|syscall.CLONE_NEWUTS)
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
err = syscall.Sethostname([]byte("container"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
cmd := exec.Command("/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()

让我们构建程序并进行测试。对于 host 中的第一个命令,我运行 ps 来监视正在运行的进程,然后获取主机名和当前 shell PID(例如 self,$$ 代表当前进程 PID)。

1
2
3
4
5
6
7
8
$ ps
PID TTY TIME CMD
27973 pts/2 00:00:00 sh
27984 pts/2 00:00:00 ps
$ hostname
host
$ echo $$
27973

现在让我们看看运行程序后会发生什么。获取主机名它返回“container”。似乎有用!

1
2
$ hostname
container

让我们看看进程 ID 是什么。是的!它是 1,可行。

1
2
$ echo $$
1

让我们运行 ps 来查看在容器内运行的进程。

1
2
3
4
5
6
$ ps
PID TTY TIME CMD
27973 pts/2 00:00:00 sh
27998 pts/2 00:00:00 unshare
28003 pts/2 00:00:00 sh
28011 pts/2 00:00:00 ps

发生什么事了!?我们可以看到带有大型 pid 的容器内的主机进程没有意义。

我将终止其中一个进程,看看会发生什么:

1
2
$ kill 27998
sh: kill: (27998) - No such process

没有这样的进程,它说。精彩吗?让我解释一下。代码实际上是有效的,我们在一个新的 PID 命名空间中,我们可以看到我们的进程 ID 是 1。问题是 ps 命令。下面的 ps 使用 proc 伪文件系统列出所有正在运行的进程。为了能够拥有我们自己的 proc 文件系统,我们需要一个新的挂载名称空间,以及一个新的根路径来将 proc 挂载到其中。我们将在下一部分深入讨论。

Clone in Go

在我看来,Go 没有 clone 功能。但是,有一个名为 goclone 的包,它包装了 Go 的 clone 系统调用。但是我们将要使用的解决方案是不同的。在 vessel 中,我们使用一个叫做 reexec 的包,它是 Docker 团队开发的。

reexec 是什么?

Go 允许您使用一组新的名称空间运行命令。reexec 背后的思想是用新的名称空间重新执行正在运行的程序本身。reexec 包,后台的 reexec 包将从调用 /proc/self/exe 的 Go 标准库返回 *exec.Cmd。该文件基本上是指向正在运行的程序可执行文件的链接。

现在您已经了解了 reexec 是如何工作的,让我们从容器中深入研究一些代码。下面的代码,是在 vessel 的早期阶段。它实际上是使用一组新名称空间运行新进程的代码。这个过程就是我们的容器。在第 1 行到第 4 行,函数创建参数和新的 reexec 命令,然后为其设置标准的输入、输出和错误。

注意: 容器的 fork 子命令(第一行)是容器模式。虽然它被隐藏在使用中。

1
2
3
4
5
6
7
8
9
10
11
args := []string{"fork"}
...

cmd := reexec.Command(args...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS,
}

Go 中的 SysProcAttr 命令包含操作系统特定的属性。这些属性之一是 Cloneflags,通过将 flags 传递到这个值,该命令将使用新的特定名称空间运行。这样,我们的新进程就有了新的 IPC、UTS、PID 和 Mount (NS) 命名空间。但是网络命名空间呢?!

深入研究网络命名空间

正如我已经提到的,命名空间只能隔离资源和容器感知的边界。因此,使用新的网络命名空间运行容器不会有太大帮助。我们也应该做一些连接容器到外部网络的事情。但这怎么可能?!

什么是虚拟以太网设备?

veth 可以充当网络命名空间之间的隧道。这意味着它可以在另一个命名空间中创建与网络设备的连接。
figure 1: Virtual Ethernet Devices

虚拟以太网设备总是成对地创建,并相互连接。在一对中的一个设备上传输的所有数据将立即在另一个设备上接收。当任一设备关闭时,这对设备的链路状态也关闭。

例如,在图 1 中,有两个 veth 对。在每对设备中,一个对等设备位于主机网络命名空间内,另一个位于容器内。主机命名空间中的设备连接到网桥,该网桥被路由到名为 eth0 的物理互联网连接设备。

现在让我们来看看 vessel 是如何创建这样一个网络的。

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
func (c *Container) SetupNetwork(bridge string) (filesystem.Unmounter, error) {
nsMountTarget := filepath.Join(netnsPath, c.Digest)
vethName := fmt.Sprintf("veth%.7s", c.Digest)
peerName := fmt.Sprintf("P%s", vethName)

if err := network.SetupVirtualEthernet(vethName, peerName); err != nil {
return nil, err
}
if err := network.LinkSetMaster(vethName, bridge); err != nil {
return nil, err
}
unmount, err := network.MountNewNetworkNamespace(nsMountTarget)
if err != nil {
return unmount, err
}
if err := network.LinkSetNsByFile(nsMountTarget, peerName); err != nil {
return unmount, err
}

// Change current network namespace to setup the veth
unset, err := network.SetNetNSByFile(nsMountTarget)
if err != nil {
return unmount, nil
}
defer unset()

ctrEthName := "eth0"
ctrEthIPAddr := c.GetIP()
if err := network.LinkRename(peerName, ctrEthName); err != nil {
return unmount, err
}
if err := network.LinkAddAddr(ctrEthName, ctrEthIPAddr); err != nil {
return unmount, err
}
if err := network.LinkSetup(ctrEthName); err != nil {
return unmount, err
}
if err := network.LinkAddGateway(ctrEthName, "172.30.0.1"); err != nil {
return unmount, err
}
if err := network.LinkSetup("lo"); err != nil {
return unmount, err
}

return unmount, nil
}

上面的代码涵盖了容器包的 SetupNetwork 方法。这个方法的职责是创建一个如图 1 所示的网络。

在调用此方法之前,vessel 将创建其名为 vessel0 的桥梁。这是实际传递给 SetupNetwork 网桥值的名称。

从现在开始,事情可能会有点混乱,但别担心。请务必多阅读几次,并遵循代码。

在第 3-4 行,定义了 veth 设备对名称。然后在第 6 行,将使用关联的名称创建 veth。在第 9 行,veth 将指定 vessel0 作为其主服务器,以便进一步通信。
docker_ns_2

现在是时候创建一个新的网络名称空间,并将其中一个 veth 对移动到其中。我们的容器终究会加入这个网络命名空间。然而,问题是命名空间的生命周期!如前所述,当最后一个进程成员离开名称空间时,名称空间将被删除。我也提到了一些例外。其中一个例外是绑定挂载命名空间时。这就是为什么有一个名为 MountNewNetworkNamespace 的函数。这个函数创建一个新的名称空间,并将其绑定到一个文件以保持其活动。下面的代码涵盖了此功能。

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
func MountNewNetworkNamespace(nsTarget string) (filesystem.Unmounter, error) {
_, err := os.OpenFile(nsTarget, syscall.O_RDONLY|syscall.O_CREAT|syscall.O_EXCL, 0644)
if err != nil {
return nil, errors.Wrap(err, "unable to create target file")
}

// store current network namespace
file, err = os.OpenFile("/proc/self/ns/net", os.O_RDONLY, 0)
if err != nil {
return nil, err
}
defer file.Close()

if err := syscall.Unshare(syscall.CLONE_NEWNET); err != nil {
return nil, errors.Wrap(err, "unshare syscall failed")
}
mountPoint := filesystem.MountOption{
Source: "/proc/self/ns/net",
Target: nsTarget,
Type: "bind",
Flag: syscall.MS_BIND,
}
unmount, err := filesystem.Mount(mountPoint)
if err != nil {
return unmount, err
}

// reset previous network namespace
if err := unix.Setns(int(file.Fd()), syscall.CLONE_NEWNET); err != nil {
return unmount, errors.Wrap(err, "setns syscall failed: ")
}

return unmount, nil
}

在第 2 行,函数创建一个文件。此文件将用于绑定新的网络命名空间。然后在第 9 行,函数存储了当前的命名空间链接,以便能够返回到它。现在是时候创建一个新的网络命名空间,并在第 15 行使用 unshare 系统调用连接它。该函数现在将 /proc/self/ns/net 绑定到第 2 行创建的文件。记住,/proc/self/ns/net 将在 unshare 系统调用后改变。

这一切都很好,我们只需要离开当前的网络命名空间,然后使用第 29 行的 setns 系统调用返回到我们以前的命名空间。这就是为什么函数首先存储了进程网络名称空间(第 9 行)。

回到 SetupNetwork 函数,现在让我们将对等设备移动到我们刚刚在 MountNewNetworkNamespace 函数中创建的命名空间。由于 nsMountTarget 值绑定到网络名称空间,因此它表示命名空间本身。因此,我们可以使用该文件的描述符来指定命名空间。

好吧,毕竟我们有一个虚拟以太网设备对,其中一个设备在主机网络命名空间内,另一个在新的命名空间内。

现在剩下的唯一任务是在新命名空间内配置设备。问题是设备在主机网络命名空间中不再可见,因此,我们需要使用 SetNetNsByFile 函数(第21行)再次加入网络命名空间。此函数仅使用给定文件的描述符调用 setns 系统调用。注意,我们需要 defer unset 函数(第 25 行),以将容器网络命名空间保留在函数的末尾。

现在,代码的其余部分(第 22-43 行)在容器网络命名空间内运行。首先要做的是将容器设备重命名为 eth0(第 29行),然后关联一个新的 IP 地址(第 32 行),设置设备(第 35 行),添加设备的网关(第 38 行),最后设置回环(127.0.0.1)网络接口。现在我们完成了这里的工作,我们的网络命名空间已经完全准备好了。

还要提到 172.30.0.1 是 vessel0 网桥的默认 IP 地址,这并不是最好的方法,因为这个 IP 地址可能已经在使用了。我这样做是为了简单。现在你的任务是让它变得更好,并发送一个 Pull 请求。

结论

我们了解到命名空间是 Linux 特性之一,它为一组进程隔离全局系统资源,因此它是大多数容器中的基本技术。我们还学习了如何在 Go 中使用 unshareclonesetns 系统调用与命名空间进行交互。

它还没有完成。我们将在下一部分中讨论 union 文件系统,但是现在让我们试着阅读容器代码来理解它。

另外,别忘了用谷歌搜索 “Liz Rice”,看她谈论容器。

感谢阅读!

作者:Ali Josie 来源:medium.com

微信订阅号

-------------本文结束感谢您的阅读-------------