Build Containers From Scratch in Go用Go从零实现容器

转载自 Ali Josie-Build Containers From Scratch in Go

2020/9/17,在过去的几年里,容器的使用量显著增加。容器的概念已经出来好多年了,但是由于Docker易于使用的命令行使得容器从2013年开始在开发者里流行(我觉得主要是可复用的镜像)。

在这个系列中,我将尝试演示容器底层是如何工作的,以及我是如何开发vessel

What is vessel?

vessel 是我的一个教育目的的项目,它实现了一个小版本的Docker来管理容器。它既不使用containerd 也不使用 runc,而是使用一些列Linux features来创建容器。github仓库

vessel 既不是生产就绪(production-ready)也不是(well-tested)的软件,这只是一个用来学习容器的简单项目。

Let’s start: reading about Docker!

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

根据Docker官方文档,Docker利用几个Linux内核特性并将它们打包成容器格式,这些特性包括:

  • Namespaces
  • Control groups
  • Union file systems

现在让我们分别简单地了解这些特性

What is Namespace!?

Linux namespace 是最现代容器实现背后的底层技术。Namespace是进程级的概念,允许在一组进程中隔离全局系统资源。例如network namespace,它隔离网络栈,这意味着在网络命名空间中的进程可以有它自己独立的路由、防火墙规则和网络设备。

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

What kind of resource can isolate using namespaces?

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

  • Cgroup: 这个namespace立隔离 Control Groups root directory,我将在第二部分解释cgroups,但简单解释一下就是cgroup允许系统对一组进程定义资源限制。然而,这里需要注意的是,cgroup namesapce只是控制命名空间中哪些cgroups是可见的,namesapce无法分配资源限制,我们将很快对此进行深入解释

  • IPC:该namespace隔离进程间通讯机制,如System V和POSIX消息队列

  • Network:该namespace隔离路由、防火墙规则和网络设备

  • Mount:该namesapce隔离装载点列表

  • PID:该namesapce隔离进程的ID号,它还可以开启 suspending/resuming 进程的能力

  • Time:该namespace隔离CLOCK_MONOTONICCLOCK_BOOTTIME 系统时钟,这两种时钟会影响基于时间测量的API(例如系统开机时间uptime)

  • User:该namespace隔离用户ID、组ID、根目录、keys、capabilities。这云心进程在namespace中是root,但在namesapce外(例如host)不是

  • UTS:该namespace隔离主机名和域名

An important note about namespaces

Namespace除了隔离没有做任何事情,这意味着,例如,加入一个新的network namespace不会给你一组独立的独立的网络设备,你必须自己创建它们。同样的事情对于UST namespace,它将不会改变你的hostname,它只是隔离hostname相关的系统调用

Namespaces lifetime

当namespace中的最后一个进程离开namespace时,namesapce将自动关闭。然而,这有许多例外情况使得namesapce在没有任何进程时扔保持活动状态(alive),我们将在vessel中创建network namespace中了解其中一种情况

Namespaces system calls

现在我们简单地了解了namespace是什么,是时候看看如何与它们交互了。在Linux中,有一组系统调用支持创建、加入和发现namesapce。

  • clone :这个系统调用会创建一个新进程,但在flags参数的帮助下,新进程将创建自己新的namespace

  • setns:这个系统调用允许正在运行的进程加入一个已存在的namespace

  • unshare:这个系统调用实际上和clone相同,不同的地方是该调用会创建一个新的namesapce并将当前进程移进去,而clone将会创建一个带有新namespace的进程。

Bonus point:fork vfork 只是使用不同参数的 clone调用

Namespace Flags

上面提到的系统调用需要一个标志来指定所需的名称空间。

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

例如,如果你想为当前的进程创建一个新的namesapce,你应该调用 unshare 并使用 CLONE_NEWNET 参数。如果你想创建一个具有新 User and UTS namespace 的进程,你应该调用 clone 并使用 CLONE_NEWUSER|CLONE_NEWUTS 参数。

Namespace file

上面讲过,我们可以使用 setns 在namesapce之间移动正在运行的进程,但是我们要怎样指定要移到哪个namespace呢?其实,当创建好namespace后,成员进程将会有一个到namespace files的符号链接。

毕竟,Unix至理名言,“In Unix, Everything is a file.”

例如,在shell,我们可以列出 /proc/[pid]/ns 目录,你可以看到进程的namespace。在这里,您可以看到正在运行的shell的名称空间(self代表当前shell的pid):

$ 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命令查看进程名称空间的列表:

# 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 系统调用所做的事情就是 /proc/[pid]/ns 目录下的文件链接

Enough talk, LET’S CODE!

现在我们已经知道想知道的一切,是时候写一个运行在独立namespace上的代码了。第一个尝试是看 unshare是如何工作的,代码如下,第1行是使用 syscall 包和unshare 方法为当前的Go程序创建一个新的namespace,然后第5行设置hostname为“container”,第9行创建一个新的命令行并运行它,Run 开启命令行并等待它完成。
注:创建namespace需要CAP_SYS_ADMIN capability,因此你需要以root身份运行程序。

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 来监控正在运行的进程,然后得到hostname和PID(像self$$ 也是当前进程的PID)

$ ps
    PID TTY          TIME CMD
  27973 pts/2    00:00:00 sh
  27984 pts/2    00:00:00 ps
$ hostname
host
$ echo $$
27973

现在让我们看一看运行程序后发生了什么,获取hostname返回的是"container",似乎生效了!

$ hostname
container

