20 世纪 80 年代末至 90 年代初,面向对象编程思想给软件开发带来了一轮技术革新,就像润物细无声的春雨那般,向全世界的程序员们快速普及了模块化构建应用程序的方法,一直流行至今。
当下,我们可以看到类似的革新出现在了分布式系统开发,具体特点如下:
- 基于容器的微服务架构体系日益流行
- 容器天然隔离的属性非常适合作为分布式系统中的基本对象
基于面向对象,四人帮基于经验提出和总结了对于一些常见软件设计问题的标准解决方案,其描述了一系列基于接口的模式,可以在各种环境中重用,这被称之为软件设计模式。历史一定程度上来说是重复的,随着这种架构模式的成熟,基于容器的分布式系统的设计模式也就自然而然地浮现了。
本篇主要阐述的是Brendan Burns
在基于容器的分布式系统中发现的三种设计模式:
- single-container patterns for container management:容器管理之单容器模式
- single-node patterns of closely cooperating containers:容器协调之单节点(多容器)模式
- multi-node patterns for distributed algorithms:分布式算法之多节点模式
基于容器分布式系统的设计模式会给分布式计算编码带来以下优势:
- 最佳实践,给没有经验的程序员带来相对正确的使用方式
- 简化开发
- 提升系统可靠性
模式的价值
模式的目的是提供一般建议或结构来指导设计,这样做的好处有以下三点:
- 站在巨人的肩膀上,对于经验不怎么丰富的开发者,可以通过模式来指引走在正确的道路上,从而少踩坑,提升项目质量
- 提供通用的名称和定义,有共同的领域语言进行交流是一件很重要的事情
- 方便识别并构建共享的通用组件
单容器模式
就像对象会定义边界一样,容器为定义接口提供了天然的边界;它不仅可以暴露特定应用的功能,还可以通过钩子函数来管理系统。传统的容器管理接口是极其有限的,如:
- run
- pause
- stop
这些接口只能说满足基础的使用需求,但是就目前的现状来看,更丰富的接口可以为系统开发者与操作者提供更多的功能。鉴于HTTP
和JSON
的普及程度,可以考虑通过容器在特定的节点托管一个Web
服务来实现。这样做的目的是什么,可以从下面两个角度来看待:
- upward:容器可以暴露丰富的应用信息,比如:
- 各类监控指标(QPS、应用健康等)
- 一些开发人员感兴趣的信息如(线程、堆栈、锁、网络消息统计等)
- 组件配置、日志等
- downward:任何开发者在编写软件组件的时候,都可以使用容器原生支持的生命周期接口来进行管控。比如一个集群管理系统通常会给任务分配对应的优先级,高优先级的任务即使在集群被超额订阅的情况下也能保证运行,这种保证是通过逐出已经运行的低优先级任务来实现的,然后这些低优先级任务能否运行取决于后面是否还有资源分配过来;但是这样有个问题就是开发者需要承担一些没必要的复返,比如处理一些优先级比较低的任务被抛弃的情况。相反,如果在应用程序和管理系统之间定义了正式的生命周期,那么应用程序组件将变得更易于管理,比如
k8s
使用Docker
的graceful deletion
功能,这就允许应用程序通过完成当前任务,把状态写入磁盘等等操作之后再终止,将这个功能扩展一下就可以使使有状态的分布式系统的状态管理更加容易。
单节点(多容器)模式
上面提到了单容器的接口,我们稍稍延伸一下,对于一个多容器组成的应用,会有怎样的设计模式呢?当然,此时我们仍旧有些限制条件需要讲清楚:
- 容器都处于单节点下
- 容器管理系统需要支持将多容器作为原子单元协调编排,这也侧面印证为什么
k8s
需要有Pod
这个逻辑概念
边车模式(Sidecar pattern)
扩展和增强现有的应用容器
目前最常见的多容器部署模式就是边车模式,边车模式就是由两个容器组成的单节点模式:
- 核心是应用程序容器,这个就是应用程序的轴心
- 其次就是边车容器,作用就是改进和增强应用程序容器
- 边车模式的一般方式如上图所示,可以看到应用程序容器和边车容器共享了许多资源:
- 部分文件系统
- 主机名
- 网络
- 其他
我们通过下面的例子来看一下边车容器存在的必要性以及好处,图示如下:
其中主容器是一个web
服务,而日志处理
边车容器的工作就是收集本地磁盘的服务器日志,并将其流式传输至存储集群,这样做的好处有:
- 容器是资源计算和调度的基本单位,所以可以优先配置主
Web
服务器的cgroup
使得其处理延时降低,而日志处理容器则在web
服务器空闲时使用cpu
时间片进行日志处理 - 将模块化和可重用的组件封装成边车,可达到功能内聚,应用可被划分明确的边界进行解耦(方便接入、测试调试、状态处理等),最重要的是可以被不同主容器作为边车容器复用
大使模式(Ambassadors pattern)
改变和管理应用容器与外部世界的通信方式
第一次看大使模式,很可能会想这不就是另一种形式的边车模式吗?其实不然,首先第一点,大使模式下所有的请求响应信息交换全部是大使容器来完成的,应用程序容器只能和大使容器进行交流。
这种模式主要利用的特性是同一Pod
中的容器可以共享相同的localhost
网络接口,而且可以从两个角度看大使容器:
- 内到外:让我们以访问一个存储区域为例,假设该存储区域的大小不断增长,必须分成更多的子系统。在这种情况下,为了不干预主容器并且必须对所有受影响的服务实施相同的新访问逻辑,创建一个大使容器来调解对存储区域的访问是个不错的选择
- 外到内:让我们设想一下,我们要测试微服务的新版本,可以通过大使容器控制请求量到相关部署
适配器模式(Adapter pattern)
确保应用程序实现统一的监控接口
真实世界的应用程序大概率会有出现下面列出的几种情况:
- 一部分服务自行开发(可能有新老标准差异),一部分使用开源项目
- 服务的编写语言多样,日志记录、监控也多样
假设我们需要有效地监控和运维应用程序,这就要求应用程序可以提供统一的通用接口来进行指标收集。这就是适配器发挥作用的场景了,对于不同应用容器提供的不同接口,可以使用适配器适配这种异构性并转化为一致的接口且原有服务代码不需要做任何改动。
主应用程序通过localhost
或者volume
与适配器容器通信,适配器经过一层处理提供统一的输出给外部使用者,一些常用的使用场景如下:
- 监控:适配器将应用程序容器公开的监控接口转换为通用监控系统所期望的接口
- 日志:适配器提供统一的日志记录输出
多节点模式
不要将模块化容器局限于单机容器协调上,其实模块化容器还可以使构建协调的多节点分布式应用程序变得更加容易。接下来将描述其中的三种分布式系统模,与前一节中的模式一样,这些模式也需要对 Pod
这个逻辑概念的支持。
领导选举模式
分布式系统中最常见的问题就是领导选举(Leader election)问题,副本被普遍使用在一个组件的多个相同的实例之间共享负载,副本的另一个更加复杂的作用就是使得某一特定副本作为整个部署集的leader,其他副本作为热备(这个区分过程比较复杂),当原本 Leader 宕机时可以快速被选举为新的 Leader,以恢复系统功能。系统甚至可以并行地进行领导者选举,例如多个分片均需要确定领导者。
上图介绍了一个简单的分布式选举的例子:图中三个副本,任何一个副本都有可能成为主副本,首先第一个副本为主,若其不巧发生故障,第二阶段就会通过选举将第三个副本变成主副本,最后,第一个副本回复,重新加入集群,第三副本依旧作为主节点运行调度。
现在确实有许多类库可以进行领导者选举,但它们通常比较复杂并且难以被正确理解和使用,此外,它们还受到特定编程语言实现的限制。
所以本部分探讨的就是将领导者选举机制从应用程序中剥离至领导者选举专属容器中,我们可以考虑提供一组领导者选举容器,每个容器都与需要进行领导者选举的应用程序共同调度,这样就可以在这些领导者选举容器之间执行选举。
同时,它们可以在localhost
上为需要进行领导者选举的应用程序容器提供一个简化的HTTP API
(例如becomeLeader
、renewLeadership
等)。
这些领导者选举容器只需要由这个复杂领域的专家进行一次性构建即可,然后不管应用程序开发人员选择何种编程语言,都可以复用其简化的接口。这种方式代表了软件工程中最好的抽象和封装过程。
工作队列模式(Work queue pattern)
一个简单通用的容器化工作队列图示如下:
最左侧提供了一组需要被执行的工作项,然后工作队列管理容器接受输入工作项,将其分发给多个执行器进行消费,并且多个执行器中间没有任何交互,这样的好处是可以根据实际运行情况增加执行器数量来赢取时间。
虽然工作队列和领导者选举一样,是一个研究得很透彻的课题并且有很多框架对它们进行了很好的实现,但这些分布式系统设计模式仍然是可以在面向容器的架构中获益。在以前的系统中,框架将程序限制在单一的语言环境中(如Python
中的Celery
)。
对于一个容器,由于run()&mount()
接口的实现,使得实现一个通用的工作队列框架变得简单直接,可以将任意的处理代码打包成一个容器,再结合任意数据就构建成了一个完整的工作队列系统。开发完整工作队列所涉及的所有其他工作都可以由通用工作队列框架处理,并且可以被任何有相同需求的系统复用,用户代码集成等细节让我们看看下面的图示:
通用工作队列的图示,可重用框架容器以深灰色显示,而开发人员容器以浅灰色显示。
让我们结合上面的两张图一起看看,在我看来这就是一个从第一张图抽象出用户自定义代码从而形成第二张图的过程,且看我详细列出关键点。
源容器接口
用户定义的工作项由工作队列管理容器接收,此时涉及到一个论文中没有描述的点就是队列管理容器对于接收的工作项是默认进行了标准定义的,也就是说工作项都是定义好的标准输入。但实际情况并不可能让所有的输入项都有相同的输入标准,必须由一段用户自定义的处理代码来将输入标准化,不知机智的你是否想到了大使模式:
执行器容器接口
当工作队列管理容器获取了对应的工作项,接下来的工作就是下发给执行器进行处理,具体做法是调用一次性API
触发工作流,并且在工作容器的整个生命周期是不会有其他调用产生的。
分散/聚集模式
最后一个我们要着重介绍的设计模式是分散/收集模式(Scatter/gather pattern),首先简单介绍一下这个模式:
分散/收集模式是一个树形模式,当外部客户端向根节点发送一个初始请求,根节点会将这个请求分发给大量服务器,每个服务器分片均返回部分数据,然后跟节点将这些部分结果组合起来形成一个针对原始请求的完整响应。
对于上面这样一个流程,实际上可以抽象出以下几个标准出来:
- 分散请求(fanning out the requests)
- 收集响应(gathering the responses)
- 与客户端交互(interacting with the client)
- ...
上述流程的大部分代码都是通用的,就像面向对象编程中一样,我们可以将这个一个个拆分实现并进行容器化。
值得一提的是要实现一个分散/收集系统,需要一个用户提供两个容器:
- 第一种容器实现叶子节点计算,该容器执行部分计算并返回相应的结果
- 第二种容器是合并容器,该容器需要汇总所有叶子容器的计算结果,并组织成一个单一的响应后输出给用户
说明
主体内容来自:
- 论文《Design patterns for container-based distributed systems[1]》
- 书籍《Designing Distributed Systems[2]》
不错的资料:
- [译] 基于容器的分布式系统设计模式[3]
最近一直在了解低代码开发平台相关知识,就是前面提到的一站式机器学习云研发平台[4],其中关于资源管理这块离不开k8s
的使用,因此花了不少精力在这上面,不出意外会出一个系列的学习笔记: