Gocker:在1.3k行围棋中实现Docker

2020-06-18 10:00:12

它们很受欢迎,但也被误解了。容器已经成为应用程序在服务器上打包和运行的默认方式,最初由Docker推广。现在,Docker本身被误解了。允许您轻松管理容器(创建、运行、删除、网络)的是公司名称和命令(更确切地说,是一套命令)。但是,容器本身是从一组操作系统原语创建的。在本文中,我们将关注Linux操作系统上的容器,并简单地当作Windows上的容器根本不存在。

Linux下没有创建容器的单一系统调用。它们是利用Linux名称空间和控制组或cgroup构建的松散结构。

Gocker是在Go编程语言中从头开始实现Docker的核心功能。这里的主要目的是了解容器在Linux系统调用级别的确切工作方式。Gocker允许您创建容器、管理容器映像、在现有容器中执行进程等。

Gocker可以模拟Docker的核心,允许您管理Docker镜像(从Docker Hub获取)、运行容器、列出正在运行的容器或在已经运行的容器中执行进程:

Gocker使用Ovelay文件系统快速创建容器,无需复制整个文件系统,同时还可以在多个容器实例之间共享相同的容器映像。

Gocker容器拥有自己的网络名称空间,并且能够访问互联网。请参阅下面的限制。

您可以控制CPU百分比、RAM大小和进程数等系统资源。Gocker通过利用cgroup实现了这一点。

使用Gocker创建的容器拥有其自己的以下命名空间(请参见.run.go和network.go):

虽然创建了限制以下内容的cgroup,但是,除非您在dgocker run命令中指定了--mem、c--cpu或s--pid选项,否则大陆可以使用无限的资源。这些标志分别限制容器可以使用的最大RAM、CPU核心和PID。

所有Linux机器在引导时都是一组“默认”名称空间的一部分。在计算机上创建的进程也继承默认名称空间。换句话说,进程可以看到哪些其他进程正在运行、列出网络接口、列出装入点、列出权限允许的指定IPC对象或文件,例如因为所有对象也存在于默认名称空间中。例如,当创建一个进程时,我们可以告诉Linux为我们创建一个新的PID名称空间,在这种情况下,新进程及其任何后代形成一个新的层次结构或PID,新创建的初始进程为PID 1,就像Linux机器上特殊的init进程一样。假设使用新的PID名称空间创建了一个名为“new_Child”的进程。当该进程或其后代使用getpid()或getppid()等系统调用时,它们会看到新名称空间中的PID。例如,对于这两个系统调用,新创建的PID名称空间中的new_Child将得到1。但是,当您从默认名称空间查看new_Child的PID时,当然不会为其分配1。这将是默认名称空间中的init。它将被分配一个更符合当时分配的PID进程系列的PID。

Linux操作系统提供了在创建进程时创建新名称空间的方法,或者为现有的正在运行的进程提供与其相关联的方法。所有命名空间,无论其类型如何,都分配有内部ID。命名空间是一种内核对象。对于每种命名空间类型,一个进程只能属于一个命名空间。例如,假设进程new_Child的PID名称空间被设置为内部ID为0x87654321的名称空间,它不能属于另一个PID名称空间。但是,可能存在属于同一PID命名空间0x87654321的其他进程。此外,new_Child的后代将自动属于相同的PID名称空间。命名空间是继承的。

您可以使用lsns实用程序列出计算机中的各种名称空间。即使您的机器上没有运行任何容器,您也很可能会看到与各种名称空间相关联的其他进程。这表明名称空间不仅仅必须在容器上下文中使用。它们可以在任何地方使用。它们提供了隔离。它们是一个很棒的安全功能。在现代Linux系统上,您将看到init、systemd、几个系统守护进程、Chrome、Slake,当然还有使用各种名称空间的Docker容器。让我们看看我的机器上lsns实用程序的一部分输出:

NS类型NPROCS PID USER COMMAND4026532281 mnt 1 313 root/usr/lib/systemd/systemd-udevd4026532282 uts 1 313 root/usr/lib/systemd/systemd/systemd-udevd4026532313 mnt 1 483 systemd-timesync/usr/lib/systemd/systemd-timesyncd4026532332 uts 1 483 systemd-timesync。nacl_helper4026532343 PID 2 1941 Shuveb/opt/google/chrome/chrome--type=zygote4026532345 net 50 1941 Shuveb/opt/google/chrome/chrome--type=zygote4026532449 mnt 1 547 root/usr/lib/boltd4026532489 mnt 1 580 root/usr/lib/Bluetooth/Bluothothd4026532579 net 1 1943 Shuveb/op。/usr/lib/colord4026532769 user 1 1943 Shuveb/opt/google/chrome/nacl_helper4026532770 user 50 1941 Shuveb/opt/google/chrome/chrome--type=zygote4026532771 PID 1 2010 Shuveb/opt/google/chrome/chrome--type=renderer4026532772 PID 1 2765 Shuveb/opt/google/chrome/chrome-type=renderer40265318。init4026532912 PID 2 3249 Shuveb/usr/lib/slack/slack--type=zygote4026532914 net 2 3249 Shuveb/usr/lib/slack/slack--type=zygote4026533003 user 2 3249 Shuveb/usr/lib/slack/slack--type=zygote。

即使您没有显式创建命名空间,进程也将是默认命名空间的一部分。所有名称空间的详细信息都记录在/proc文件系统中。您可以通过键入ls-l/proc/self/ns/查看shell进程所属的名称空间。这是我的结果。此外,这些代码大多继承自init:

➜~ls-l/进程/自身/n

➜~sudo unshare--fork--pid--mount/proc/bin/bash[ROOT@kodai Shuveb]#ps aux USER PID%CPU%MEMVSZ RSS TTY stat start time COMMANDroot 1 0.5 0.0 8296 4944 pts/1 S 08:59 0:00/bin/bashroot 2 0.0 0.0 8816 3336 pts/1 R+08:59 0:00 ps aux[root@kodai Shuveb]#。

在上面的调用中,unshare实用程序派生了一个新进程,调用unshare()系统调用创建一个新的PID名称空间,然后在其中执行/bin/bash。我们还告诉unshare实用程序在新进程中挂载proc文件系统。这就是ps实用程序获取信息的地方。从ps命令的输出中,您确实可以看到此shell有一个新的PID名称空间,其中PID为1,并且由于ps是由具有新PID名称空间的shell启动的,因此它继承了该名称空间并获得PID为2。作为练习,您可以计算出在此容器中运行的shell进程在主机上的PID。

了解了PID名称空间之后,让我们试着了解还有哪些其他名称空间以及它们的含义。Namespaces手册页介绍了8种不同的名称空间。以下是具有简短说明以及相关手册页链接的不同类型:

命名空间标志隔离cgroup CLONE_NEWCGROUP cgroup根目录IPC CLONE_NEWIPC SYSTEM V IPC,POSIX消息队列Network CLONE_NEWNET网络设备、堆栈、端口等。MOUNT CLONE_NENS挂载点PID CLONE_NEWPID进程ID TIME CLONE_NEWTIME BOOT和单调时钟USER CLONE_NEWUSER用户和组ID UTS CLONE_NEWUTS主机名和NIS域名。

您可以想象一下,对于新的或现有的流程,您可以使用这些名称空间做些什么。当它们在同一台机器上运行时,您几乎可以将它们隔离,就像它们在单独的虚拟机上运行一样。您可以将多个进程隔离在它们自己的名称空间中,并在同一主机内核上运行。这比运行多个虚拟机效率高得多。

默认情况下,使用fork()创建进程时,子级继承调用fork()的进程的名称空间。如果您希望正在创建的新进程成为一组新名称空间的一部分,该怎么办呢?如您所见,fork()正好有0个参数,并且不允许我们在创建子对象之前控制子对象的属性。但是,您可以通过clone()系统调用施加这种控制,它允许对它创建的新进程进行非常细粒度的控制。

在Linux下,虽然有不同的系统调用(如fork()、vfork()和clone())来创建新进程。不过在内部,内核中的fork()和vfork()只是使用不同的参数调用clone()。围绕这一点的内核源代码(为了更清晰起见,我做了一些编辑)非常容易理解。在文件kernel/fork.c中,您可以看到:

SYSCALL_DEFINE0(Fork){struct kernel_clone_args args={.exit_Signal=SIGCHLD,};return_do_fork(&;args);}SYSCALL_DEFINE0(Vfork){struct kernel_clone_args args={.flag=CLONE_VFORK|CLONE_VM,.exit_Signal=SIGCHLD,};return_do_fork(&;args。}SYSCALL_DEFINE5(CLONE,UNSIGNED LONG,CLONE_FLAGS,UNSIGNED LONG,newsp,int__user*,parent_tidptr,int__user*,Child_tidptr,UNSIGNED LONG,TLS){struct kernel_clone_args args={.flag=(LOWER_32_BITS(CLONE_FLAGS)&;~CSIGNAL),.pidfd=parent_tidptr,.Child_tid。CSIGNAL),.stack=newsp,.tls=TLS,};IF(!Legacy_CLONE_ARGS_VALID(&;args))return-EINVAL;return_do_fork(&;args);}

如您所见,这三个系统调用都只是使用不同的参数调用_do_fork()。_do_fork()实现创建新进程的逻辑。

Gocker通过GO的“exec”包执行以下操作来使用clone()系统调用。在run.go(处理与运行容器相关的内容)中,您可以看到:

cmd=exec.Command(";/proc/self/exe";,args.)cmd.Stdin=os.Stdin cmd.Stdout=os.Stdout cmd.Stderr=os.Stderr cmd.SysProcAttr=&;syscall.SysProcAttr{克隆标志:syscall.CLONE_NEWPID|syscall.CLONE_NEWUTS|syscall.CLONE_NEWUTS|syscall.CLONE_NEWIPC,}doOrDie(cc.CLONE_NEWUTS|syscall.CLONE_NEWIPC,}doOrDie(cc.CLONE_NEWUTS|syscall.CLONE_NEWIPC,}。

在syscall.SysProcAttr中,我们可以传入Cloneflag,然后它将被传递到对clone()系统调用的调用中。精明的读者应该已经注意到,我们在这里没有设置单独的网络名称空间。在Gocker中,我们设置了一个虚拟以太网接口,将其添加到一个新的网络名称空间,并使用不同的Linux系统调用让容器加入该名称空间。我们将在后面讨论这个问题。

如果您希望为现有进程创建新的名称空间,而不必使用clone()创建新的子进程,那么Linux提供了unshare()系统调用。

要联接文件引用的命名空间或联接其他进程所属的命名空间,Linux使setns()系统调用可用。这是非常有用的,我们很快就会看到。

Gocker的一些日志消息一直保留着,因为Gocker的主要目的是帮助理解Linux容器。从这个意义上说,它比运行Docker要冗长得多。让我们看一下日志来指导我们关于程序执行的事情。然后,我们可以向下钻取,看看事情是如何实际工作的:

➜sudo./gocker run alpine/bin/sh2020/06/13 12:37:53 cmd参数:[./gocker run alpine/bin/sh]2020/06/13 12:37:53新容器ID:33c20f9ee6002020/06/13 12:37:53镜像已存在。未下载。2020/06/13 12:37:53要覆盖装载的映像:a24bb40132962020/06/13 12:37:53命令参数:[/proc/self/exe setup-netns 33c20f9ee600]2020/06/13 12:37:53命令参数:[/proc/self/exe setup-veth 33c20f9ee600]2020/06/13 12:37:53命令参数:[

在这里,我们要求Gocker从Alpine Linux映像运行一个shell。稍后我们将了解图像是如何管理的。现在,请注意以“Cmd args:”开头的日志行。这一行意味着一个新的进程诞生了。第一个日志行显示了shell在我们运行Gocker命令后启动的进程。然而,在接近尾声时,我们看到又启动了三个进程。第二个参数为“子模式”的最后一个参数是执行我们在Alpine Linux映像中要求的shell/bin/sh的参数。在此之前,我们看到另外两个进程,参数分别为“setup-netns”和“setup-veth”。这些进程设置新的网络名称空间,并设置虚拟以太网设备对的容器端,该虚拟以太网设备对分别允许容器与外部世界通信。

由于各种原因,GO语言不直接支持fork()系统调用。我们通过创建一个新进程来解决此限制,但在其中再次执行当前程序。当前运行的可执行文件的路径由/proc/self/exe指向。根据命令行参数,我们传递不同的命令行参数来调用适当的函数(当子进程中返回fork()时会调用该函数)。

Gocker源代码按类似命令的参数组织在文件中。例如,主要为gocker run命令行参数提供服务的函数位于run.go文件中。同样,gocker exec主要需要的函数在exec.go文件中。这并不意味着这些文件是自包含的。它们可以自由地调用其他文件中的函数。还有一些实现常见功能的文件,如cgroups.go和utils.go。

在main.go中,您可以看到,如果gocker命令正在运行,我们将检查以确保gocker0桥已启动并正在运行。否则,我们首先调用setupGockerBridge()来完成该工作。最后,我们调用在run.go中实现的函数initContainer()。让我们仔细看看该函数:

func initContainer(mem int,Swap int,PIDS int,CPU float64,src string,args[]string){tainerID:=createContainerID()log.Printf(";新容器ID:%s\n";,tainerID)imageShaHex:=downloadImageIfRequired(Src)log.Printf(";要覆盖的映像挂载:%s\n&#。无法在主机上设置Veth0:%v";,err)}准备AndExecuteContainer(mem,swp,PID,CPU,tainerID,imageShaHex,args)log.Printf(";容器已完成。\n";)unmount网络名称空间(TainerID)卸载ContainerFs(TainerID)removeCGroups(TainerID)os.RemoveAll。

首先,我们通过调用createContainerID()创建一个唯一的容器ID。然后调用downloadImageIfRequired(),以便容器镜像在本地不可用时可以从Docker Hub下载。Gocker使用/var/run/gocker/tainers中的子目录挂载容器根文件系统。createContainerDirectory()负责这一点。installtOverlayFileSystem()知道如何处理多层docker映像,并为/var/run/gocker/containers/<;container-id>;/fs/mnt.上的可用映像挂载合并的文件系统。虽然这看起来可能令人望而生畏,但如果您阅读源代码,这并不难理解。覆盖文件系统允许您创建堆叠文件系统,其中较低层(在本例中来自Docker根文件系统)是只读的,而所有更改将保存到“upperdir”,而不会更改较低层中的任何文件。这允许多个容器共享一个Docker镜像。当我们在虚拟机上下文中说“映像”时,它通常指的是磁盘映像。但在这里,它只是一个目录或一组目录(别出心裁的名字:Layers),其中的文件构成了Docker“映像”的根文件系统,可以使用覆盖文件系统挂载该文件系统,从而为新容器创建根文件系统。

接下来,我们创建一个虚拟以太网配对设备,它非常类似于调用setupVirtualethOnHost()的管道。它们采用名称veth0_<;容器-id>;和veth1_<;容器-id>;的形式。我们将该对的veth0部分连接到主机上的网桥gocker0。稍后,我们将在容器内使用该对的veth1部分。此对类似于管道,是从具有其自己的网络名称空间的容器内进行网络通信的秘密。我们随后将介绍如何在容器中设置veth1部分。

最后,调用prepaareAndExecuteContainer(),它实际执行容器中的流程。当此函数返回时,容器已执行完毕。最后,我们进行一些清理并退出。让我们看看prepaareAndExecuteContainer()做些什么。它实质上创建了我们看到的3个流程

设置新的网络命名空间非常简单。您只需将CLONE_NEWNET作为传递给clone()系统调用的标志位掩码的一部分。棘手的是确保容器内部可以有一个网络接口,它可以通过该接口与外部通信。在Gocker中,我们创建的第一个新名称空间是网络名称空间。当使用setup-ns和setup-veth参数调用gocker时,会发生这种情况。首先,我们设置一个新的网络命名空间。setns()系统调用可以将调用进程的名称空间设置为由指向/proc/<;pid>;/ns中的文件的文件描述符引用的名称空间,该文件描述符列出了进程所属的所有名称空间。让我们看一下setupNewNetworkNamespace()函数,该函数是使用setup-netns参数调用gocker而调用的结果。

函数setupNewNetworkNamespace(ContainerID字符串){_=getGockerNetNsPath()+";/";+ContainerID if_,err:=syscall.open(nsmount,createDirsIfDontExist([]string{getGockerNetNsPath()})0644);err!=nil{log.Fatalf(";无法打开绑定装载文件:%v\n";,err)}fd,err。

..