避免分布式系统的级联故障 | IDCF

DevOps

共 9193字,需浏览 19分钟

 ·

2022-05-20 16:50


来源:DevOpsXP
作者:XP
原文:How to Avoid Cascading Failures in Distributed Systems 发表于 2020年2月20日 https://www.infoq.com/articles/anatomy-cascading-failure/
原作者:Laura Nolan

级联故障与混沌工程有什么关系?混沌工程能发现级联故障吗?

译者语:本文虽然与混沌工程无直接相关,但是其中提到的各种真实发生的事故,故障都是混沌工程致力于发现和解决的。发掘问题的本质能够帮助我们在分析系统架构,详细周密的设计混沌工程实验的时候更有针对性。很多掩盖在表面现象之下的根本原因往往都需要深入分析各种内在外在的条件,而不是仅仅的把一台实例的CPU负载提高到100%!通过了解和科学的学习分析级联故障发生的原因,我们将能够更缜密的进行混沌工程实验。

译者再次精炼了本文要点:

  • 级联故障是系统中正反馈循环引起的使原始问题越来越严重的灾难。
  • 有时候添加资源并不会解除故障。
  • 有时候人为介入关闭系统重启是唯一解决方案。
  • 分布式系统的级联故障风险与生俱来。
原文总结:
  • 级联故障是牵涉到某些类型的反馈机制的故障。在分布式软件系统中级联故障通常涉及到反馈机制的循环回路,它们通常会引起能力下降,延迟增加或者一连串错误。而它们从系统其他组件得到的响应使得原始的问题变得更加严重。
  • 向你的服务系统中添加资源和扩展能力通常很难使你逃脱级联故障。健康的新实例会被过量的负载立刻冲击而达到负载的饱和状态。在这样的情况下,服务能力始终无法处理现有的负载。
  • 有些时候关闭整个系统并重新引入流量是惟一修复这个问题的办法。
  • 潜在的级联故障,虽然不是大部分,但是在很多分布式系统中是与生俱来的。如果你并没有在你的系统中遇到,并不代表你的系统会幸免于此,因为你的系统也许仅仅在它的安全舒适范围之内运行而并没有到达其极限。对于明天或是下周是否发生,并没有任何保证。
作者语:在这篇文章中我将讨论生产事故的公开解释。我对于所有有关的工程组织致以最崇高的敬意。分布式系统的运行者都经历过系统的宕机,对于生产环境的事故,并不是所有组织都愿意开放并足够诚实的记录清晰的公开解释。而那些能够分享的企业和组织,真是值得感激和尊敬的(有时候还有我们的同情!)。

一、什么是级联故障



级联故障是牵涉到某些类型的反馈机制的故障。换句话说就是,有毒的有错误的循环正在运转。
2015年9月20日亚马逊DynamoDB在美东1区的持续了四个多小时的事故[1]是一个典型的级联故障的例子。其中有两个子系统被牵涉:存储服务器和一个元数据服务。存储服务器从多备份的跨数据中心的元数据服务请求他们的数据分区作业。当事故发生的时刻,因为一个新的索引类型引入(Global Secondary Indexes or GSIs),取回分区作业的平均时间显著的增加了。但是元数据服务的能力并没有提升,而分区作业请求操作的最终期限也并没有被设置。任何在最终期限内没有成功的请求被认为失败,客户端即开始重试。
(图1 - 2015年DynamoDB事故涉及到的服务)
事故被一个短暂的网络问题触发,该问题造成一些存储服务没有收到他们的分区作业。
这些存储服务器把他们自己从服务中移除并且持续的请求分区作业。元数据服务器在这些请求的负载下不堪重负,降低了其响应的时间。而这又造成了更多的请求超时并被重试。这些重试的请求再次加重了服务器的负载。元数据服务被非常严重的过载了以至于运维人员只得把,存储服务器从防火墙上下线以提高元数据服务器的可用性。但对于用户而言这也意味着整个DynamoDB服务在美东一区全部下线不可用。

二、为什么级联故障如此糟糕



