mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 15:43:44 +08:00
mod
This commit is contained in:
148
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/05 | 白话容器基础(一):从进程说开去.md
Normal file
148
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/05 | 白话容器基础(一):从进程说开去.md
Normal file
@@ -0,0 +1,148 @@
|
||||
<audio id="audio" title="05 | 白话容器基础(一):从进程说开去" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/6d/d27445e4726f0346049e74c314140a6d.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:白话容器基础之从进程说开去。
|
||||
|
||||
在前面的4篇预习文章中,我梳理了“容器”这项技术的来龙去脉,通过这些内容,我希望你能理解如下几个事实:
|
||||
|
||||
- 容器技术的兴起源于PaaS技术的普及;
|
||||
- Docker公司发布的Docker项目具有里程碑式的意义;
|
||||
- Docker项目通过“容器镜像”,解决了应用打包这个根本性难题。
|
||||
|
||||
紧接着,我详细介绍了容器技术圈在过去五年里的“风云变幻”,而通过这部分内容,我希望你能理解这样一个道理:
|
||||
|
||||
>
|
||||
容器本身没有价值,有价值的是“容器编排”。
|
||||
|
||||
|
||||
也正因为如此,容器技术生态才爆发了一场关于“容器编排”的“战争”。而这次战争,最终以Kubernetes项目和CNCF社区的胜利而告终。所以,这个专栏后面的内容,我会以Docker和Kubernetes项目为核心,为你详细介绍容器技术的各项实践与其中的原理。
|
||||
|
||||
不过在此之前,你还需要搞清楚一个更为基础的问题:
|
||||
|
||||
>
|
||||
容器,到底是怎么一回事儿?
|
||||
|
||||
|
||||
在第一篇预习文章[《小鲸鱼大事记(一):初出茅庐》](https://time.geekbang.org/column/article/14254)中,我已经提到过,容器其实是一种沙盒技术。顾名思义,沙盒就是能够像一个集装箱一样,把你的应用“装”起来的技术。这样,应用与应用之间,就因为有了边界而不至于相互干扰;而被装进集装箱的应用,也可以被方便地搬来搬去,这不就是PaaS最理想的状态嘛。
|
||||
|
||||
不过,这两个能力说起来简单,但要用技术手段去实现它们,可能大多数人就无从下手了。
|
||||
|
||||
**所以,我就先来跟你说说这个“边界”的实现手段。**
|
||||
|
||||
假如,现在你要写一个计算加法的小程序,这个程序需要的输入来自于一个文件,计算完成后的结果则输出到另一个文件中。
|
||||
|
||||
由于计算机只认识0和1,所以无论用哪种语言编写这段代码,最后都需要通过某种方式翻译成二进制文件,才能在计算机操作系统中运行起来。
|
||||
|
||||
而为了能够让这些代码正常运行,我们往往还要给它提供数据,比如我们这个加法程序所需要的输入文件。这些数据加上代码本身的二进制文件,放在磁盘上,就是我们平常所说的一个“程序”,也叫代码的可执行镜像(executable image)。
|
||||
|
||||
然后,我们就可以在计算机上运行这个“程序”了。
|
||||
|
||||
首先,操作系统从“程序”中发现输入数据保存在一个文件中,所以这些数据就会被加载到内存中待命。同时,操作系统又读取到了计算加法的指令,这时,它就需要指示CPU完成加法操作。而CPU与内存协作进行加法计算,又会使用寄存器存放数值、内存堆栈保存执行的命令和变量。同时,计算机里还有被打开的文件,以及各种各样的I/O设备在不断地调用中修改自己的状态。
|
||||
|
||||
就这样,一旦“程序”被执行起来,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。**像这样一个程序运行起来后的计算机执行环境的总和,就是我们今天的主角:进程。**
|
||||
|
||||
所以,对于进程来说,它的静态表现就是程序,平常都安安静静地待在磁盘上;而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现。
|
||||
|
||||
而**容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。**
|
||||
|
||||
对于Docker等大多数Linux容器来说,**Cgroups技术**是用来制造约束的主要手段,而**Namespace技术**则是用来修改进程视图的主要方法。
|
||||
|
||||
你可能会觉得Cgroups和Namespace这两个概念很抽象,别担心,接下来我们一起动手实践一下,你就很容易理解这两项技术了。
|
||||
|
||||
假设你已经有了一个Linux操作系统上的Docker项目在运行,比如我的环境是Ubuntu 16.04和Docker CE 18.05。
|
||||
|
||||
接下来,让我们首先创建一个容器来试试。
|
||||
|
||||
```
|
||||
$ docker run -it busybox /bin/sh
|
||||
/ #
|
||||
|
||||
```
|
||||
|
||||
这个命令是Docker项目最重要的一个操作,即大名鼎鼎的docker run。
|
||||
|
||||
而-it参数告诉了Docker项目在启动容器后,需要给我们分配一个文本输入/输出环境,也就是TTY,跟容器的标准输入相关联,这样我们就可以和这个Docker容器进行交互了。而/bin/sh就是我们要在Docker容器里运行的程序。
|
||||
|
||||
所以,上面这条指令翻译成人类的语言就是:请帮我启动一个容器,在容器里执行/bin/sh,并且给我分配一个命令行终端跟这个容器交互。
|
||||
|
||||
这样,我的Ubuntu 16.04机器就变成了一个宿主机,而一个运行着/bin/sh的容器,就跑在了这个宿主机里面。
|
||||
|
||||
上面的例子和原理,如果你已经玩过Docker,一定不会感到陌生。此时,如果我们在容器里执行一下ps指令,就会发现一些更有趣的事情:
|
||||
|
||||
```
|
||||
/ # ps
|
||||
PID USER TIME COMMAND
|
||||
1 root 0:00 /bin/sh
|
||||
10 root 0:00 ps
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们在Docker里最开始执行的/bin/sh,就是这个容器内部的第1号进程(PID=1),而这个容器里一共只有两个进程在运行。这就意味着,前面执行的/bin/sh,以及我们刚刚执行的ps,已经被Docker隔离在了一个跟宿主机完全不同的世界当中。
|
||||
|
||||
这究竟是怎么做到的呢?
|
||||
|
||||
本来,每当我们在宿主机上运行了一个/bin/sh程序,操作系统都会给它分配一个进程编号,比如PID=100。这个编号是进程的唯一标识,就像员工的工牌一样。所以PID=100,可以粗略地理解为这个/bin/sh是我们公司里的第100号员工,而第1号员工就自然是比尔 · 盖茨这样统领全局的人物。
|
||||
|
||||
而现在,我们要通过Docker把这个/bin/sh程序运行在一个容器当中。这时候,Docker就会在这个第100号员工入职时给他施一个“障眼法”,让他永远看不到前面的其他99个员工,更看不到比尔 · 盖茨。这样,他就会错误地以为自己就是公司里的第1号员工。
|
||||
|
||||
这种机制,其实就是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,比如PID=1。可实际上,他们在宿主机的操作系统里,还是原来的第100号进程。
|
||||
|
||||
**这种技术,就是Linux里面的Namespace机制**。而Namespace的使用方式也非常有意思:它其实只是Linux创建新进程的一个可选参数。我们知道,在Linux系统中创建线程的系统调用是clone(),比如:
|
||||
|
||||
```
|
||||
int pid = clone(main_function, stack_size, SIGCHLD, NULL);
|
||||
|
||||
```
|
||||
|
||||
这个系统调用就会为我们创建一个新的进程,并且返回它的进程号pid。
|
||||
|
||||
而当我们用clone()系统调用创建一个新进程时,就可以在参数中指定CLONE_NEWPID参数,比如:
|
||||
|
||||
```
|
||||
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
|
||||
|
||||
```
|
||||
|
||||
这时,新创建的这个进程将会“看到”一个全新的进程空间,在这个进程空间里,它的PID是1。之所以说“看到”,是因为这只是一个“障眼法”,在宿主机真实的进程空间里,这个进程的PID还是真实的数值,比如100。
|
||||
|
||||
当然,我们还可以多次执行上面的clone()调用,这样就会创建多个PID Namespace,而每个Namespace里的应用进程,都会认为自己是当前容器里的第1号进程,它们既看不到宿主机里真正的进程空间,也看不到其他PID Namespace里的具体情况。
|
||||
|
||||
而**除了我们刚刚用到的PID Namespace,Linux操作系统还提供了Mount、UTS、IPC、Network和User这些Namespace,用来对各种不同的进程上下文进行“障眼法”操作。**
|
||||
|
||||
比如,Mount Namespace,用于让被隔离进程只看到当前Namespace里的挂载点信息;Network Namespace,用于让被隔离进程看到当前Namespace里的网络设备和配置。
|
||||
|
||||
**这,就是Linux容器最基本的实现原理了。**
|
||||
|
||||
所以,Docker容器这个听起来玄而又玄的概念,实际上是在创建容器进程时,指定了这个进程所需要启用的一组Namespace参数。这样,容器就只能“看”到当前Namespace所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。
|
||||
|
||||
**所以说,容器,其实是一种特殊的进程而已。**
|
||||
|
||||
## 总结
|
||||
|
||||
谈到为“进程划分一个独立空间”的思想,相信你一定会联想到虚拟机。而且,你应该还看过一张虚拟机和容器的对比图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/96/d1bb34cda8744514ba4c233435bf4e96.jpg" alt="">
|
||||
|
||||
这幅图的左边,画出了虚拟机的工作原理。其中,名为Hypervisor的软件是虚拟机最主要的部分。它通过硬件虚拟化功能,模拟出了运行一个操作系统需要的各种硬件,比如CPU、内存、I/O设备等等。然后,它在这些虚拟的硬件上安装了一个新的操作系统,即Guest OS。
|
||||
|
||||
这样,用户的应用进程就可以运行在这个虚拟的机器中,它能看到的自然也只有Guest OS的文件和目录,以及这个机器里的虚拟设备。这就是为什么虚拟机也能起到将不同的应用进程相互隔离的作用。
|
||||
|
||||
而这幅图的右边,则用一个名为Docker Engine的软件替换了Hypervisor。这也是为什么,很多人会把Docker项目称为“轻量级”虚拟化技术的原因,实际上就是把虚拟机的概念套在了容器上。
|
||||
|
||||
**可是这样的说法,却并不严谨。**
|
||||
|
||||
在理解了Namespace的工作方式之后,你就会明白,跟真实存在的虚拟机不同,在使用Docker的时候,并没有一个真正的“Docker容器”运行在宿主机里面。Docker项目帮助用户启动的,还是原来的应用进程,只不过在创建这些进程时,Docker为它们加上了各种各样的Namespace参数。
|
||||
|
||||
这时,这些进程就会觉得自己是各自PID Namespace里的第1号进程,只能看到各自Mount Namespace里挂载的目录和文件,只能访问到各自Network Namespace里的网络设备,就仿佛运行在一个个“容器”里面,与世隔绝。
|
||||
|
||||
不过,相信你此刻已经会心一笑:这些不过都是“障眼法”罢了。
|
||||
|
||||
## 思考题
|
||||
|
||||
<li>
|
||||
鉴于我对容器本质的讲解,你觉得上面这张容器和虚拟机对比图右侧关于容器的部分,怎么画才更精确?
|
||||
</li>
|
||||
<li>
|
||||
你是否知道最新的Docker项目默认会为容器启用哪些Namespace吗?
|
||||
</li>
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
219
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/06 | 白话容器基础(二):隔离与限制.md
Normal file
219
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/06 | 白话容器基础(二):隔离与限制.md
Normal file
@@ -0,0 +1,219 @@
|
||||
<audio id="audio" title="06 | 白话容器基础(二):隔离与限制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/67/6e9fa7ea0cb73d690a7c2fb70c026167.mp3"></audio>
|
||||
|
||||
你好,我是张磊。我今天和你分享的主题是:白话容器基础之隔离与限制。
|
||||
|
||||
在上一篇文章中,我详细介绍了Linux容器中用来实现“隔离”的技术手段:Namespace。而通过这些讲解,你应该能够明白,**Namespace技术实际上修改了应用进程看待整个计算机“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定的内容**。但对于宿主机来说,这些被“隔离”了的进程跟其他进程并没有太大区别。
|
||||
|
||||
说到这一点,相信你也能够知道我在上一篇文章最后给你留下的第一个思考题的答案了:在之前虚拟机与容器技术的对比图里,不应该把Docker Engine或者任何容器管理工具放在跟Hypervisor相同的位置,因为它们并不像Hypervisor那样对应用进程的隔离环境负责,也不会创建任何实体的“容器”,真正对隔离环境负责的是宿主机操作系统本身:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/96/d1bb34cda8744514ba4c233435bf4e96.jpg" alt="">
|
||||
|
||||
所以,在这个对比图里,我们应该把Docker画在跟应用同级别并且靠边的位置。这意味着,用户运行在容器里的应用进程,跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置过的Namespace参数。而Docker项目在这里扮演的角色,更多的是旁路式的辅助和管理工作。
|
||||
|
||||
我在后续分享CRI和容器运行时的时候还会专门介绍,其实像Docker这样的角色甚至可以去掉。
|
||||
|
||||
这样的架构也解释了为什么Docker项目比虚拟机更受欢迎的原因。
|
||||
|
||||
这是因为,使用虚拟化技术作为应用沙盒,就必须要由Hypervisor来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的Guest OS才能执行用户的应用进程。这就不可避免地带来了额外的资源消耗和占用。
|
||||
|
||||
根据实验,一个运行着CentOS的KVM虚拟机启动后,在不做优化的情况下,虚拟机自己就需要占用100~200 MB内存。此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源、网络和磁盘I/O的损耗非常大。
|
||||
|
||||
而相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用Namespace作为隔离手段的容器并不需要单独的Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。
|
||||
|
||||
所以说,**“敏捷”和“高性能”是容器相较于虚拟机最大的优势,也是它能够在PaaS这种更细粒度的资源管理平台上大行其道的重要原因。**
|
||||
|
||||
不过,有利就有弊,基于Linux Namespace的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:**隔离得不彻底。**
|
||||
|
||||
首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。
|
||||
|
||||
尽管你可以在容器里通过Mount Namespace单独挂载其他不同版本的操作系统文件,比如CentOS或者Ubuntu,但这并不能改变共享宿主机内核的事实。这意味着,如果你要在Windows宿主机上运行Linux容器,或者在低版本的Linux宿主机上运行高版本的Linux容器,都是行不通的。
|
||||
|
||||
而相比之下,拥有硬件虚拟化技术和独立Guest OS的虚拟机就要方便得多了。最极端的例子是,Microsoft的云计算平台Azure,实际上就是运行在Windows服务器集群上的,但这并不妨碍你在它上面创建各种Linux虚拟机出来。
|
||||
|
||||
其次,在Linux内核中,有很多资源和对象是不能被Namespace化的,最典型的例子就是:时间。
|
||||
|
||||
这就意味着,如果你的容器中的程序使用settimeofday(2)系统调用修改了时间,整个宿主机的时间都会被随之修改,这显然不符合用户的预期。相比于在虚拟机里面可以随便折腾的自由度,在容器里部署应用的时候,“什么能做,什么不能做”,就是用户必须考虑的一个问题。
|
||||
|
||||
此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。
|
||||
|
||||
更为棘手的是,尽管在实践中我们确实可以使用Seccomp等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这种方法因为多了一层对系统调用的过滤,必然会拖累容器的性能。何况,默认情况下,谁也不知道到底该开启哪些系统调用,禁止哪些系统调用。
|
||||
|
||||
所以,在生产环境中,没有人敢把运行在物理机上的Linux容器直接暴露到公网上。当然,我后续会讲到的基于虚拟化或者独立内核技术的容器实现,则可以比较好地在隔离与性能之间做出平衡。
|
||||
|
||||
**在介绍完容器的“隔离”技术之后,我们再来研究一下容器的“限制”问题。**
|
||||
|
||||
也许你会好奇,我们不是已经通过Linux Namespace创建了一个“容器”吗,为什么还需要对容器做“限制”呢?
|
||||
|
||||
我还是以PID Namespace为例,来给你解释这个问题。
|
||||
|
||||
虽然容器内的第1号进程在“障眼法”的干扰下只能看到容器里的情况,但是宿主机上,它作为第100号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第100号进程表面上被隔离了起来,但是它所能够使用到的资源(比如CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个100号进程自己也可能把所有资源吃光。这些情况,显然都不是一个“沙盒”应该表现出来的合理行为。
|
||||
|
||||
而**Linux Cgroups就是Linux内核中用来为进程设置资源限制的一个重要功能。**
|
||||
|
||||
有意思的是,Google的工程师在2006年发起这项特性的时候,曾将它命名为“进程容器”(process container)。实际上,在Google内部,“容器”这个术语长期以来都被用于形容被Cgroups限制过的进程组。后来Google的工程师们说,他们的KVM虚拟机也运行在Borg所管理的“容器”里,其实也是运行在Cgroups“容器”当中。这和我们今天说的Docker容器差别很大。
|
||||
|
||||
**Linux Cgroups的全称是Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等等。**
|
||||
|
||||
此外,Cgroups还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。在今天的分享中,我只和你重点探讨它与容器关系最紧密的“限制”能力,并通过一组实践来带你认识一下Cgroups。
|
||||
|
||||
在Linux中,Cgroups给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的/sys/fs/cgroup路径下。在Ubuntu 16.04机器里,我可以用mount指令把它们展示出来,这条命令是:
|
||||
|
||||
```
|
||||
$ mount -t cgroup
|
||||
cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
|
||||
cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
|
||||
cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
|
||||
blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
|
||||
memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
它的输出结果,是一系列文件系统目录。如果你在自己的机器上没有看到这些目录,那你就需要自己去挂载Cgroups,具体做法可以自行Google。
|
||||
|
||||
可以看到,在/sys/fs/cgroup下面有很多诸如cpuset、cpu、 memory这样的子目录,也叫子系统。这些都是我这台机器当前可以被Cgroups进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。比如,对CPU子系统来说,我们就可以看到如下几个配置文件,这个指令是:
|
||||
|
||||
```
|
||||
$ ls /sys/fs/cgroup/cpu
|
||||
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
|
||||
cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks
|
||||
|
||||
```
|
||||
|
||||
如果熟悉Linux CPU管理的话,你就会在它的输出里注意到cfs_period和cfs_quota这样的关键词。这两个参数需要组合使用,可以用来限制进程在长度为cfs_period的一段时间内,只能被分配到总量为cfs_quota的CPU时间。
|
||||
|
||||
而这样的配置文件又如何使用呢?
|
||||
|
||||
你需要在对应的子系统下面创建一个目录,比如,我们现在进入/sys/fs/cgroup/cpu目录下:
|
||||
|
||||
```
|
||||
root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container
|
||||
root@ubuntu:/sys/fs/cgroup/cpu$ ls container/
|
||||
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
|
||||
cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks
|
||||
|
||||
```
|
||||
|
||||
这个目录就称为一个“控制组”。你会发现,操作系统会在你新创建的container目录下,自动生成该子系统对应的资源限制文件。
|
||||
|
||||
现在,我们在后台执行这样一条脚本:
|
||||
|
||||
```
|
||||
$ while : ; do : ; done &
|
||||
[1] 226
|
||||
|
||||
```
|
||||
|
||||
显然,它执行了一个死循环,可以把计算机的CPU吃到100%,根据它的输出,我们可以看到这个脚本在后台运行的进程号(PID)是226。
|
||||
|
||||
这样,我们可以用top指令来确认一下CPU有没有被打满:
|
||||
|
||||
```
|
||||
$ top
|
||||
%Cpu0 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
|
||||
|
||||
```
|
||||
|
||||
在输出里可以看到,CPU的使用率已经100%了(%Cpu0 :100.0 us)。
|
||||
|
||||
而此时,我们可以通过查看container目录下的文件,看到container控制组里的CPU quota还没有任何限制(即:-1),CPU period则是默认的100 ms(100000 us):
|
||||
|
||||
```
|
||||
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
|
||||
-1
|
||||
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us
|
||||
100000
|
||||
|
||||
```
|
||||
|
||||
接下来,我们可以通过修改这些文件的内容来设置限制。
|
||||
|
||||
比如,向container组里的cfs_quota文件写入20 ms(20000 us):
|
||||
|
||||
```
|
||||
$ echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
|
||||
|
||||
```
|
||||
|
||||
结合前面的介绍,你应该能明白这个操作的含义,它意味着在每100 ms的时间里,被该控制组限制的进程只能使用20 ms的CPU时间,也就是说这个进程只能使用到20%的CPU带宽。
|
||||
|
||||
接下来,我们把被限制的进程的PID写入container组里的tasks文件,上面的设置就会对该进程生效了:
|
||||
|
||||
```
|
||||
$ echo 226 > /sys/fs/cgroup/cpu/container/tasks
|
||||
|
||||
```
|
||||
|
||||
我们可以用top指令查看一下:
|
||||
|
||||
```
|
||||
$ top
|
||||
%Cpu0 : 20.3 us, 0.0 sy, 0.0 ni, 79.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
|
||||
|
||||
```
|
||||
|
||||
可以看到,计算机的CPU使用率立刻降到了20%(%Cpu0 : 20.3 us)。
|
||||
|
||||
除CPU子系统外,Cgroups的每一个子系统都有其独有的资源限制能力,比如:
|
||||
|
||||
- blkio,为块设备设定I/O限制,一般用于磁盘等设备;
|
||||
- cpuset,为进程分配单独的CPU核和对应的内存节点;
|
||||
- memory,为进程设定内存使用的限制。
|
||||
|
||||
**Linux Cgroups的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合**。而对于Docker等Linux容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的PID填写到对应控制组的tasks文件中就可以了。
|
||||
|
||||
而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行docker run时的参数指定了,比如这样一条命令:
|
||||
|
||||
```
|
||||
$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash
|
||||
|
||||
```
|
||||
|
||||
在启动这个容器后,我们可以通过查看Cgroups文件系统下,CPU子系统中,“docker”这个控制组里的资源限制文件的内容来确认:
|
||||
|
||||
```
|
||||
$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us
|
||||
100000
|
||||
$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us
|
||||
20000
|
||||
|
||||
```
|
||||
|
||||
这就意味着这个Docker容器,只能使用到20%的CPU带宽。
|
||||
|
||||
## 总结
|
||||
|
||||
在这篇文章中,我首先介绍了容器使用Linux Namespace作为隔离手段的优势和劣势,对比了Linux容器跟虚拟机技术的不同,进一步明确了“容器只是一种特殊的进程”这个结论。
|
||||
|
||||
除了创建Namespace之外,在后续关于容器网络的分享中,我还会介绍一些其他Namespace的操作,比如看不见摸不着的Linux Namespace在计算机中到底如何表示、一个进程如何“加入”到其他进程的Namespace当中,等等。
|
||||
|
||||
紧接着,我详细介绍了容器在做好了隔离工作之后,又如何通过Linux Cgroups实现资源的限制,并通过一系列简单的实验,模拟了Docker项目创建容器限制的过程。
|
||||
|
||||
通过以上讲述,你现在应该能够理解,一个正在运行的Docker容器,其实就是一个启用了多个Linux Namespace的应用进程,而这个进程能够使用的资源量,则受Cgroups配置的限制。
|
||||
|
||||
这也是容器技术中一个非常重要的概念,即:**容器是一个“单进程”模型。**
|
||||
|
||||
由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里PID=1的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的PID=1的程序来充当两个不同应用的父进程,这也是为什么很多人都会用systemd或者supervisord这样的软件来代替应用本身作为容器的启动进程。
|
||||
|
||||
但是,在后面分享容器设计模式时,我还会推荐其他更好的解决办法。这是因为容器本身的设计,就是希望容器和应用能够**同生命周期**,这个概念对后续的容器编排非常重要。否则,一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。
|
||||
|
||||
另外,跟Namespace的情况类似,Cgroups对资源的限制能力也有很多不完善的地方,被提及最多的自然是/proc文件系统的问题。
|
||||
|
||||
众所周知,Linux下的/proc目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如CPU使用情况、内存占用率等,这些文件也是top指令查看系统信息的主要数据来源。
|
||||
|
||||
但是,你如果在容器里执行top指令,就会发现,它显示的信息居然是宿主机的CPU和内存数据,而不是当前容器的数据。
|
||||
|
||||
造成这个问题的原因就是,/proc文件系统并不知道用户通过Cgroups给这个容器做了什么样的资源限制,即:/proc文件系统不了解Cgroups限制的存在。
|
||||
|
||||
在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取到的CPU核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险。这也是在企业中,容器化应用碰到的一个常见问题,也是容器相较于虚拟机另一个不尽如人意的地方。
|
||||
|
||||
## 思考题
|
||||
|
||||
<li>
|
||||
你是否知道如何修复容器中的top指令以及/proc文件系统中的信息呢?(提示:lxcfs)
|
||||
</li>
|
||||
<li>
|
||||
在从虚拟机向容器环境迁移应用的过程中,你还遇到哪些容器与虚拟机的不一致问题?
|
||||
</li>
|
||||
|
||||
感谢你的收听,欢迎给我留言一起讨论,也欢迎分享给更多的朋友一起阅读。
|
||||
472
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/07 | 白话容器基础(三):深入理解容器镜像.md
Normal file
472
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/07 | 白话容器基础(三):深入理解容器镜像.md
Normal file
@@ -0,0 +1,472 @@
|
||||
<audio id="audio" title="07 | 白话容器基础(三):深入理解容器镜像" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/11/20/117b7b7fa2752c42a9d41096d9342720.mp3"></audio>
|
||||
|
||||
你好,我是张磊。我在今天这篇文章的最后,放置了一张Kubernetes的技能图谱,希望对你有帮助。
|
||||
|
||||
在前两次的分享中,我讲解了Linux容器最基础的两种技术:Namespace和Cgroups。希望此时,你已经彻底理解了“容器的本质是一种特殊的进程”这个最重要的概念。
|
||||
|
||||
而正如我前面所说的,Namespace的作用是“隔离”,它让应用进程只能看到该Namespace内的“世界”;而Cgroups的作用是“限制”,它给这个“世界”围上了一圈看不见的墙。这么一折腾,进程就真的被“装”在了一个与世隔绝的房间里,而这些房间就是PaaS项目赖以生存的应用“沙盒”。
|
||||
|
||||
可是,还有一个问题不知道你有没有仔细思考过:这个房间四周虽然有了墙,但是如果容器进程低头一看地面,又是怎样一副景象呢?
|
||||
|
||||
换句话说,**容器里的进程看到的文件系统又是什么样子的呢?**
|
||||
|
||||
可能你立刻就能想到,这一定是一个关于Mount Namespace的问题:容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如/tmp)下进行操作,而完全不会受宿主机以及其他容器的影响。
|
||||
|
||||
那么,真实情况是这样吗?
|
||||
|
||||
“左耳朵耗子”叔在多年前写的一篇[关于Docker基础知识的博客](https://coolshell.cn/articles/17010.html)里,曾经介绍过一段小程序。这段小程序的作用是,在创建子进程时开启指定的Namespace。
|
||||
|
||||
下面,我们不妨使用它来验证一下刚刚提到的问题。
|
||||
|
||||
```
|
||||
#define _GNU_SOURCE
|
||||
#include <sys/mount.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <stdio.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#define STACK_SIZE (1024 * 1024)
|
||||
static char container_stack[STACK_SIZE];
|
||||
char* const container_args[] = {
|
||||
"/bin/bash",
|
||||
NULL
|
||||
};
|
||||
|
||||
int container_main(void* arg)
|
||||
{
|
||||
printf("Container - inside the container!\n");
|
||||
execv(container_args[0], container_args);
|
||||
printf("Something's wrong!\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
printf("Parent - start a container!\n");
|
||||
int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
|
||||
waitpid(container_pid, NULL, 0);
|
||||
printf("Parent - container stopped!\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码的功能非常简单:在main函数里,我们通过clone()系统调用创建了一个新的子进程container_main,并且声明要为它启用Mount Namespace(即:CLONE_NEWNS标志)。
|
||||
|
||||
而这个子进程执行的,是一个“/bin/bash”程序,也就是一个shell。所以这个shell就运行在了Mount Namespace的隔离环境中。
|
||||
|
||||
我们来一起编译一下这个程序:
|
||||
|
||||
```
|
||||
$ gcc -o ns ns.c
|
||||
$ ./ns
|
||||
Parent - start a container!
|
||||
Container - inside the container!
|
||||
|
||||
```
|
||||
|
||||
这样,我们就进入了这个“容器”当中。可是,如果在“容器”里执行一下ls指令的话,我们就会发现一个有趣的现象: /tmp目录下的内容跟宿主机的内容是一样的。
|
||||
|
||||
```
|
||||
$ ls /tmp
|
||||
# 你会看到好多宿主机的文件
|
||||
|
||||
```
|
||||
|
||||
也就是说:
|
||||
|
||||
>
|
||||
即使开启了Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。
|
||||
|
||||
|
||||
这是怎么回事呢?
|
||||
|
||||
仔细思考一下,你会发现这其实并不难理解:**Mount Namespace修改的,是容器进程对文件系统“挂载点”的认知**。但是,这也就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。
|
||||
|
||||
这时,你可能已经想到了一个解决办法:创建新进程时,除了声明要启用Mount Namespace之外,我们还可以告诉容器进程,有哪些目录需要重新挂载,就比如这个/tmp目录。于是,我们在容器进程执行前可以添加一步重新挂载 /tmp目录的操作:
|
||||
|
||||
```
|
||||
int container_main(void* arg)
|
||||
{
|
||||
printf("Container - inside the container!\n");
|
||||
// 如果你的机器的根目录的挂载类型是shared,那必须先重新挂载根目录
|
||||
// mount("", "/", NULL, MS_PRIVATE, "");
|
||||
mount("none", "/tmp", "tmpfs", 0, "");
|
||||
execv(container_args[0], container_args);
|
||||
printf("Something's wrong!\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,在修改后的代码里,我在容器进程启动之前,加上了一句mount(“none”, “/tmp”, “tmpfs”, 0, “”)语句。就这样,我告诉了容器以tmpfs(内存盘)格式,重新挂载了/tmp目录。
|
||||
|
||||
这段修改后的代码,编译执行后的结果又如何呢?我们可以试验一下:
|
||||
|
||||
```
|
||||
$ gcc -o ns ns.c
|
||||
$ ./ns
|
||||
Parent - start a container!
|
||||
Container - inside the container!
|
||||
$ ls /tmp
|
||||
|
||||
```
|
||||
|
||||
可以看到,这次/tmp变成了一个空目录,这意味着重新挂载生效了。我们可以用mount -l检查一下:
|
||||
|
||||
```
|
||||
$ mount -l | grep tmpfs
|
||||
none on /tmp type tmpfs (rw,relatime)
|
||||
|
||||
```
|
||||
|
||||
可以看到,容器里的/tmp目录是以tmpfs方式单独挂载的。
|
||||
|
||||
更重要的是,因为我们创建的新进程启用了Mount Namespace,所以这次重新挂载的操作,只在容器进程的Mount Namespace中有效。如果在宿主机上用mount -l来检查一下这个挂载,你会发现它是不存在的:
|
||||
|
||||
```
|
||||
# 在宿主机上
|
||||
$ mount -l | grep tmpfs
|
||||
|
||||
```
|
||||
|
||||
**这就是Mount Namespace跟其他Namespace的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。**
|
||||
|
||||
可是,作为一个普通用户,我们希望的是一个更友好的情况:每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能做到这一点呢?
|
||||
|
||||
不难想到,我们可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于Mount Namespace的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。
|
||||
|
||||
在Linux操作系统里,有一个名为chroot的命令可以帮助你在shell中方便地完成这个工作。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。它的用法也非常简单。
|
||||
|
||||
假设,我们现在有一个$HOME/test目录,想要把它作为一个/bin/bash进程的根目录。
|
||||
|
||||
首先,创建一个test目录和几个lib文件夹:
|
||||
|
||||
```
|
||||
$ mkdir -p $HOME/test
|
||||
$ mkdir -p $HOME/test/{bin,lib64,lib}
|
||||
$ cd $T
|
||||
|
||||
```
|
||||
|
||||
然后,把bash命令拷贝到test目录对应的bin路径下:
|
||||
|
||||
```
|
||||
$ cp -v /bin/{bash,ls} $HOME/test/bin
|
||||
|
||||
```
|
||||
|
||||
接下来,把bash命令需要的所有so文件,也拷贝到test目录对应的lib路径下。找到so文件可以用ldd 命令:
|
||||
|
||||
```
|
||||
$ T=$HOME/test
|
||||
$ list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
|
||||
$ for i in $list; do cp -v "$i" "${T}${i}"; done
|
||||
|
||||
```
|
||||
|
||||
最后,执行chroot命令,告诉操作系统,我们将使用$HOME/test目录作为/bin/bash进程的根目录:
|
||||
|
||||
```
|
||||
$ chroot $HOME/test /bin/bash
|
||||
|
||||
```
|
||||
|
||||
这时,你如果执行"ls /",就会看到,它返回的都是$HOME/test目录下面的内容,而不是宿主机的内容。
|
||||
|
||||
更重要的是,对于被chroot的进程来说,它并不会感受到自己的根目录已经被“修改”成$HOME/test了。
|
||||
|
||||
这种视图被修改的原理,是不是跟我之前介绍的Linux Namespace很类似呢?
|
||||
|
||||
没错!
|
||||
|
||||
**实际上,Mount Namespace正是基于对chroot的不断改良才被发明出来的,它也是Linux操作系统里的第一个Namespace。**
|
||||
|
||||
当然,为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如Ubuntu16.04的ISO。这样,在容器启动之后,我们在容器里通过执行"ls /"查看根目录下的内容,就是Ubuntu 16.04的所有目录和文件。
|
||||
|
||||
**而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。**
|
||||
|
||||
所以,一个最常见的rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如/bin,/etc,/proc等等:
|
||||
|
||||
```
|
||||
$ ls /
|
||||
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var
|
||||
|
||||
```
|
||||
|
||||
而你进入容器之后执行的/bin/bash,就是/bin目录下的可执行文件,与宿主机的/bin/bash完全不同。
|
||||
|
||||
现在,你应该可以理解,对Docker项目来说,它最核心的原理实际上就是为待创建的用户进程:
|
||||
|
||||
<li>
|
||||
启用Linux Namespace配置;
|
||||
</li>
|
||||
<li>
|
||||
设置指定的Cgroups参数;
|
||||
</li>
|
||||
<li>
|
||||
切换进程的根目录(Change Root)。
|
||||
</li>
|
||||
|
||||
这样,一个完整的容器就诞生了。不过,Docker项目在最后一步的切换上会优先使用pivot_root系统调用,如果系统不支持,才会使用chroot。这两个系统调用虽然功能类似,但是也有细微的区别,这一部分小知识就交给你课后去探索了。
|
||||
|
||||
另外,**需要明确的是,rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在Linux操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。**
|
||||
|
||||
所以说,rootfs只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”。
|
||||
|
||||
那么,对于容器来说,这个操作系统的“灵魂”又在哪里呢?
|
||||
|
||||
实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。
|
||||
|
||||
这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。
|
||||
|
||||
这也是容器相比于虚拟机的主要缺陷之一:毕竟后者不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的Guest OS给应用随便折腾。
|
||||
|
||||
不过,**正是由于rootfs的存在,容器才有了一个被反复宣传至今的重要特性:一致性。**
|
||||
|
||||
什么是容器的“一致性”呢?
|
||||
|
||||
我在专栏的第一篇文章[《小鲸鱼大事记(一):初出茅庐》](https://time.geekbang.org/column/article/14254)中曾经提到过:由于云端与本地服务器环境不同,应用的打包过程,一直是使用PaaS时最“痛苦”的一个步骤。
|
||||
|
||||
但有了容器之后,更准确地说,有了容器镜像(即rootfs)之后,这个问题被非常优雅地解决了。
|
||||
|
||||
**由于rootfs里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。**
|
||||
|
||||
事实上,对于大多数开发者而言,他们对应用依赖的理解,一直局限在编程语言层面。比如Golang的Godeps.json。但实际上,一个一直以来很容易被忽视的事实是,**对一个应用来说,操作系统本身才是它运行所需要的最完整的“依赖库”。**
|
||||
|
||||
有了容器镜像“打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。
|
||||
|
||||
**这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。**
|
||||
|
||||
不过,这时你可能已经发现了另一个非常棘手的问题:难道我每开发一个应用,或者升级一下现有的应用,都要重复制作一次rootfs吗?
|
||||
|
||||
比如,我现在用Ubuntu操作系统的ISO做了一个rootfs,然后又在里面安装了Java环境,用来部署我的Java应用。那么,我的另一个同事在发布他的Java应用时,显然希望能够直接使用我安装过Java环境的rootfs,而不是重复这个流程。
|
||||
|
||||
一种比较直观的解决办法是,我在制作rootfs的时候,每做一步“有意义”的操作,就保存一个rootfs出来,这样其他同事就可以按需求去用他需要的rootfs了。
|
||||
|
||||
但是,这个解决办法并不具备推广性。原因在于,一旦你的同事们修改了这个rootfs,新旧两个rootfs之间就没有任何关系了。这样做的结果就是极度的碎片化。
|
||||
|
||||
那么,既然这些修改都基于一个旧的rootfs,我们能不能以增量的方式去做这些修改呢?这样做的好处是,所有人都只需要维护相对于base rootfs修改的增量内容,而不是每次修改都制造一个“fork”。
|
||||
|
||||
答案当然是肯定的。
|
||||
|
||||
这也正是为何,Docker公司在实现Docker镜像时并没有沿用以前制作rootfs的标准流程,而是做了一个小小的创新:
|
||||
|
||||
>
|
||||
Docker在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs。
|
||||
|
||||
|
||||
当然,这个想法不是凭空臆造出来的,而是用到了一种叫作联合文件系统(Union File System)的能力。
|
||||
|
||||
Union File System也叫UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。比如,我现在有两个目录A和B,它们分别有两个文件:
|
||||
|
||||
```
|
||||
$ tree
|
||||
.
|
||||
├── A
|
||||
│ ├── a
|
||||
│ └── x
|
||||
└── B
|
||||
├── b
|
||||
└── x
|
||||
|
||||
```
|
||||
|
||||
然后,我使用联合挂载的方式,将这两个目录挂载到一个公共的目录C上:
|
||||
|
||||
```
|
||||
$ mkdir C
|
||||
$ mount -t aufs -o dirs=./A:./B none ./C
|
||||
|
||||
```
|
||||
|
||||
这时,我再查看目录C的内容,就能看到目录A和B下的文件被合并到了一起:
|
||||
|
||||
```
|
||||
$ tree ./C
|
||||
./C
|
||||
├── a
|
||||
├── b
|
||||
└── x
|
||||
|
||||
```
|
||||
|
||||
可以看到,在这个合并后的目录C里,有a、b、x三个文件,并且x文件只有一份。这,就是“合并”的含义。此外,如果你在目录C里对a、b、x文件做修改,这些修改也会在对应的目录A、B中生效。
|
||||
|
||||
那么,在Docker项目中,又是如何使用这种Union File System的呢?
|
||||
|
||||
我的环境是Ubuntu 16.04和Docker CE 18.05,这对组合默认使用的是AuFS这个联合文件系统的实现。你可以通过docker info命令,查看到这个信息。
|
||||
|
||||
AuFS的全称是Another UnionFS,后改名为Alternative UnionFS,再后来干脆改名叫作Advance UnionFS,从这些名字中你应该能看出这样两个事实:
|
||||
|
||||
<li>
|
||||
它是对Linux原生UnionFS的重写和改进;
|
||||
</li>
|
||||
<li>
|
||||
它的作者怨气好像很大。我猜是Linus Torvalds(Linux之父)一直不让AuFS进入Linux内核主干的缘故,所以我们只能在Ubuntu和Debian这些发行版上使用它。
|
||||
</li>
|
||||
|
||||
对于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,往往由多个“层”组成:
|
||||
|
||||
```
|
||||
$ docker image inspect ubuntu:latest
|
||||
...
|
||||
"RootFS": {
|
||||
"Type": "layers",
|
||||
"Layers": [
|
||||
"sha256:f49017d4d5ce9c0f544c...",
|
||||
"sha256:8f2b771487e9d6354080...",
|
||||
"sha256:ccd4d61916aaa2159429...",
|
||||
"sha256:c01d74f99de40e097c73...",
|
||||
"sha256:268a067217b5fe78e000..."
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个Ubuntu镜像,实际上由五个层组成。这五个层就是五个增量rootfs,每一层都是Ubuntu操作系统文件与目录的一部分;而在使用镜像时,Docker会把这些增量联合挂载在一个统一的挂载点上(等价于前面例子里的“/C”目录)。
|
||||
|
||||
这个挂载点就是/var/lib/docker/aufs/mnt/<ID>,比如:
|
||||
|
||||
```
|
||||
/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
|
||||
|
||||
```
|
||||
|
||||
不出意外的,这个目录里面正是一个完整的Ubuntu操作系统:
|
||||
|
||||
```
|
||||
$ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
|
||||
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
|
||||
|
||||
```
|
||||
|
||||
那么,前面提到的五个镜像层,又是如何被联合挂载成这样一个完整的Ubuntu文件系统的呢?
|
||||
|
||||
这个信息记录在AuFS的系统目录/sys/fs/aufs下面。
|
||||
|
||||
首先,通过查看AuFS的挂载信息,我们可以找到这个目录对应的AuFS的内部ID(也叫:si):
|
||||
|
||||
```
|
||||
$ cat /proc/mounts| grep aufs
|
||||
none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0
|
||||
|
||||
```
|
||||
|
||||
即,si=972c6d361e6b32ba。
|
||||
|
||||
然后使用这个ID,你就可以在/sys/fs/aufs下查看被联合挂载在一起的各个层的信息:
|
||||
|
||||
```
|
||||
$ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]*
|
||||
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw
|
||||
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh
|
||||
/var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh
|
||||
/var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh
|
||||
/var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh
|
||||
/var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh
|
||||
/var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh
|
||||
|
||||
```
|
||||
|
||||
从这些信息里,我们可以看到,镜像的层都放置在/var/lib/docker/aufs/diff目录下,然后被联合挂载在/var/lib/docker/aufs/mnt里面。
|
||||
|
||||
**而且,从这个结构可以看出来,这个容器的rootfs由如下图所示的三部分组成:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/5f/8a7b5cfabaab2d877a1d4566961edd5f.png" alt="" />
|
||||
|
||||
**第一部分,只读层。**
|
||||
|
||||
它是这个容器的rootfs最下面的五层,对应的正是ubuntu:latest镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即readonly+whiteout,至于什么是whiteout,我下面马上会讲到)。
|
||||
|
||||
这时,我们可以分别查看一下这些层的内容:
|
||||
|
||||
```
|
||||
$ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0...
|
||||
etc sbin usr var
|
||||
$ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2...
|
||||
run
|
||||
$ ls /var/lib/docker/aufs/diff/a524a729adadedb900...
|
||||
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
|
||||
|
||||
```
|
||||
|
||||
可以看到,这些层,都以增量的方式分别包含了Ubuntu操作系统的一部分。
|
||||
|
||||
**第二部分,可读写层。**
|
||||
|
||||
它是这个容器的rootfs最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。
|
||||
|
||||
可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?
|
||||
|
||||
为了实现这样的删除操作,AuFS会在可读写层创建一个whiteout文件,把只读层里的文件“遮挡”起来。
|
||||
|
||||
比如,你要删除只读层里一个名叫foo的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo的文件。这样,当这两个层被联合挂载之后,foo文件就会被.wh.foo文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读+whiteout的含义。我喜欢把whiteout形象地翻译为:“白障”。
|
||||
|
||||
所以,最上面这个可读写层的作用,就是专门用来存放你修改rootfs后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用docker commit和push指令,保存这个被修改过的可读写层,并上传到Docker Hub上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量rootfs的好处。
|
||||
|
||||
**第三部分,Init层。**
|
||||
|
||||
它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init层是Docker项目单独生成的一个内部层,专门用来存放/etc/hosts、/etc/resolv.conf等信息。
|
||||
|
||||
需要这样一层的原因是,这些文件本来属于只读的Ubuntu镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如hostname,所以就需要在可读写层对它们进行修改。
|
||||
|
||||
可是,这些修改往往只对当前的容器有效,我们并不希望执行docker commit时,把这些信息连同可读写层一起提交掉。
|
||||
|
||||
所以,Docker做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行docker commit只会提交可读写层,所以是不包含这些内容的。
|
||||
|
||||
最终,这7个层都被联合挂载到/var/lib/docker/aufs/mnt目录下,表现为一个完整的Ubuntu操作系统供容器使用。
|
||||
|
||||
## 总结
|
||||
|
||||
在今天的分享中,我着重介绍了Linux容器文件系统的实现方式。而这种机制,正是我们经常提到的容器镜像,也叫作:rootfs。它只是一个操作系统的所有文件和目录,并不包含内核,最多也就几百兆。而相比之下,传统虚拟机的镜像大多是一个磁盘的“快照”,磁盘有多大,镜像就至少有多大。
|
||||
|
||||
通过结合使用Mount Namespace和rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境。当然,这个功能的实现还必须感谢chroot和pivot_root这两个系统调用切换进程根目录的能力。
|
||||
|
||||
而在rootfs的基础上,Docker公司创新性地提出了使用多个增量rootfs联合挂载一个完整rootfs的方案,这就是容器镜像中“层”的概念。
|
||||
|
||||
通过“分层镜像”的设计,以Docker镜像为核心,来自不同公司、不同团队的技术人员被紧密地联系在了一起。而且,由于容器镜像的操作是增量式的,这样每次镜像拉取、推送的内容,比原本多个完整的操作系统的大小要小得多;而共享层的存在,可以使得所有这些容器镜像需要的总空间,也比每个镜像的总和要小。这样就使得基于容器镜像的团队协作,要比基于动则几个GB的虚拟机磁盘镜像的协作要敏捷得多。
|
||||
|
||||
更重要的是,一旦这个镜像被发布,那么你在全世界的任何一个地方下载这个镜像,得到的内容都完全一致,可以完全复现这个镜像制作者当初的完整环境。这,就是容器技术“强一致性”的重要体现。
|
||||
|
||||
而这种价值正是支撑Docker公司在2014~2016年间迅猛发展的核心动力。容器镜像的发明,不仅打通了“开发-测试-部署”流程的每一个环节,更重要的是:
|
||||
|
||||
>
|
||||
容器镜像将会成为未来软件的主流发布方式。
|
||||
|
||||
|
||||
## 思考题
|
||||
|
||||
<li>
|
||||
既然容器的rootfs(比如,Ubuntu镜像),是以只读方式挂载的,那么又如何在容器里修改Ubuntu镜像的内容呢?(提示:Copy-on-Write)
|
||||
</li>
|
||||
<li>
|
||||
除了AuFS,你知道Docker项目还支持哪些UnionFS实现吗?你能说出不同宿主机环境下推荐使用哪种实现吗?
|
||||
</li>
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。<br />
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/cb/0da944e5bac4fe1d00d3f01a747e86cb.jpg" alt="" />
|
||||
|
||||
点击[这里](https://time.geekbang.org/column/article/17841)查看大图。
|
||||
|
||||
|
||||
504
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/08 | 白话容器基础(四):重新认识Docker容器.md
Normal file
504
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/08 | 白话容器基础(四):重新认识Docker容器.md
Normal file
@@ -0,0 +1,504 @@
|
||||
<audio id="audio" title="08 | 白话容器基础(四):重新认识Docker容器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/79/31/79e8421b8d2f88dc0b4733aa635def31.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:白话容器基础之重新认识Docker容器。
|
||||
|
||||
在前面的三次分享中,我分别从Linux Namespace的隔离能力、Linux Cgroups的限制能力,以及基于rootfs的文件系统三个角度,为你剖析了一个Linux容器的核心实现原理。
|
||||
|
||||
>
|
||||
备注:之所以要强调Linux容器,是因为比如Docker on Mac,以及Windows Docker(Hyper-V实现),实际上是基于虚拟化技术实现的,跟我们这个专栏着重介绍的Linux容器完全不同。
|
||||
|
||||
|
||||
而在今天的分享中,我会通过一个实际案例,对“白话容器基础”系列的所有内容做一次深入的总结和扩展。希望通过这次的讲解,能够让你更透彻地理解Docker容器的本质。
|
||||
|
||||
在开始实践之前,你需要准备一台Linux机器,并安装Docker。这个流程我就不再赘述了。
|
||||
|
||||
这一次,我要用Docker部署一个用Python编写的Web应用。这个应用的代码部分(`app.py`)非常简单:
|
||||
|
||||
```
|
||||
from flask import Flask
|
||||
import socket
|
||||
import os
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/')
|
||||
def hello():
|
||||
html = "<h3>Hello {name}!</h3>" \
|
||||
"<b>Hostname:</b> {hostname}<br/>"
|
||||
return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname())
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host='0.0.0.0', port=80)
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我使用Flask框架启动了一个Web服务器,而它唯一的功能是:如果当前环境中有“NAME”这个环境变量,就把它打印在“Hello”之后,否则就打印“Hello world”,最后再打印出当前环境的hostname。
|
||||
|
||||
这个应用的依赖,则被定义在了同目录下的requirements.txt文件里,内容如下所示:
|
||||
|
||||
```
|
||||
$ cat requirements.txt
|
||||
Flask
|
||||
|
||||
```
|
||||
|
||||
**而将这样一个应用容器化的第一步,是制作容器镜像。**
|
||||
|
||||
不过,相较于我之前介绍的制作rootfs的过程,Docker为你提供了一种更便捷的方式,叫作Dockerfile,如下所示。
|
||||
|
||||
```
|
||||
# 使用官方提供的Python开发镜像作为基础镜像
|
||||
FROM python:2.7-slim
|
||||
|
||||
# 将工作目录切换为/app
|
||||
WORKDIR /app
|
||||
|
||||
# 将当前目录下的所有内容复制到/app下
|
||||
ADD . /app
|
||||
|
||||
# 使用pip命令安装这个应用所需要的依赖
|
||||
RUN pip install --trusted-host pypi.python.org -r requirements.txt
|
||||
|
||||
# 允许外界访问容器的80端口
|
||||
EXPOSE 80
|
||||
|
||||
# 设置环境变量
|
||||
ENV NAME World
|
||||
|
||||
# 设置容器进程为:python app.py,即:这个Python应用的启动命令
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
```
|
||||
|
||||
通过这个文件的内容,你可以看到**Dockerfile的设计思想,是使用一些标准的原语(即大写高亮的词语),描述我们所要构建的Docker镜像。并且这些原语,都是按顺序处理的。**
|
||||
|
||||
比如FROM原语,指定了“python:2.7-slim”这个官方维护的基础镜像,从而免去了安装Python等语言环境的操作。否则,这一段我们就得这么写了:
|
||||
|
||||
```
|
||||
FROM ubuntu:latest
|
||||
RUN apt-get update -yRUN apt-get install -y python-pip python-dev build-essential
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
其中,RUN原语就是在容器里执行shell命令的意思。
|
||||
|
||||
而WORKDIR,意思是在这一句之后,Dockerfile后面的操作都以这一句指定的/app目录作为当前目录。
|
||||
|
||||
所以,到了最后的CMD,意思是Dockerfile指定python app.py为这个容器的进程。这里,app.py的实际路径是/app/app.py。所以,`CMD ["python", "app.py"]`等价于"`docker run <image> python app.py`"。
|
||||
|
||||
另外,在使用Dockerfile时,你可能还会看到一个叫作ENTRYPOINT的原语。实际上,它和CMD都是Docker容器进程启动所必需的参数,完整执行格式是:“ENTRYPOINT CMD”。
|
||||
|
||||
但是,默认情况下,Docker会为你提供一个隐含的ENTRYPOINT,即:/bin/sh -c。所以,在不指定ENTRYPOINT时,比如在我们这个例子里,实际上运行在容器里的完整进程是:`/bin/sh -c "python app.py"`,即CMD的内容就是ENTRYPOINT的参数。
|
||||
|
||||
>
|
||||
备注:基于以上原因,**我们后面会统一称Docker容器的启动进程为ENTRYPOINT,而不是CMD。**
|
||||
|
||||
|
||||
需要注意的是,Dockerfile里的原语并不都是指对容器内部的操作。就比如ADD,它指的是把当前目录(即Dockerfile所在的目录)里的文件,复制到指定容器内的目录当中。
|
||||
|
||||
读懂这个Dockerfile之后,我再把上述内容,保存到当前目录里一个名叫“Dockerfile”的文件中:
|
||||
|
||||
```
|
||||
$ ls
|
||||
Dockerfile app.py requirements.txt
|
||||
|
||||
```
|
||||
|
||||
接下来,我就可以让Docker制作这个镜像了,在当前目录执行:
|
||||
|
||||
```
|
||||
$ docker build -t helloworld .
|
||||
|
||||
```
|
||||
|
||||
其中,-t的作用是给这个镜像加一个Tag,即:起一个好听的名字。docker build会自动加载当前目录下的Dockerfile文件,然后按照顺序,执行文件中的原语。而这个过程,实际上可以等同于Docker使用基础镜像启动了一个容器,然后在容器中依次执行Dockerfile中的原语。
|
||||
|
||||
**需要注意的是,Dockerfile中的每个原语执行后,都会生成一个对应的镜像层**。即使原语本身并没有明显地修改文件的操作(比如,ENV原语),它对应的层也会存在。只不过在外界看来,这个层是空的。
|
||||
|
||||
docker build操作完成后,我可以通过docker images命令查看结果:
|
||||
|
||||
```
|
||||
$ docker image ls
|
||||
|
||||
REPOSITORY TAG IMAGE ID
|
||||
helloworld latest 653287cdf998
|
||||
|
||||
```
|
||||
|
||||
通过这个镜像ID,你就可以使用在[《白话容器基础(三):深入理解容器镜像》](https://time.geekbang.org/column/article/17921)中讲过的方法,查看这些新增的层在AuFS路径下对应的文件和目录了。
|
||||
|
||||
**接下来,我使用这个镜像,通过docker run命令启动容器:**
|
||||
|
||||
```
|
||||
$ docker run -p 4000:80 helloworld
|
||||
|
||||
```
|
||||
|
||||
在这一句命令中,镜像名helloworld后面,我什么都不用写,因为在Dockerfile中已经指定了CMD。否则,我就得把进程的启动命令加在后面:
|
||||
|
||||
```
|
||||
$ docker run -p 4000:80 helloworld python app.py
|
||||
|
||||
```
|
||||
|
||||
容器启动之后,我可以使用docker ps命令看到:
|
||||
|
||||
```
|
||||
$ docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED
|
||||
4ddf4638572d helloworld "python app.py" 10 seconds ago
|
||||
|
||||
```
|
||||
|
||||
同时,我已经通过-p 4000:80告诉了Docker,请把容器内的80端口映射在宿主机的4000端口上。
|
||||
|
||||
这样做的目的是,只要访问宿主机的4000端口,我就可以看到容器里应用返回的结果:
|
||||
|
||||
```
|
||||
$ curl http://localhost:4000
|
||||
<h3>Hello World!</h3><b>Hostname:</b> 4ddf4638572d<br/>
|
||||
|
||||
```
|
||||
|
||||
否则,我就得先用docker inspect命令查看容器的IP地址,然后访问“http://<容器IP地址>:80”才可以看到容器内应用的返回。
|
||||
|
||||
至此,我已经使用容器完成了一个应用的开发与测试,如果现在想要把这个容器的镜像上传到DockerHub上分享给更多的人,我要怎么做呢?
|
||||
|
||||
为了能够上传镜像,**我首先需要注册一个Docker Hub账号,然后使用docker login命令登录**。
|
||||
|
||||
接下来,我要**用docker tag命令给容器镜像起一个完整的名字**:
|
||||
|
||||
```
|
||||
$ docker tag helloworld geektime/helloworld:v1
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
注意:你自己做实验时,请将"geektime"替换成你自己的Docker Hub账户名称,比如zhangsan/helloworld:v1
|
||||
|
||||
|
||||
其中,geektime是我在Docker Hub上的用户名,它的“学名”叫镜像仓库(Repository);“/”后面的helloworld是这个镜像的名字,而“v1”则是我给这个镜像分配的版本号。
|
||||
|
||||
**然后,我执行docker push:**
|
||||
|
||||
```
|
||||
$ docker push geektime/helloworld:v1
|
||||
|
||||
```
|
||||
|
||||
这样,我就可以把这个镜像上传到Docker Hub上了。
|
||||
|
||||
此外,我还可以使用docker commit指令,把一个正在运行的容器,直接提交为一个镜像。一般来说,需要这么操作原因是:这个容器运行起来后,我又在里面做了一些操作,并且要把操作结果保存到镜像里,比如:
|
||||
|
||||
```
|
||||
$ docker exec -it 4ddf4638572d /bin/sh
|
||||
# 在容器内部新建了一个文件
|
||||
root@4ddf4638572d:/app# touch test.txt
|
||||
root@4ddf4638572d:/app# exit
|
||||
|
||||
#将这个新建的文件提交到镜像中保存
|
||||
$ docker commit 4ddf4638572d geektime/helloworld:v2
|
||||
|
||||
```
|
||||
|
||||
这里,我使用了docker exec命令进入到了容器当中。在了解了Linux Namespace的隔离机制后,你应该会很自然地想到一个问题:docker exec是怎么做到进入容器里的呢?
|
||||
|
||||
实际上,Linux Namespace创建的隔离空间虽然看不见摸不着,但一个进程的Namespace信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。
|
||||
|
||||
比如,通过如下指令,你可以看到当前正在运行的Docker容器的进程号(PID)是25686:
|
||||
|
||||
```
|
||||
$ docker inspect --format '{{ .State.Pid }}' 4ddf4638572d
|
||||
25686
|
||||
|
||||
```
|
||||
|
||||
这时,你可以通过查看宿主机的proc文件,看到这个25686进程的所有Namespace对应的文件:
|
||||
|
||||
```
|
||||
$ ls -l /proc/25686/ns
|
||||
total 0
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835]
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278]
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276]
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281]
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279]
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279]
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837]
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]
|
||||
|
||||
```
|
||||
|
||||
可以看到,一个进程的每种Linux Namespace,都在它对应的/proc/[进程号]/ns下有一个对应的虚拟文件,并且链接到一个真实的Namespace文件上。
|
||||
|
||||
有了这样一个可以“hold住”所有Linux Namespace的文件,我们就可以对Namespace做一些很有意义事情了,比如:加入到一个已经存在的Namespace当中。
|
||||
|
||||
**这也就意味着:一个进程,可以选择加入到某个进程已有的Namespace当中,从而达到“进入”这个进程所在容器的目的,这正是docker exec的实现原理。**
|
||||
|
||||
而这个操作所依赖的,乃是一个名叫setns()的Linux系统调用。它的调用方法,我可以用如下一段小程序为你说明:
|
||||
|
||||
```
|
||||
#define _GNU_SOURCE
|
||||
#include <fcntl.h>
|
||||
#include <sched.h>
|
||||
#include <unistd.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
int fd;
|
||||
|
||||
fd = open(argv[1], O_RDONLY);
|
||||
if (setns(fd, 0) == -1) {
|
||||
errExit("setns");
|
||||
}
|
||||
execvp(argv[2], &argv[2]);
|
||||
errExit("execvp");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码功能非常简单:它一共接收两个参数,第一个参数是argv[1],即当前进程要加入的Namespace文件的路径,比如/proc/25686/ns/net;而第二个参数,则是你要在这个Namespace里运行的进程,比如/bin/bash。
|
||||
|
||||
这段代码的核心操作,则是通过open()系统调用打开了指定的Namespace文件,并把这个文件的描述符fd交给setns()使用。在setns()执行后,当前进程就加入了这个文件对应的Linux Namespace当中了。
|
||||
|
||||
现在,你可以编译执行一下这个程序,加入到容器进程(PID=25686)的Network Namespace中:
|
||||
|
||||
```
|
||||
$ gcc -o set_ns set_ns.c
|
||||
$ ./set_ns /proc/25686/ns/net /bin/bash
|
||||
$ ifconfig
|
||||
eth0 Link encap:Ethernet HWaddr 02:42:ac:11:00:02
|
||||
inet addr:172.17.0.2 Bcast:0.0.0.0 Mask:255.255.0.0
|
||||
inet6 addr: fe80::42:acff:fe11:2/64 Scope:Link
|
||||
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
|
||||
RX packets:12 errors:0 dropped:0 overruns:0 frame:0
|
||||
TX packets:10 errors:0 dropped:0 overruns:0 carrier:0
|
||||
collisions:0 txqueuelen:0
|
||||
RX bytes:976 (976.0 B) TX bytes:796 (796.0 B)
|
||||
|
||||
lo Link encap:Local Loopback
|
||||
inet addr:127.0.0.1 Mask:255.0.0.0
|
||||
inet6 addr: ::1/128 Scope:Host
|
||||
UP LOOPBACK RUNNING MTU:65536 Metric:1
|
||||
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
|
||||
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
|
||||
collisions:0 txqueuelen:1000
|
||||
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)
|
||||
|
||||
```
|
||||
|
||||
正如上所示,当我们执行ifconfig命令查看网络设备时,我会发现能看到的网卡“变少”了:只有两个。而我的宿主机则至少有四个网卡。这是怎么回事呢?
|
||||
|
||||
实际上,在setns()之后我看到的这两个网卡,正是我在前面启动的Docker容器里的网卡。也就是说,我新创建的这个/bin/bash进程,由于加入了该容器进程(PID=25686)的Network Namepace,它看到的网络设备与这个容器里是一样的,即:/bin/bash进程的网络设备视图,也被修改了。
|
||||
|
||||
而一旦一个进程加入到了另一个Namespace当中,在宿主机的Namespace文件上,也会有所体现。
|
||||
|
||||
在宿主机上,你可以用ps指令找到这个set_ns程序执行的/bin/bash进程,其真实的PID是28499:
|
||||
|
||||
```
|
||||
# 在宿主机上
|
||||
ps aux | grep /bin/bash
|
||||
root 28499 0.0 0.0 19944 3612 pts/0 S 14:15 0:00 /bin/bash
|
||||
|
||||
```
|
||||
|
||||
这时,如果按照前面介绍过的方法,查看一下这个PID=28499的进程的Namespace,你就会发现这样一个事实:
|
||||
|
||||
```
|
||||
$ ls -l /proc/28499/ns/net
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:18 /proc/28499/ns/net -> net:[4026532281]
|
||||
|
||||
$ ls -l /proc/25686/ns/net
|
||||
lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -> net:[4026532281]
|
||||
|
||||
```
|
||||
|
||||
在/proc/[PID]/ns/net目录下,这个PID=28499进程,与我们前面的Docker容器进程(PID=25686)指向的Network Namespace文件完全一样。这说明这两个进程,共享了这个名叫net:[4026532281]的Network Namespace。
|
||||
|
||||
此外,Docker还专门提供了一个参数,可以让你启动一个容器并“加入”到另一个容器的Network Namespace里,这个参数就是-net,比如:
|
||||
|
||||
```
|
||||
$ docker run -it --net container:4ddf4638572d busybox ifconfig
|
||||
|
||||
```
|
||||
|
||||
这样,我们新启动的这个容器,就会直接加入到ID=4ddf4638572d的容器,也就是我们前面的创建的Python应用容器(PID=25686)的Network Namespace中。所以,这里ifconfig返回的网卡信息,跟我前面那个小程序返回的结果一模一样,你也可以尝试一下。
|
||||
|
||||
而如果我指定–net=host,就意味着这个容器不会为进程启用Network Namespace。这就意味着,这个容器拆除了Network Namespace的“隔离墙”,所以,它会和宿主机上的其他普通进程一样,直接共享宿主机的网络栈。这就为容器直接操作和使用宿主机网络提供了一个渠道。
|
||||
|
||||
**转了一个大圈子,我其实是为你详细解读了docker exec这个操作背后,Linux Namespace更具体的工作原理。**
|
||||
|
||||
**这种通过操作系统进程相关的知识,逐步剖析Docker容器的方法,是理解容器的一个关键思路,希望你一定要掌握。**
|
||||
|
||||
现在,我们再一起回到前面提交镜像的操作docker commit上来吧。
|
||||
|
||||
docker commit,实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。
|
||||
|
||||
而由于使用了联合文件系统,你在容器里对镜像rootfs所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。这就是所谓的:Copy-on-Write。
|
||||
|
||||
而正如前所说,Init层的存在,就是为了避免你执行docker commit时,把Docker自己对/etc/hosts等文件做的修改,也一起提交掉。
|
||||
|
||||
有了新的镜像,我们就可以把它推送到Docker Hub上了:
|
||||
|
||||
```
|
||||
$ docker push geektime/helloworld:v2
|
||||
|
||||
```
|
||||
|
||||
你可能还会有这样的问题:我在企业内部,能不能也搭建一个跟Docker Hub类似的镜像上传系统呢?
|
||||
|
||||
当然可以,这个统一存放镜像的系统,就叫作Docker Registry。感兴趣的话,你可以查看[Docker的官方文档](https://docs.docker.com/registry/),以及[VMware的Harbor项目](https://github.com/goharbor/harbor)。
|
||||
|
||||
最后,我再来讲解一下Docker项目另一个重要的内容:Volume(数据卷)。
|
||||
|
||||
前面我已经介绍过,容器技术使用了rootfs机制和Mount Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。这时候,我们就需要考虑这样两个问题:
|
||||
|
||||
<li>
|
||||
容器里进程新建的文件,怎么才能让宿主机获取到?
|
||||
</li>
|
||||
<li>
|
||||
宿主机上的文件和目录,怎么才能让容器里的进程访问到?
|
||||
</li>
|
||||
|
||||
这正是Docker Volume要解决的问题:**Volume机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。**
|
||||
|
||||
在Docker项目里,它支持两种Volume声明方式,可以把宿主机目录挂载进容器的/test目录当中:
|
||||
|
||||
```
|
||||
$ docker run -v /test ...
|
||||
$ docker run -v /home:/test ...
|
||||
|
||||
```
|
||||
|
||||
而这两种声明方式的本质,实际上是相同的:都是把一个宿主机的目录挂载进了容器的/test目录。
|
||||
|
||||
只不过,在第一种情况下,由于你并没有显示声明宿主机目录,那么Docker就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的/test目录上。而在第二种情况下,Docker就直接把宿主机的/home目录挂载到容器的/test目录上。
|
||||
|
||||
那么,Docker又是如何做到把一个宿主机上的目录或者文件,挂载到容器里面去呢?难道又是Mount Namespace的黑科技吗?
|
||||
|
||||
实际上,并不需要这么麻烦。
|
||||
|
||||
在《白话容器基础(三):深入理解容器镜像》的分享中,我已经介绍过,当容器进程被创建之后,尽管开启了Mount Namespace,但是在它执行chroot(或者pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。
|
||||
|
||||
而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在/var/lib/docker/aufs/diff目录下,在容器进程启动后,它们会被联合挂载在/var/lib/docker/aufs/mnt/目录中,这样容器所需的rootfs就准备好了。
|
||||
|
||||
所以,我们只需要在rootfs准备好之后,在执行chroot之前,把Volume指定的宿主机目录(比如/home目录),挂载到指定的容器目录(比如/test目录)在宿主机上对应的目录(即/var/lib/docker/aufs/mnt/[可读写层ID]/test)上,这个Volume的挂载工作就完成了。
|
||||
|
||||
更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时Mount Namespace已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就**保证了容器的隔离性不会被Volume打破**。
|
||||
|
||||
>
|
||||
注意:这里提到的"容器进程",是Docker创建的一个容器初始化进程(dockerinit),而不是应用进程(ENTRYPOINT + CMD)。dockerinit会负责完成根目录的准备、挂载设备和目录、配置hostname等一系列需要在容器内进行的初始化操作。最后,它通过execv()系统调用,让应用进程取代自己,成为容器里的PID=1的进程。
|
||||
|
||||
|
||||
而这里要使用到的挂载技术,就是Linux的**绑定挂载(bind mount)机制**。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。
|
||||
|
||||
其实,如果你了解Linux 内核的话,就会明白,绑定挂载实际上是一个inode替换的过程。在Linux操作系统中,inode可以理解为存放文件内容的“对象”,而dentry,也叫目录项,就是访问这个inode所使用的“指针”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/c6/95c957b3c2813bb70eb784b8d1daedc6.png" alt=""><br>
|
||||
正如上图所示,mount --bind /home /test,会将/home挂载到/test上。其实相当于将/test的dentry,重定向到了/home的inode。这样当我们修改/test目录时,实际修改的是/home目录的inode。这也就是为何,一旦执行umount命令,/test目录原先的内容就会恢复:因为修改真正发生在的,是/home目录里。
|
||||
|
||||
**所以,在一个正确的时机,进行一次绑定挂载,Docker就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。**
|
||||
|
||||
这样,进程在容器里对这个/test目录进行的所有操作,都实际发生在宿主机的对应目录(比如,/home,或者/var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像的内容。
|
||||
|
||||
那么,这个/test目录里的内容,既然挂载在容器rootfs的可读写层,它会不会被docker commit提交掉呢?
|
||||
|
||||
也不会。
|
||||
|
||||
这个原因其实我们前面已经提到过。容器的镜像操作,比如docker commit,都是发生在宿主机空间的。而由于Mount Namespace的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的/test目录(/var/lib/docker/aufs/mnt/[可读写层ID]/test),**始终是空的。**
|
||||
|
||||
不过,由于Docker一开始还是要创建/test这个目录作为挂载点,所以执行了docker commit之后,你会发现新产生的镜像里,会多出来一个空的/test目录。毕竟,新建目录操作,又不是挂载操作,Mount Namespace对它可起不到“障眼法”的作用。
|
||||
|
||||
结合以上的讲解,我们现在来亲自验证一下:
|
||||
|
||||
首先,启动一个helloworld容器,给它声明一个Volume,挂载在容器里的/test目录上:
|
||||
|
||||
```
|
||||
$ docker run -d -v /test helloworld
|
||||
cf53b766fa6f
|
||||
|
||||
```
|
||||
|
||||
容器启动之后,我们来查看一下这个Volume的ID:
|
||||
|
||||
```
|
||||
$ docker volume ls
|
||||
DRIVER VOLUME NAME
|
||||
local cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d
|
||||
|
||||
```
|
||||
|
||||
然后,使用这个ID,可以找到它在Docker工作目录下的volumes路径:
|
||||
|
||||
```
|
||||
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
|
||||
|
||||
```
|
||||
|
||||
这个_data文件夹,就是这个容器的Volume在宿主机上对应的临时目录了。
|
||||
|
||||
接下来,我们在容器的Volume里,添加一个文件text.txt:
|
||||
|
||||
```
|
||||
$ docker exec -it cf53b766fa6f /bin/sh
|
||||
cd test/
|
||||
touch text.txt
|
||||
|
||||
```
|
||||
|
||||
这时,我们再回到宿主机,就会发现text.txt已经出现在了宿主机上对应的临时目录里:
|
||||
|
||||
```
|
||||
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
|
||||
text.txt
|
||||
|
||||
```
|
||||
|
||||
可是,如果你在宿主机上查看该容器的可读写层,虽然可以看到这个/test目录,但其内容是空的(关于如何找到这个AuFS文件系统的路径,请参考我上一次分享的内容):
|
||||
|
||||
```
|
||||
$ ls /var/lib/docker/aufs/mnt/6780d0778b8a/test
|
||||
|
||||
```
|
||||
|
||||
可以确认,容器Volume里的信息,并不会被docker commit提交掉;但这个挂载点目录/test本身,则会出现在新的镜像当中。
|
||||
|
||||
以上内容,就是Docker Volume的核心原理了。
|
||||
|
||||
## 总结
|
||||
|
||||
在今天的这次分享中,我用了一个非常经典的Python应用作为案例,讲解了Docke容器使用的主要场景。熟悉了这些操作,你也就基本上摸清了Docker容器的核心功能。
|
||||
|
||||
更重要的是,我着重介绍了如何使用Linux Namespace、Cgroups,以及rootfs的知识,对容器进行了一次庖丁解牛似的解读。
|
||||
|
||||
借助这种思考问题的方法,最后的Docker容器,我们实际上就可以用下面这个“全景图”描述出来:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/e5/3116751445d182687ce496f2825117e5.jpg" alt="">
|
||||
|
||||
这个容器进程`“python app.py”`,运行在由Linux Namespace和Cgroups构成的隔离环境里;而它运行所需要的各种文件,比如python,`app.py`,以及整个操作系统文件,则由多个联合挂载在一起的rootfs层提供。
|
||||
|
||||
这些rootfs层的最下层,是来自Docker镜像的只读层。
|
||||
|
||||
在只读层之上,是Docker自己添加的Init层,用来存放被临时修改过的/etc/hosts等文件。
|
||||
|
||||
而rootfs的最上层是一个可读写层,它以Copy-on-Write的方式存放任何对只读层的修改,容器声明的Volume的挂载点,也出现在这一层。
|
||||
|
||||
通过这样的剖析,对于曾经“神秘莫测”的容器技术,你是不是感觉清晰了很多呢?
|
||||
|
||||
## 思考题
|
||||
|
||||
<li>
|
||||
你在查看Docker容器的Namespace时,是否注意到有一个叫cgroup的Namespace?它是Linux 4.6之后新增加的一个Namespace,你知道它的作用吗?
|
||||
</li>
|
||||
<li>
|
||||
如果你执行docker run -v /home:/test的时候,容器镜像里的/test目录下本来就有内容的话,你会发现,在宿主机的/home目录下,也会出现这些内容。这是怎么回事?为什么它们没有被绑定挂载隐藏起来呢?(提示:Docker的“copyData”功能)
|
||||
</li>
|
||||
<li>
|
||||
请尝试给这个Python应用加上CPU和Memory限制,然后启动它。根据我们前面介绍的Cgroups的知识,请你查看一下这个容器的Cgroups文件系统的设置,是不是跟我前面的讲解一致。
|
||||
</li>
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
262
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/09 | 从容器到容器云:谈谈Kubernetes的本质.md
Normal file
262
极客时间专栏/深入剖析Kubernetes/容器技术概念入门篇/09 | 从容器到容器云:谈谈Kubernetes的本质.md
Normal file
@@ -0,0 +1,262 @@
|
||||
<audio id="audio" title="09 | 从容器到容器云:谈谈Kubernetes的本质" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/10/f3aacd4be68691eb9b35a02d9adc3110.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:从容器到容器云,谈谈Kubernetes的本质。
|
||||
|
||||
在前面的四篇文章中,我以Docker项目为例,一步步剖析了Linux容器的具体实现方式。通过这些讲解你应该能够明白:一个“容器”,实际上是一个由Linux Namespace、Linux Cgroups和rootfs三种技术构建出来的进程的隔离环境。
|
||||
|
||||
从这个结构中我们不难看出,一个正在运行的Linux容器,其实可以被“一分为二”地看待:
|
||||
|
||||
<li>
|
||||
一组联合挂载在/var/lib/docker/aufs/mnt上的rootfs,这一部分我们称为“容器镜像”(Container Image),是容器的静态视图;
|
||||
</li>
|
||||
<li>
|
||||
一个由Namespace+Cgroups构成的隔离环境,这一部分我们称为“容器运行时”(Container Runtime),是容器的动态视图。
|
||||
</li>
|
||||
|
||||
更进一步地说,作为一名开发者,我并不关心容器运行时的差异。因为,在整个“开发-测试-发布”的流程中,真正承载着容器信息进行传递的,是容器镜像,而不是容器运行时。
|
||||
|
||||
这个重要假设,正是容器技术圈在Docker项目成功后不久,就迅速走向了“容器编排”这个“上层建筑”的主要原因:作为一家云服务商或者基础设施提供商,我只要能够将用户提交的Docker镜像以容器的方式运行起来,就能成为这个非常热闹的容器生态图上的一个承载点,从而将整个容器技术栈上的价值,沉淀在我的这个节点上。
|
||||
|
||||
更重要的是,只要从我这个承载点向Docker镜像制作者和使用者方向回溯,整条路径上的各个服务节点,比如CI/CD、监控、安全、网络、存储等等,都有我可以发挥和盈利的余地。这个逻辑,正是所有云计算提供商如此热衷于容器技术的重要原因:通过容器镜像,它们可以和潜在用户(即,开发者)直接关联起来。
|
||||
|
||||
从一个开发者和单一的容器镜像,到无数开发者和庞大的容器集群,容器技术实现了从“容器”到“容器云”的飞跃,标志着它真正得到了市场和生态的认可。
|
||||
|
||||
这样,**容器就从一个开发者手里的小工具,一跃成为了云计算领域的绝对主角;而能够定义容器组织和管理规范的“容器编排”技术,则当仁不让地坐上了容器技术领域的“头把交椅”。**
|
||||
|
||||
这其中,最具代表性的容器编排工具,当属Docker公司的Compose+Swarm组合,以及Google与RedHat公司共同主导的Kubernetes项目。
|
||||
|
||||
我在前面介绍容器技术发展历史的四篇预习文章中,已经对这两个开源项目做了详细的剖析和评述。所以,在今天的这次分享中,我会专注于本专栏的主角Kubernetes项目,谈一谈它的设计与架构。
|
||||
|
||||
跟很多基础设施领域先有工程实践、后有方法论的发展路线不同,Kubernetes项目的理论基础则要比工程实践走得靠前得多,这当然要归功于Google公司在2015年4月发布的Borg论文了。
|
||||
|
||||
Borg系统,一直以来都被誉为Google公司内部最强大的“秘密武器”。虽然略显夸张,但这个说法倒不算是吹牛。
|
||||
|
||||
因为,相比于Spanner、BigTable等相对上层的项目,Borg要承担的责任,是承载Google公司整个基础设施的核心依赖。在Google公司已经公开发表的基础设施体系论文中,Borg项目当仁不让地位居整个基础设施技术栈的最底层。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/bd/c7ed0043465bccff2efc1a1257e970bd.png" alt=""><br>
|
||||
图片来源:[Malte Schwarzkopf. “Operating system support for warehouse-scale computing”. PhD thesis. University of Cambridge Computer Laboratory (to appear), 2015, Chapter 2.](http://malteschwarzkopf.de/research/assets/google-stack.pdf)
|
||||
|
||||
上面这幅图,来自于Google Omega论文的第一作者的博士毕业论文。它描绘了当时Google已经公开发表的整个基础设施栈。在这个图里,你既可以找到MapReduce、BigTable等知名项目,也能看到Borg和它的继任者Omega位于整个技术栈的最底层。
|
||||
|
||||
正是由于这样的定位,Borg可以说是Google最不可能开源的一个项目。而幸运的是,得益于Docker项目和容器技术的风靡,它却终于得以以另一种方式与开源社区见面,这个方式就是Kubernetes项目。
|
||||
|
||||
所以,相比于“小打小闹”的Docker公司、“旧瓶装新酒”的Mesos社区,**Kubernetes项目从一开始就比较幸运地站上了一个他人难以企及的高度**:在它的成长阶段,这个项目每一个核心特性的提出,几乎都脱胎于Borg/Omega系统的设计与经验。更重要的是,这些特性在开源社区落地的过程中,又在整个社区的合力之下得到了极大的改进,修复了很多当年遗留在Borg体系中的缺陷和问题。
|
||||
|
||||
所以,尽管在发布之初被批评是“曲高和寡”,但是在逐渐觉察到Docker技术栈的“稚嫩”和Mesos社区的“老迈”之后,这个社区很快就明白了:Kubernetes项目在Borg体系的指导下,体现出了一种独有的“先进性”与“完备性”,而这些特质才是一个基础设施领域开源项目赖以生存的核心价值。
|
||||
|
||||
为了更好地理解这两种特质,我们不妨从Kubernetes的顶层设计说起。
|
||||
|
||||
首先,Kubernetes项目要解决的问题是什么?
|
||||
|
||||
编排?调度?容器云?还是集群管理?
|
||||
|
||||
实际上,这个问题到目前为止都没有固定的答案。因为在不同的发展阶段,Kubernetes需要着重解决的问题是不同的。
|
||||
|
||||
但是,对于大多数用户来说,他们希望Kubernetes项目带来的体验是确定的:现在我有了应用的容器镜像,请帮我在一个给定的集群上把这个应用运行起来。
|
||||
|
||||
更进一步地说,我还希望Kubernetes能给我提供路由网关、水平扩展、监控、备份、灾难恢复等一系列运维能力。
|
||||
|
||||
等一下,这些功能听起来好像有些耳熟?这不就是经典PaaS(比如,Cloud Foundry)项目的能力吗?
|
||||
|
||||
而且,有了Docker之后,我根本不需要什么Kubernetes、PaaS,只要使用Docker公司的Compose+Swarm项目,就完全可以很方便地DIY出这些功能了!
|
||||
|
||||
所以说,如果Kubernetes项目只是停留在拉取用户镜像、运行容器,以及提供常见的运维功能的话,那么别说跟“原生”的Docker Swarm项目竞争了,哪怕跟经典的PaaS项目相比也难有什么优势可言。
|
||||
|
||||
而实际上,在定义核心功能的过程中,Kubernetes项目正是依托着Borg项目的理论优势,才在短短几个月内迅速站稳了脚跟,进而确定了一个如下图所示的全局架构:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/67/8ee9f2fa987eccb490cfaa91c6484f67.png" alt="">
|
||||
|
||||
我们可以看到,Kubernetes项目的架构,跟它的原型项目Borg非常类似,都由Master和Node两种节点组成,而这两种角色分别对应着控制节点和计算节点。
|
||||
|
||||
其中,控制节点,即Master节点,由三个紧密协作的独立组件组合而成,它们分别是负责API服务的kube-apiserver、负责调度的kube-scheduler,以及负责容器编排的kube-controller-manager。整个集群的持久化数据,则由kube-apiserver处理后保存在Etcd中。
|
||||
|
||||
而计算节点上最核心的部分,则是一个叫作kubelet的组件。
|
||||
|
||||
**在Kubernetes项目中,kubelet主要负责同容器运行时(比如Docker项目)打交道**。而这个交互所依赖的,是一个称作CRI(Container Runtime Interface)的远程调用接口,这个接口定义了容器运行时的各项核心操作,比如:启动一个容器需要的所有参数。
|
||||
|
||||
这也是为何,Kubernetes项目并不关心你部署的是什么容器运行时、使用的什么技术实现,只要你的这个容器运行时能够运行标准的容器镜像,它就可以通过实现CRI接入到Kubernetes项目当中。
|
||||
|
||||
而具体的容器运行时,比如Docker项目,则一般通过OCI这个容器运行时规范同底层的Linux操作系统进行交互,即:把CRI请求翻译成对Linux操作系统的调用(操作Linux Namespace和Cgroups等)。
|
||||
|
||||
**此外,kubelet还通过gRPC协议同一个叫作Device Plugin的插件进行交互**。这个插件,是Kubernetes项目用来管理GPU等宿主机物理设备的主要组件,也是基于Kubernetes项目进行机器学习训练、高性能作业支持等工作必须关注的功能。
|
||||
|
||||
而**kubelet的另一个重要功能,则是调用网络插件和存储插件为容器配置网络和持久化存储**。这两个插件与kubelet进行交互的接口,分别是CNI(Container Networking Interface)和CSI(Container Storage Interface)。
|
||||
|
||||
实际上,kubelet这个奇怪的名字,来自于Borg项目里的同源组件Borglet。不过,如果你浏览过Borg论文的话,就会发现,这个命名方式可能是kubelet组件与Borglet组件的唯一相似之处。因为Borg项目,并不支持我们这里所讲的容器技术,而只是简单地使用了Linux Cgroups对进程进行限制。
|
||||
|
||||
这就意味着,像Docker这样的“容器镜像”在Borg中是不存在的,Borglet组件也自然不需要像kubelet这样考虑如何同Docker进行交互、如何对容器镜像进行管理的问题,也不需要支持CRI、CNI、CSI等诸多容器技术接口。
|
||||
|
||||
可以说,kubelet完全就是为了实现Kubernetes项目对容器的管理能力而重新实现的一个组件,与Borg之间并没有直接的传承关系。
|
||||
|
||||
>
|
||||
备注:虽然不使用Docker,但Google内部确实在使用一个包管理工具,名叫Midas Package Manager (MPM),其实它可以部分取代Docker镜像的角色。
|
||||
|
||||
|
||||
那么,Borg对于Kubernetes项目的指导作用又体现在哪里呢?
|
||||
|
||||
答案是,Master节点。
|
||||
|
||||
虽然在Master节点的实现细节上Borg项目与Kubernetes项目不尽相同,但它们的出发点却高度一致,即:如何编排、管理、调度用户提交的作业?
|
||||
|
||||
所以,Borg项目完全可以把Docker镜像看作一种新的应用打包方式。这样,Borg团队过去在大规模作业管理与编排上的经验就可以直接“套”在Kubernetes项目上了。
|
||||
|
||||
这些经验最主要的表现就是,**从一开始,Kubernetes项目就没有像同时期的各种“容器云”项目那样,把Docker作为整个架构的核心,而仅仅把它作为最底层的一个容器运行时实现。**
|
||||
|
||||
而Kubernetes项目要着重解决的问题,则来自于Borg的研究人员在论文中提到的一个非常重要的观点:
|
||||
|
||||
>
|
||||
运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些关系的处理,才是作业编排和管理系统最困难的地方。
|
||||
|
||||
|
||||
事实也正是如此。
|
||||
|
||||
其实,这种任务与任务之间的关系,在我们平常的各种技术场景中随处可见。比如,一个Web应用与数据库之间的访问关系,一个负载均衡器和它的后端服务之间的代理关系,一个门户应用与授权组件之间的调用关系。
|
||||
|
||||
更进一步地说,同属于一个服务单位的不同功能之间,也完全可能存在这样的关系。比如,一个Web应用与日志搜集组件之间的文件交换关系。
|
||||
|
||||
而在容器技术普及之前,传统虚拟机环境对这种关系的处理方法都是比较“粗粒度”的。你会经常发现很多功能并不相关的应用被一股脑儿地部署在同一台虚拟机中,只是因为它们之间偶尔会互相发起几个HTTP请求。
|
||||
|
||||
更常见的情况则是,一个应用被部署在虚拟机里之后,你还得手动维护很多跟它协作的守护进程(Daemon),用来处理它的日志搜集、灾难恢复、数据备份等辅助工作。
|
||||
|
||||
但容器技术出现以后,你就不难发现,在“功能单位”的划分上,容器有着独一无二的“细粒度”优势:毕竟容器的本质,只是一个进程而已。
|
||||
|
||||
也就是说,只要你愿意,那些原先拥挤在同一个虚拟机里的各个应用、组件、守护进程,都可以被分别做成镜像,然后运行在一个个专属的容器中。它们之间互不干涉,拥有各自的资源配额,可以被调度在整个集群里的任何一台机器上。而这,正是一个PaaS系统最理想的工作状态,也是所谓“微服务”思想得以落地的先决条件。
|
||||
|
||||
当然,如果只做到“封装微服务、调度单容器”这一层次,Docker Swarm项目就已经绰绰有余了。如果再加上Compose项目,你甚至还具备了处理一些简单依赖关系的能力,比如:一个“Web容器”和它要访问的数据库“DB容器”。
|
||||
|
||||
在Compose项目中,你可以为这样的两个容器定义一个“link”,而Docker项目则会负责维护这个“link”关系,其具体做法是:Docker会在Web容器中,将DB容器的IP地址、端口等信息以环境变量的方式注入进去,供应用进程使用,比如:
|
||||
|
||||
```
|
||||
DB_NAME=/web/db
|
||||
DB_PORT=tcp://172.17.0.5:5432
|
||||
DB_PORT_5432_TCP=tcp://172.17.0.5:5432
|
||||
DB_PORT_5432_TCP_PROTO=tcp
|
||||
DB_PORT_5432_TCP_PORT=5432
|
||||
DB_PORT_5432_TCP_ADDR=172.17.0.5
|
||||
|
||||
```
|
||||
|
||||
而当DB容器发生变化时(比如,镜像更新,被迁移到其他宿主机上等等),这些环境变量的值会由Docker项目自动更新。**这就是平台项目自动地处理容器间关系的典型例子。**
|
||||
|
||||
可是,如果我们现在的需求是,要求这个项目能够处理前面提到的所有类型的关系,甚至还要能够支持未来可能出现的更多种类的关系呢?
|
||||
|
||||
这时,“link”这种单独针对一种案例设计的解决方案就太过简单了。如果你做过架构方面的工作,就会深有感触:一旦要追求项目的普适性,那就一定要从顶层开始做好设计。
|
||||
|
||||
所以,**Kubernetes项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。**
|
||||
|
||||
比如,Kubernetes项目对容器间的“访问”进行了分类,首先总结出了一类非常常见的“紧密交互”的关系,即:这些应用之间需要非常频繁的交互和访问;又或者,它们会直接通过本地文件进行信息交换。
|
||||
|
||||
在常规环境下,这些应用往往会被直接部署在同一台机器上,通过Localhost通信,通过本地磁盘目录交换文件。而在Kubernetes项目中,这些容器则会被划分为一个“Pod”,Pod里的容器共享同一个Network Namespace、同一组数据卷,从而达到高效率交换信息的目的。
|
||||
|
||||
Pod是Kubernetes项目中最基础的一个对象,源自于Google Borg论文中一个名叫Alloc的设计。在后续的章节中,我们会对Pod做更进一步地阐述。
|
||||
|
||||
而对于另外一种更为常见的需求,比如Web应用与数据库之间的访问关系,Kubernetes项目则提供了一种叫作“Service”的服务。像这样的两个应用,往往故意不部署在同一台机器上,这样即使Web应用所在的机器宕机了,数据库也完全不受影响。可是,我们知道,对于一个容器来说,它的IP地址等信息不是固定的,那么Web应用又怎么找到数据库容器的Pod呢?
|
||||
|
||||
所以,Kubernetes项目的做法是给Pod绑定一个Service服务,而Service服务声明的IP地址等信息是“终生不变”的。这个**Service服务的主要作用,就是作为Pod的代理入口(Portal),从而代替Pod对外暴露一个固定的网络地址**。
|
||||
|
||||
这样,对于Web应用的Pod来说,它需要关心的就是数据库Pod的Service信息。不难想象,Service后端真正代理的Pod的IP地址、端口等信息的自动更新、维护,则是Kubernetes项目的职责。
|
||||
|
||||
像这样,围绕着容器和Pod不断向真实的技术场景扩展,我们就能够摸索出一幅如下所示的Kubernetes项目核心功能的“全景图”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/16/06/16c095d6efb8d8c226ad9b098689f306.png" alt="">
|
||||
|
||||
按照这幅图的线索,我们从容器这个最基础的概念出发,首先遇到了容器间“紧密协作”关系的难题,于是就扩展到了Pod;有了Pod之后,我们希望能一次启动多个应用的实例,这样就需要Deployment这个Pod的多实例管理器;而有了这样一组相同的Pod后,我们又需要通过一个固定的IP地址和端口以负载均衡的方式访问它,于是就有了Service。
|
||||
|
||||
可是,如果现在两个不同Pod之间不仅有“访问关系”,还要求在发起时加上授权信息。最典型的例子就是Web应用对数据库访问时需要Credential(数据库的用户名和密码)信息。那么,在Kubernetes中这样的关系又如何处理呢?
|
||||
|
||||
Kubernetes项目提供了一种叫作Secret的对象,它其实是一个保存在Etcd里的键值对数据。这样,你把Credential信息以Secret的方式存在Etcd里,Kubernetes就会在你指定的Pod(比如,Web应用的Pod)启动时,自动把Secret里的数据以Volume的方式挂载到容器里。这样,这个Web应用就可以访问数据库了。
|
||||
|
||||
**除了应用与应用之间的关系外,应用运行的形态是影响“如何容器化这个应用”的第二个重要因素。**
|
||||
|
||||
为此,Kubernetes定义了新的、基于Pod改进后的对象。比如Job,用来描述一次性运行的Pod(比如,大数据任务);再比如DaemonSet,用来描述每个宿主机上必须且只能运行一个副本的守护进程服务;又比如CronJob,则用于描述定时任务等等。
|
||||
|
||||
如此种种,正是Kubernetes项目定义容器间关系和形态的主要方法。
|
||||
|
||||
可以看到,Kubernetes项目并没有像其他项目那样,为每一个管理功能创建一个指令,然后在项目中实现其中的逻辑。这种做法,的确可以解决当前的问题,但是在更多的问题来临之后,往往会力不从心。
|
||||
|
||||
相比之下,在Kubernetes项目中,我们所推崇的使用方法是:
|
||||
|
||||
- 首先,通过一个“编排对象”,比如Pod、Job、CronJob等,来描述你试图管理的应用;
|
||||
- 然后,再为它定义一些“服务对象”,比如Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等。这些对象,会负责具体的平台级功能。
|
||||
|
||||
**这种使用方法,就是所谓的“声明式API”。这种API对应的“编排对象”和“服务对象”,都是Kubernetes项目中的API对象(API Object)。**
|
||||
|
||||
这就是Kubernetes最核心的设计理念,也是接下来我会重点剖析的关键技术点。
|
||||
|
||||
最后,我来回答一个更直接的问题:Kubernetes项目如何启动一个容器化任务呢?
|
||||
|
||||
比如,我现在已经制作好了一个Nginx容器镜像,希望让平台帮我启动这个镜像。并且,我要求平台帮我运行两个完全相同的Nginx副本,以负载均衡的方式共同对外提供服务。
|
||||
|
||||
<li>
|
||||
如果是自己DIY的话,可能需要启动两台虚拟机,分别安装两个Nginx,然后使用keepalived为这两个虚拟机做一个虚拟IP。
|
||||
</li>
|
||||
<li>
|
||||
而如果使用Kubernetes项目呢?你需要做的则是编写如下这样一个YAML文件(比如名叫nginx-deployment.yaml):
|
||||
</li>
|
||||
|
||||
```
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-deployment
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.7.9
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
```
|
||||
|
||||
在上面这个YAML文件中,我们定义了一个Deployment对象,它的主体部分(spec.template部分)是一个使用Nginx镜像的Pod,而这个Pod的副本数是2(replicas=2)。
|
||||
|
||||
然后执行:
|
||||
|
||||
```
|
||||
$ kubectl create -f nginx-deployment.yaml
|
||||
|
||||
```
|
||||
|
||||
这样,两个完全相同的Nginx容器副本就被启动了。
|
||||
|
||||
不过,这么看来,做同样一件事情,Kubernetes用户要做的工作也不少嘛。
|
||||
|
||||
别急,在后续的讲解中,我会陆续介绍Kubernetes项目这种“声明式API”的种种好处,以及基于它实现的强大的编排能力。
|
||||
|
||||
拭目以待吧。
|
||||
|
||||
## 总结
|
||||
|
||||
首先,我和你一起回顾了容器的核心知识,说明了容器其实可以分为两个部分:容器运行时和容器镜像。
|
||||
|
||||
然后,我重点介绍了Kubernetes项目的架构,详细讲解了它如何使用“声明式API”来描述容器化业务和容器间关系的设计思想。
|
||||
|
||||
实际上,过去很多的集群管理项目(比如Yarn、Mesos,以及Swarm)所擅长的,都是把一个容器,按照某种规则,放置在某个最佳节点上运行起来。这种功能,我们称为“调度”。
|
||||
|
||||
而Kubernetes项目所擅长的,是按照用户的意愿和整个系统的规则,完全自动化地处理好容器之间的各种关系。**这种功能,就是我们经常听到的一个概念:编排。**
|
||||
|
||||
所以说,Kubernetes项目的本质,是为用户提供一个具有普遍意义的容器编排工具。
|
||||
|
||||
不过,更重要的是,Kubernetes项目为用户提供的不仅限于一个工具。它真正的价值,乃在于提供了一套基于容器构建分布式系统的基础依赖。关于这一点,相信你会在今后的学习中,体会越来越深。
|
||||
|
||||
## 思考题
|
||||
|
||||
<li>
|
||||
这今天的分享中,我介绍了Kubernetes项目的架构。你是否了解了Docker Swarm(SwarmKit项目)和Kubernetes在架构上和使用方法上的异同呢?
|
||||
</li>
|
||||
<li>
|
||||
在Kubernetes之前,很多项目都没办法管理“有状态”的容器,即,不能从一台宿主机“迁移”到另一台宿主机上的容器。你是否能列举出,阻止这种“迁移”的原因都有哪些呢?
|
||||
</li>
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
Reference in New Issue
Block a user