孙科 分布式实验室
截至上周,Docker一直处于上升势头,这其中也涉及到一些实质性问题。对于当今许多产品的用户而言,使用Docker却是弊大于利的。Docker漂亮地吸引了开发者,尤其是在开发、测试以及CI环境下,但是它也确实破坏了生产。
在DockerCon 2015的『Docker in Production』一主题中,我想开诚布公地谈谈Docker面临的挑战,以使它能适合产品的用例。这里提到的都不是新问题;它们均在GitHub中以某种形式存在着。其中绝大多数问题,我都已经在会议中提及,或已和Docker团队讨论过。本文不会明确指出哪些问题已经不复存在:例如,新的注册机制改进了旧版本的很多缺板。
虽然许多看似存在问题的地方不会在文中提及,但是我个人坚信接下来提到的问题是至关重要的,需要在短时间内修复,以使得更多的组织能够在产品中使用容器。在Shopify,我们大规模地使核心平台运行于容器之上,现已超过一年时间,因此这里的列表无疑更倾向于本人使用Docker的经验。对于像Docker这样发展如此迅速的技术,保证一切通用基本是不可能的。因此,当你发现错误时,还望能够伸出援手。
为大型应用搭建容器镜像依旧是一项挑战。如果我们依靠容器镜像来进行测试、CI和应急部署,那么我们便须在1分钟内准备好这个镜像。Dockerfile的存在使其这对于大型应用而言基本是不可能的完成的事。纵使它易于使用,但是它的抽象层次太高,以至于不能应对复杂的用例:
为重量级的面向应用的依赖提供带外(out-of-band)缓存
在构建期间的访问密钥,避免提交到镜像
对于最终镜像层级的完全控制
层级构建的并行化
绝大多数人不需要这些特性,但是对于大型应用而言,它们却是快速构建的前提条件。诸如Chef和Puppet之类的广泛应用的配置管理软件,对于镜像构建而言依旧过于笨拙。我敢打赌,未来5年内当前这种形式的系统将不复存在,并为容器取代。然而,许多应用依靠它们实现供应、部署和业务流。
Dockerfile并不能实实在在地应对当前由配置管理所管控的复杂度,但在某些地方这种复杂度确实需要被控制。在Shopify,我们最终通过Docker的commit API重新实现了我们的系统。这是痛苦的,我希望大家不会经历这个“痛苦”并渴望摒弃它,但目前我们真的只能自己来排除困难。只是很少有人会如此大篇幅地来讨论面向产品的容器。
很难说这个领域将出现什么,并且当前在这个领域也并未进行多少探索(其中一个例子是dockramp,另一个是packer)。未来Docker引擎将继续改进,以拆分客户端(Dockerfile)的构建原语(如添加文件、设置入口等)。合并到1.8版本的工作将会使得上述工作变得更加简单,它将向配置管理供应商、爱好者及公司开放一块试验田。考虑到供应系统的历史,认为一个标准会解决这些问题是不切实际的,正如运行时那样。同样,关于可伸缩镜像构建这一特性当前也是相当不明确的。据我所知,并没有人在积极地倡导,更糟糕的是,它已经有一年以上没变化了。
每个Docker的主要部署人员最终都会写一个垃圾回收器(以下简称GC),来移除主机中的旧镜像。这用到了各种方法,例如移除x天之前的镜像,并确保至多只有y个镜像存在主机中。Spotify最近也将它们开源了。很早之前,我们也一样实现了我们自己的GC。
我能够理解为GC实现一个可预见的UI有多难,但这确实是最需要的东西。大多数人发现他们只是在其产品容器的空间不足时,才偶尔需要GC。最后,你也将会遇到这个问题:传到Docker Registry的相同镜像溢出为大镜像[译者:复数],好在,这个问题已经在分布式规划中提出。
在1.x的发行版中Docker引擎关注的是稳定性。Pre-1.5则是投入了少量工作来降低产品接入使用的门槛。开放容器生态模型是Docker成功所必须的,并且他们害怕破坏这个模型。
当前,Docker迭代速度总是得忍受如下问题:每次UX变更都要经历过多流程。Docker 1.7则是发布了网络和存储插件牵头的实验性版本。这些特性被明确标注为“不适用于产品”并且随时可能会被移除或修改。
对于那些已经押注Docker的公司而言,这是一则好消息,因为这将使得核心团队对于新特性完成更快速的迭代,且无需关心“最佳实践”中的向后兼容性。对于公司而言,修改Docker内核依旧很困难,因为这需要fork或是取得上游的采用,前者风险高,维护负担重,后者对于那些有趣的补丁而言则显得耗时费力。
随着1.7版插件模式的公布,处理这类问题的策略变得明晰:使每个可选组件都是可插拔的。这种“水果拼盘”式的哲学于DockerCon Europe 2014首次引入,尽管还很模糊。在6月的DockerCon,很高兴地得知这个策略由作为第一纵列的Plumbing团队全权文档化(这对我是最重要的,因为海象是我最喜爱的海洋哺乳动物,而Plumbing正是以它作为吉祥物)[译者:呵呵]。正如过去2年一样,它现在依旧是一个痛处,即使未来终于看起来有点盼头了。
日志是本可以早点改进而获利的领域之一。它很难成为一个有吸引力的问题,但是它绝对是一个普遍性的问题。当前并没有优秀且通用的解决方案。这里充斥着各种方案:tail日志文件,记录到容器内,记录到挂载的主机,记录到主机的syslog,通过某些东西暴露日志(如fluentd),通过应用记录到网络,或者是记录到一个文件并由另一个进程将日志发送到Kafka。
Docker1.6支持合并到内核中的日志驱动;然而,驱动被内核采纳是很不容易的。1.7版本中,进程外插件被实验性支持,但是,令人失望的是,它并未附带日志驱动。我认为这将是1.8版本的计划,但并未找到任何官方记录。就这点来看,供应商未来将能够编写它们自己的日志驱动。或许,社区内分享将会变得微不足道,而且大型应用将不再需要定制解决方案。
在“频繁而遍存”这一类目中,我们同样找到了密钥。大多数人在迁移容器时,依赖配置管理来安全地提供机器上的密钥;然而,因为密钥而一直采用同样的在配置管理路径并不太理想。
一个可选方案是通过镜像来散布它们,但这具有安全隐患,并且会使得在开发、持续集成以及生产之间循环镜像变得困难。最纯粹的方案是通过网络获取密钥,保持镜像的文件系统无状态。直到最近,这个领域还不存在什么面向容器的解决方案,但是即将有2款引人注目的密钥代理开源,分别是Vault和Keywhiz。
在Shopify,我们于一年半前开发了ejson来解决这个问题,它被用来管理JSON格式的非对称加密密钥文件;然而,它会对其运行环境做一定的假设,因此和密钥代理相比,显得不是那么通用(如果你感兴趣,可以阅读这篇文章(https://www.shopify.com/technology/26892292-secrets-at-shopify-introducing-ejson))。
Docker依赖于文件系统的写时复制CoW(LWN上关于联合文件系统Union FS的系列文章,它们使CoW成为可能)。这项特性确保当你拥有100个容器时,不需要对应的100倍磁盘空间。每个容器都只是在镜像的顶部创建CoW层,它们只在改变原始镜像的文件时才使用必要的磁盘空间。
好的容器使用者会尽可能避免对容器内部的文件系统产生影响,因为这意味着使容器保持某种状态,是为大忌。这种状态应该被存储在映射到主机的卷或者网络上。此外,层级存储能够在部署时节省空间,因为镜像通常是相似的,它们拥有一些共同的层。在Linux上支持CoW的问题在于它们太“新”了。在Shopify有几百台主机在重负荷下运行,这里有一些经验:
AUFS 曾发现在需要重新挂载时,整个分区被锁住的情况。迟钝且占用大量内存。代码基巨大,难以阅读,似乎这正是它不被上游接受的原因,因此需要一个定制的内核。
BTRFS 因为du和ls不能工作,新的工具集有一定的学习曲线。和AUFS一样,我们同样发现了分区冻结和内核锁死,不管我们如何更新内核版本。当接近磁盘空间上限时,BTRFS的行为变得不可预测,并且当我们拥有1000份CoW层(BTRFS中的子卷)时,BTRFS同样会占用大量内存。
OverlayFS 这在Linux3.18内核中并入,对于我们而言,它现在已经相当稳定和快速了。由于采用在inode间共享页缓存,它占用的内存少了很多。不幸的是,它需要你运行最新的内核,而这些内核往往未被大多数发行版采用,因此通常需要我们自己来编译一个。
所幸的是,对于Docker而言,Overlay很快就会普及了,而默认的AUFS对于产品而言依旧不太安全,根据我们的经验,这种情况在运行大量节点时尤甚。很难说这里需要做些什么,因为大多数发行版并未配备兼容Overlay的内核(Overlay被提议为默认FS,但是由于这里的原因最终被拒了)。虽然Overlay正是此领域的发展方向,不过我们还需等一阵子。
正如Docker依赖前沿的文件系统那样,它同样依赖大量内核的新特性,也就是namespace和cgroup(不是新的但却是最常用的)。这些特性(尤其是命名空间)还不够成熟以在生产中广泛应用。我们偶尔会遇到由于它们所造成的模糊的bug。
在产品中,我们并未开启网络命名空间,因为我们曾经历过一些软锁死的情况,并追踪到它的实现,但没能修复其上游。内存cgroup会使用相当数量的内存,并且我们也收到过外界谓之不可靠的报告。随着容器被越来越频繁地使用,相信大公司应该会率先解决稳定性问题的。
我们曾遇到过的一个顽固的例子就是僵尸进程。一个容器运行在PID命名空间,意味着这个容器内的第一个进程的pid就是1。容器内的init需要有获知子进程结束的特殊能力。当一个进程结束时,它并不会马上从内核的进程数据结构中消失,而是成为一个僵尸进程。这保证了其父进程可以通过wait(2)来检测其是否终止。
然而,如果一个进程没有父进程,那么它的父进程将被设置为init。当这个进程结束时,通过wait(2)获知子进程的终止就成了init的工作,否则这个僵尸进程就永远不会结束。通过这种方式,你便可以通过僵尸进程耗尽内核的进程数据结构。在基于进程的master/worker模型中,这种场景是十分普遍的。
如果一个worker需要调用命令[原文:shell out],这会占用很长世间,master可能会通过SIGKILL终止worker对shell命令的等待(除非你使用进程组并一次性kill整个组,但基本不这么做)。由于命令调用而Fork出来的进程之后会被init继承。当它最终结束时,init需要对其使用wait(2)。Docker引擎可以通过PR_SET_CHILD_SUBREAPER来承认部分容器内的僵尸进程,这在#11529中有描述。
对于容器而言,运行时的安全在某种情况下依旧是一个问题,并且要让其对于产品足够“坚固”是一个经典“鸡与蛋”的安全问题。在我们的例子中,我们并不依靠容器提供任何额外的安全保证。不过,有些用例却这么做了。出于这个原因,大多数供应商依旧在虚拟机上运行容器,这种安全是经过考验过的。
我希望在未来10年内看到虚拟机绝迹,因为操作系统虚拟化赢得了这场“战争”,正如某人曾经在Linux邮件列表中所说:“我曾听到过一种言论:管理程序的存在是操作系统无能的证据”。容器提供了介于虚拟机(硬件级虚拟化)和PaaS(应用层次)之间的完美的中庸之道。
我知道更多对于运行时的改进已经完成,比如系统调用黑名单。围绕镜像的安全也已经引起关注,但是Docker正在通过libtrust和notary改进这个问题,后者是新的分布层的一部分。
在Docker的第一次迭代中,它对于镜像构建、传输和运行时就走了一条捷径。它并不是针对每个问题选取正确的工具,而是选择了一个能够解决所有情况的工具:文件系统层。这种抽象一直暴露到在产品中运行容器。[译者:This abstraction leaks all the way down to running the container in production。大家自己理解下吧。。。]这是一种完全可以接受的最小成本的产品使用主义,但是其中的每个问题本都可以被更加有效地解决:
镜像创建 这项工作可以用有向图表示。它允许识别出可以缓存和并行化的部分,以完成快速的可预测的构建。
镜像传输 它完全可以使用二进制diff,而不是使用镜像层。这是一个已经研究了几十年的课题。分布层和运行时层变得越来越分离,开放了这方面的优化空间。
运行时 应该只是做一个单独的CoW层,而不是再次使用任意的镜像层抽象。如果你正在使用一个联合文件系统,比如AUFS,在第一次读取时,你需要便利一个文件链表来组装成最终的文件,缓慢且完全不必要的。
这个层次模型对于传输而言是一个问题(正如之前所述,对构建也一样)。这意味着,您需要格外关心镜像中的每一层有些什么东西,否则你可能轻易地需要为一个巨大的应用传输100多兆数据。如果在你的数据中心中有大量链接,这并不是什么大问题,但是我如果你希望使用诸如Docker Hub之类的注册服务,这可是在开放网络上传输的。
镜像分布在继续工作着。这里有很多理由促使Docker公司让其变得坚固、安全和迅速。正如构建一样,我希望它能够对插件开放,以形成一个好的解决方案。与构建器相反,如果有诸如bittorrent分布的特殊机制,人们应该能够普遍认可。
这里有意避免提及许多话题,诸如存储、网络、多租户、业务流以及服务发现。Docker现在更需要的是让更多人在生产环境中大规模使用容器。不幸的是,许多公司从一开始就朝着在PaaS的光辉下过分补偿它们的技术栈。这种方式只适用于小规模产品,且计划使用Docker进行绿场部署[译者:从无到有],这种情况下基本不会遭遇产品的模糊性[译者: 联系灰场部署理解]。为了见到Docker在产品中更广泛的应用,我们需要解决上面强调的问题,来支持Docker。
Docker置身于一个有趣的位置,它作为PaaS的接口进行探索,使得应用的网络发现和服务发现不需要关心下层设施。这是一则好消息,因为正如Solomon所说,关于Docker最好的部分便是它让人们就某些事达成共识。我们终于开始赞同除了镜像和运行时之外的东西。
上述的话题,我都已经和Docker公司的好些人讨论过了,并且GitHub Issue也有很多留给他们。这里我所尝试做的只是,为最重要的领域提供一个可选的视角,来降低准入门槛。对于未来,我很兴奋,但是我们依旧要做很多工作来使在产品中运行Docker更加可行。