Littledriver's Blog

Kubernetes 的基石 — 容器技术

2019.09.15

Overview

随着容器技术的诞生和发展,它不再仅仅是一个发布你所开发软件的新姿势,更是在后端开发生态中,慢慢的成为了工程师开发模式的一部分。

容器技术最核心的一个优势,就是抹平了由于开发环境和部署环境的差异导致的部署线上服务困难的问题。任何一个以“容器”,都会被独立发布和部署。并且在“构建”的过程中,可以同时将服务所依赖的“环境”进行打包,从而保证了线上和本地的环境一致,减少部署过程中出现的问题。

除此之外,容器技术的实现借助了 Linux 操作系统中两个比较重要的技术 Namespace 和 Cgroup。前者让某一个容器运行起来之后可以在环境或者说是视图上保持良好的隔离性,后者则让一个容器在使用操作系统提供的资源的时候,与其他容器甚至是宿主机产生良好的隔离。

另外,对于容器技术值得提及的一点就是。虽然它所实现的『隔离性』不及虚拟机提供的那样完善,但它在性能上以及资源占用上表现的要比虚拟机更加优秀。这里说的资源占用是指非容器内部的业务应用所消耗的部分。因为一个容器,本质上就是一个操作系统的进程。

不过,在计算机世界中没有绝对的『银弹』。容器技术虽然在性能和资源上更好一些,但是在隔离性的保证上面往往表现的不如虚拟机。这是因为,软件层面上的隔离终究不是彻底的。某些难以隔离的资源,比如 Linux 内核是对所有的容器共享的。所以,使用容器技术带来的一个明显的缺陷,就是我们要额外的考虑很多『安全性』的问题。

隔离技术

容器技术在实现『隔离性』的时候,借助了两项技术,一个为 Namespace,另外一个为 Cgroup。

Namespace

简单来说,Linux Namespace 是一项 Linux 系统提供的进程间的隔离技术。它能够在『视图』层面上对进程的一些信息进行隔离。

在理解 Namespace 的时候,我们可以把整个操作系统默认的空间认为是一个全局的 Namespace。如果不加过多的处理,那么所有的进程都是在这一个 Namespace 下的。他们共享网络设备,共享同一组进程 ID 等等。

根据要隔离的东西的不同,Linux 对 Namespace 进行了分类:Mount Namespace, UTS Namespace,PID Namespace,IPC Namespace,Network Namespace, User Namespace。

例如,在某一个容器中,我们希望第一个启动的进程 PID 就应该是 1。这相当于在给进程编号这件事上,我们要和原本的操作系统的全局空间隔离开来。所以,Linux 在实现这个功能的时候,同时保留了两个空间内的 PID。全局空间下,容器作为一个进程仍然按照当前的规则继续进行命名,但是到了容器内部,在开启了 PID Namespace 之后,容器外的 PID 命名情况就完全对容器内部不可见了。也就是在容器内部,对于 PID 命名这件事来说,一切都是从头开始了。

看过上面对于 PID Namespace 的使用,我想你应该能够明白,“Namespace 提供的仅仅是『视图』上隔离“ 这句话的含义了。

到此为止,虽然你可能了解了『Namespace 是负责为容器实现环境隔离』的事实。但是你有没有再深入的想一下:为什么在容器中一定要把第一个进程的 PID 作为 1 呢?以及 Mount Namespace 到底是隔离了什么呢?

PID = 1 的进程

在 Linux 操作系统启动的过程中,内核经过 Boot Loader 的加载并初始化之后,会启动一个名为 Init 的进程,它的进程号为 1。Init 进程还有几个别名:超级父进程,根进程。

从字面意思上我们就可以看出,在 Linux 系统启动后创建的进程都是这个超级父进程的子进程,孙子进程等等。换句话说,如果某个(孙)子进程的父进程异常退出了,那么 Init 进程将会接管这个『孤儿』进程。这种『接管』操作,是超级父进程众多功能之一。

为了让某个容器启动之后,在视图级别上与其他进程隔离开来。除了使用 chroot 命令更改进程的根目录之外,我们还将在容器内部通过创建一个新的 PID Namespace,来重置 PID 命名规则。这使得容器内第一个启动的进程为超级父进程。

