读Google著名的分布式设计论文-从Borg到Kubernetes的演进
Kubernetes是目前炙手可热的云计算管理基础设施平台,并且是一个诞生不久就迅速鉴定了业界实际标准地位的一个容器编排平台。
Google于2016年底发表了这篇著名的Borg, Omega, and Kubernetes的论文,几年来已经被引用无数。 深入学习这篇论文,可以学习到很多分布式系统设计的宝贵经验和知识最终在工作和学习中为我所用。
云计算平台发展的简单回顾
云计算技术从概念到落地已经经历了超过十年的打磨,从早期的CloudFoundry到中间热闹非凡的开源项目OpenStack,云计算的应用一度处于叫好不叫座的尴尬境地。 随后Docker项目的横空出世一下子简化了应用服务部署上云的步伐,从而在一段时间内出现了容器云管理平台百家争鸣的局面。
然而直到两三年前的容器云管理市场上,还有DockerSwarm、MeSOS、Kubernetes等几个大的玩家在进行着激烈的竞争,企业做技术选项的时候还要考虑它们之间的优缺点,做仔细的权衡和技术决策。随着2017年底Docker母公司宣布自己的DockerSwarm工具也拥抱和支持Kubernetes开始, 正常竞争就变得毫无悬念了;Kubernetes毫无疑问笑到了最后。
Kubernetes为什取得最后的成功
略去商业和管理上的原因不谈,来自Google的这篇论文帮我们讲述了深层次上的技术原因:原来Kubernetes从来不是一个表面上看起来很年轻稚嫩的开源项目。 一切成功的背后有着Google几十年来分布式系统开发、部署、管理经验的演进在助推。
在Google内部,其分布式大规模应用的部署和管理平台的开发经历了三代,每一代项目都是在前一个的基础上做了进一步的改进
- Borg是第一代分布式基础设施管理系统,可以管理长期运行的服务和短时间运行的批处理作业任务,但是两者是采用两个内部的组件来完成的。这是一个早在Google成立之初就开始存在并继续完善的项目,传言很多牛人在进入Google之前本来都以开发开源的好用的工具为乐,一旦被Google收入囊中之后就因为Borg太好用了而不再继续投入开源事业;这也侧面表明了Borg的强大。
- Omega是基于Borg的第二代产品
- Kubernetes则是今天我们看到的站在Omega项目肩上的产品
Borg的开发经验
在开发Borg的过程中,Google认识到大部分情况下很多应用程序实际在运行时占用的资源比它们声明的要少得多。而管理平台需要做的事情就是尽力地共享物理的机器,提高资源的使用效率。
从一开始,Google就奔着使用已有的Linux内核中的命名空间的概念做轻量级的资源隔离,从而有效地避开了虚拟机带来的巨大的性能开销。 在此过程中,Google本身也贡献了大量的代码到Linux内核相关cgroups控制的子系统中,他的出发点其实还是为了服务于自己需要面临的隔离不同的应用程序负载的目的。
从这个角度看,Kubernetes的核心概念甚至比外面看到的开源出来的Docker还要早的多,只是Google密而不发而已。也许DockerSwarm/MeSos之类的平台才算作起步较晚。
Omega
随着Google内部的应用程序越来越多地被部署到Borg上,各种复杂、使用不同编程语言、结构各异的应用程序对这一基础设施平台提出了越来越高的要求, 而Borg一直被期望于完成包括服务发现、负载均衡、自动扩展、机器生存周期管理、资源分配额度控制等任务。 为了提升Borg中发现的一些问题,Google在吸取Borg经验的基础上开发了Omega系统。
Omega的开发并没有复用Borg的代码但是吸取了Borg的设计思想。 它将集群的状态信息保存在一个基于Paxos算法控制的的一个中心化的数据库中,然后系统的各个部分的控制模块都可以来访问这个中心化的数据存储。当有冲突发生的时候,Omega使用乐观并发锁来协调访问冲突。
这样做的好处是系统被解耦成为若干个互相不干扰的子部分,尤其是多个控制调度模块的想法是继承了Borg的成功经验。
Kubernetes
Kubernetes是外部容器技术已经变得流行之后,Google用云时代的Go语言重写之后开源给社区使用的第三代产品。 中心化的数据被一个可以被多个调度模块共享的状态数据库锁代替(来自于etcd的贡献),而各个分布式的控制器则通过云时代的REST API的方式来访问对象的状态。
Kubernetes的重心是面向容器云时代的开发者,他们需要编写能够在集群中运行的基于容器的应用程序。 因此它的设计目标是要尽量简化复杂分布式系统的部署和管理,同时复用前辈产品中的基于容器的良好的资源使用率。
设计经验和知识
论文中总结了一下几个方面的经验和知识:
容器技术
容器的概念无疑是最核心的技术;而容器概念本身却是一个很古老的技术,从早期的基于chroot
的单纯的Unix根文件系统隔离技术,
到FreeBSD系统提出的Jails的概念开始为进程ID加上名字空间隔离,
再到后来的Solaris系统实现了完整的网络空间隔离,提出了Solaris Zone的概念,这些最底层的核心技术的演进其实是非常缓慢的。某种程度上说这些底层技术才是计算机科学最核心的领域知识。
Linux容器技术其实是基于以上这些既有的技术逐步发展壮大起来的。 它本身提供了一系列基于CPU和内存资源的隔离控制手段,并在内核层面防止一个进程干扰到另外一个进程的执行,Borg利用了这些技术然而做的并不是特别完善。 比如对于一些Linux内核不能控制和隔离的资源,容器技术就无能为了了,这样的例子包括CPU的L3缓存和内存带宽这些,就是虚拟机技术可以对其管理的对象而提供的隔离和保护,而容器却毫无办法。
现代容器技术处理提供资源隔离外,另外还有一个很重要的机制是实现应用程序依赖文件的打包和部署。Google内部使用了一个叫MPM的工具来构建和部署。这一技术本身和容器的关系就如同docker容器和docker image之间的关系。
面向应用程序的基础设施
Google越来越深地意识到,容器化技术的最大的益处早就超越了单纯的提高硬件资源使用率的范畴;更大的变化在于数据中心运营的范畴已经从以机器为中心迁移到了以应用程序为中心。 容器封装了应用程序所依赖的程序开发环境,从而使得程序开发人员可以无需关注繁琐的操作系统和机器的细节。 同时因为设计良好的容器和容器镜像包裹了应用程序,管理容器也就等同于应用程序管理,而不再是管理机器本身。
这一管理中心的转移极大地提高了应用程序部署和管理的效率。
应用开发环境
最初内核提供的类似于cgroup/chroot/namespace这样的机制的目的是想保护应用程序运行时候的资源使用,避免相邻的应用程序(他们共享一个内核)带来的噪音和干扰。 随着这些隔离能力和容器镜像被结合在一起,甚至使用不同操作系统(可能是异构的系统)的应用程序也可以在同样的内核里面被调度执行。
这种镜像和实际运行的操作系统的解耦使得为开发环境和实际生产环境部署同样的运行环境成为可能;从而又极大地提高了部署的可靠性,经由减少环境的不一致而加速了应用程序开发的步伐。
这种做法能够成功的关键在于使用一个具有良好隔离性的容器镜像,保证该镜像可以封装应用程序所需要的几乎所有的依赖; 然后唯一剩下的依赖是容器和宿主内核之间的系统调用接口。 因为这些系统接口是非常稳定很少变更的,容器镜像的可移植性得到了极大的提高。
当然这样做也不是万无一失,应用程序仍然可能通过调用一些没有被隔离的调用而逃逸到宿主内核空间中,譬如使用socket套接字的选项,读取/proc
文件系统,或者使用底层的ioctl
接口等。
好在正在进行中的开放容器接口项目OCI可以逐渐理清这些灰色地带,逐步提高容器的隔离能力。
容器镜像良好的隔离性至关重要;而Borg的做法是强迫所有的二进制程序都使用静态编译的方法来严格地保证程序库的一致性。 这样能工作良好的基本前提是Google内部又一个巨大的单一代码库。 即便如此,Borg的容器镜像还是没有达到足够好的紧凑性:
- 基础镜像是按照每个机器一份的方式来安装的,而不是按照每个容器一份
- 基础镜像中包含了类似
tar
和libc库这样的应用程序工具和库,导致每次升级基础镜像所有的运行的容器必须得随之升级,这样反而对正在运行得应用带来了巨大的影响,即便是这样的基础镜像升级并不是很常见
幸好现代的Docker技术已经将这些抽象限制的更为严格,我们今天无需为类似的问题担忧。
管理任务的基本单元迁移到了容器
构建面向容器的API而不是面向机器的API这一范式的转变带来了如下巨大的好处
- 它减轻了应用程序开发团队对特定的机器细节或者操作细节可能对程序造成巨大影响的担忧
- 它赋予了基础设施管理团队自由部署新的硬件或者升级操作系统方面的巨大的灵活性,这些升级几乎不对运行的应用程序造成太多影响
- 它将从机器收集到的诸如CPU/内存使用情况的统计数据和应用程序自身关联起来,这样极大地提高了应用程序监控和干预的效率,尤其是我们想支持自愈、自动伸缩或者应对硬件故障和维护的时候,便利性得到了极大提高
容器提供了一种抽象API的机制来完成应用程序运行状态到基础设施管理系统之间的信息流转,使得管理平台甚至无需直到具体的因公程序实现。
比如实现健康情况检查的系统仅仅依赖于容器应用提供一系列服务端点;Borg通过让应用程序提供一些HTTP服务端点来完成,
而Kubernetes则可以支持一个用户声明的HTTP端点或者一个可以运行在容器里面的exec
命令。
利用该机制,管理平台可以在检测到程序发生异常的时候,自动终止它并重启之,实现高级的自愈能力,这是高级可靠的分布式系统必须具备的能力。
容器管理系统还可以将资源限制情况、容器元数据信息发送给容器内的应用程序,而容器管理系统还可以提供基于应用程序级别的日志监控和性能诊断,乃至于应用程序自定义的一些统计和控制信息。
从而我们很少需要像传统的管理一台Linux机器一样频繁地用ssh命令进入容器内用top
命令来查看资源使用情况。
监控是一方面的例子,同时传统的负载均衡、日志收集方式也不再是按照传统的依照机器调度分类的方式,而是采用按照应用程序分开处理的方式。 同时来自多个应用程序的信息也不会担心被互相干扰和混淆,因为任何一个应用程序的信息都有唯一的身份识别信息(容器管理系统可以唯一地编号和识别)。 对应用程序开发者而言,这样构建、管理和调试应用程序都变得更加轻松容易。
容器封装的分级
Borg里容器被分为两级,最外层的提供对于池化资源的集合,而内层的容器则负责具体的部署;外层的容器被成为Alloc。 Kubernetes里外层的容器被叫做POD。 Borg甚至允许应用程序跑在最外层的容器外面,然而这一设计变成了一系列麻烦的来源,所以Kubernetes统一化了所有的应用程序部署和调度方法。
一种常见的范式是将一个复杂的应用程序的一个实例防止在外层的容器中,然后将其内部的不同的部分防止在不同的内部子容器中。 这样做的好处是一个应用的不同子部分可以有不同的逻辑资源上下文,享有不同的日志等,便于解耦和快速开发。
容器编排仅仅是个开始
起初Borg仅仅是一个编排不同容器以调高资源使用率的系统,随着系统的演进,Google发现如下的服务也可以被该容器管理系统所提供,这些服务今天已经成为微服务治理的一些通用基础设施
- 域名服务和服务发现
- 主节点选举
- 应用程序可感知的负载均衡
- 水平方向和垂直方向两个维度的自动扩展
- 用于部署应用程序二进制文件和配置数据的外围工具的自动部署
- 工作流控制工具,比如运行运行多个并行的批处理作业的流水线,并且里面的子任务会存在相互依赖的逻辑关系等
- 收集、汇聚容器信息并将它们显示于专门的仪表盘,或根据里面的条件自动产生告警的能力
对象元信息
这些工具本来是被创造出来解决一些具体服务的特定问题的,随着它们被更广泛的采纳和部署,慢慢地它们就演进成了更为通用的工具使得所有的不属于容器中的微服务都可以采用。 由于这种做法是通过演进地方式得来的,早期的Borg系统中集成这些服务会遭遇诸如文件位置等类似的惯例带来的部署复杂性。 Kubernetes则尝试采用一致的基于API的方式来降低复杂性。 Kubernetes里面使用ObjectMetadata、Specification、Status这三类元信息,并将它们放置在所有的API对象的属性集中。
ObjectMetadata
包含了对象的名字、UUID、版本号等信息,所有的API队形都包含同样的结构Spec
和Status
字段则依API对象的类型的不同而具有不同的结构;前者用于描述期望的状态,而后者用于展示目前的对象状态
通用API的好处
统一形式的API带来的好处是多重的
- 学习系统的API变得简单 - 尽管很多初学者反应Kubernetes非常不好理解有陡峭的学习曲线
- 创建通用的工具变得简单,因为API对象具有很多相似的地方
- 开发体验的一致性,这是上面两点自然而然的结果
- 未来扩展新的对象更加容易
这些概念是Kubernetes从Borg系统设计上继承之后新发展出来的超强的可扩展能力;这种扩展能力对于平台性的应用来说是至关重要的。
一致性
Kubernetes的一致性是通过不同API对象相互之间的解耦来实现的;各个API组件基本关注于不同的任务,它们除了共享这些基本的元信息之外,
互相之间是尽量地保持功能上的正交。
比如负责无状态服务实例部署复制replication
的控制器和负责自动扩展的控制器互相之间就可以互不干扰;前者负责控制有多少个POD实例需要被创建,而后者会基于这一能力来动态地调整POD的个数,而无需关心这些POD具体是怎么被创建和删除的。
这一设计思路对应到设计模式上来说就是单一职责模式的直接化用。
一致性还体现在这些API对象的通用的外观上,比如Kubernetes提供了三种POD级别部署复制的控制器
ReplicationController
负责运行诸如Web服务器这样的多实例负载均衡的无状态服务DaemonSet
保证单个集群节点上总有一个唯一的实例再运行Job
表示一个运行完毕就结束的批处理作业 尽管它们内部的控制逻辑和策略完全不同,它们都共享同样的POD模型。
控制器协调调度循环
Borg、Omega、Kubernetes都遵循了同样的控制器协调调度循环的的概念来增强系统的灵活性。该机制的大噶i思路是
- 循环比较对象的期望状态(
spec
)和当前的实际运行期状态(status
) - 如果发现不一致,则执行控制器对应的动作来尝试协调差异
- 重新回到第一步继续这个循环
这个处理思路是基于观察者-控制器模式,而不是基于复杂的状态图,因此它更容易处理系统错误。 任何时候控制器因为失效等原因需要重启的时候,它只需要从上次的状态继续运行下去就可以了。
这种结合了微服务和微控制循环的设计模式是一种典型的通过编排来达成控制的思想。该设计思路是被精心选择设计出来的,并有意和传统的基于中心化的编排系统不同。 中心化的编排系统可能一开始起步比较容易,当系统的规模和需求变得更加复杂的时候,它就会因为过于严格而变得更加不容易维护。
需要避免的一些陷阱
Google自己总结了一些自己所犯过的错误,同时也期望其他人不要再误入同样的误区。
不要尝试让容器管理系统直接来管理端口
早期的Borg系统由于允许所有的服务共享宿主机的IP地址,所以它为每个容器都分配了唯一的端口号,同时该端口号成为平台调度处理的一部分。 问题是当容器被移动到一个新的机器上的时候,它会得到一个新的端口号;甚至它再同一个物理机器上重启的时候也可能重新分得一个端口号。 这样的缺陷是传统的基于名字的DNS服务将不再正常工作,容器管理平台不得不创建私有的机制来解决这个问题。 更糟糕的是,端口号不能很轻松地潜入的URL中,服务要想正常工作,基于端口的重定向不得不被适应来保证应用服务在这些异常常见下还可以正常工作。
基于这些教训,Kubernetes的设计就采用了每个POD分配一个IP地址的策略,使得IP地址成为POD唯一的网络身份标识。这样一来, 已有的第三方基于IP/DNS的工具都可以正常工作了,不管用户选用基于SDN的网络还是基于传统单机的网络方案。
不为容器编号,而是采用标签
当平台允许用户很方便地创建容器的时候,用户总是倾向于创建大量的容器;很快平台就需要提供一种机制来对它们进行分组化的组织。 Borg提供了一种叫做jobs的机制来对容器进行分组,一个job里面包含多个执行等同任务的容器,它们用基于0开始的连续的整数下标作为索引。 看起来种方式很自然和直接,然而随着复杂性的增加这一方案很快就露出了它的弊端
- 这个数组中的下标不得不承担双重职责:定位某个实例的拷贝,并在程序员需要调试的时候指向老的版本
- 当位于中间的一个拷贝退出的时候,数组下标就会出现空洞
- 需要执行横跨多个集群的任务的时候,下标的安插就显得很困难
- Borg无法支持应用程序层面的基于角色的版本指定,比如用户想用基于金丝雀部署的方式来滚动升级,这种死板的分配方式就无能为力,以至于用户不得不将类似信息编码到Job名字上的方式来间接达成目标
Kubernetes则基于这些问题的考虑而采用了基于松散的标签的方式来对容器进行分组。如果一个容器头上打了多个标签,那么它就同时隶属于不同的组。 Kubernetes支持动态地添加、删除和修改这些标签,并支持用标签选择器的类似集合的语法来查询某个标签下的所有容器。
当心不必要的所有权绑定
Borg系统中,一个任务是不能独立于它的Job而单独存在的;创建了一个Job的同时也就创建了对应的Task;随后这些Task就永远地和对应的Job关联起来;删除了Job也同时意味着对应的Task被删除。 这样虽然方便但是却有一个大大的缺陷:因为只有一个基于下标的分组,Borg需要管理所有的可能的场景。 如果一个Job需要存储仅仅对某个服务有意义的参数,那么用户就必须寻找一种间接的方式来完成。
Kubernetes则通过上述的基于Label的解耦方式来分离POD的生存周期管理和容器选择策略。 这样的灵活性允许下面这种用户场景:当用户想对他的服务POD进行调试的时候,他可以去掉某个POD头上的标签,然后后台的部署控制器就会注意到这个标签的变更,进而将其从服务实例列表中删除。 此时用户就可以在这个运行的POD上做调试而不用担心对正在运行的业务造成任何影响。 同时后台的控制器会根据实现定义的POD数量要求,重新创建一个新的POD来替换这个不正常的POD实例。
不要泄露原始状态
API架构的不同是Kubernetes和它的前辈们的主要的不同。
Borg是一个基于单体架构的复杂软件,它的核心模块直到所有的API操作的予以逻辑。其内部维护了包括机器和机器上跑的Job和Task的集群状态控制逻辑,并使用基于Paxos的中心化存储来保存这些状态信息。
Omega则不在保留中心化的状态管理逻辑,而仅仅保留了一个处于从属角色的全局的状态存储用于异常恢复。所有的逻辑语义控制操作则被下放给了数据存储的使用端,它们会直接读写对应的数据存储。 实现上每一个Omega组件使用了完全相同的客户端库来做数据结构的序列化、反序列化,重试和语义一致性处理。
Kubernetes走了一条中间路线:既保留了Omega的去中心化存储架构的扩展性和灵活性,又复用了系统范围的数据修改策略的一致性。 这种做法依赖于一个中心化的API Server,该Server屏蔽了底层存储和对象校验、默认值初始化、版本处理的细节。
悬而未决的问题
Google同时列举了一些截至论文发表时还没有很好解决的问题,以便社区可以进一步探讨找到更好的解决方案。
配置问题
如何处理传递给应用程序容器它们想使用的配置数据,是Google自己所遇到的耗费了最多智力和代码的最显著的一个问题。 这个问题的本质上,我们需要找到一种方式传递一系列的值给应用程序,而不需要用写死的方式传递给它们。 Borg历史上的这类信息包含
- 繁琐的信息推导,如默认的重启策略
- 调整和校验应用程序的命令行参数传递
- 可以给一些列程序共同使用的配置文件模板库
- 镜像文件版本规范
- 发布管理工具
想解决这些问题,配置管理系统可能不得不创造新的领域特定语言,并最终衍生出一套图灵完备的语言;最终这又变成了程序员想拜托的配置即代码的窘境。 因为这种方式本身并没有降低运维的复杂性,还将负担从真正的业务代码变成语言转嫁到了领域特定的语言上,给调试和修改带来新的麻烦。
Google认为最终可能的方案是采用一些简单的数据语言格式,比如JSON或者YAML格式;因为它们自身有完整的工具链,并且被大部分人所熟悉和理解。
依赖管理
如果一个服务自身需要依赖于其它服务才能运行,一个很自然的想法是将依赖管理和部署的任务交给容器管理平台,从而让它可以自动地根据依赖规则来管理相关联的服务的发布、部署和升级。
乍一看似乎一个基于传递依赖关系的有向图可以解决,然而现实应用的时候却有不少其它的复杂性需要考虑而不仅仅是一个启动状态的处理
- 依赖于外部认证服务并且需要传入对应的认证、授权信息,这期间可能需要启动一个外部服务的连接
- 传递依赖的过程中,服务的认证、授权、计费信息可能需要被通盘处理
同时如何保持这些依赖信息总是和程序的开发更新步骤一致,也没有很好的答案,因为很多时候这些信息的追踪和更新都是靠已于出错的人工步骤来完成的;该事实反而加强了自动化这些繁琐操作的动机。
结论
Google认为它已经将自己十数年构建容器管理系统的经验和教训总结在最新的Kubernetes项目中了;论文的最后Google号召社区能够加入这一开源社区,共同努力来提升容器管理基础设施的能力,使云时代的程序员能得到最大的效率提升。
Leave a Comment
Your email address will not be published. Required fields are marked *