理解 Docker 很好的教程:用 Go 从头实现一个迷你 Docker — Gocker

理解 Docker 很好的教程:用 Go 从头实现一个迷你 Docker — Gocker

容器很受欢迎。容器已成为应用程序在服务器上打包和运行的默认方式,最初是由 Docker 普及的。现在,Docker 是公司的名称和一个命令(一组命令),使您可以轻松管理容器(创建,运行,删除,网络)。但是,容器本身是从一组操作系统原语创建的。在本文中,我们将关注 Linux 操作系统上的容器,并简单地说明为什么 Windows 上的容器根本不存在。

Linux 下没有创建容器的单个系统调用。它们是利用 Linux 命名空间和控制组或 cgroups 构成的松散构造。

Gocker 是什么?

Gocker 是一个使用 Go 编程语言从头开始实现 Docker 核心功能的项目。它主要目的是提供对容器在 Linux 系统调用级别上如何工作的理解。Gocker 允许你创建容器,管理容器镜像(Image),在容器中执行进程等。

Gocker 的功能

Gocker 可以模拟 Docker 的内核,让你管理 Docker 镜像(从 Docker Hub 获取),运行容器,列出正在运行的容器或在已经运行的容器中运行进程:

  • 在容器中运行进程
    • gocker run <–cpus=cpus-max> <–mem=mem-max> <–pids=pids-max> <image[:tag]> </path/to/command>
  • 列出正在运行的容器
    • gocker ps
  • 在运行的容器中执行进程
    • gocker exec </path/to/command>
  • 列出本地可用的镜像
    • gocker images
  • 删除本地可用的镜像
    • gocker rmi

其他功能

  • Gocker 使用 Overlay 文件系统快速创建容器,而无需复制整个文件系统,同时还可以在多个容器实例之间共享同一容器镜像。
  • Gocker 容器拥有自己的网络命名空间,并且能够访问 Internet。请参阅下面的限制。
  • 您可以控制系统资源,例如 CPU 百分比,RAM 数量和进程数。Gocker 通过利用 cgroups 实现了这一目标。

Gocker 容器隔离性

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

  • 文件系统 File system (via chroot)
  • PID
  • IPC
  • UTS (hostname)
  • Mount
  • Network

在创建用于限制以下内容的 cgroup 时,除非你在 gocker run 命令中指定了 –mem,–cpus 或 –pids 选项,否则容器将使用无限的资源。这些标志分别限制了容器可以使用的最大 RAM,CPU 内核和 PID。

  • CPU 核心数
  • RAM
  • PID 数量(限制进程)

命名空间(Namespaces)基础

所有 Linux 计算机在启动时都是 “default” 命名空间的一部分。在计算机上创建的进程也继承默认命名空间。换句话说,因为所有对象也都存在于默认命名空间中,进程可以看到正在运行的其他进程,网络接口,挂载点,名为 IPC 的对象或权限允许的文件。当创建一个进程时,我们可以告诉 Linux 为我们创建一个新的 PID 命名空间,在这种情况下,新进程及其任何后代形成一个新的层次结构或 PID,而新创建的初始进程为 PID 1,就像 Linux 机器上特殊的初始化进程一样。假设使用新的 PID 命名空间创建了一个名为 “new_child” 的进程。当该进程或其后代使用诸如 getpid() 或 getppid() 之类的系统调用时,它们会在新命名空间中看到 PID。例如,对于这两个系统调用,在新创建的 PID 命名空间中的 new_child 将获得 1。而当您从默认命名空间查看 new_child 的 PID 时,当然不会为其分配 1(那是默认命名空间中的 init 了)。

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

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

        NS TYPE   NPROCS   PID USER             COMMAND
