Go 从 0 开始打造 container

date
Nov 19, 2023
slug
go-build-container
status
Published
tags
Docker
summary
使用不到 100 行的代码打造一个容器
type
Post

简介

 
在这篇文章中,我们将介绍容器的三个重要组件,分别是:Namespace、Cgroups、layered filesytems。
然后我们将用不到 100 行的代码,实现一个最基本的 container。

Container

容器(container)是一种轻量级的虚拟化技术,用于隔离应用程序及其依赖,使其能够在不同的环境中一致地运行。
容器包含了应用程序及其运行所需的所有组件,如代码、依赖库、运行时环境和配置文件等。
通过容器,我们可以提供应用程序的隔离环境,使其能够在不同的环境中一致地运行,并且还能简化应用的部署和维护,我们只需要维护一个容器镜像即可。
我们可以把容器想像成一个集装箱,集装箱可以放在任何平台上,不管是陆地、船、空运飞机,都不影响集装箱本身,服务就运行在集装箱之内。

Namespaces 命名空间

命名空间用于隔离容器内的各种资源,如进程ID、网络、文件系统、用户和主机名等。
通过使用命名空间,容器可以拥有自己独立的资源视图,使得容器内的进程无法感知和干扰其他容器的运行。
假如把容器比做集装箱,那么命名空间(Namespaces)就是集装箱的外壳。
Docker 中有 6 个命名空间,分别是:
  1. PID 命名空间(PID Namespace): 使得每个容器都拥有独立的进程 ID 空间,使得容器内的进程看起来就像在一个独立的系统中运行一样。
  1. Network 命名空间(Network Namespace): 使得每个容器都有自己独立的网络栈,独立的 IP 地址、端口等。这样容器之间的网络互相隔离,每个容器有自己的网络设备、路由表等。
  1. Mount 命名空间(Mount Namespace): 使得每个容器都有自己独立的文件系统视图,使得容器内的文件系统和宿主机的文件系统互相隔离。
  1. UTS 命名空间(UTS Namespace): 使得容器拥有独立的主机名和域名。
  1. IPC 命名空间(IPC Namespace): 使得每个容器都有独立的 System V IPC 资源和 POSIX 消息队列。
  1. User 命名空间(User Namespace): 允许容器在用户和用户组方面进行隔离,使得容器内的进程可以拥有在宿主机上没有的权限,同时提高了容器的安全性。
默认情况下,Docker 会为容器创建 PID、Network、Mount 和 UTS 命名空间,这足够组成可用的容器了,如果用户还需要扩展,那么可以考虑添加 IPC 和 User 命名空间。

Cgroups 控制组

cgroups(control groups)是 Linux 内核提供的一种机制,用于对进程组进行资源限制、优先级和控制。cgroups 允许你将一组进程放入一个命名的控制组中,然后为这个控制组分配或限制系统资源。
cgroups 的主要作用包括:
  1. 资源限制: 你可以使用 cgroups 为一个进程组或一组进程设置资源限制,如 CPU、内存、网络带宽等。这有助于防止某个进程或一组进程占用过多系统资源,导致其他进程性能下降。
  1. 优先级控制: cgroups 允许你为不同的进程组设置不同的优先级,确保高优先级的进程组获得更多的系统资源。
  1. 进程组管理: 通过 cgroups,你可以方便地管理一组相关的进程,对它们进行统一的控制和监视。
控制组(Cgroups)定义了集装箱的体积,不同大小的集装箱能装对应体积的货物,类似的,能运行多大的服务取决于容器分配到的资源,而这是控制组决定的。
在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。
假如我们想要限制 cpu,那么我们就需要去 cpu 的目录下新建一个目录:
我们创建好之后,Linux 会在该目录下,自动生成该子系统对应的资源限制文件。这些文件都有特定的用途,举个例子,如果我们想限制进程的 CPU 使用时间为 20 ms,那么需要在cfs_quota中写入 20 ms,然后再把进程 PID 写入到 tesks 中,就会限制该 PID 使用时间最长不超过 20 ms 了。
容器的本质就是进程,只不过通过 namespaces、cgroups、layered filesystems 来实现隔离。
在 docker 中,通过指令能达到同样的效果,背后的实现还是 Cgroups:

layered filesystems 分层文件系统

通过 Namespace 和 Cgroups 我们限制了容器的活动空间和使用资源,但是应用还有一个很重要的概念,那就是文件系统。理想状态下,运行在容器中的应用,看到的应该都是独立的文件系统,但是我们要知道,容器只不过是一个进程,进程之间共用的还是同一套文件系统,这就需要我们好好考虑下如何实现容器中文件系统的隔离了。
我们在Namespace小节中,提到了 Mount 命名空间
  1. Mount 命名空间(Mount Namespace): 使得每个容器都有自己独立的文件系统视图,使得容器内的文件系统和宿主机的文件系统互相隔离。
但需要注意的是,Mount Namespace 是在挂载动作发生之后再修改容器对文件系统的配置,也就是说,我们需要先完成挂载的动作,然后再通过 Mount Namespace 来重新配置容器的文件系统。
挂载 mount
而挂载的动作如何完成呢?在 Linux 中,可以使用 mount 来完成。
在计算机领域,挂载(Mount)是指将一个文件系统连接到文件系统目录树的过程,使得文件系统中的内容能够被访问。挂载可以将一个存储设备或者远程文件系统合并到一个已存在的目录结构中。
我们可以通过 $ man mount 来查看说明:
简单来说,通过mount [选项] <设备> <挂载点>可以将设备挂载到指定目录下,在 C 中,我们可以通过函数调用 mount("none", "/mnt", "tmpfs", "") 来将 tmpfs 文件系统格式挂载到 /mnt 目录下
tmpfs 是一种在内存中创建的临时文件系统,用于存储临时数据。它通常用于将文件系统挂载到内存中,以提供快速的读写访问速度。tmpfs 不存储在物理存储介质(如硬盘)上,而是完全存在于系统的内存中。
继续以 C 为例,当我们通过mount(…) 挂载好文件系统后,再通过clone函数进行命名空间的配置:clone(child_function, stack + STACK_SIZE, CLONE_NEWNS | SIGCHLD, NULL);
调用CLONE_NEWNS | SIGCHLD 意味着会给新的子进程配置挂载命名空间。
此时如果我们进入到这个子进程,我们就会发现 /mnt 变为了一个空目录,该子进程内部并不能看到宿主机下的其它文件。
在这里,我给出完整代码,大家感兴趣可以自己跑一下:
执行gcc mount.c -o mount.o & sudo ./mount.o,我们就可以进入到子容器启动的sh中了
你可以看到输出:
当我们执行 ls /mnt 的时候,此时会得到一个空目录:
当我们执行 ls /bin 的时候,此时会输出很多宿主机的文件:
这是因为我们只挂载了 /mnt 这个目录,没有挂载 /bin,因此能看到宿主机的文件。
这里我们会产生一个想法,我们能不能直接挂载根目录 /,达到最大限度的隔离?
当然可以,上述的操作可以通过 chroot 这个命令来实现,这是它的描述:
例如,我们通过chroot /mychroot /bin/bash就能将 mychroot 作为这个 bash 的根目录。
一般为了方便,我们容器的根目录下面挂载一个完成操作系统的文件系统,例如 Ubuntu 的文件系统。
总结来说,就是通过挂载配置目录,然后指定开启挂载命名空间,就能实现容器初步的文件系统隔离。
rootfs 根文件系统
挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,称为rootfs(根文件系统)。
一般我们进入到容器后, ls / 就能看到类似的输出:
而这些文件和宿主机完全无关。
题外话,roofts 只包含了操作系统的文件、配置、目录,不包含内核。容器的内核实际上是共用宿主机操作系统的内核的。
我们可以感受到 rootfs 的强大,它提供了一个和宿主以及其它容器隔离的文件系统供容器使用,且 rootfs 完全可以配置成和应用开发时的环境一样的文件系统,打造出了容器的一致性。
有了这种深入到操作系统级别的一致性,容器可以真正做到平台无关,我们只需要编写好容器镜像就能保证容器可以运行在不同平台上。
但是 rootfs 有一个麻烦的地方,就是我们改动了一个字符,那么这两个 rootfs 都是完全不同的,和 git 类似,任何一个改动都可以记录为新的 commit,这将导致 rootfs 非常碎片化,难以维护。
Docker 使用 layer 这个概念解决了该问题。
具体来说,用户在制作镜像时的每一步操作,都可以生成一个层,层可以复用,也可以在层的基础上进行修改。通过这个增量的概念来解决 rootfs 碎片化的问题。
下面我们用 极客时间——深入剖析 kubernetes 中的例子来说明这个概念。
layer 的实现,基于联合文件系统(Union File System,UnionFS)。
所谓联合文件系统就将多个不同位置的目录联合挂载(union mount)到同一个目录下,例如现在有下列的目录:
我们通过挂载,合并A、B目录到C目录下
结果:
UnionFS 有不同的实现,在 Docker 中,使用的是 AuFS。
对于 AuFS 来说,它最关键的目录结构在 /var/lib/docker 路径下的 diff 目录:/var/lib/docker/aufs/diff/<layer_id>
现在我们启动一个容器:$ docker run -d ubuntu:latest sleep 3600
这时候,Docker 就会从 Docker Hub 上拉取一个 Ubuntu 镜像到本地。
这个所谓的“镜像”,实际上就是一个 Ubuntu 操作系统的 rootfs,它的内容是 Ubuntu 操作系统的所有文件和目录。不过,与之前我们讲述的 rootfs 稍微不同的是,Docker 镜像使用的 rootfs,往往由多个“层”组成:
可以看到,这个 Ubuntu 镜像,实际上由五个层组成。这五个层就是五个增量 rootfs,每一层都是 Ubuntu 操作系统文件与目录的一部分;而在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上(等价于前面例子里的“/C”目录)。
这个挂载点就是 /var/lib/docker/aufs/mnt/,比如:/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
这个目录里面正是一个完整的 Ubuntu 操作系统:
那么,前面提到的五个镜像层,又是如何被联合挂载成这样一个完整的 Ubuntu 文件系统的呢?
这个信息记录在 AuFS 的系统目录 /sys/fs/aufs 下面。
首先,通过查看 AuFS 的挂载信息,我们可以找到这个目录对应的 AuFS 的内部 ID(也叫:si):
然后使用这个 ID,我们就可以在 /sys/fs/aufs 下查看被联合挂载在一起的各个层的信息:
从这些信息里,我们可以看到,镜像的层都放置在 /var/lib/docker/aufs/diff 目录下,然后被联合挂载在 /var/lib/docker/aufs/mnt 里面。
而且,从这个结构可以看出来,这个容器的 rootfs 由如下图所示的三部分组成:
notion image
第一部分,只读层
它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout,至于什么是 whiteout,我下面马上会讲到)。
可以看到,这些层,都以增量的方式分别包含了 Ubuntu 操作系统的一部分。
第二部分,可读写层。
它是这个容器的 rootfs 最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即 read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。
可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?
为了实现这样的删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。
比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读 +whiteout 的含义。
第三部分,Init 层。
它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。
需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。
可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。
所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。
最终,这 7 个层都被联合挂载到 /var/lib/docker/aufs/mnt 目录下,表现为一个完整的 Ubuntu 操作系统供容器使用。

