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


    针对github开源项目vessl的学习笔记
    转载自 Ali Josie-Build Containers From Scratch in Go

    在这个系列中,我将尝试演示容器底层是如何工作的,以及我是如何开发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中的网络设备创建连接。

    虚拟以太网设备(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指定为其主设备,以便进一步通信。

    现在需要创建一个新的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系统调用与名称空间交互。

  • 相关阅读:
    6 网络爬虫引发的问题及Robots协议
    WEB测试方法总结-笔记(转)
    最全的Http协议、get和post请求的整理
    random()函数的应用---面试
    求两个列表的交集、差集、并集---面试
    python中函数参数传递--引用传递(面试)
    linux重定向命令>和>>---面试
    正则表达式re.findall和re.search的使用---面试
    关于可迭代对象的详解
    sorted()函数排序的灵活运用---面试
  • 原文地址:https://www.cnblogs.com/lfri/p/15831644.html
Copyright © 2020-2023  润新知