级联故障的最大问题是他们能够把你整个系统摧毁,一台一台的摧毁你的服务实例直到整个负载均衡服务不再健康。
第二个问题是级联故障是极端异常的难以恢复的故障。他们通常从一些小的扰动开始:比如短暂的网络问题,一小串的负载,或者少量的实例故障。系统并不会随着时间逐渐恢复,取而代之的是系统进入了更糟糕的状态。系统的级联故障不会自愈,它只能由人力的介入被修复。
第三个问题是一旦某些条件出现在你的系统中,级联故障会毫无预警的侵袭而来。不幸的是,级联故障基础的先决条件很难避免:比如简单的故障切换。如一个组件的故障触发了重试或者导致流量切换到系统的另外部分,级联故障的基础条件就被满足了。但是一切都没有丢失,我们能够实践一些模式来保护你的系统免于级联故障。

三、反馈循环:级联故障是怎么干掉我们的系统的



在分布式软件系统中级联故障通常涉及到反馈机制的循环回路,它们通常会引起能力下降,延迟增加或者一连串错误。而它们从系统其他组件得到的响应使得原始的问题变得更加严重。
因果循环图(Causal Loop Diagram (CLD))是一个帮助我们理解这些事故的得力工具。下图是一个早前DynamoDB事故的因果循环图。
(图2 - 2015年DynamoDB事故的因果循环图)
因果循环图是来自系统动力学的工具,MIT的Jay Forrester发明了它来为复杂系统建模。每个箭头表示系统中的两种数量如何相互作用。箭头边的加号'+'代表第一个数量的增加将趋向于第二个数量的增加,减号'-'代表两者有相反的作用关系。所以,如图中的服务他的实例数量(the number of instances serving it),它遵循了这样的规律,服务能力增加会减少每个实例的负载。添加新的索引类型,或者重试失败的请求则会增加实例的负载。
正如我们在一个有循环图中需要做的,我们要看循环中的混在一起的加减号是否可以平衡这个循环。这里的途中我们只有加号'+',则意味着该循环不平衡。系统动力学把它称为正反馈回路(reinforcing cycle)(因此'R'在箭头中央)。
你的系统中拥有一个正反馈回路并不意味着会时常过载。如果能力充分的满足了需求,一切正常工作。但是,这也意味着在正常环境下,能力的下降,一连串负载,或者任何增加了延迟或超过阈值超时,会造成级联事故的发生,正如DynamoDB一样。
一个关键的现实,一个非常相似的回路存在于大多数具有主备服务上以保证客户的失败重试,这是一个非常非常普通的模式。在后文中我们会检测一些模式从而防止这样的回路进入级联事故的场景。
让我们看看另一个级联事故的例子:Parsely's Kafkapocalyspe[2]. 其牵涉的系统不同但是模式却相似。由于一次发布,Parsely提高了他们系统的负载,包含了他们Kafka集群。然而他们所不知道的是,他们已经离运行Kafka Broker(缓存代理)的EC2节点的网络限制非常接近了。在某个时刻,一个缓存代理节点达到了网络限制而变成不可用状态。其他缓存代理节点的负载增高了,而当客户端发生失败切换重试,其他的缓存代理节点很快就被击垮了。
从早先的AWS场景来看,我们从Parsely的故障看到了一个系统一旦违背了某些现实,便极速地从稳定和可预测的状态进入非线性和失能状态,并且直到运维人工介入才能恢复。

四、从级联故障中恢复



向你的服务系统中添加资源和扩展能力通常很难使你逃脱级联故障。健康的新实例会被过量的负载立刻冲击而达到负载的饱和状态。在这样的情况下,服务能力始终无法处理现有的负载。很多负载均衡系统使用健康检查只将请求发送给健康的实例,所以你可能需要在事故中关闭这个行为来避免所有的流量被发送到新加入的实例上。
另外一个相同的事实是这些服务编排和管理工具将会把你未通过健康检查的服务器删除(比如Kubernetes liveness probes[3]);他们会移除过载的实例,以试图解决服务能力问题。有些时候关闭整个系统并重新引入流量是惟一修复这个问题的办法。我们在DynamoDB的实践中看到了这个方法。Sptify在2013年的事故[4]中也使用了这种方式,把受到影响的服务下线来恢复系统。这个特别像过载的服务没有对负载队列的数量和当前的请求施加任何限制。

