转载自 Ali Josie-Build Containers From Scratch in Go
2020/9/17,在过去的几年里,容器的使用量显著增加。容器的概念已经出来好多年了,但是由于Docker易于使用的命令行使得容器从2013年开始在开发者里流行(我觉得主要是可复用的镜像)。
在这个系列中,我将尝试演示容器底层是如何工作的,以及我是如何开发vessel
的
vessel
是我的一个教育目的的项目,它实现了一个小版本的Docker来管理容器。它既不使用containerd
也不使用 runc
,而是使用一些列Linux features来创建容器。github仓库。
vessel
既不是生产就绪(production-ready)也不是(well-tested)的软件,这只是一个用来学习容器的简单项目。
我发现在开始编写代码之前,先看看Docker文档并深入了解容器是很有用的。
根据Docker官方文档,Docker利用几个Linux内核特性并将它们打包成容器格式,这些特性包括:
现在让我们分别简单地了解这些特性
Linux namespace 是最现代容器实现背后的底层技术。Namespace是进程级的概念,允许在一组进程中隔离全局系统资源。例如network namespace,它隔离网络栈,这意味着在网络命名空间中的进程可以有它自己独立的路由、防火墙规则和网络设备。
因此如果没有命名空间,一个容器中的进程可以卸载(unmount)文件系统,或者设置另外一个文件系统中网络接口。
在当前的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_MONOTONIC
和 CLOCK_BOOTTIME
系统时钟,这两种时钟会影响基于时间测量的API(例如系统开机时间uptime)
User:该namespace隔离用户ID、组ID、根目录、keys、capabilities。这云心进程在namespace中是root,但在namesapce外(例如host)不是
UTS:该namespace隔离主机名和域名
Namespace除了隔离没有做任何事情,这意味着,例如,加入一个新的network namespace不会给你一组独立的独立的网络设备,你必须自己创建它们。同样的事情对于UST namespace,它将不会改变你的hostname,它只是隔离hostname相关的系统调用
当namespace中的最后一个进程离开namespace时,namesapce将自动关闭。然而,这有许多例外情况使得namesapce在没有任何进程时扔保持活动状态(alive),我们将在vessel中创建network namespace中了解其中一种情况
现在我们简单地了解了namespace是什么,是时候看看如何与它们交互了。在Linux中,有一组系统调用支持创建、加入和发现namesapce。
clone
:这个系统调用会创建一个新进程,但在flags参数的帮助下,新进程将创建自己新的namespace
setns
:这个系统调用允许正在运行的进程加入一个已存在的namespace
unshare
:这个系统调用实际上和clone
相同,不同的地方是该调用会创建一个新的namesapce并将当前进程移进去,而clone
将会创建一个带有新namespace的进程。
Bonus point:fork
和 vfork
只是使用不同参数的 clone
调用
上面提到的系统调用需要一个标志来指定所需的名称空间。
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
参数。
上面讲过,我们可以使用 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
目录下的文件链接
现在我们已经知道想知道的一切,是时候写一个运行在独立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。我们将在下一节深入挖掘这一点。
到目前为止,Go还没有clone功能。然而,一个叫做goclone
的包打包了clone系统调用,但是我们采用的解决方案稍有不同,在vessel中,我使用的一个叫做reexec
的包,它是由Docker团队开发的
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呢?
正如我已经提到的,namespace只隔离了资源和容器感知的边界。因此,在新的Network Namespace中运行容器不会有多大的帮助。我们还应该做一些事情将容器与外部网络连接,但这怎么可能呢?!
veth
可以作为Network Namesapce之间的通道,这意味着可以与另一个namesapce中的网络设备创建连接。
虚拟以太网设备(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
的桥,这个名字实际上传给了SetupNetwork
的bridge
在第3-4行中,定义了veth设备对名称。然后在第6行,将使用相关名称创建veth。在第9行,veth将vessel0
指定为其主设备,以便进一步通信。
现在需要创建一个新的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...
我们了解到,名称空间是Linux的一个特性,它隔离了一组进程的全局系统资源,因此它是大多数容器中的基本技术。此外,我们还学习了如何在Go中使用unshare
、clone
和setns
系统调用与名称空间交互。
文章还没有完成,我们将在下一节讨论联合文件系统。但是现在,让我们动手尝试,并结合vessel的源码理解它。
除此,不要忘了google的 “Liz Rice”,看她关于容器的演讲。