This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View 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 NamespaceLinux操作系统还提供了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>
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View 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 &amp;
[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还没有任何限制-1CPU period则是默认的100 ms100000 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 ms20000 us
```
$ echo 20000 &gt; /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
```
结合前面的介绍你应该能明白这个操作的含义它意味着在每100 ms的时间里被该控制组限制的进程只能使用20 ms的CPU时间也就是说这个进程只能使用到20%的CPU带宽。
接下来我们把被限制的进程的PID写入container组里的tasks文件上面的设置就会对该进程生效了
```
$ echo 226 &gt; /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的每一个子系统都有其独有的资源限制能力比如
- blkioI/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>
感谢你的收听,欢迎给我留言一起讨论,也欢迎分享给更多的朋友一起阅读。

View 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 &lt;sys/mount.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/wait.h&gt;
#include &lt;stdio.h&gt;
#include &lt;sched.h&gt;
#include &lt;signal.h&gt;
#include &lt;unistd.h&gt;
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
&quot;/bin/bash&quot;,
NULL
};
int container_main(void* arg)
{
printf(&quot;Container - inside the container!\n&quot;);
execv(container_args[0], container_args);
printf(&quot;Something's wrong!\n&quot;);
return 1;
}
int main()
{
printf(&quot;Parent - start a container!\n&quot;);
int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
waitpid(container_pid, NULL, 0);
printf(&quot;Parent - container stopped!\n&quot;);
return 0;
}
```
这段代码的功能非常简单在main函数里我们通过clone()系统调用创建了一个新的子进程container_main并且声明要为它启用Mount NamespaceCLONE_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(&quot;Container - inside the container!\n&quot;);
// 如果你的机器的根目录的挂载类型是shared那必须先重新挂载根目录
// mount(&quot;&quot;, &quot;/&quot;, NULL, MS_PRIVATE, &quot;&quot;);
mount(&quot;none&quot;, &quot;/tmp&quot;, &quot;tmpfs&quot;, 0, &quot;&quot;);
execv(container_args[0], container_args);
printf(&quot;Something's wrong!\n&quot;);
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=&quot;$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')&quot;
$ for i in $list; do cp -v &quot;$i&quot; &quot;${T}${i}&quot;; done
```
最后执行chroot命令告诉操作系统我们将使用$HOME/test目录作为/bin/bash进程的根目录
```
$ chroot $HOME/test /bin/bash
```
这时,你如果执行&quot;ls /&quot;,就会看到,它返回的都是$HOME/test目录下面的内容而不是宿主机的内容。
更重要的是对于被chroot的进程来说它并不会感受到自己的根目录已经被“修改”成$HOME/test了。
这种视图被修改的原理是不是跟我之前介绍的Linux Namespace很类似呢
没错!
**实际上Mount Namespace正是基于对chroot的不断改良才被发明出来的它也是Linux操作系统里的第一个Namespace。**
当然为了能够让容器的这个根目录看起来更“真实”我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统比如Ubuntu16.04的ISO。这样在容器启动之后我们在容器里通过执行&quot;ls /&quot;查看根目录下的内容就是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 TorvaldsLinux之父一直不让AuFS进入Linux内核主干的缘故所以我们只能在Ubuntu和Debian这些发行版上使用它。
</li>
对于AuFS来说它最关键的目录结构在/var/lib/docker路径下的diff目录
```
/var/lib/docker/aufs/diff/&lt;layer_id&gt;
```
**而这个目录的作用,我们不妨通过一个具体例子来看一下。**
现在,我们启动一个容器,比如:
```
$ docker run -d ubuntu:latest sleep 3600
```
这时候Docker就会从Docker Hub上拉取一个Ubuntu镜像到本地。
这个所谓的“镜像”实际上就是一个Ubuntu操作系统的rootfs它的内容是Ubuntu操作系统的所有文件和目录。不过与之前我们讲述的rootfs稍微不同的是Docker镜像使用的rootfs往往由多个“层”组成
```
$ docker image inspect ubuntu:latest
...
&quot;RootFS&quot;: {
&quot;Type&quot;: &quot;layers&quot;,
&quot;Layers&quot;: [
&quot;sha256:f49017d4d5ce9c0f544c...&quot;,
&quot;sha256:8f2b771487e9d6354080...&quot;,
&quot;sha256:ccd4d61916aaa2159429...&quot;,
&quot;sha256:c01d74f99de40e097c73...&quot;,
&quot;sha256:268a067217b5fe78e000...&quot;
]
}
```
可以看到这个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)查看大图。

View 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 DockerHyper-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 = &quot;&lt;h3&gt;Hello {name}!&lt;/h3&gt;&quot; \
&quot;&lt;b&gt;Hostname:&lt;/b&gt; {hostname}&lt;br/&gt;&quot;
return html.format(name=os.getenv(&quot;NAME&quot;, &quot;world&quot;), hostname=socket.gethostname())
if __name__ == &quot;__main__&quot;:
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 [&quot;python&quot;, &quot;app.py&quot;]
```
通过这个文件的内容,你可以看到**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 &lt;image&gt; 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 &quot;python app.py&quot; 10 seconds ago
```
同时,我已经通过-p 4000:80告诉了Docker请把容器内的80端口映射在宿主机的4000端口上。
这样做的目的是只要访问宿主机的4000端口我就可以看到容器里应用返回的结果
```
$ curl http://localhost:4000
&lt;h3&gt;Hello World!&lt;/h3&gt;&lt;b&gt;Hostname:&lt;/b&gt; 4ddf4638572d&lt;br/&gt;
```
否则我就得先用docker inspect命令查看容器的IP地址然后访问“http://&lt;容器IP地址&gt;: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 -&gt; cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -&gt; ipc:[4026532278]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -&gt; mnt:[4026532276]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -&gt; net:[4026532281]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -&gt; pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -&gt; pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -&gt; user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -&gt; uts:[4026532277]
```
可以看到一个进程的每种Linux Namespace都在它对应的/proc/[进程号]/ns下有一个对应的虚拟文件并且链接到一个真实的Namespace文件上。
有了这样一个可以“hold住”所有Linux Namespace的文件我们就可以对Namespace做一些很有意义事情了比如加入到一个已经存在的Namespace当中。
**这也就意味着一个进程可以选择加入到某个进程已有的Namespace当中从而达到“进入”这个进程所在容器的目的这正是docker exec的实现原理。**
而这个操作所依赖的乃是一个名叫setns()的Linux系统调用。它的调用方法我可以用如下一段小程序为你说明
```
#define _GNU_SOURCE
#include &lt;fcntl.h&gt;
#include &lt;sched.h&gt;
#include &lt;unistd.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;stdio.h&gt;
#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(&quot;setns&quot;);
}
execvp(argv[2], &amp;argv[2]);
errExit(&quot;execvp&quot;);
}
```
这段代码功能非常简单它一共接收两个参数第一个参数是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 -&gt; net:[4026532281]
$ ls -l /proc/25686/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -&gt; 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>
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View 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项目打交道**。而这个交互所依赖的是一个称作CRIContainer Runtime Interface的远程调用接口这个接口定义了容器运行时的各项核心操作比如启动一个容器需要的所有参数。
这也是为何Kubernetes项目并不关心你部署的是什么容器运行时、使用的什么技术实现只要你的这个容器运行时能够运行标准的容器镜像它就可以通过实现CRI接入到Kubernetes项目当中。
而具体的容器运行时比如Docker项目则一般通过OCI这个容器运行时规范同底层的Linux操作系统进行交互把CRI请求翻译成对Linux操作系统的调用操作Linux Namespace和Cgroups等
**此外kubelet还通过gRPC协议同一个叫作Device Plugin的插件进行交互**。这个插件是Kubernetes项目用来管理GPU等宿主机物理设备的主要组件也是基于Kubernetes项目进行机器学习训练、高性能作业支持等工作必须关注的功能。
而**kubelet的另一个重要功能则是调用网络插件和存储插件为容器配置网络和持久化存储**。这两个插件与kubelet进行交互的接口分别是CNIContainer Networking Interface和CSIContainer 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的副本数是2replicas=2
然后执行:
```
$ kubectl create -f nginx-deployment.yaml
```
这样两个完全相同的Nginx容器副本就被启动了。
不过这么看来做同样一件事情Kubernetes用户要做的工作也不少嘛。
别急在后续的讲解中我会陆续介绍Kubernetes项目这种“声明式API”的种种好处以及基于它实现的强大的编排能力。
拭目以待吧。
## 总结
首先,我和你一起回顾了容器的核心知识,说明了容器其实可以分为两个部分:容器运行时和容器镜像。
然后我重点介绍了Kubernetes项目的架构详细讲解了它如何使用“声明式API”来描述容器化业务和容器间关系的设计思想。
实际上过去很多的集群管理项目比如Yarn、Mesos以及Swarm所擅长的都是把一个容器按照某种规则放置在某个最佳节点上运行起来。这种功能我们称为“调度”。
而Kubernetes项目所擅长的是按照用户的意愿和整个系统的规则完全自动化地处理好容器之间的各种关系。**这种功能,就是我们经常听到的一个概念:编排。**
所以说Kubernetes项目的本质是为用户提供一个具有普遍意义的容器编排工具。
不过更重要的是Kubernetes项目为用户提供的不仅限于一个工具。它真正的价值乃在于提供了一套基于容器构建分布式系统的基础依赖。关于这一点相信你会在今后的学习中体会越来越深。
## 思考题
<li>
这今天的分享中我介绍了Kubernetes项目的架构。你是否了解了Docker SwarmSwarmKit项目和Kubernetes在架构上和使用方法上的异同呢
</li>
<li>
在Kubernetes之前很多项目都没办法管理“有状态”的容器不能从一台宿主机“迁移”到另一台宿主机上的容器。你是否能列举出阻止这种“迁移”的原因都有哪些呢
</li>
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。