4026532281 mnt         1   313 root             /usr/lib/systemd/systemd-udevd
4026532282 uts         1   313 root             /usr/lib/systemd/systemd-udevd
4026532313 mnt         1   483 systemd-timesync /usr/lib/systemd/systemd-timesyncd
4026532332 uts         1   483 systemd-timesync /usr/lib/systemd/systemd-timesyncd
4026532334 mnt         1   502 root             /usr/bin/NetworkManager --no-daemon
4026532335 mnt         1   503 root             /usr/lib/systemd/systemd-logind
4026532336 uts         1   503 root             /usr/lib/systemd/systemd-logind
4026532341 pid         1  1943 shuveb           /opt/google/chrome/nacl_helper
4026532343 pid         2  1941 shuveb           /opt/google/chrome/chrome --type=zygote
4026532345 net        50  1941 shuveb           /opt/google/chrome/chrome --type=zygote
4026532449 mnt         1   547 root             /usr/lib/boltd
4026532489 mnt         1   580 root             /usr/lib/bluetooth/bluetoothd
4026532579 net         1  1943 shuveb           /opt/google/chrome/nacl_helper
4026532661 mnt         1   766 root             /usr/lib/upowerd
4026532664 user        1   766 root             /usr/lib/upowerd
4026532665 pid         1  2521 shuveb           /opt/google/chrome/chrome --type=renderer
4026532667 net         1   836 rtkit            /usr/lib/rtkit-daemon
4026532753 mnt         1   943 colord           /usr/lib/colord
4026532769 user        1  1943 shuveb           /opt/google/chrome/nacl_helper
4026532770 user       50  1941 shuveb           /opt/google/chrome/chrome --type=zygote
4026532771 pid         1  2010 shuveb           /opt/google/chrome/chrome --type=renderer
4026532772 pid         1  2765 shuveb           /opt/google/chrome/chrome --type=renderer
4026531835 cgroup    294     1 root             /sbin/init
4026531836 pid       237     1 root             /sbin/init
4026531837 user      238     1 root             /sbin/init
4026531838 uts       289     1 root             /sbin/init
4026531839 ipc       292     1 root             /sbin/init
4026531840 mnt       283     1 root             /sbin/init
4026531992 net       236     1 root             /sbin/init
4026532912 pid         2  3249 shuveb           /usr/lib/slack/slack --type=zygote
4026532914 net         2  3249 shuveb           /usr/lib/slack/slack --type=zygote
4026533003 user        2  3249 shuveb           /usr/lib/slack/slack --type=zygote

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

➜  ~ ls -l /proc/self/ns
total 0
lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 net -> 'net:[4026531992]'
lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 user -> 'user:[4026531837]'
lrwxrwxrwx 1 shuveb shuveb 0 Jun 13 11:44 uts -> 'uts:[4026531838]'

没有容器的命名空间

从 lsns 的输出中,我们看到容器并不是唯一使用命名空间的对象。为此,让我们创建一个具有自己的 PID 命名空间的 shell 实例。我们将使用 unshare 实用程序来做到这一点。“unshare” 这个名字很明显。还有一个同名的 Linux 系统调用,可让您取消共享默认命名空间,从而使调用进程加入新创建的命名空间。

➜  ~ sudo unshare --fork --pid --mount-proc /bin/bash
[root@kodai shuveb]# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.5  0.0   8296  4944 pts/1    S    08:59   0:00 /bin/bash
root           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 启动的,因此它继承了该 Shell 并获得 PID 为 2。作为练习,您可以弄清楚在此容器中运行的 Shell 进程在主机上的 PID 是什么。

命名空间的类型

了解 PID 命名空间后,让我们尝试了解其他命名空间以及它们的含义。命名空间手册页讨论了 8 种不同的命名空间。以下是带有简短说明的各种类型,以及指向相关手册页的链接:

Namespace Flag Isolates
Cgroup CLONE_NEWCGROUP Cgroup root directory
IPC CLONE_NEWIPC System V IPC, POSIX message queues
Network CLONE_NEWNET Network devices,stacks, ports, etc.
Mount CLONE_NEWNS Mount points
PID CLONE_NEWPID Process IDs
Time CLONE_NEWTIME Boot and monotonic clocks
User CLONE_NEWUSER User and group IDs
UTS CLONE_NEWUTS Hostname and NIS domain name

