由许多协同工作的微服务组成的云原生应用程序架构形成了一个分布式系统。确保分布式系统可用——减少其停机时间——需要提高系统的弹性。弹性是使用提高可用性的策略。弹性策略的示例包括负载平衡、超时和自动重试、截止日期和断路器。
弹性可以通过不止一种方式添加到分布式系统中。例如,让每个微服务的代码都包含对具有弹性功能的代码库的调用,或者让特殊的网络代理处理微服务请求和回复。弹性的最终目标是确保特定微服务实例的故障或降级不会导致导致整个分布式系统停机的级联故障。
在分布式系统的上下文中,弹性是指分布式系统能够在不利情况发生时自动适应以继续服务于其目的。
术语“可用性”和“弹性”具有不同的含义。可用性是分布式系统启动的时间百分比。弹性是使用策略来提高分布式系统的可用性。
弹性的主要目标之一是防止一个微服务实例的问题导致更多问题,这些问题升级并最终导致分布式系统故障。这被称为级联故障。
弹性策略
分布式系统的弹性策略通常用于 OSI 模型的多个层,如图所示。例如,物理和数据链路层(第 1 层和第 2 层)涉及物理网络组件,例如与 Internet 的连接,因此数据中心和云服务提供商将负责为这些层选择和实施弹性策略。
应用层是应用驻留的地方;这是人类用户(以及其他应用程序)直接与之交互的层。应用程序级(第 7 层)弹性策略内置于微服务本身。组织的开发人员可以设计和编写应用程序,使其在降级状态下继续工作,提供重要功能,即使其他功能由于一个或多个微服务的错误、妥协或其他问题而失败。
在流行的视频流应用程序的推荐功能中可以看到此类功能的一个示例。大多数时候主页包含个性化推荐,但如果相关的后端组件出现故障,则会显示一系列通用推荐。此故障不会影响您搜索和播放视频的能力。
传输层(第 4 层)提供网络通信能力,例如确保可靠的通信传输。网络级弹性策略在第 4 层工作,以监控每个微服务已部署实例的网络性能,并将微服务使用请求定向到最佳实例。例如,如果某个特定的微服务实例由于其所在位置的故障(例如网络中断)而停止响应请求,则新请求将自动定向到该微服务的其他实例。
将云原生应用程序部署为分布式系统的组织应考虑网络和/或应用程序级别的弹性策略。在这里,我们将研究云原生应用程序的四种此类策略:
- 负载均衡
- 超时和自动重试
- 截止日期
- 断路器
负载平衡、超时和自动重试支持分布式系统组件的冗余。截止日期和断路器有助于减少分布式系统任何部分的降级或故障的影响。
负载均衡
云原生应用程序的负载平衡可以在 OSI 模型的多个层执行。就像我们刚刚讨论的弹性策略一样,负载平衡可以在第 4 层(网络或连接级别)或第 7 层(应用程序级别)执行。
对于 Kubernetes,第 4 层负载均衡默认使用 kube-proxy 实现。它在网络连接级别平衡负载。Pod IP 地址的管理和虚拟/物理网络适配器之间的流量路由是通过容器网络接口 (CNI) 实现或覆盖网络(例如 Calico 或 Weave Net)来处理的。
回到第 4 层负载均衡,假设一个网络连接每秒向一个应用程序发送一百万个请求,而另一个网络连接每秒向同一个应用程序发送一个请求。负载均衡器不知道这种差异;它只看到两个连接。如果它将第一个连接发送到一个微服务实例,并将第二个连接发送到第二个微服务实例,它会认为负载是平衡的。
第 7 层负载平衡基于请求本身,而不是连接。第 7 层负载均衡器可以看到连接中的请求,并将每个请求发送到最佳微服务实例,这可以提供比第 4 层负载均衡器更好的均衡。一般来说,当我们说“负载平衡”时,我们指的是第 7 层负载平衡。此外,虽然第 7 层负载均衡可以应用于服务或微服务,但这里我们只关注将其应用于微服务。
对于云原生应用程序,负载平衡是指在微服务的运行实例之间平衡应用程序的请求。负载均衡假设每个微服务有多个实例;每个实例都有多个实例提供了冗余。只要可行,实例都是分布式的,因此如果特定服务器甚至站点出现故障,并非任何微服务的所有实例都将变得不可用。
理想情况下,每个微服务都应该有足够的实例,这样即使发生故障(例如站点中断),每个微服务仍然有足够的可用实例,以便分布式系统继续为当时需要它的所有人正常运行。
负载平衡算法
有许多用于执行负载平衡的算法。让我们仔细看看其中的三个。
- 轮询是最简单的算法。每个微服务的实例轮流处理请求。例如,如果微服务 A 有三个实例——1、2 和 3——第一个请求将发送到实例 1,第二个发送到实例 2,第三个发送到实例 3。一旦每个微服务收到请求,下一个请求将分配给实例 1 以开始另一个循环通过实例。
- 最少请求是一种负载均衡算法,它将新请求分发给当时待处理请求最少的微服务实例。例如,假设微服务 B 有四个实例,实例 1、2 和 3 现在分别处理 10 个请求,但实例 4 只处理两个请求。使用最小请求算法,下一个请求将转到实例 4。
- 会话亲和性,也称为粘性会话,是一种尝试将会话中的所有请求发送到相同微服务实例的算法。例如,如果用户 Z 正在使用一个应用程序并导致请求被发送到微服务 C 的实例 1,那么在同一用户会话中对微服务 C 的所有其他请求都将被定向到实例 1。
这些算法有许多变体——例如,加权通常被添加到循环和最小请求算法中,这样一些微服务实例接收的请求份额比其他微服务实例更大或更小。例如,您可能希望支持通常比其他人更快地处理请求的微服务实例。
在实践中,单独的负载平衡算法通常不能提供足够的弹性。例如,它们会继续向已经失败且不再响应请求的微服务实例发送请求。这就是添加超时和自动重试等策略的好处。
超时和自动重试
超时是任何分布式系统的基本概念。如果系统的一部分发出请求,而另一部分在一定时间内未能处理该请求,则请求超时。然后,请求者可以使用系统故障部分的冗余实例自动重试请求。
对于微服务,在两个微服务之间建立并强制执行超时。如果微服务 A 的实例向微服务 B 的实例发出请求,而微服务 B 的实例没有及时处理,则请求超时。然后,微服务 A 实例可以使用微服务 B 的不同实例自动重试请求。
无法保证在超时后重试请求会成功。例如,如果微服务 B 的所有实例都有相同的问题,则对其中任何一个的请求都可能失败。但是,如果只有一些实例受到影响——例如,一个数据中心的中断——那么重试很可能会成功。
此外,请求不应总是自动重试。一个常见的原因是避免意外复制已经成功的事务。假设从微服务 A 到微服务 B 的请求被 B 成功处理,但它对 A 的回复延迟或丢失。在某些情况下可以重新发出此请求,但在其他情况下则不行。
- 安全事务是相同请求导致相同结果的事务。这类似于 HTTP 中的 GET 请求。GET 是一个安全事务,因为它从服务器检索数据但不会导致服务器上的数据被更改。多次读取相同的数据是安全的,因此重新发出安全事务的请求应该没问题。安全事务也称为幂等事务。
- 不安全的事务是相同请求导致不同结果的事务。例如,在 HTTP 中,POST 和 PUT 请求是潜在的不安全事务,因为它们将数据发送到服务器。复制请求可能会导致服务器不止一次地接收该数据并可能不止一次地处理它。如果交易正在授权付款或订单,您当然不希望它发生太多次。
截止日期
除了超时,分布式系统还有所谓的分布式超时,或者更常见的是截止日期。这些涉及系统的两个以上部分。假设有四个相互依赖的微服务:A 向 B 发送请求,B 对其进行处理并将自己的请求发送给 C,C 对其进行处理并向 D 发送请求。回复以另一种方式流动,从 D 流向 C ,C 到 B,B 到 A。
下图描述了这种情况。假设微服务 A 需要在 2.0 秒内回复其请求。在最后期限内,完成请求的剩余时间与中间请求一起移动。这使每个微服务能够优先处理它收到的每个请求,并且当它联系下一个微服务时,它会通知该微服务剩余的时间。
断路器
超时和截止日期分别处理分布式系统中的每个请求和回复。断路器对分布式系统有更多的“全局”视图。如果一个特定的微服务实例没有回复请求或者回复请求的速度比预期的慢,那么断路器可能会导致后续请求被发送到其他实例。
断路器通过为单个微服务实例设置服务降级或故障程度的限制来工作。当实例超过该级别时,会触发断路器并导致微服务实例暂时停止使用。
断路器的目标是防止一个微服务实例的问题对其他微服务产生负面影响并可能导致级联故障。问题解决后,可以再次使用微服务实例。
级联故障通常是由于针对经历降级或故障的微服务实例的自动重试而开始的。假设您有一个微服务实例,请求不堪重负,导致它响应缓慢。如果断路器检测到这一点并暂时阻止新请求进入实例,则实例有机会赶上其请求并恢复。
但是,如果断路器不动作并且新请求继续发送到实例,则该实例可能会完全失败。这会强制所有请求转到其他实例。如果这些实例已经接近容量,新请求也可能使它们不堪重负并最终导致它们失败。这个循环继续下去,最终整个分布式系统都失败了。
使用库实施弹性策略
到目前为止,我们已经讨论了几种弹性策略,包括三种形式的负载平衡加上超时和自动重试、截止日期和断路器。现在是时候开始考虑如何实施这些策略了。
首次部署微服务时,实施弹性策略的最常见方法是让每个微服务使用支持一个或多个策略的标准库。Hystrix 就是一个例子,它是一个为分布式系统添加弹性特性的开源库。由 Netflix 开发到 2018 年,Hystrix 调用可以包裹在一个微服务中依赖于另一个微服务请求的任何调用。弹性库的另一个示例是 Resilience4j,它旨在用于使用 Java 进行函数式编程。
通过应用程序库实施弹性策略当然是可行的,但它并不适用于所有情况。弹性库是特定于语言的,微服务开发人员通常为每个微服务使用最好的语言,因此弹性库可能不支持所有必要的语言。为了使用弹性库,开发人员可能必须使用提供不理想性能或具有其他重大缺陷的语言编写一些微服务。
另一个问题是依赖库意味着为每个微服务中的每个易受攻击的调用添加调用包装器。一些调用可能会丢失,一些包装可能包含错误——让所有微服务的所有开发人员一致地做事情是一个挑战。还有维护问题——未来每个从事微服务工作的新开发人员都必须了解调用包装器。
使用代理实施弹性策略
随着时间的推移,基于库的弹性策略实现已被基于代理的实现所取代。
一般来说,代理位于两方之间的通信中间,并为这些通信提供某种服务。代理通常在两方之间提供某种程度的分离。例如,A 方向 B 方发出请求,但该请求实际上是从 A 发送到代理,代理处理该请求并将自己的请求发送给 B。A 和 B 不直接相互通信。
下图显示了这种通信流程的示例。一个会话发生在微服务 A 的实例及其代理之间,一个单独的会话发生在 A 的代理和微服务 B 的实例之间。A 到代理和代理到 B 会话共同提供 A 和B.
在分布式系统中,代理可以在微服务实例之间实现弹性策略。继续前面的示例,当微服务 A 的实例向微服务 B 发送请求时,该请求实际上会发送到代理。代理会处理 A 的请求并决定它应该转到哪个微服务 B 的实例,然后它会代表 A 发出请求。
代理会监视来自 B 实例的回复,如果没有及时收到回复,它可以自动使用不同的微服务 B 实例重试请求。图中,微服务 A 的代理有微服务 B 的三个实例可供选择,它选择了第三个。如果第三个实例的响应速度不够快,则代理可以使用第一个或第二个实例来代替。
基于代理的弹性的主要优点是无需修改单个微服务即可使用特殊库;任何微服务都可以被代理。