为一个封闭的环境指定超级父进程的好处在于,我们可以更加精细化的管理这个环境内部衍生出来的其他子进程。Pause 程序以及 Systemd 都可以借助这个特性应用到我们的容器应用开发中来。这两个特性,我会在之后的文章中,单独拿出来讲。

(关于超级父进程,可以读一下耗子叔这篇文章,写的很有趣:Linux PID 1 和 Systemd | | 酷 壳 - CoolShell

Mount Namespace

本着 Namespace 是为容器进程提供『视图』上的隔离这一想法。我们来看下 Mount Namespace 究竟做了些什么。

Mount Namespace 主要是为容器进程隔离了”挂载点“相关信息。其实提到挂载点,我们很容易把它和在该挂载点挂载的文件搞混。例如,在容器内部我们执行了一次 Mount 操作,并且更改了这个被挂载文件的内容。很多人以为这个操作是被隔离的,但其实,当你退出这个容器进程之后,你之前所做的更改是会出现在Host 的原始文件上的。

如果你对这个现象感到奇怪,那么证明你对两个概念理解的不够深刻:

  1. Namespace 只是提供了『视图』层面上的隔离。换句话说,Namespace 就是一种障眼法。
  2. 容器实质上是 OS 上的一个特殊的进程。既然是进程,它的一些修改操作,尤其是在数据层面上,势必会影响到 Host 上的内容。

所以,在容器进程启动后,且执行 Mount 操作以前,你会发现在容器内部看到的挂载点信息和在 Host 上看到的是一样的。这是因为在开启 Mount Namespace 隔离之后,容器进程会先继承父进程所看到的挂载点信息。之后在容器内部执行 Mount 操作的记录,不会被外部看见。

仔细理解好这里所说的关于 Mount Namespace 的内容,因为之后再讨论容器 Volume 机制的实现的时候,还会提到它。

CGroup

有了前面对 Namespace 技术的了解,我相信你应该能够意识到 Namespace 技术只是把容器进程 Jail 到了一个特定的环境中。但是这个进程所使用的资源,所做的修改类的操作,还是会影响到 Host 上的其他进程。

如果对资源的使用不加限制,那么其他进程很可能会因为『吃不饱』的原因来获取更多甚至抢占其他进程的资源。这是在容器技术中我们所不愿意看到的情况。

Linux CGroup 实际上是内核提供的一个功能,由多个不同类型的子系统组成,通过文件系统的形式暴露给用户使用。它不仅仅可以对进程使用资源进行限制,还可以动态的对进程的状态进行调整,比如将某个进程挂起或者恢复。所以,在容器技术中,我们使用 Linux CGroup 来实现『资源隔离』。

CGroup 中有几个重要的概念需要了解:

  1. Task:即受到 CGroup 限制的进程
  2. Processes Group: 即进程组,多个进程按照一定的层级关系组合起来的整体
  3. Hierarchy: CGroup 中进程组的组织模式,效仿了 Linux 目录的树形结构。子进程默认继承父进程的限制条件。

容器技术对于 CGroup 以及操作系统来说,并没有什么特殊之处。他们会把每一个容器都当做是普通的进程来对待。通过设置进程组或者某个进程的资源限制条件,即可达到将容器进程与其他进程在资源使用上隔离的目的。

不过,「CGroup」 也不是万能的,有些操作系统的资源是不能够被完全隔离或者限制的。比如:操作系统的时间是不能在容器中随意更改的,/proc 文件系统因为没有感知到「CGroup」对于容器进程做的事情,所以在容器内部执行 top 等命令仍然看到的是宿主机的情况。

对于「系统时间」问题,这可能不单单涉及到对一个容器进行某些修改行为的限制,还涉及到一定的安全性问题。而对于 /proc 文件系统来说,可以使用 lxcfs + 挂载的方式进行解决。如果是在 Kubernetes 当中,可以将 lxcfs 以 DaemonSet 的方式部署到集群的各个节点上。

容器镜像

如果你把一个容器想成一个圆柱体,那么我们刚才所讲的 Namespace 就相当于是这个圆柱体的『侧面』,站在这个圆柱体内,我们对外面发生的事情一无所知。这就是所谓的『环境隔离』。

但是,当视角从上向下俯视的时候,你就会发现,你所看的目录,文件都是属于 Host 的并且和其他进程是共享的。也就是说,虽然这个容器的『前后左右』四个方向被隔离了,但是『上下』两个方向并没有。『上下』方向其实就是我们常说『文件系统』,你可以将它简单的理解为,容器内部的目录与文件。

既然想做到文件系统层面上的隔离。那我们就必须要满足两个条件:

  1. 干净并且完整的文件系统
  2. 可以重新挂载容器进程根目录且此行为不会影响其他进程

第一个是比较好准备的,一个普通的 OS Image 就会包含完整的文件系统所需要的目录结构和文件。第二个工具就是容器技术当中常说的 Mount Namespace 隔离。

如果你对 Linux 系统比较熟悉的话,应该可以很快想到一个名为『chroot』的命令。它可以改变一个进程的根目录。在某个进程内部调用这个命令,相当于将这个进程 jail 到了一个特定的目录下。在这个进程内向上下两个方向看的时候,就会给它造成一种,自己处于一个单独且隔离的文件系统中的错觉。

所以,Mount Namespace 和 chroot 命令以及文件系统,三者为容器进程补齐了『隔离』层面上最后的缺口。而我们上面提到的,为容器进程准备的『纯净的文件系统』还有另外一个名字,就是根文件系统。正因为有了根文件系统和容器镜像的概念,才从根本上解决了本地开发环境和远端部署环境不一致的问题。

这里需要注意一点,根文件系统并不等于操作系统内核。『内核』其实说白了也只是一段程序,是软件。在操作系统启动的时候会加载内核的镜像。内核是只有一个的,但是『根文件系统』却可以有多个。这个特性,也从侧面证明了容器的隔离性并没有虚拟机那么好,因为所有的容器进程都会共享一个内核。

复用

既然提到了文件系统,那么在进程运行期间是不可避免的会修改其中的文件的。但是,如果每一个容器进程在启动之后,都需要一份完成的文件系统 copy 的话,这就是对磁盘资源极大的浪费。并且,在容器终止之后,如果不需要持久化它修改过的内容,我们还需要清理掉这些文件。

基于上述问题,看起来在制作容器镜像的时候,光有一个文件系统还不够。我们得需要借助一些特殊的手段来构建镜像,希望它能够达到以下几个目标:

  1. 修改过的内容在容器终止之后默认被丢弃,如果需要持久化的话,一种方案是借助 Volume 机制,另外一种是通过 docker commit 保存修改
  2. 按照一定的维度将整个文件系统进行细粒度的切分,能够尽可能的复用其中的内容

在容器技术中,我们借助『联合文件系统』以及『镜像分层』两项技术来满足上面的要求。『联合文件系统』最普遍的实现方式就是 AUFS。首先,我们将一个容器镜像划分为三层:只读层,Init 层和读写层。

其中只读层指的就是一个纯净的文件系统全部的内容,为了保证复用,该层的内容不允许被修改。Init 的层指的是那些在容器运行过程中,需要被修改,但是不需要传递给其他容器的部分,比如 host 文件。而读写层指的就是可以被修改的部分。

看起来这个分层的措施虽然满足了『复用』的目的,但是『读写层』和『只读层』的划分明显是矛盾的。此时,就轮到 AUFS 发挥用处了。AUFS 将原生文件系统中的多个目录联合在一起,挂载到一个特定的目录下。容器进程的根目录,其实就是在这个挂载点上获取的。读操作一般来说没有问题。当容器进程想执行写操作的时候,AUFS 会自上而下的寻找到第一个符合要求的文件,并把它 copy 到读写层所在的目录,然后再进行修改。这就是我们常说的 copy-on-write 机制。不但写操作的时候是按照从上至下的顺序,读操作也是一样。所以上层的文件会覆盖掉下层同名的文件。

写操作和删除操作的实现方式略有不同。虽然都以『上层覆盖下层』的原则为基础,但是删除操作是在读写层创建一个以『.wh.』+ 原文件名的组合为新文件名的特殊文件。在容器内部读取文件信息的时候,AUFS 会自动的屏蔽掉原文件。造成一种这个文件已经被删除的假象。

在讨论容器镜像复用的时候,必须要提到的一点就是。无论容器如何隔离,镜像如何分层,其修改的内容,都是体现在宿主机上的。只不过非持久化的部分在你不知情的情况下被清理掉了。

持久化

容器镜像的持久化,是依靠 docker commit 命令来实现的。当你想调用 docker commit 的时候就证明你想保存下你当前在容器中所做的修改到一个新的镜像内部,然后开放给其他人使用。从某个角度来说,这也是一种复用,只不过复用的基础改变了。

docker commit 的实现原理也很简单,它在之前提到的镜像分层以及复用的基础上,为所修改的内容单独加上了一层。『上层屏蔽下层』的原则,在构建新镜像的时候也有所体现。

我在自己的本地环境中,进行了一次构建新镜像的实验。以 busybox 镜像为例,在原生镜像中,使用 docker inspect 命令可以看到镜像只有一层

        "RootFS": {
            "Type": "layers",
            "Layers": [
                "sha256:6c0ea40aef9d2795f922f4e8642f0cd9ffb9404e6f3214693a1fd45489f38b44"
            ]
        }

使用 busybox 镜像启动容器,并且尝试做一些修改。

[root ~]$ sudo docker run -it busybox sh
/ # ls
bin   dev   etc   home  proc  root  sys   tmp   usr   var
/ # mkdir test
/ # cd test/
/test # ls
/test # echo "hello world" >> a.log
/test # cat a.log
hello world

然后,在宿主机上执行 docker commit 命令,生成一个名为 newbusybox 的新镜像:

 sudo docker commit 66f0c49b6948 newbusybox:1.0

此时,再通过 docker inspect 查看这个新镜像的结构,可以发现,新的镜像多出来一个layer。而且,原有的 layer 的 hash 值也没有变。这证明,新镜像的构造是一次纯粹的『叠加』操作。

        "RootFS": {
            "Type": "layers",
            "Layers": [
                #"sha256:6c0ea40aef9d2795f922f4e8642f0cd9ffb9404e6f3214693a1fd45489f38b44",
                "sha256:918cb44f6e232c43550ba9405c565f6a5dcb69a8b323d53d07f9cddc1c710d66"
            ]
        }

最终,我们使用新的镜像启动一个新的容器,其内部果然能够看到我们之前作出的修改:

[root ~]$ sudo docker run -it newbusybox:1.0 sh
/ # ls
bin   dev   etc   home  proc  root  sys   test  tmp   usr   var
/ # cd test/
/test # cat a.log
hello world
/test #

容器持久化

虽然容器和宿主机之间是有一定的隔离机制的。但是,在容器运行的过程中,两者不可避免的要进行一些数据层面上的交互。这就涉及到了如下两个问题:

  1. 容器如何将内部的数据共享给宿主机上的程序
  2. 宿主机上的程序如何将 local 目录的东西共享给容器内部

顺着『容器也是一个特殊的进程』和『容器所使用的文件系统也是处于宿主机上』两个思路来思考。上述两个问题的解决方案,看起来是要找到一种方式来打通容器和宿主机的数据共享通道。这种方式就是我们所熟悉的 Volume 机制。(Docker 的 Volume 机制和 Kubernetes 中的 Volume 机制是有所不同的,之后再介绍 Kubernetes Volume 概念的时候会着重阐述一下)

Volume 机制的实现也是比较简单的。借助 Linux 操作系统中的 Bind-Mount 技术,将需要共享的目录挂载至容器内部的某个目录下。但是转念一想,这个『挂载』的操作很可能会影响到宿主机上的『挂载点』信息,即使它是在容器进程内部执行的。那么,为了在容器进程内部隐藏起这个操作,我们需要借助 Mount Namespace 的帮助。即在容器进程(docker init 进程)创建后且mount namespace 开启后,chroot 或者类似改变进程根目录的操作执行之前,进行 Mount 操作,将共享的目录挂载至容器内。此时 Mount 操作的记录对宿主机是不可见的,所以不用担心污染挂载点信息的问题。最后,当容器进程(应用进程)运行的时候,就会在内部看到这个共享的目录,从而实现的数据的互通。

但是,在使用这种 Volume 机制的时候也要注意一点。如果我想通过 docker commit 的形式来构建一个新的镜像的话。在宿主机上执行的 commit 的命令并不会把共享目录内的数据打包至新的镜像。原因就是:docker commit 命令是在宿主机内执行的。而在宿主机上我们是无法感知到共享目录的挂载信息的,因为 mount namespace 隔离的原因。但是,在容器内部因为共享目录挂载产生的挂载点目录,会被保存到新的镜像中,只不过这个目录内没有内容。

因为这个挂载点目录的信息,已经属于镜像的更改了。它存在于镜像的读写层,在构建新镜像的时候,是会被包含进来的。

小结

经过上面的叙述,相信你在头脑中对于容器技术会有如下的认识:

一个容器就是一个操作系统中比较特殊的进程,它由动态和静态两部分组件构成。

静态部分指的就是容器的 rootfs,也就是人们常说的镜像。它为一个容器内部的应用进程打包了所有必要的依赖,使得远程部署环境和本地开发环境得到了统一。并且,在设计容器镜像的时候,使用了分层的方式来组织镜像的结构,最大限度的提升了容器镜像的复用性。

而动态部分可以分为两个方面:Namespace 和 CGroup。前者负责容器视角和环境的隔离后者负责资源使用的隔离。

最后,也请你记住,无论容器技术设计的多么巧妙。它最终还是运行在宿主机上的。所以在容器内部进行的一些数据层面上的更改,尤其是隔离不彻底会被共享的部分,还是会影响到宿主机的。这也给使用者提了个醒:容器并不是完全隔离的。

容器与 Kubernetes 的联系

如果你还记得我们讨论容器技术出现意义时所提到的『本地和远端部署环境不一致』的问题,就应该明白,任何一项技术的出现都是为了解决一类特定的问题,或者优化已有的方案。

那么 Kubernetes 既然是在容器技术的基础(准确的说,应该是在 CGroup 和 Namespace 等更加底层的隔离技术之上。因为 Borg 系统在创立之初是没有容器这个概念的)之上产生的,就证明两者之前肯定有一定的联系。

首先,开发容器应用的小伙伴的精力变得更加集中,他们专注于开发自己的应用以及如何构建一个合格的容器镜像。至于这个镜像如何运行,以及如何更好的运行,甚至是如何和一起第三方工具(如日志,监控等组件)一起良好的运行,都不应该再让开发者来操心。这些问题,应该由维护容器部署环境的人来解决。而这个环境的维护者,就是 Kubernetes。

Kubernetes 在不同的时期,针对不同的任务解决很多不同类型的问题。比如,集群管理,任务调度,服务编排等等。但是它们都可以抽象成为一种能力:如何打造一个良好的环境让容器能够更高效的运行。

如果单单是需要容器的运行,编排等等功能,那么普通的 PaaS 平台是可以在一定程度上满足开发者的,即使 Kubernetes 做得更好一些,但也不足以让 Kubernetes 在服务编排领域获得这么高的地位。

Kubernetes 的优势在我看来,主要体现在以下几点:

  1. Kubernetes 在与底层的操作系统和上层的应用交互的时候,都采取了耦合度较低的方式。即 API Server 和 各类底层接口标准的诞生(OCI,CRI, CSI…)。它让 Kubernetes 变得更加的包容和开放,扩展性更强。
  2. Kubernetes 更加注重对于分布式集群中所运行任务的分类以及这些任务之间关系的处理。Kubernetes 根据任务的特点以及关系抽象出了很多 Workload,比如 Pod,Deployment,StatefulSet 等。它让各类任务的划分更加清晰,在部署的时候也能更加精细的被调度。
  3. Kubernetes 没有使用『命令式』的方式来处理这个平台内的任务,而是采用了『声明式』。这种方案的优势,其一是提升了任务执行的成功率以及整个平台的容错性。因为处理逻辑是依靠状态信息来进行处理的,这些处理过程即使遇到异常被终止,重启之后仍然能够正常运行。状态信息也被持久化到内部的 etcd 数据库中。其二是 Kubernetes 再一次把 Kubernetes 的使用者和 Kubernetes 的开发者的角色分开了。前者更注重使用,只需要关心选择合适的资源对象来描述自己的任务并且创建相应的『服务对象』(Service 是为了 Pod 的负载均衡而存在的)加以配合。而后者更侧重于『如何将当前状态转变为期望状态』的逻辑开发。

综上所述,Kubernetes 借助了容器技术为广大开发者打造了一个平台。这个平台不仅可以完成最基本 PaaS 平台的职责:调度应用到合适的节点运行,而且还根据不同任务的特点为我们抽象出了多种 Workload,以便更好的描述我们自己的应用的运行特点。最重要的是,Kubernetes 全自动的为我们处理了这一切,并且更加注重对多个任务之间关系的处理。