您可以想象使用这些命名空间为新的或现有的流程做什么。当它们在同一台计算机上运行时,您几乎可以将它们隔离在一个虚拟机上运行。您可以将多个进程隔离在各自的命名空间中,并在同一主机内核上运行。这比运行多个虚拟机要有效得多。

创建新的命名空间或加入现有的命名空间

默认情况下,当您使用 fork() 创建进程时,子进程将继承调用 fork() 的进程的命名空间。如果您希望创建的新进程成为新命名空间的一部分,该怎么办?但 fork() 没有参数,不允许我们在创建子进程之前对其进行控制。然而,您可以使用 clone() 系统调用来施加这种控制,从而可以非常精细地控制它创建的新进程。

有关 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 = {
    .flags    = 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 = {
    .flags    = (lower_32_bits(clone_flags) & ~CSIGNAL),
    .pidfd    = parent_tidptr,
    .child_tid  = child_tidptr,
    .parent_tid  = parent_tidptr,
    .exit_signal  = (lower_32_bits(clone_flags) & CSIGNAL),
    .stack    = newsp,
    .tls    = tls,
  };

  if (!legacy_clone_args_valid(&args))
    return -EINVAL;

  return _do_fork(&args);
}

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

使用 clone() 创建具有新命名空间的进程

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{
  Cloneflags: syscall.CLONE_NEWPID |
    syscall.CLONE_NEWNS |
    syscall.CLONE_NEWUTS |
    syscall.CLONE_NEWIPC,
}
doOrDie(cmd.Run())

在 syscall.SysProcAttr 中,我们可以传入 Cloneflags,然后将其传递给对 clone() 系统调用。细心的读者会注意到,我们不在这里设置单独的网络命名空间。在 Gocker 中,我们设置了一个虚拟以太网接口,将其添加到新的网络命名空间,并使用另一个 Linux 系统调用使容器加入该命名空间。我们将在后面讨论。

使用 unshare() 创建和加入新的命名空间

如果要为现有进程创建新的命名空间,则不必使用 clone() 创建新的子进程,Linux 提供了 unshare() 系统调用。

加入其他进程所属的命名空间

为了加入文件引用的命名空间或加入其他进程所属的命名空间,Linux 提供了 setns() 系统调用。我们将很快看到,这非常有用。

Gocker 如何创建容器

由于 Gocker 的主要目的是帮助理解 Linux 容器,因此保留了一些来自 Gocker 的日志消息。从这个意义上讲,它比运行 Docker 更为冗长。让我们看一下日志,以指导我们执行程序。然后,我们可以进行深入分析,看看实际情况如何:

➜  sudo ./gocker run alpine /bin/sh
2020/06/13 12:37:53 Cmd args: [./gocker run alpine /bin/sh]
2020/06/13 12:37:53 New container ID: 33c20f9ee600
2020/06/13 12:37:53 Image already exists. Not downloading.
2020/06/13 12:37:53 Image to overlay mount: a24bb4013296
2020/06/13 12:37:53 Cmd args: [/proc/self/exe setup-netns 33c20f9ee600]
2020/06/13 12:37:53 Cmd args: [/proc/self/exe setup-veth 33c20f9ee600]
2020/06/13 12:37:53 Cmd args: [/proc/self/exe child-mode --img=a24bb4013296 33c20f9ee600 /bin/sh]
/ # 

在这里,我们要求 Gocker 从 Alpine Linux 镜像运行 shell。稍后我们将了解如何管理镜像(Image)。现在,请注意以 “ Cmd args:” 开头的日志行。此行表示产生了一个新进程。第一行日志向我们显示了由于运行 Gocker 命令而使 shell 程序启动的过程。但是,到最后,我们看到了另外三个进程。最后一个是带有参数 “child-mode” 的 /bin/sh,我们在 Alpine Linux 镜像中使用它。在此之前,我们看到其他两个进程分别带有参数 “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() 来启动它。最后,我们调用函数 initContainer(),该函数在 run.go 中实现。让我们仔细看看该函数:

func initContainer(mem int, swap int, pids int, cpus float64, 
                                src string, args []string) {
  containerID := createContainerID()
  log.Printf("New container ID: %s\n", containerID)
  imageShaHex := downloadImageIfRequired(src)
  log.Printf("Image to overlay mount: %s\n", imageShaHex)
  createContainerDirectories(containerID)
  mountOverlayFileSystem(containerID, imageShaHex)
  if err := setupVirtualEthOnHost(containerID); err != nil {
    log.Fatalf("Unable to setup Veth0 on host: %v", err)
  }
  prepareAndExecuteContainer(mem, swap, pids, cpus, containerID, 
                                imageShaHex, args)
  log.Printf("Container done.\n")
  unmountNetworkNamespace(containerID)
  unmountContainerFs(containerID)
  removeCGroups(containerID)
  os.RemoveAll(getGockerContainersPath() + "/" + containerID)
}

首先,我们通过调用 createContainerID() 创建唯一的容器 ID。然后,我们调用 downloadImageIfRequired(),以便可以从Docker Hub 下载容器镜像(如果本地尚不可用)。Gocker 使用 /var/run/gocker/containers 中的子目录来挂载容器根文件系统。createContainerDirectories() 会解决这个问题。mountOverlayFileSystem() 知道如何处理多层 Docker 镜像,并在 /var/run/gocker/containers/<container-id>/fs/mnt 上为可用镜像安装合并的文件系统。尽管这看起来令人生畏,但如果您阅读源代码,这并不难理解。覆盖(Overlay)文件系统允许您创建一个堆叠的文件系统,其中较低的层(在这种情况下是 Docker 根文件系统)是只读的,而任何更改都将保存到 “upperdir”,而无需更改较低层中的任何文件。这允许许多容器共享一个 Docker 镜像。当我们在虚拟机上下文中说“镜像”时,它通常是指磁盘镜像。但是在这里,它只是一个目录或一组目录(奇特的名字:layers),带有构成 Docker “镜像”根文件系统的文件,可以使用 Overlay 文件系统挂载该文件来创建根文件系统一个新的容器。

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

最后,调用 prepareAndExecuteContainer(),它实际上在容器中执行该过程。当此函数返回时,容器已完成执行。最后,我们进行一些清理并退出。让我们看看 prepareAndExecuteContainer() 的作用。它实际上创建了我们看到的日志的 3 个进程,并使用 setup-netns,setup-veth 和 child-mode 参数运行相同的 gocker 二进制文件。

设置可在容器内工作的网络

设置新的网络命名空间非常容易。您只需将 CLONE_NEWNET 包含在传递给 clone() 系统调用的标志位掩码中即可。棘手的是确保容器内部可以具有网络接口,通过该接口可以与外部进行通信。在 Gocker 中,我们创建的第一个新命名空间是网络的命名空间。当使用 setup-ns 和 setup-veth 参数调用 gocker 时会发生这种情况。首先,我们设置一个新的网络命名空间。setns() 系统调用可以将调用进程的命名空间设置为由文件描述符所引用的命名空间,该文件描述符指向 /proc/<pid>/ns 中的文件,该文件列出了进程所属的所有命名空间。让我们看一下 setupNewNetworkNamespace() 函数,该函数是通过使用 setup-netns 作为参数调用 gocker 而被调用的。(译注:即上文提到的 Cmd args: [/proc/self/exe setup-netns 33c20f9ee600] )