五、六种级联故障的反例



反例模式1:接受无限制的入栈请求数量
任何做了很多基准测试的人大概都注意到了单独的服务实例通常会到达吞吐量的顶峰。之后如果深入增加你会看到吞吐量下降并且有一个实例出现了延迟。这个变化发生的原因是任何服务的一些服务不是并行的(Baron Schwartz的演讲'接近不可接受的负载边界[5]'有一个很好的数学解释)。在级联故障的某一个状态,单独的服务实例会达到很多的正在排队的请求,或者有很多的并行线程在试图执行,因此这个服务变成完全的无响应状态并且可能不会自主恢复(通常来说,需要介入重新启动)。
2018年有一个故障的例子[6]同时牵涉到了这两种条件:“我们确定在等待数据库连结的时候我们应用程序排列请求队列的方式使得限制失效了。在此情况下,队列中由此方式建立的请求使得数据库无法从大量积压的请求尝试进程中恢复,甚至在流量平息并且限制生效后(仍然无法恢复)。”
这就是为什么为你这样的服务实例设置负载的限制非常重要。负载均衡器的卸除负载(Loadshedding[7])可以完成这样的工作,但是你仍然也需要为你的服务设置限制来进行深入的防御。基于你使用的不同编程语言和服务器框架,实施对于并行请求限制的机制变化多样,但是也会像信号灯一样简单。Netflix的concurrency-limits[8]工具就是一个基于Java的例子。
当服务器负载严重的时候使得请求越早的失败实际上是对于客户端非常好的做法。使得请求尽早的失败并在服务的其他实例上进行重试,或者发出错误或者降级的经验,都比等待直到请求超时(或者不确定的来说,如果请求没有超时的设置)来的更好。允许上述的场景发生会导致减速在整个微服务架构上的蔓延,这样在所有服务都逐渐停滞下来的时候,如何发现出现问题的根本服务有的时候就会非常棘手。
反例模式2:危险客户端重试行为
我们不总是通过客户端的行为来进行控制,但是如果你确实控制你的客户端,调节客户端的请求模式就会变成非常有用的工具。在最基础的层面,客户端应该限制在某一小段时间内重试失败请求的次数。在一个系统中如果客户端在一小段循环中重试过多的次数,任何一小串错误会造成重试的洪水,事实上造成服务阻断。Square在2017年三月的故障[9]中经历了这个,他们的Redis实例在一段代码尝试重试一条事务500次后变得不可用。下面就是一段简单的Golong语言的重试循环代码:
const MAX_RETRIES = 500
for i := 0; i < MAX_RETRIES; i++ {
_, err := doServerRequest()
if err == nil {
break
}
}

当Square的工程师们推出了一个减小重试次数的修复之后,这个回馈循环立刻结束并且他们的服务也变得正常了。

客户端在重试之间应该使用一个指数退避的方式。为退避的等待时间增加一个小的随机噪音或者震动也是很好的实践。这个实践使得重试在时间上产生了一些波动,这样服务就不会被正常的两次重试负载同时的访问到,因为上述的波动使得服务有几毫秒的‘暂时故障’。重试的次数和等待时间是应用程序独有的。面向用户的请求应该快速失败或者返回某些降级的结果,而批处理或者异步处理的结果则可以等待长一些时间。

这就是Golang的带有指数退避和抖动的重试代码示例:

const MAX_RETRIES = 5
const JITTER_RANGE_MSEC = 200
steps_msec := []int{100, 500, 1000, 5000, 15000}
rand.Seed(time.Now().UTC().UnixNano())
for i := 0; i < MAX_RETRIES; i++ {
_, err := doServerRequest()
if err == nil {
break
}
time.Sleep(time.Duration(steps_msec[i] + rand.Intn(JITTER_RANGE_MSEC)) *
time.Millisecond)
}