概念总结

我们依次介绍了以下概念:
  • Namespaces
    • 可以理解为容器的外壳,指定容器的活动范围
  • Cgroups
    • 容器的体积,限定容器的可用资源
  • layered filesystems
    • 在这一节中,我们首先介绍了挂载的概念,然后叙述了 mount 和 mount namesapce 搭配来给容器提供隔离的文件系统,接着介绍了 chroot 这个命令用于快速的挂载根目录,然后介绍了 rootfs 的概念,以及 rootfs 中的分层文件系统。

代码实现

代码其实很少,我们只需要实现一个很简单的容器:
/proc/self/exe 是Unix类操作系统(包括Linux)中的一个特殊文件。它是一个符号链接,指向当前进程的可执行文件。
具体来说,/proc/self/exe 的解释如下:
  • /proc:这是提供有关进程信息的虚拟文件系统,它被挂载在根文件系统上。
  • self:这是一个符号链接,指向当前进程的目录。换句话说,它引用当前进程的进程ID(PID)。
  • exe:这是一个符号链接,指向当前进程的可执行文件。
这里,exec.Command 创建了一个新的命令,指定要执行的命令是当前可执行文件 (/proc/self/exe)。命令被设置为使用附加的参数运行,目的是基于相同的可执行文件执行一个名为 "child" 的子进程。
现在我们完成了第一步,启动了一个进程,接下来我们要给该进程添加命名空间了:
只需要上面的代码,我们就能添加 UTS、PID 和 Mount 命名空间了。
最后,我们需要配置文件系统:
这段代码具体作用如下:
  1. syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, "")
      • 这一行代码执行了 Linux 的 mount 系统调用,将 "rootfs" 目录绑定到自身 ("rootfs")。这通常是为了创建一个文件系统的"view",在这个文件系统的视图中对文件和目录的更改将影响到 "rootfs" 目录。
  1. os.MkdirAll("rootfs/oldrootfs", 0700)
      • 这一行代码创建了一个目录 "rootfs/oldrootfs",用于作为旧的根文件系统的挂载点。
      • 因为在调用 syscall.PivotRoot 时,必须指定一个不在新根文件系统中的目录作为旧根文件系统的挂载点,所以这一步是必须的。
  1. syscall.PivotRoot("rootfs", "rootfs/oldrootfs")
      • PivotRoot 是一个 Linux 系统调用,用于在一个进程的文件系统中更改根目录。在这里,它将 "rootfs" 目录切换为 "rootfs/oldrootfs",使得 "rootfs" 成为新的根目录。
      • 这个操作是 Linux 容器技术中实现容器根文件系统隔离的关键步骤之一。通过这种方式,容器内的进程将以 "rootfs" 为根文件系统,而不影响主机的根文件系统。
  1. os.Chdir("/")
      • 这一行代码将当前工作目录更改为根目录 ("/")。这是为了确保在切换了根文件系统后,程序的当前工作目录正确设置为新的根目录。
总体来说,这段代码实现了容器化环境中的文件系统隔离。它将 "rootfs" 目录绑定到自身,创建一个用于挂载旧根文件系统的目录 "rootfs/oldrootfs",然后使用 PivotRoot 切换根文件系统,使得 "rootfs" 成为新的根文件系统。这样就为容器内的进程提供了一个独立的文件系统视图,而不影响主机系统。
到这一步,我们已经有了一个基础的容器了,它有自己的命名空间,也有独立的文件系统。
如果想补充更完全,我们可以做以下扩展:
控制组(cgroups):
  • Cgroups 允许你限制和隔离进程的资源使用。你可以控制诸如 CPU、内存和设备等资源。
  • 实现 cgroups 可以帮助你管理容器内运行进程的资源分配。
网络命名空间:
  • 添加网络命名空间隔离允许你为容器创建一个独立的网络栈。这可能涉及设置虚拟网络接口、管理 IP 地址和控制网络访问
生命周期管理:
  • 实现容器的生命周期管理功能,包括优雅地启动、停止和重新启动容器。
篇幅有限,这里就不一一介绍了,下面是完整的代码:

Ref

极客时间——深入剖析 kubernetes
 

© hhmy 2019 - 2024