func setupNewNetworkNamespace(containerID string) {
  _ = createDirsIfDontExist([]string{getGockerNetNsPath()})
  nsMount := getGockerNetNsPath() + "/" + containerID
  if _, err := syscall.Open(nsMount, 
                syscall.O_RDONLY|syscall.O_CREAT|syscall.O_EXCL,
                0644); err != nil {
    log.Fatalf("Unable to open bind mount file: :%v\n", err)
  }

  fd, err := syscall.Open("/proc/self/ns/net", syscall.O_RDONLY, 0)
  defer syscall.Close(fd)
  if err != nil {
    log.Fatalf("Unable to open: %v\n", err)
  }

  if err := syscall.Unshare(syscall.CLONE_NEWNET); err != nil {
    log.Fatalf("Unshare system call failed: %v\n", err)
  }
  if err := syscall.Mount("/proc/self/ns/net", nsMount, 
                                "bind", syscall.MS_BIND, ""); err != nil {
    log.Fatalf("Mount system call failed: %v\n", err)
  }
  if err := unix.Setns(fd, syscall.CLONE_NEWNET); err != nil {
    log.Fatalf("Setns system call failed: %v\n", err)
  }
}

每当 Linux 内核中的最后一个进程终止时,它都会自动删除该命名空间。但是,有一种技术可以通过绑定来保留命名空间,即使其中没有任何进程。在 setupNewNetworkNamespace() 函数中,我们使用此技术。我们首先打开进程的网络命名空间文件,该文件位于 /proc/self/ns/net 中。然后,我们使用 CLONE_NEWNET 参数调用 unshare() 系统调用。这会将与其所属的命名空间解除关联,并创建一个新的新网络命名空间,同时将其设置为该进程的网络命名空间。然后,我们将此进程的网络命名空间专用文件的绑定到一个已知的文件名,即 /var/run/gocker/net-ns/<container-id>。该文件可随时用于引用该网络命名空间。现在,我们可以退出此进程,但是由于此进程的新网络命名空间已绑定到新文件上,因此内核将保留此命名空间。

接下来,使用 setup-veth 参数调用 gocker。这将调用函数 setupContainerNetworkInterfaceStep1() 和 setupContainerNetworkInterfaceStep2()。在第一个函数中,我们查找 veth1_<container-id> 接口,并将其命名空间设置为在上一步中创建的新网络命名空间。原本该接口将在主机上不可见。但问题是:由于它与 veth0_<container-id> 接口配对,该接口在主机上仍然可见,因此加入此网络命名空间的任何进程都可以与主机进行通信。第二个函数将 IP 地址添加到网络接口,并将 gocker0 网桥设置为其默认网关设备。

现在,主机上有一个网络接口,而新的网络命名空间上有一个可以相互通信的接口。而且由于该网络命名空间可以由文件引用,因此我们可以随时使用 setns() 系统调用打开该文件并加入该网络命名空间。这正是我们要做的。

此后,prepareAndExecuteContainer() 调用将设置一个新进程,该进程使用 child-mode 参数运行 gocker。这是最后一个进程,将产生我们要在容器中运行的命令。让我们看一下运行 child-mode 的进程的新命名空间。我们之前已经看过了这段代码:

cmd = exec.Command("/proc/self/exe", args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
  Cloneflags: syscall.CLONE_NEWPID |
    syscall.CLONE_NEWNS |
    syscall.CLONE_NEWUTS |
    syscall.CLONE_NEWIPC,
}
doOrDie(cmd.Run())

在这里,我们设置新的 PID,mount,UTS 和 IPC 命名空间。请记住,我们有一个通过文件可以引用的新网络命名空间。我们只需要加入它。我们将很快完成。child-mode 进程将调用函数 execContainerCommand()。这里代码:

func execContainerCommand(mem int, swap int, pids int, cpus float64,
  containerID string, imageShaHex string, args []string) {
  mntPath := getContainerFSHome(containerID) + "/mnt"
  cmd := exec.Command(args[0], args[1:]...)
  cmd.Stdin = os.Stdin
  cmd.Stdout = os.Stdout
  cmd.Stderr = os.Stderr

  imgConfig := parseContainerConfig(imageShaHex)
  doOrDieWithMsg(syscall.Sethostname([]byte(containerID)), "Unable to set hostname")
  doOrDieWithMsg(joinContainerNetworkNamespace(containerID), "Unable to join container network namespace")
  createCGroups(containerID, true)
  configureCGroups(containerID, mem, swap, pids, cpus)
  doOrDieWithMsg(copyNameserverConfig(containerID), "Unable to copy resolve.conf")
  doOrDieWithMsg(syscall.Chroot(mntPath), "Unable to chroot")
  doOrDieWithMsg(os.Chdir("/"), "Unable to change directory")
  createDirsIfDontExist([]string{"/proc", "/sys"})
  doOrDieWithMsg(syscall.Mount("proc", "/proc", "proc", 0, ""), "Unable to mount proc")
  doOrDieWithMsg(syscall.Mount("tmpfs", "/tmp", "tmpfs", 0, ""), "Unable to mount tmpfs")
  doOrDieWithMsg(syscall.Mount("tmpfs", "/dev", "tmpfs", 0, ""), "Unable to mount tmpfs on /dev")
  createDirsIfDontExist([]string{"/dev/pts"})
  doOrDieWithMsg(syscall.Mount("devpts", "/dev/pts", "devpts", 0, ""), "Unable to mount devpts")
  doOrDieWithMsg(syscall.Mount("sysfs", "/sys", "sysfs", 0, ""), "Unable to mount sysfs")
  setupLocalInterface()
  cmd.Env = imgConfig.Config.Env
  cmd.Run()
  doOrDie(syscall.Unmount("/dev/pts", 0))
  doOrDie(syscall.Unmount("/dev", 0))
  doOrDie(syscall.Unmount("/sys", 0))
  doOrDie(syscall.Unmount("/proc", 0))
  doOrDie(syscall.Unmount("/tmp", 0))
}

在这里,我们将容器的主机名设置为容器 ID,加入之前创建的新网络命名空间,创建允许我们控制 CPU,PID 和 RAM 使用率的 Linux 控制组,并加入这些 Cgroup,然后复制主机的 DNS 解析文件进入容器的文件系统,对已安装的 Overlay 文件系统执行 chroot(),挂载所需的文件系统,以使容器能够平稳运行,设置本地网络接口,根据容器镜像的建议设置环境变量并最终运行用户希望我们运行的命令。现在,此命令将在一组新的命名空间中运行,从而使它几乎完全与主机隔离。

限制容器资源

除了使用命名空间实现隔离之外,容器的另一个重要特征:限制容器可以消耗的资源量的能力。Linux 下的 Cgroup 很简单,通过它我们能够做到这一点。虽然命名空间是通过诸如 unshare(),setns() 和 clone() 之类的系统调用来实现的,但 Cgroup 是通过创建目录并将文件写入虚拟文件系统(位于 /sys/fs/cgroup 下)来管理的。在 Cgroups 虚拟文件系统层次结构中,每个容器创建了 3 个目录:

  • /sys/fs/cgroup/pids/gocker/<container-id>
  • /sys/fs/cgroup/cpu/gocker/<container-id>
  • /sys/fs/cgroup/mem/gocker/<container-id>

对于每个创建的目录,内核都会添加各种文件,从而可以自动配置该 cgroup。

这是我们配置容器的方式:

  • 当容器启动时,我们将创建 3 个目录,每个目录用于我们关心的三个 cgroup:CPU,PID 和 Memory。
  • 然后,我们通过写入该目录内的文件来设置 cgroup 的限制。例如,要设置容器中允许的最大 PID 数量,我们将该最大数量写入 /sys/fs/cgroup/pids/gocker/<cont-id>/pids.max。这将配置此 Cgroup。
  • 现在,我们可以通过将其 PID 添加到 /sys/fs/cgroup/pids/gocker/<cont-id>/cgroup.procs 中来添加需要由该 Cgroup 控制的进程。