现代的最佳实践超越了指数退避和抖动。Circuit Breaker[10]应用模式包裹了外部调用的请求并且跟踪其随时间推移的成功和失败。一系列的失败调用会使断路器触发,也就是说不能再有外部服务的失败调用,一旦客户端再有失败的请求便会立即得到一个错误。断路器会定期地允许一个请求通过用于探测外部服务。一旦探针请求成功,断路器会重置并且恢复开始外部请求的调用。

断路器非常强大因为他能在来自客户端的请求中分享所有请求的状态给统一的后端,而指数退避只能针对单独一个具体的请求。断路器比起其他途径更能减轻挣扎中的后台服务的负载。这是一个Golang的断路器实施[11]。Netflix的Hystrix包含了一个Java断路器[12]。

反例模式3:非法输入导致崩溃-"死亡查询"

所谓"死亡查询"是能够造成你系统崩溃的请求。客户端会发送死亡请求,使得服务的一个实例崩溃,然后(客户端)会重试使得更多的实例宕机。由于所剩无几的实例会在正常的负载下变得超载,所以减少的能力会使得你的整体服务进而崩溃。

这种场景可以是你服务被攻击的一种结果,但是也许并不是恶意的,仅仅是运气不好。这也是为什么非预期的输入从不会造成退出或崩溃是一个最佳实践。一个程序应该仅在内部状态看起来不正确的时候非预期的退出并且不再安全的继续服务。

模糊测试(Fuzz Testing)是一种自动化测试的实践,用来帮助我们探测程序是否在恶意的输入中崩溃。模糊测试在所以暴露给非信任的输入(任何你组织外部的)的服务上尤其重要。

反例模式4:接邻故障切换和多米诺效应

当整个数据中心或者可用区都宕机的时候你的系统会怎么样呢?如果你的答案是“故障切换到下一个最近的(数据中心或可用区)”那么你的系统就会有潜在的级联事故风险。

(图3 - 数据中心地图)

如果你失去了拓扑学上美国东海岸的数据用心,像上图所示,那么另一个在该地区的数据中心就会粗略的接收到两倍的负载。如果剩下的这个东海岸数据中心无法处理负载从而崩溃,那么负载就会主要地去向西海岸的数据中心(通常会比发往欧洲便宜)。如果西海岸的数据中心也崩溃,那么你剩下地区的数据中心也会一个接一个的崩溃:像多米诺骨牌一样。你预期用来提高系统稳定性故障切换计划引发了整个服务的宕机。

地理均衡系统需要从两个事情中抉择:要么确保故障切换负载不会使得其他数据中心过载,要么确保所有地方有很高的能力冗余。

基于IP Anycast(和大部分DNS服务和CDN相似)的系统通常产能过剩,特别地因为无限传播,它从英特网的多个地方为一个IP提供服务,使得你无法控制入境流量。

这个级别的产能过量失败是非常昂贵的。在很多系统中,使得有能力的数据中心直接处理负载是更合理的方式。这种方式往往通过DNS负载均衡来实现(比如NS1的intelligent traffic distribution[13])

反例模式5:故障导致工作升级

有的时候我们的服务在错误发生的时候确实也能工作。考虑一个假想的分布式数据存储系统,它把我们的数据分隔成块。我们想要每一块都有最小数量的备份,并且我们会定时周期的检查其数量是否正确。如果不正确,那么我们开始新的备份。这是一个伪代码片段:

replicaChecker()
while true {
for each block in filesystem.GetAllBlocks() {
if block.replicasHeartbeatedOK() < minReplicas {
block.StartCopyNewReplica()
}
}
}
}

(图4 - 失败后数据块复制)

这个途径在我们失去一块儿数据的时候大概还能工作,或者失去很多服务器中的一个。那么如果我们失去了很大比例的服务器呢?一整个机柜呢?服务能力会下降,剩下的服务器则要变得十分忙碌因为要重新备份数据。我们没有给同时复制设置任何限制。这是该反馈循环的CLD图。

(图5 - 带有反馈回路的因果图)

通常围绕这个的做法是延迟复制(因为故障通常是短暂的),并且通过类似token bucket[14]的算法限制正在复制的进程数。下面的因果循环图描述了它是怎么改变系统的:我们仍然有反馈回路,但是现在这里有了一个内部平衡回路来防止反馈回路超速。

