本文最初发布于 The Startup 博客,经原作者授权由 InfoQ 中文站翻译并分享。
注:本文是本系列文章的第 3 部分。如果你想从本系列文章第一篇开始阅读,请点击这里。
在本系列文章的上一部分中,我们讨论了迁移到云原生方法可能会对人员组织和流程简化产生怎样的影响。在这篇文章中,我们将深入探讨它与架构和设计原则之间的关系。
云原生要素:架构与设计
是架构方法赋予技术生命。我们可以将传统的、竖井式的、有状态的粗粒度应用程序组件部署到现代的基于容器的云基础设施上。对一些人来说,这是一种让他们可以涉足云的方法,但这应该只是一个开始。如果这样做,你几乎体验不到云原生的任何优势。在这一部分中,我们将考虑如何设计一个应用程序,使其有机会充分利用底层云基础设施。显然,使用不可变部署滚动发布解耦良好的组件与采用敏捷方法和流程同样重要。下图展示了云原生架构的组成部分:
云原生架构的组件
细粒度组件
不久之前,为了有效地使用硬件和软件资源,我们还是以大块的代码构建和运行软件。最近的技术发展(如容器),让我们可以将应用程序分解成更小的块并单独运行。我们所说的细粒度涉及几个不同的方面:
功能驱动的粒度——每个组件执行一个定义良好的任务;
自包含组件——在可能的情况下,该组件包括其所有依赖项;
独立的生命周期、可伸缩性和弹性——组件从单个代码库构建,通过专用管道,并且在运行时独立托管。
通常,这种构建应用程序的方式被称为微服务,不过应该注意,真正的“微服务方法所涉及的面”比细粒度组件要广泛得多,而且确实与这里描述的云原生概念有很大的重叠。
更细粒度组件的主要有如下好处:
更强的灵活性:它们足够小,可以完全理解并单独更改;
弹性伸缩:每个组件都可以单独伸缩,从而可以最大限度地提高云原生基础设施的效率;
离散弹性(Discrete resilience):通过适当的解耦,一个微服务运行不稳定不会影响其他服务。
虽然在适当的环境中,上面的方法可以带来显著的好处,但是设计高度分布式的系统并不是一件简单的事情,管理它们更不容易。确定微服务组件的大小本身就是一个需要深入讨论的话题,然后还要进行进一步的设计决策,确定它们应该如何解耦,以及如何管理剩下的紧耦合部分的版本。发现必要的内聚性与引入适当的解耦同样重要,经常会有因为粒度过细而不得不中止的项目。简而言之,只有设计良好、方法及流程成熟时,微服务应用程序才能具备灵活性和可伸缩性。
注意:人们经常会对微服务架构和面向服务的体系结构(SOA)进行不恰当的比较,因为它们有相同的词汇,并且似乎处于相同的概念空间中。然而,它们涉及不同的范围。微服务与应用程序架构有关,SOA 与企业架构有关。这个区别非常关键,文章“微服务与SOA:如何分辨”对此进行了进一步探讨。
适当的解耦
如果细粒度组件之间不能相互解耦,那么它们的许多优点(敏捷性、可伸缩性和弹性)就会丧失。这些细粒度的组件需要满足以下条件:
清晰的所有者边界
形式化的接口(API 和事件/消息)
单独的持久化存储
编写模块化软件并不是什么新鲜事。所有的设计方法,从功能分解到面向对象的编程,再到面向服务的体系结构,都旨在将大问题分解成更小的、更易于管理的部分。云原生领域的机遇在于,利用容器等技术,我们可以将每个容器作为真正独立的组件运行。每个组件都有自己的 CPU、内存、文件存储和网络连接,就像一个完整的操作系统一样。因此,只能通过网络访问它,这本身就在组件之间创建了一个非常清晰的强制性分离。不过,底层平台提供的解耦只是其中的一个方面。
从组织的角度来看,所有权要清晰。每个组件都需要由一个能够完全控制其实现的团队拥有。这并不是说团队不应该接受变更请求,即来自其他团队的 pull 请求,但是他们可以控制合并什么以及何时合并。这是敏捷的关键,只要是遵循与其他团队约定的接口,他们就可以修改组件,并且满怀信心地部署变更。当然,即使这样,团队也应该在组织整体设置的架构边界内工作,但是在这些边界内,他们应该有相当大的自由。
组件应该显式地声明接口,并且锁定所有其他的访问方式。它们应该只使用成熟的标准协议。同步通信最简单,HTTP 的普遍性使其成为首选项。更具体地说,我们通常会看到 RESTful API 使用 JSON,不过,其他协议(如 gRPC)也可以用于特定的需求。
区分相同所有权边界(例如应用程序边界)内跨组件的调用和对另一个所有权边界内的组件的调用,这很重要,但超出了本文的范围。更多信息请阅读这篇文章。
然而,HTTP API 的同步特性将调用者与下游组件的可用性和性能绑定在了一起。通过事件和消息进行异步通信,借助“存储转发”或“发布/订阅”模式,可以更彻底地将它们与其他组件解耦。
一种常见的异步模式是,数据的所有者发布有关数据更改的事件(创建、更新和删除)。其他需要该数据的组件监听事件流并构建自己的本地数据存储,这样,当它们需要该数据时,就有一份副本。这个流程涉及其他事件驱动的架构模式(如事件源和 CQRS)。
尽管异步模式可以提高可用性和性能,但它有一个不可避免的缺点:它会导致各种形式的最终一致性,这可能使设计、实现,甚至问题诊断都更加复杂。因此,对于基于事件和基于消息的通信的使用,应该适当地加以限制。
最小化状态
在云原生解决方案中,清晰地分离组件状态至关重要。这通常涉及三个关键的主题:
容易水平扩展
没有调用者或会话关联
组件之间不存在两段提交
无状态使编排平台能够以最佳方式管理组件,根据需要添加和删除副本。无状态意味着在组件启动后,不应该对配置或组件所持有的数据进行任何更改,导致其不同于任何其他副本。
关联性是最常见的问题之一。希望特定用户或使用者的请求在下一次调用时会回到相同的组件,这可能是因为特定的数据缓存。但突然之间,编排平台无法进行简单的负载均衡路由、重定位或副本伸缩了。
跨组件的两段事务提交也应该避免,因为 REST 和基于事件的协议的语义不支持标准化事务协调通信。每个细粒度组件的独立性及其最小化状态,使得在任何情况下,2PC 协调所需的耦合都存在问题。因此,必须使用处理分布式更新的方法,如 Saga 模式,并且要考虑前面提到过的最终一致性问题。
请注意,不要把最小化状态的概念和与保存状态的下游系统进行交互的组件相混淆。例如,一个与持久化状态的数据库或远程消息队列进行交互的组件。不管怎样,这并不会使组件成为有状态的,它只是将有状态请求传递到下游系统。
总会有一些组件需要状态。像 Kubernetes(K8s)这样的平台,通过额外的特性及相关限制,提供了处理有状态组件的机制。关键是将状态最小化,当确实有状态时,要明确地声明和管理它。
不可变部署
如果我们要将控制权交给一个云平台,由它来部署和管理我们的组件,就需要让它尽可能简单。简而言之,我们希望确保只有一种方法来部署组件,并且一旦部署,就不能更改。这称为不可变部署,它有以下三个特点:
基于镜像的部署——部署一个固定的“镜像”,包含所有依赖项。
无运行时管理——部署后不直接对运行时进行更改。
通过替换进行更新和回滚——通过部署组件镜像的新版本来执行更改。
通过“适当的解耦”,我们已经知道,组件应该是自包含的。我们需要有一种方式来打包代码和所有的依赖,从而实现部署的一致性。语言总是会提供机制将代码构建为不变的“可执行文件”,所以这并不是什么新东西。容器使我们有机会更进一步,将代码/可执行文件与特定版本的语言运行时,甚至是操作系统的相关内容打包到一个不变的“镜像”中。我们还可以包括安全配置,比如要提供哪些端口,以及关键元数据,比如要在启动时运行什么进程。
这使我们能够一致地部署到任何环境中。开发、测试和生产都将具有相同的全栈配置。集群中的每个副本都是相同的。容器镜像是一个不变的黑盒,可以部署到任何环境、任何位置和(在理想情况下)任何容器平台上,并且行为表现完全相同。
一旦启动,镜像就不能在运行时进一步地配置,以保证它始终一致。这意味着它不会应用操作系统补丁,不会应用新版本的语言运行时,也不会加入新代码。如果你想要更改其中的任何内容,就必须构建一个新的镜像,然后部署它,并逐步淘汰原来的镜像。这保证我们在任何时候,都对部署到环境中的东西有绝对的把握。它还为我们提供了非常简单的方法,让我们可以回滚任何此类更改。由于之前的镜像还有,我们可以简单地重新部署它——当然,前提是你坚持了“最小化状态”。
传统的环境是在部署任何代码之前预先构建好的,然后随着时间推移,通过在上面运行命令来对它进行维护。很容易看出,这种方法经常会导致环境之间的配置差异。不可变部署方法确保始终将代码与其自身的所有依赖项副本和测试时的配置一起部署。这提高了测试可信度,让我们可以更轻松地重建环境,用于功能、性能和诊断测试,并有助于保证弹性扩展的简单性。
请注意,理论上我们可以利用虚拟机镜像进行基于镜像的部署,但是它们会非常大,难以管理。因此,在一个虚拟机实例上运行多个组件会更高效,这意味着代码与其依赖项之间不再存在一对一的关系。
零信任
简单地说,零信任就是假定所有威胁都可能发生。威胁建模已经有很长的历史了,但是云原生解决方案的特点迫使我们重新考虑这些威胁以及如何有针对性地做好防护。以下是零信任方法的一些(但不是全部)关键原则:
最小特权——在默认情况下,组件和人员应该都没有特权。所有特权都是基于身份明确授予的。
隐式数据安全——数据应该始终是安全的,无论是静态数据,还是在传输中的数据。
安全性左移(DevSecOps)——安全性应该在生命周期中尽可能早的被包含进来。
众所周知,传统的基于防火墙的访问控制会导致对内部网络的不当信任。实际上,在网络中创建受信任区域充其量只是第一道防线。身份需要成为新的边界。对于我们试图保护的对象,我们应该基于身份进行细粒度地访问控制:用户、设备、应用程序组件和数据。基于这些身份,我们应该只提供最低限度的特权。默认情况下,组件之间的连接应该显式声明并加以保护(加密)。管理员应该被精确授予其角色必需的特权。我们还必须定期执行漏洞测试,以确保没有权限升级的路径。
我们必须接受这样一个事实:在任何时候,应用程序都有责任保证用户数据的安全。现在有更成熟的方法可以关联来自多个源的数据,出于恶意推导出新的信息。应用程序应考虑其所存储的所有数据的隐秘性,加密任何静态以及传输中的敏感数据,并确保只有明确获得过许可的身份才能访问这些数据。
应用程序组件必须从一开始就安全地构建。我们必须假设所有环境,不仅仅是生产环境,都容易受到攻击。但更重要的是,通过安全关切“左移”,我们可以确保应用程序设计人员尽早与安全团队协作,并无缝地嵌入安全实践,例如在构建和部署管道中。这进一步提高了速度、一致性和信心,我们可以持续地将代码交付到生产环境。
在下一篇文章中,我们将探讨云技术和基础设施的独特之处,以了解它们如何赋能云原生方法。
查看英文原文:
The “How” of Cloud Native: Architecture and Design Perspective
延伸阅读:
评论