Docker这两年可谓大红大紫,仿佛一夜之间,街坊邻居茶余饭后都在说Docker,我这也掰扯掰扯Docker那点儿事情,免得不好意思跟人搭讪~~
本文有点儿杂,笔者一直在思考,为什么用Docker,Docker到底能做到什么,不能做到什么,什么样的用法是正确的,围绕着这些,总结了这么一篇文章。
首次发文,目的是为了交流观点,文中认识若有偏差,还请不吝赐教,我始终认为不断交流沟通总结才是进步的最快阶梯。
1. Docker为啥火了?
1.1. 虚拟机的痛点
传统云平台上的虚拟机,为了兼容各种系统,都在宿主机上再虚拟了一层硬件层,业务系统都运行在这层虚拟硬件层之上,意识不到托管主机的存在,并且因为这种虚拟硬件的隔离性,使得虚拟主机操作系统可以不用意识宿主机系统特性而运行,这种特性最大的受益者曾经是陈旧服务器上的老系统,因为硬件老化,这些系统都面临着无法运行的风险,但是虚拟机的出现,使得这种系统可以顺利迁移到新服务器上继续提供服务,大大降低了企业IT投入成本。
曾经,虚拟机就是很多需求的最终解决方案,老系统只要虚拟好硬件就可以继续工作无需更换,新系统在云上申请几个主机(而不用花大价钱去搞几个刀片)就可以运行,还不用担心硬件升级的成本,生活多么美好。
但是,时代变了,需求变了。
如今物联网时代,公众系统都面临动辄上千万客户的冲击,很多系统都需要管理上千台服务器。在这种背景下,性能的弹性扩张,故障的自我愈合,部署的自动化等需求变得极为迫切。(此处可以闭上眼睛)想象一下一台虚拟机镜像部署上千台服务器的盛景,想象一下当用户潮汐涌来时虚拟机那扩张的缓慢景象,再想象一下突然因为一个小需求的修改,大批服务器又要重新规划上线部署的痛苦。如今背景下必须得寻找到一个更合适的方案来应对这些。
1.2. 新的需求
虚拟机的速度,弹性已经很难应对如今快速变化的世界,IT业界必须寻找一个更快,更轻量的方案来应对。
- 部署要快,能够通过几个简单命令就能完成大规模(很多时候都是上千服务器实例)新系统的自动部署。并且这样的部署重启最好是秒级的。
- 弹性扩张要快,平时系统都能稳定运行,到了双11就应对不了直接宕机,这是无法接受的,但是也不能因为一年一度的双11就购买大量的昂贵硬件来应对,用户更希望能够通过客户使用量来进行灵活弹性伸缩,能够在几秒之内就扩张大量的实例来应对使用量潮汐。
- 版本更新或者故障恢复时也要快,并且要尽量少进行手动干预。
- 能够充分利用宿主机资源,这个需求也许没那么强烈,因为就算是有损耗,还可以堆硬件来弥补这样的问题。榨干宿主机资源也许是作为IT人的一个目标。
说了这么多,就是让Docker能够用一个优雅的姿势闪亮登场。
2. 实现Docker的技术基础
虚拟机的劣势自然就是Docker的优势,启动快,便于部署,因为没有虚拟硬件层因此性能损耗小,而运行环境基本上做到了隔离,不会对宿主机造成影响(注意“基本上”这个词,因为共享内核,理论上某一个容器破坏内核搞掉整台宿主机是可以的)。
Docker使用的技术基础,都不是什么新东西,但是Docker用go语言将其捏合到了一起,后面我们讨论Docker各种效率问题的时候也会发现,其实我们讨论的更多的是Linux本身的特性,Docker只是将这些特性组合到了一起而已。
使用的核心几个技术都在下面的图中:
2.1. 名字空间(namespace)技术
洪荒时代(其实是1979年,也没那么久远),Unix出现了一种技术,叫做chroot(change root directory),可以在UNIX系统上创造一个封闭的目录空间,程序就直接在这个封闭目录空间中运行而不影响系统整体,是系统环境隔离的雏形。这技术非常成熟,我最近一次用还是在chroot空间里面自己编译LFS系统,但是其沉寂了很久没有继续发展,也许是没有需求就没有推动动力吧。
2002年,从Linux kernel 2.4.19开始,linux加入了一个新的技术,叫做名字空间(namespace),这项技术提供了更丰富的隔离方案,不仅仅能够进行目录隔离了,连PID,UID,UTS(host),NET,IPC,MNT都能够进行隔离。下面这个表格可以看到如今的Linux内核都支持那些隔离内容,基本上已经能够满足一般应用的隔离需要了。
分类 | 系统调用参数 | 相关内核版本 |
---|---|---|
Mount namespaces | CLONE_NEWNS | Linux 2.4.19 |
IPC namespaces | CLONE_NEWIPC | linux 2.6.19 |
UTS namespaces | CLONE_NEWUTS | Linux 2.6.19 |
PID namespaces | CLONE_NEWPID | Linux 2.6.24 |
Network namespaces | CLONE_NEWNET | 始于Linux 2.6.24 完成于 Linux 2.6.29 |
User namespaces | CLONE_NEWUSER | 始于 Linux 2.6.23 完成于 Linux 3.8 |
更详细的内容可以参照这个:Namespaces in operation
2.2. Cgroup(Control Group)
隔离了进程的运行环境,但是各个进程的资源还是共享的,一个进程如果消耗资源太多,别的进程就活不下去了,这是无法接受的,虚拟机的资源隔离就做的很好,各个虚拟机就按照事先分配好的资源,在其配额内运行,互不打扰。
Cgroup的详细信息可以参照:Using Control Groups
cgroup的基本原理如下图,如果想控制某一个进程,只要建立好cgroup文件,然后将相应的进程ID放进去就可以了。
作为Docker使用者,只要了解其能够控制那些内容就可以了:
分类 | 系统调用参数 |
---|---|
cpu | 指定CPU时间配额,这是一个相对的概念,例如如果两个容器都被分配了50,那么这两个容器应该平分CPU时间,也就是50%,如果再加一个是100,那之前两个容器只能各分到25% |
cpuset | 在多核系统中,指定分配哪几个CPU核心给该组下的进程使用 |
blkio | 用于分配磁盘IO速度配额 |
memory | 用于分配内存限额,此处一定要小心,如果超出限额,Linux会杀死这个进程(报Out Of Memory错误) |
net_cls | 用于分配网络流量配额 |
device | 可以允许或者拒绝cgroup中的进程访问设备 |
freezer | 用于挂起和恢复cgroup中的进程 |
2.3. Docker镜像(Docker image)存储技术
话不多说先扔个图上来,没看过这图的先盯着这个图看上一分钟:
在Linux世界,提到image这个词儿,第一个反应想必是LiveCD,通过一张光盘,就可以引导系统,甚至可以在系统上做修改,并且这样的修改不会反映到LiveCD中,而保存在别的什么地方,下一次你再启动这个LiveCD,之前的修改还会看到。
神奇吗?Docker镜像/容器存储的原理和这个差不多,先创建一个基础镜像层,然后将其他定制化修改也按照层的形式一层层的叠加,最后就成为了最终的镜像,读取这个镜像和平时Linux mount 一个普通镜像没有任何分别,特别的是,容器在启动的时候,为了支持写操作,又在上面加了一个读写层。所有被修改的文件的内容都会保存在这里。底层的镜像层就会被多个容器所共享,可供反复读取与启动。
因为历史原因,Docker并没有提供统一的存储方案,而是用插件驱动形式提供了好几种存储方案,包括AUFS,DeviceMapper,OverlayFS,Overlay2等等(还有几个酱油方案),下面挑几个主流方案讲讲。
1. AUFS(advanced multi-layered unification filesystem):
要说AUFS,我认为它不算一个文件系统,被称作一个多层文件组织方式也许更合适一些,可以在同一个文件夹下面进行层层叠加,对于同样的文件,选取其中一个文件呈现在用户面前,这实在是太符合Docker的设计理念了,自然而然就成为Docker长久以来默认的文件系统。如果你用Ubuntu/Debian,默认就会用到它,但是Linux Kernel团队不喜欢它,AUFS作者也放弃加入内核的努力了,所以在其他版本,尤其是Redhat系列版本上,都不会出现它的身影。
2. DeviceMapper:
这技术有点儿意思,以前存储设备可以挂到各个文件夹下面使用,但是各个设备资源不能互通,比如说,有两个1T硬盘,但是想存1.5T的内容,这事儿以前是没得搞的,你总不能把1.5T的文件中间一刀切开然后分别存储吧,但是DeviceMapper技术解决了这个问题,它把两个设备做成一个虚拟设备,然后系统访问这个虚拟设备就可以了,不用关心下面存储调度的细节。更牛逼的是,这个虚拟设备可以混合虚拟设备,成为了一个递归的结构,其理论上可以无限扩展其存储。我在Docker里面看到这个技术的时候,我的第一反应是,这玩意跟Docker镜像分层技术有什么关系?为什么要用这个技术来做一个实现?这就要说Linux kernel团队了,AUFS他们不喜欢,但是他们很喜欢DeviceMapper,把它做进了Kernel Mainline里面,为了保证兼容性和可维护性,Docker团队做了一个折衷,使用DeviceMapper的Thin Provisioning Snapshot技术,来保证对于Redhat系列Linux的兼容。原理图就是下面这个:
只是为了兼容性硬贴上去的技术,效率能好到哪里去啊,所以这项技术在Docker世界里面被黑就是很正常的事情了,不推荐使用。
3. OverlayFS:
AUFS的层次比较复杂,而且兼容性存在问题;DeviceMapper倒是血统正了,但是跟分层技术风马牛不相及,并且IO速度被人黑成了碳。OverLayFS天生就分成了两层,一个Upper层,还有一个Lower层,这大大简化了层次结构,从Linux Kernel 3.18开始支持这种格式,所以它也是一个正统血统的解决方案,兼容性没有问题,性能也不差,因此推荐使用。
几个小话题:
- Docker commit 命令是如何实现的?自然是把读写层固化,直接做成镜像层的一部分了而已。
- 磁盘IO效率问题其实跟Docker本身关系不大了,那完全是镜像文件系统和读写层驱动来决定的,后面也会提到。
- Linux发行版真是多如牛毛,但是万变不离其宗,基本结构都是 内核 + 包管理器 + 周边运行环境,特别是内核,都可以互换,也就是说,用CentOS的内核,给加上Debian的deb包,一样会跑的溜的飞起,DockerHub上的各种发行版的Docker镜像,就是基于这种原理做出来的,而且都可以互换运行。
3. Docker的效率
提到虚拟化,第一反应肯定是效率问题,Docker真的快吗?我们从几个维度来讨论一下Docker容器在性能上的表现如何,以及为什么它会这样。
3.1. CPU效率
通过上面的讨论,我们知道,Docker并没有对于CPU有任何类似于虚拟机一样的虚拟指令化的操作,Docker用的就是实际的CPU来进行计算,仅仅是用cgroup进行了一点儿隔离而已,因此在这个维度,CPU的损耗几乎可以忽略不计,Docker里面的进程和Native的进程没任何区别,事实上各种Performance报告也证实了这一观点,IBM那份儿著名的报告里面,Docker的CPU效率和Navite进程的效率相差无几。
参考:效率比对图
自然的,Docker也提供了参数可以限制其CPU资源占用率,可以按照实际情况进行分配,管理方法和管理Navite进程一样。
3.2. 内存效率
内存这个维度的情况和CPU类似,Docker并没有任何内存页虚拟映射等操作,都是直接向内核申请内存并直接进行读写,因此,此处的性能也和Navite进程无异,不再赘述。
参考:效率比对图
同样的,Docker也提供了参数来限制每个容器的内存使用量。
3.3. 存储效率
关于存储话就有点儿长,这要分几条来进行讨论。
1. AUFS:
AUFS不算一个文件系统,它必须跟后面的设备读写驱动一起工作才可以(比如ext4, xfs )。因此,对于新文件的读写,其实说的就是ext4和xfs的性能,这跟普通的Native程序读写没有太大的区别。
2. DeviceMapper:
之前说过,这种技术本质上是一种设备管理方案。他压根就没有分层的概念,而且默认的读写方式是loop-lvm方式,IO非常差,必须更改成Direct-lvm才可以,有这个大坑我觉得这种方案不用也罢,就算是在Redhat系列上也不用它。
3. OverlayFS:
这个解决方案是目前平衡性最好的解决方案。虽然因为inode会被耗尽的bug,它被黑了很久,但是,无论从血统上来讲,还是其本身的技术特征与Docker的契合度来讲,它是平衡性比较好的解决方案。建议在生产环境中使用它。它的读写效率也是跟读写文件系统的驱动有关,跟它本身没有太大的关系。
4. Overlay2:
这是下一代的解决方案了,其设计理念天然解决了inode消耗的问题,但是还处于试验阶段,是将来解决Docker存储问题的希望。
5. Volume挂载:
这是容器内外映射的一种方式,仅仅调用了linux标准的 mount 功能,因此读写效率和Native的mount之后读写没有区别。
综上,在创建新文件,读写新文件情况下,AUFS,OverlayFS都和Native效率差不多,DeviceMapper必须要管理裸设备才能让自己的IO跟上,DeviceMapper(loop-lvm)的IO效率最差。
这幅图是官方推荐的各种解决方案的概要,以供参考:
3.4. 网络效率
这也是一个值得细细讨论的问题,也分几种情况说说。
1. HOST模式:
我们曾经说过,Linux内核的Namespace技术可以个Docker提供一个独立的网络栈来使用,但是HOST不同,它直接和宿主机共享网络栈,简而言之,你如果打算追求效率的话,那就来用HOST模式吧,没有任何效率损失,其共享的程度甚至达到了连端口都会冲突,因此,在带来效率的同时,还要注意这部分的冲突问题。
2. Bridge模式:
Docker在宿主机虚拟了一个网卡叫做Docker0,所有和外部的通信都通过Docker0来进行转发,这样就达到了内部网络栈的一个独立性和可访问性,如下图:
一个数据包要走出去或者走进来,层次增加了不止一个,既有NAT,又有Iptable,效率不差才怪,事实也确实证明了这一点。如下图:
和Native相比,响应速度损失不止一倍,比KVM还差。
3. Overlay网络模式:
之前的两种模式仅仅是在一个主机内部,但是我们的使用场景通常都是跨主机的,服务之间要通过跨主机进行通信,这通常的做法是:
1. 上一个服务注册发现中心,比如Docker用的consul
2. 每台服务主机上都加一个agent,所有跨主机通信,都走这个agent,用UDP进行转发,可以参考下面的图。
在两侧都搞NAT!都要走虚拟网卡!之前已经慢了很多了,这次要慢多少,自己可以计算一下。当然,这不是说Overlay网络不能用,事实上生产环境也用了很多,但是用之前一定要知道要付出什么代价,在代价与收获之间做出权衡。
3.5. 共享内核参数问题
原则上,容器之间共享内核,宿主机内核的参数调整自然会影响在其上运行的所有容器,但是从Docker 1.12开始,这个情况有了改变,为了能够让各个容器能有限的进行一些内核参数的调整,特别是针对TCP/IP协议栈(net.*)能够进行参数调整,docker run 增加了 sysctl 参数,允许用户能够修改适用于namespace隔离的内核参数。如下:
类别 | 可配置内核参数 |
---|---|
IPC Namespace | kernel.msgmax, kernel.msgmnb, kernel.msgmni, kernel.sem, kernel.shmall, kernel.shmmax, kernel.shmmni, kernel.shm_rmid_forced Sysctls beginning with fs.mqueue.* |
Network Namespace | Sysctls beginning with net.* |
但是这毕竟有限,更多的参数只能在宿主机范围内设定,不能在容器里面做定制修改。
4. 使用Docker的几点建议
讲了这么多,我想关于Docker应该有一点儿大致的印象了吧。最后再罗嗦几点。
4.1. 业务服务无状态化
Docker最大的特点就是轻量,启动速度快,扩张快,部署快,因此具体实现业务的服务,都应该放在Docker里面进行部署,但是一定要强调,并且一定要保证无状态化,这是快速扩张,自主更新的基础。
无状态化包括:
1. 没有Session
2. 磁盘中没有任何中间结果文件
3. 内存中没有任何处理中间结果,状态
比较现实的替代方案是Redis,NFS文件共享等等。
4.2. 使用服务名访问其他容器
一个生产环境主机肯定不止一台,服务肯定也是很多,因此跨主机访问容器不可避免,尤其时下Rancher,kubernetes这些都有资源调度机制,能够对各个服务的容器进行动态迁移,因此使用IP地址直接进行访问显然是不现实的,幸好这种带有资源调度的套件都有各自的DNS查询方式,可以通过服务名直接访问到后面的容器,因此,将有关联的业务服务都放在一个网络里面去吧,事半功倍,保持最大的灵活性。
4.3. 针对某些对内核参数有要求的服务,采取独立的资源调度机制
Docker容器是共享内核的,而内核很多参数都做不到容器隔离,因此,如果宿主机内核更改了参数,会对其上所有容器造成影响,某些参数对某些服务很有效,对其他服务就是副作用,针对这种情况,推荐采取特化的方式,让那样的服务独立部署在特定调整好内核参数的宿主机上。
4.4. 针对内核版本敏感的服务,配给特定版本内核的服务器
总会有这样的服务存在,所以在将服务迁移到Docker之前要仔细的检查内核兼容性问题,确保服务能够正确运行,时刻谨记,内核是被共享的,Docker不是完全隔离的!
4.5. 计算好每个容器的性能容量,并规定上限
虚拟机时代,不规划好资源,虚拟机根本无法启动,所以这根本不是一个问题,但是到了容器时代,就算是不规定上限,Docker也能跑,但是因为所有的容器都共享一个宿主机的资源,因此容器互相挤占,某些容器无端占用资源(内存泄漏之类),就会把别的容器挤死。所以,还是事先规划好容量吧,至少把资源侵占问题能够控制在一个容器之内。
4.6. 基础服务需要容器化吗?
这里留了一个问号,也许你有很大的冲动恨不得把所有的服务,包括nginx,mysql,redis都给容器化了,这些服务都是有状态的,非常难迁移,做动态扩展,并且在网络IO上,在磁盘IO上,Docker都有各种各样的问题。所以,对于这些服务,一定要谨慎,好好的检讨,到底要不要容器化。
4.7. 为了能够快速扩张,事先在宿主机上加载基础镜像
Docker的快速启动让人津津乐道,但是Docker容器在启动的时候需要先将Docker镜像拉下来,然后再启动,如果镜像比较大,那网络传输时间消耗也是不可小视的,因此,预先为各个宿主机先传输好镜像吧,这样“有备无患”,需要的时候,容器马上就能启动并投入工作。
4.8. 优化你的Docker镜像?
这又是一个问号,Docker镜像是分层的,在Dockerfile里面多写命令,只要这些命令涉及到了修改文件,那么一条指令就是一层,层数多了自然就会有影响读写效率的担心,但是这个证据不足,Docker团队也一直无视这个问题,所以我只是在这里提一下,尽量用一个命令去做好镜像的配置,减少镜像层数,有备无患吧。
4.9. 关于安全
说到底,容器之间是共享内核的,一个容器如果是恶意容器,那么会影响整台服务器,而且在容器内部确实能做得到,比突破虚拟机然后控制宿主机容易的多的多得多。将“它是共享内核的”时刻记在脑海里面是非常重要的。
1. 不要使用来源不明的容器,尽量使用DockerHub上的官方出品的容器,使用前要对Dockerfile做Review。
2. 在容器中不要用root用户去运行你的应用,以防意外。
3. Docker可以限定容器的权限和能力,具体参照在这里(Docker容器权限能力), 去最小化它,但是要注意这可能会引起容器运行的不稳定。
4. Docker官方推了一个安全检查程序,可以时不时的去检查一下。