这就是全部。一旦添加了要由 Cgroup 控制的进程,内核将自动将所有进程后代的 PID 添加到适当的 Cgroup 的 cgroup.procs 文件中。我们在容器(添加到了上面的 3 个 Cgroups 中)中启动一个进程,并且该进程是容器启动其他进程的祖先进程,所以所有限制也都会被继承。

限制 CPU

让我们尝试将容器可以使用的 CPU 限制为主机系统 1 个 CPU 内核的 20%。让我们开始一个受此限制的容器,安装 Python 并运行一个 while 循环。我们通过向 gocker 传递 --cpu = 0.2 标志来实现:

sudo ./gocker run --cpus=0.2 alpine /bin/sh
2020/06/13 18:14:09 Cmd args: [./gocker run --cpus=0.2 alpine /bin/sh]
2020/06/13 18:14:09 New container ID: d87d44b4d823
2020/06/13 18:14:09 Image already exists. Not downloading.
2020/06/13 18:14:09 Image to overlay mount: a24bb4013296
2020/06/13 18:14:09 Cmd args: [/proc/self/exe setup-netns d87d44b4d823]
2020/06/13 18:14:09 Cmd args: [/proc/self/exe setup-veth d87d44b4d823]
2020/06/13 18:14:09 Cmd args: [/proc/self/exe child-mode --cpus=0.2 --img=a24bb4013296 d87d44b4d823 /bin/sh]
/ # apk add python3
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/community/x86_64/APKINDEX.tar.gz
(1/10) Installing libbz2 (1.0.8-r1)
(2/10) Installing expat (2.2.9-r1)
(3/10) Installing libffi (3.3-r2)
(4/10) Installing gdbm (1.13-r1)
(5/10) Installing xz-libs (5.2.5-r0)
(6/10) Installing ncurses-terminfo-base (6.2_p20200523-r0)
(7/10) Installing ncurses-libs (6.2_p20200523-r0)
(8/10) Installing readline (8.0.4-r0)
(9/10) Installing sqlite-libs (3.32.1-r0)
(10/10) Installing python3 (3.8.3-r0)
Executing busybox-1.31.1-r16.trigger
OK: 53 MiB in 24 packages
/ # python3
Python 3.8.3 (default, May 15 2020, 01:53:50) 
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> while True:
...     pass
... 

在宿主机器运行 top,查看在容器内部运行的 python 进程占用了多少 CPU。

Cgroup将CPU限制为20%

从另一个终端,让我们使用 gocker exec 命令在同一容器内启动另一个 python 进程,并在其中运行 while 循环。

➜  sudo ./gocker ps                       
2020/06/13 18:21:10 Cmd args: [./gocker ps]
CONTAINER ID  IMAGE    COMMAND
d87d44b4d823  alpine:latest  /usr/bin/python3.8
➜  sudo ./gocker exec d87d44b4d823 /bin/sh
2020/06/13 18:21:24 Cmd args: [./gocker exec d87d44b4d823 /bin/sh]
/ # python3
Python 3.8.3 (default, May 15 2020, 01:53:50) 
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> while True:
...     pass
... 

现在有 2 个 python 进程,在不受 Cgroup 限制的情况下,不出意外的话,将消耗 2 个完整的 CPU 内核。现在,让我们看一下主机上 top 命令的输出:

Cgroup通过2个进程将CPU限制为20%

从主机 top 命令的输出中可以看到,两个 python 进程(都运行循环)都限制为每个 CPU 占用 10%。容器的 20% CPU 配额由调度程序公平分配给容器中的 2 个进程。请注意,也可以指定一个以上 CPU 内核的余量。例如,如果要允许一个容器最大使用 2 个半核心,请在标志中将其指定为 --cpu = 2.5

限制 PID