(图6 - 带有复制速率限制的因果图)

反例模式6: 长启动时间

有些时候服务设计启动时有许多工作要进行,比如从缓存中越多很多数据。这种模式最好要避免,有两个原因。首先这会造成任何形式的自动缩扩容非常困难:当你已经探测到一个实例的负载增加并且开启了一个你的启动慢(slow-to-start-up)的服务,你会陷入困境。第二方面,如果你的服务由于某些原因而失败(内存不足,或者死亡查询造成的崩溃),他会使得你需要很长时间让你的系统恢复到正常服务能力。这两种条件会让你的服务很容易的过载。


六、减少级联事故风险



潜在的级联故障,虽然不是大部分,但是在很多分布式系统中是与生俱来的。如果你并没有在你的系统中遇到,并不代表你的系统会幸免于此,因为你的系统也许仅仅在它的安全舒适范围之内运行而并没有到达其极限。对于明天或是下周是否发生,并没有任何保证。

我们已经列出了一些反例来帮助你避免和减小发生级联故障的风险。没有服务可以抵御不受控制的一串串负载。没有人想让他们的服务为错误服务,但是当你看到另一面是整个服务被每一个入境的请求按在地上摩擦的逐渐停顿下来的时候,这就没有那么邪恶了。

更多阅读

  • 'Addressing Cascading Failures,' by Mike Ulrich, in Site Reliability Engineering: How Google runs Production Systems.
  • 'Stability Patterns' chapter in Release It! by Michael T Nygard.
  • 'Handling Overload' chapter by Alejandro Forero Cuervo in Site Reliability Engineering: How Google runs Production Systems.
关于作者
Laura Nolan 是都柏林Slack公司的Senior Staff Engineer。她的背景是SRE,软件工程,分布式系统和计算机科学。她是O'Reilly出版的《Site Reliability Engineering》一书中"Managing Critical State"章节的作者,最近也更多的专注于"Seeking SRE"。她也是USENIX SREcon[15]委员会轮席委员。
引用链接
  • [1] 2015年9月20日亚马逊DynamoDB在美东1区的持续了四个多小时的事故: https://aws.amazon.com/message/5467D2/
  • [2] Parsely's Kafkapocalyspe: https://blog.parse.ly/post/1738/kafkapocalypse/
  • [3] Kubernetes liveness probes: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
  • [4] Sptify在2013年的事故: https://labs.spotify.com/2013/06/04/incident-management-at-spotify/
  • [5] 接近不可接受的负载边界: https://www.usenix.org/conference/srecon18americas/presentation/schwartz
  • [6] 2018年有一个故障的例子: https://status.duo.com/incidents/4w07bmvnt359
  • [7] Loadshedding: https://www.usenix.org/conference/srecon17europe/program/presentation/cruz
  • [8] concurrency-limits: https://github.com/Netflix/concurrency-limits
  • [9] Square在2017年三月的故障: https://medium.com/square-corner-blog/incident-summary-2017-03-16-2f65be39297
  • [10] Circuit Breaker: https://www.martinfowler.com/bliki/CircuitBreaker.html
  • [11] 一个Golang的断路器实施: https://github.com/matttproud/golang_circuitbreaker
  • [12] 一个Java断路器: https://github.com/Netflix/Hystrix/wiki/How-it-Works#CircuitBreaker
  • [13] NS1的intelligent traffic distribution: https://ns1.com/blog/using-load-shedding-for-intelligent-traffic-distribution-1
  • [14] token bucket: https://en.wikipedia.org/wiki/Token_bucket
  • [15] USENIX SREcon: https://www.usenix.org/srecon


#IDCF DevOps黑客马拉松挑战赛,独创端到端DevOps体验,精益创业+敏捷开发+DevOps流水线的完美结合。

大连站6月11-12日,北京站7月23-24日将举办线下公开课挑战赛,36小时内从0到1打造并发布一款产品。

企业组队参赛&个人参赛均可,赶紧上车~👇



浏览 42
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报