再看一下PID是多少,Yes,它是1,也生效了

$ echo $$
1

再使用 ps 查看容器内正在运行的进程

$ 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

发生了什么,我们可以在容器内看到host的进程,这是没有意义的

我们尝试杀死其中一个进程,看会发生什么?

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

它说,没有这个进程,为什么??解释一下,代码其实是生效的,我们是在一个新的PID namespace内,并且显示PID为1。问题在于 ps 命令,ps 底层使用proc 伪文件系统列出正在运行的程序,为了拥有我们自己的proc文件系统,我们需要一个新的mount namesapce,加一个新的root path用于挂载proc。我们将在下一节深入挖掘这一点。

Clone in Go

到目前为止,Go还没有clone功能。然而,一个叫做goclone的包打包了clone系统调用,但是我们采用的解决方案稍有不同,在vessel中,我使用的一个叫做reexec 的包,它是由Docker团队开发的

What is reexec?

Go允许我们在新的namesapces中运行命令行,reexec 背后的思想是在一个新的namespace中重新运行程序本身,reexec 将返回一个来自Go标准库的*exec.Cmd,它将调用 /proc/self/exe,该文件基本上就是指向正在运行的程序的可执行文件。

现在你知道reexec是如何工作的了,让我们写一些 vessel 的早期代码,这个代码实际上开启一个带有新namesapce的进程,这个进程将成为我们的容器。

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,
}

SysProcAttr 指定OS-specific属性,其中有个属性是Cloneflags ,指示命令行将运行在一个新的namesapce,因此我们新的进程有新的IPC、UTS、PID、NS(Mount) namesapce,但是Network namesapce呢?

Dive into the network namespace

正如我已经提到的,namespace只隔离了资源和容器感知的边界。因此,在新的Network Namespace中运行容器不会有多大的帮助。我们还应该做一些事情将容器与外部网络连接,但这怎么可能呢?!

What is a virtual ethernet device?

veth 可以作为Network Namesapce之间的通道,这意味着可以与另一个namesapce中的网络设备创建连接。

Build Containers From Scratch in Go用Go从零实现容器

虚拟以太网设备(Virtual Ethernet Devices)总是以成对的形式创建,一方发送的所有数据,另一方能立即接收。当其中一个停止时链路就停止。

例如,在上图中,这有两对veth,每一对中,都是一个位于host的网络命名空间,一个位于容器的。host namespace中的设备连接到一个Bridge,Bridge路由到一个物理的、与互联网连接的设备eth0

现在让我们看一下vessel是怎样创建这样一个网络

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
}

上面代码描述了vessel的container package中的 SetupNetwork 方法,它负责创建前面说的那种网络。

在调用这个方法之前,vessel创建了名为vessel0的桥,这个名字实际上传给了SetupNetworkbridge

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

Build Containers From Scratch in Go用Go从零实现容器

现在需要创建一个新的network namesapce,然后将veth pair中的其中一个移入。我们的容器之后会加入这个namesapce的,问题在于容器的生命周期,正如我们之前提到,如果namespace中的最后一个进程退出时namesapce会销毁。我们也提到有一些例外。其中一个例外就是当命名空间是绑定挂载状态(bind-mounted),这就是为什么我的函数命名是MountNewNetworkNamespace,这个函数创建一个新的命名空间并绑到一个文件,以保持存活。

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行,创建一个文件,这个文件被用来绑定这个新的网络命名空间。第8行,暂存当前的命名空间,以便之后恢复。然后创建新的网络命名空间,并使用unshare命名加入它。这个函数将第2行创建的文件绑定到/proc/self/ns/net,记住,在unshare系统调用之后/proc/self/ns/net的内容已经改变了。

这一切都很好,我们只需要离开当前的网络名称空间,并使用第29行的setns系统调用返回到上一个名称空间。这也是为什么我们在第9行存储进程的网络命名空间。

返回到SetupNetwork函数,让我们移到其中一个设备到MountNewNetworkNamespace创建的命名空间中。因为nsMountTarget的值绑定到网络命名空间,它表示命名空间本身,因此我们可以使用文件描述符指定命名空间。

很好,我们已经有一对虚拟以太网设备,它的其中一个设备位于主机网络命名空间,另一个位于新的命名空间。

现在,剩下唯一要做的事情是在我们新的命名空间内配置设备,问题是这个设备在主机的网络命名空间不再可见,因此,我们需要SetNetNsByFile函数再次加入命名空间(第21行),这个函数仅在给定的文件描述符上调用setns。注意,我们需要defer函数 unset,以便在函数的末尾离开容器的网络命名空间。

剩下的代码(第22~43行),现在,运行在容器的网络命名空间内。首先,将容器内的设备重命名为eth0(第29行),然后关联到一个新的IP(第32行),设置设备(第35行),给设备添加网关(第38行),最后设置环回(loopback, 127.0.0.1)网络接口。现在我们的网络命名空间已经完全准备好了。

值得一提的是,将172.30.0.1设置为vessel0网桥的默认IP不是最好的方式,因为这个IP可能已经被使用,这里只是为了简化。现在你的任务是做得更好然后提交PR...

Conclusion

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

文章还没有完成,我们将在下一节讨论联合文件系统。但是现在,让我们动手尝试,并结合vessel的源码理解它。

除此,不要忘了google的 “Liz Rice”,看她关于容器的演讲。

上一篇:Redis查询缓存与延时双删的实际应用(Golang)


下一篇:go 遍历文件中中的所有文件打印