在新的 PID 命名空间中运行 Shell 程序的容器似乎消耗 7 个 PID。这意味着,如果您启动一个 PID 上限为 7 的新容器,则将无法在 Shell 上启动其他进程。让我们对此进行测试。(尽管容器中只有 2 个处于运行状态的进程,但我不确定为什么要消耗 7 个 PID。这需要进一步研究。)

➜  sudo ./gocker run --pids=7 alpine /bin/sh
[sudo] password for shuveb: 
2020/06/13 18:28:00 Cmd args: [./gocker run --pids=7 alpine /bin/sh]
2020/06/13 18:28:00 New container ID: 920a577165ef
2020/06/13 18:28:00 Image already exists. Not downloading.
2020/06/13 18:28:00 Image to overlay mount: a24bb4013296
2020/06/13 18:28:00 Cmd args: [/proc/self/exe setup-netns 920a577165ef]
2020/06/13 18:28:00 Cmd args: [/proc/self/exe setup-veth 920a577165ef]
2020/06/13 18:28:00 Cmd args: [/proc/self/exe child-mode --pids=7 --img=a24bb4013296 920a577165ef /bin/sh]
/ # ls -l
/bin/sh: can't fork: Resource temporarily unavailable
/ # 

限制 RAM

开启一个新容器,将最大允许内存设置为 128M。现在,我们将在其中安装 python,并分配大量 RAM。这应该会触发内核的内存不足(OOM),使其杀死我们的 python 进程。让我们看看实际情况:

➜ sudo ./gocker run --mem=128 --swap=0 alpine /bin/sh
2020/06/13 18:30:30 Cmd args: [./gocker run --mem=128 --swap=0 alpine /bin/sh]
2020/06/13 18:30:30 New container ID: b22bbc6ee478
2020/06/13 18:30:30 Image already exists. Not downloading.
2020/06/13 18:30:30 Image to overlay mount: a24bb4013296
2020/06/13 18:30:30 Cmd args: [/proc/self/exe setup-netns b22bbc6ee478]
2020/06/13 18:30:30 Cmd args: [/proc/self/exe setup-veth b22bbc6ee478]
2020/06/13 18:30:30 Cmd args: [/proc/self/exe child-mode --mem=128 --swap=0 --img=a24bb4013296 b22bbc6ee478 /bin/sh]
/ # apk add python3
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/community/x86_64/APKINDEX.tar.gz
(1/10) Installing libbz2 (1.0.8-r1)
(2/10) Installing expat (2.2.9-r1)
(3/10) Installing libffi (3.3-r2)
(4/10) Installing gdbm (1.13-r1)
(5/10) Installing xz-libs (5.2.5-r0)
(6/10) Installing ncurses-terminfo-base (6.2_p20200523-r0)
(7/10) Installing ncurses-libs (6.2_p20200523-r0)
(8/10) Installing readline (8.0.4-r0)
(9/10) Installing sqlite-libs (3.32.1-r0)
(10/10) Installing python3 (3.8.3-r0)
Executing busybox-1.31.1-r16.trigger
OK: 53 MiB in 24 packages
/ # python3
Python 3.8.3 (default, May 15 2020, 01:53:50) 
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a1 = bytearray(100 * 1024 * 1024)
Killed
/ # 

需要注意的一件事是,我们使用 --swap = 0 将分配给该容器的 swap 设置为零。否则,Cgroup 虽然限制 RAM 使用,但它将允许容器使用无限的交换空间。当 swap 设置为零时,容器将被完全限制为所允许的 RAM 值。

关于作者

Shuveb Hussain,Linux-focused 博客的作者。您可以在 Twitter 上关注他,在那里他发布与技术相关的内容,主要针对Linux,性能,可扩展性和云技术。

原标题:Containers the hard way: Gocker: A mini Docker written in Go

原文链接:https://unixism.net/2020/06/containers-the-hard-way-gocker-a-mini-docker-written-in-go/

译者:polaris

欢迎关注我的公众号:

发表评论

电子邮件地址不会被公开。

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据