有“韧性”才能更“任性”| 微软云原生韧性设计指南

面对复杂多变的业务和运维环境,很多人绞尽脑汁想要维持业务的持续运转。
然而很多时候,无论删库跑路导致企业丢失所有关键业务数据,或外部施工出错挖断光缆电线,甚至某些内部或外部基础服务上一个小小的错误配置导致半个地球范围内的服务中断……所有这些或大或小的问题,总会让很多人手忙脚乱处理半天,还会让业务甚至企业声誉遭受不小的影响。
【有“韧性”才能更“任性”| 微软云原生韧性设计指南】虽然那句话说得好:破坏稳态的难度越大,我们对系统行为的信心就越强;并且只要能发现某个弱点,我们就有了一个改进目标。
然而以往在本地部署和运行关键应用时,包括基础架构、底层硬件在内的很多因素可由企业自行掌控,因此发现并解决弱点还是好处理的(也许需要投入大量资金和人力)。但当企业开始上云,通过云平台运行这些关键应用时,底层基础架构的管理和维护是由云平台承担的,这时又该如何解决弱点,打造更稳定、更有韧性的运维环境和应用程序?
本文将从设计思路角度出发,告诉你如何提高云原生应用的韧性,确保在遇到事件后业务依然能够稳妥运行。
与远程服务和资源通信的所有应用程序必须对临时性故障敏感。对于云中运行的应用程序尤其如此,因为其环境的性质与通过互联网建立连接的特点,意味着更容易遇到此类问题。临时性故障包括客户端和服务瞬间断开网络连接、后台服务暂时不可用,或者并发过大出现的超时等。这些错误通常是可以自我修复的,如果能把故障影响控制在一定范围内,则可将对最终用户的影响降至最低。
为什么云中会出现临时性故障? 任何环境、任何平台或操作系统以及任何类型的应用程序都会发生临时性故障。在本地基础架构上运行的解决方案中,应用程序及其组件的性能和可用性通常由昂贵且利用率不足的冗余硬件来保证。虽然此方法使故障的可能性降低,但仍可能导致临时性故障,甚至因外部电源/网络问题或其他灾难情况等不可预测的事件而中断。
托管型云服务(PaaS)可以跨多个计算节点使用共享资源、冗余、自动故障转移和动态资源分配,实现更高的整体可用性。但是这些环境的性质意味着更可能发生临时性故障,原因包括:

  • 云环境中的许多资源是共享的,为了有效管理这些资源,云通常会严格管控对这些资源的访问。例如,某些服务在负载上升到特定级别,或到达吞吐量比率上限时,会拒绝额外连接以便处理现有请求,并为所有现存用户维持服务性能。限制有助于为共享资源的邻居与其他租户维持服务质量。
  • 云环境是使用大量商用硬件单元构建而成的。云环境将负载动态分散到多个计算单元和基础架构组件上以获得更多性能,并通过自动回收或更换故障单元来提供可靠性。这种动态性意味着可能偶尔会发生临时性故障或暂时性连接失败。
  • 在应用程序与资源及其使用的服务之间通常有多个硬件组件,包括网络基础架构,例如路由器和负载均衡器。这些附加的组件偶尔会导致额外的连接延迟或临时性连接故障。
  • 客户端与服务器之间的网络状况会不时改变,尤其是通过互联网通信时。即使在本地位置,高流量负载也可能减慢通信速度,并造成间歇性的连接故障。
面临的挑战 临时性故障可能会对用户感知的可用性产生巨大影响,即使应用程序已在所有可预见的情况下进行了全面测试。若要确保云托管的应用程序可靠运行,应用程序必须能够应对以下挑战:
  • 应用程序必须能够检测到故障的发生,并确定这些故障可能是临时性的、持久性的还是终端故障。发生故障时,不同资源可能返回不同响应,这些响应可能会根据不同操作而有所不同。例如,针对从存储读取时所发生错误返回的响应,与针对写入存储时所发生错误返回的响应不同。许多资源和服务都妥善制定了临时性故障的策略。但是若不提供此类信息,则很难发现故障的性质,以及故障是否是临时性的。
  • 如果确定故障可能是临时性的,应用程序必须能够重试操作,并跟踪操作重试的次数。
  • 应用程序必须使用适当的重试策略。此策略指定应用程序应该重试的次数、每两次尝试的延迟时间,以及尝试失败后执行的操作。适当的尝试次数以及每两次尝试的延迟时间通常难以确定,会根据资源类型以及应用程序本身的当前操作条件而有所不同。
韧性设计指南 以下指南将帮助您为应用程序设计合适的临时性故障处理机制:
确定是否存在内置重试机制
  • 许多服务提供SDK或包含临时性故障处理机制的客户端库。服务使用的重试策略通常是根据目标服务的性质和要求定制的。或者对于确定重试是否正确,以及在下一次尝试重试之前要等待多长时间方面,服务的REST接口可能会返回有用的信息。
  • 如果可用,请使用内置重试机制,除非有使不同重试行为更合适的具体且明确的要求。
确定操作是否适合重试
  • 应该仅在暂时性故障,以及在重新尝试时至少有一定成功的可能性之下才进行重试操作。对于指示无效操作(如对不存在的项进行数据库更新,或对发生致命错误的服务或资源的请求)的操作,重新尝试是没有意义的。
  • 一般而言,只有在能够确定操作的全部影响并充分了解状况并可进行验证时,才建议实施重试。否则应该由调用代码来实施重试。请记住,从无法控制的资源与服务返回的错误可能会随着时间而演进,可能需要重新建立访问临时性故障的检测逻辑。
  • 创建服务或组件时,请考虑实现错误检查代码和消息处理,以帮助客户端确定是否应该重试失败的操作。特别是,指示客户端是否应该重试该操作,并在下一次重试尝试之前建议一个适当的延迟。如果构建Web服务,请考虑返回在服务契约中定义的自定义错误。即使通用客户端可能无法读取这些信息,但在构建自定义客户端时它们将非常有用。
确定适当的重试计数与间隔
  • 优化用例类型的重试计数和间隔是至关重要的。如果没有重试足够次数,应用程序将无法完成操作,并可能经历失败;如果重试过多次,或者重试间隔过短,应用程序可能会长期占用线程、连接和内存等资源,这将对应用程序的运行状况产生不利影响。
  • 时间间隔和重试次数的适当值取决于正在尝试的操作类型。例如,如果操作是用户交互的一部分,那么间隔应该很短,并且只尝试几次,以避免让用户等待响应(这会保持打开的连接并降低其他用户的可用性;如果操作是长时间运行或关键工作流的一部分,其中取消和重新启动流程是费时费力的,那么在尝试和重试之间等待更长时间是合适的。
  • 确定重试之间的适当间隔是设计成功策略中最困难的部分。典型策略使用以下类型的重试间隔:
    (a)指数延迟:应用程序在第一次重试之前短暂等待,每个后续重试的间隔时间呈指数增加。例如,在3秒、12秒、30秒后重试操作。
    (b)增量间隔:应用程序在第一次重试之前短暂等待,每个后续重试的间隔时间增量递增。例如,在3秒、7秒、13秒后重试操作。
    (c)固定间隔:应用程序每次尝试的间隔时间相同。例如,固定每3秒重试操作
    (d)立即重试:有时临时性故障很短暂,可能是由于网络数据包冲突或硬件组件出现峰值等事件。在此情况下,适合立即重试操作,因为如果故障在操作让应用程序组合并发送下一个请求时已清除,则操作可能会成功。但是不应有多次立即重试尝试,如果立即重试失败,应切换到备用策略,例如指数退让或回退操作。
    (f)随机化:上面列出的任何重试策略都可能包括随机化,以防止客户机的多个实例同时发送随后的重试尝试。例如,一个实例可能在3秒、11秒、28秒之后重试该操作,而另一个实例可能在4秒、12秒、26秒之后重试该操作。随机化是一种有用的技术,可以与其他策略相结合。
  • 一般指导原则是,为后台操作使用指数退让策略,为交互式操作使用立即或固定间隔重试策略。在上述两种情况下,应该选择延迟与重试计数,使所有重试的延迟上限都在所需的端到端延迟要求范围内。
  • 考虑影响重试操作的总的最大超时的所有因素的组合。这些因素包括失败连接产生响应所花费的时间(通常由客户端中的超时值设置)以及重试尝试和最大重试次数之间的延迟。所有这些时间的总和可能会导致较长的总体操作时间,特别是当使用指数延迟策略时,其中重试间隔在每次失败后迅速增长。如果流程必须满足特定的服务水平协议SLA,则整个操作时间(包括所有超时和延迟)必须在SLA定义的限制内。
  • 过于激进的重试策略(间隔太短或重试太频繁)可能会对目标资源或服务产生不利影响。这可能会阻止资源或服务从其重载状态中恢复,并且它将继续阻塞或拒绝请求。这导致了一个恶性循环,越来越多的请求被发送到资源或服务,从而进一步降低了其恢复能力。
  • 在选择重试间隔时,要考虑操作的超时时间,以避免立即启动后续的尝试(例如,如果超时时间与重试间隔类似)。还要考虑是否需要将可能的总时间(超时加上重试间隔)保持在特定的总时间以下。超时时间异常短或异常长的操作可能会影响等待多长时间以及重试操作的频率。
  • 使用异常的类型和它包含的任何数据,或者从服务返回的错误代码和消息,来优化重试的间隔和次数。例如,一些异常或错误代码(例如响应中带有Retry-After头的HTTP代码503 Service Unavailable)可能指示错误可能持续多长时间,或者服务已经失败,不会响应任何后续的尝试。
避免反模式
  • 在绝大多数情况下,应该避免包含重复重试代码层的实现。避免包含级联重试机制的设计,或者在涉及请求层次结构的操作的每个阶段实现重试的设计,除非有要求这样做的特定需求。在这些异常情况下,请使用防止过多重试次数和延迟时间的策略,并确保理解其后果。例如,如果某个组件对另一个组件发出请求,后者再访问目标服务,并且要对这两个调用各实施重试三次,则总共会对该服务重试九次。许多服务和资源实施内置重试机制,如果需要在较高级别实施重试,应调查如何禁用或修改此设置。
  • 永远不要实现无止境的重试机制。这可能会阻止资源或服务从过载情况中恢复,并导致节流和拒绝连接持续较长时间。使用有限的次数或重试,或实现一个模式(如断路器),以允许服务恢复。
  • 立即重试不要超过一次。
  • 避免使用常规重试间隔,特别是当访问Azure中的服务和资源时,有大量的重试尝试时。在这种情况下,最优的方法是采用具有断路能力的指数后退策略。
  • 防止同一客户端的多个实例或不同客户端的多个实例在同一时间发送重试。如果可能发生这种情况,请在重试间隔中引入随机化。
测试重试策略与实施 确保在尽可能广泛的情况下全面测试重试策略实现,特别是当应用程序和它使用的目标资源或服务都处于极端负载下时。要在测试期间检查行为,可以:
  • 将瞬态和非瞬态故障注入服务。例如,发送无效请求或添加检测测试请求的代码,并使用不同类型的错误进行响应。
  • 创建资源或服务的模拟,该模拟返回真实服务可能返回的一系列错误。确保覆盖了重试策略旨在检测的所有类型的错误。
  • 如果是自己创建和部署的自定义服务,则通过临时禁用或重载该服务来强制发生瞬态错误(当然,我们不应该尝试重载Azure中的任何共享资源或共享服务)。
  • 对于基于HTTP的API,可以考虑在自动化测试中使用FiddlerCore库,通过添加额外的往返时间或更改响应(如HTTP状态代码、头、正文或其他因素)来更改HTTP请求的结果。这样就可以对故障条件的子集进行确定性测试,无论是瞬时故障还是其他类型的故障。
  • 执行高负载系数和并发测试,以确保重试机制和策略在这些条件下正确工作,并且不会对客户机的操作产生不利影响或导致请求之间的交叉污染。
管理重试策略配置
  • 重试策略是重试策略的所有元素的组合。它定义了确定故障是否可能是暂时的检测机制、要使用的间隔类型(如常规、指数后退和随机化)、实际间隔值和重试次数。
  • 即使是最简单的应用程序,也必须在许多地方实现重试,更复杂的应用程序的每一层都必须实现重试。考虑使用一个中心点来存储所有策略,而不是在多个位置硬编码每个策略的元素。例如,将间隔和重试计数等值存储在应用程序配置文件中,在运行时读取它们,并以编程方式构建重试策略。这使得管理设置、修改和微调值以响应不断变化的需求和场景变得更加容易。但是,要设计系统来存储这些值,而不是每次都重新读取配置文件,并确保在无法从配置中获得这些值时使用合适的默认值。
  • 在Azure云原生应用程序中,考虑将用于构建运行时重试策略的值存储在服务配置文件中,这样就可以在不重启应用程序的情况下更改它们。
  • 利用客户端API中提供的内置或默认重试策略,但只在它们适合的场景使用。这些策略通常是通用的。在某些场景中,它们可能是所有必需的,但在其他场景中,它们可能不会提供所有选项来满足特定需求。通过测试确定最合适的值,我们必须了解设置将如何影响应用程序。
记录和跟踪瞬态和非瞬态故障
  • 作为重试策略的一部分,包括异常处理和记录重试尝试时的其他检测。虽然偶尔出现短暂的故障和重试是可以预期的,并且并不表明有问题,但定期的和不断增加的重试次数通常是一个可能导致故障的问题的指示器,或者当前正在降低应用程序的性能和可用性。
  • 将瞬态故障记录为警告项而不是错误项,以便监控系统不会将它们检测为可能触发错误警报的应用程序错误。
  • 考虑在日志条目中存储一个值,该值指示重试是由服务中的节流引起的,还是由其他类型的错误(如连接失败)引起的,以便在分析数据时对它们进行区分。节流错误数量的增加通常表明应用程序存在设计缺陷,或者需要转向提供专用硬件的优质服务。
  • 考虑测量和记录包含重试机制的操作所花费的总时间。这是暂时性错误对用户响应时间、处理延迟和应用程序用例效率的总体影响的一个很好的指示器。还要记录发生的重试次数,以便了解影响响应时间的因素。
  • 考虑实现一个遥测和监控系统,当失败的数量和比率、平均重试次数或操作成功所需的总时间增加时,该系统可以发出警报。
管理持续失败的操作
  • 在某些情况下,每次行动都会失败,考虑如何处理这种情况是至关重要的。
  • 尽管重试策略将定义一个操作应该重试的最大次数,但它不会阻止应用程序以相同的重试次数再次重复该操作。例如,如果一个订单处理服务由于一个致命错误而失败,使其永久停止操作,重试策略可能会检测到连接超时,并认为这是一个暂时的错误。代码将重试指定次数的操作,然后放弃。然而当另一个客户下订单时,该操作将再次尝试——即使每次都肯定会失败。
  • 为了防止对不断失败的操作进行不断重试,考虑实现断路器模式。在此模式中,如果指定时间窗口内的失败次数超过阈值,则请求将立即作为错误返回给调用者,而不尝试访问失败的资源或服务。
  • 应用程序可以周期性地测试服务(断断续续的,请求之间的间隔很长),以检测服务何时可用。适当的间隔取决于场景,例如操作的关键程度和服务的性质,可能是几分钟到几个小时之间的任何时间。在测试成功时,应用程序可以恢复正常操作,并将请求传递给新恢复的服务。
  • 与此同时,可以退回到服务的另一个实例(可能在不同的数据中心或应用程序中),使用提供兼容(可能更简单)功能的类似服务,或者执行一些替代操作,希望该服务很快可用。例如,可以将服务请求存储在队列或数据存储中,稍后再重放它们。否则我们可能会将用户重定向到应用程序的另一个实例,降低应用程序的性能,但仍然提供可接受的功能,或者只是向用户返回一条消息,指示该应用程序目前不可用。
其他的考虑
  • 在决定重试次数和策略重试间隔的值时,请考虑服务或资源上的操作是否是长时间运行或多步骤操作的一部分。当一个操作步骤失败时,补偿所有其他已经成功的操作步骤可能是困难的或昂贵的。在这种情况下,很长的间隔和大量的重试是可以接受的,只要它不通过持有或锁定稀缺资源来阻塞其他操作。
  • 请考虑重试同一操作是否可能导致数据不一致。如果重复多步骤流程的某些部分,且操作不是幂等的,则可能导致不一致。例如,递增值的操作如果重复,将产生无效的结果。如果无法检测到重复的消息,重复将消息发送到队列的操作可能会导致消息使用者中出现不一致。要防止这种情况,请确保将每个步骤设计为幂等操作。
  • 考虑将要重试的操作的范围。例如,在包含多个操作的级别上实现重试代码可能更容易,如果其中一个操作失败,则重试所有操作。但是,这样做可能会导致幂等问题或不必要的回滚操作。
  • 如果选择一个包含多个操作的重试范围,那么在确定重试间隔、监视所花费的时间以及发出失败警报之前,请考虑所有操作的总延迟。
在为云原生应用考虑韧性设计时,请务必慎重思索重试策略可能会如何影响共享应用程序中的邻居和其他租户。激进的重试策略可能导致其他用户以及共享资源和服务的应用程序出现越来越多的瞬时错误。
同样,我们的应用程序可能会受到资源和服务的其他用户所实现的重试策略影响。对于关键任务应用程序,我们可以决定使用不共享的高级服务。这为我们提供了更多的负载控制以及相应资源和服务的节流,这有助于证明额外的成本是合理的。
遵循上述思路,并结合具体情况进行调整,我们就可以顺利设计出具备足够韧性的云原生应用架构。
希望本文对你能有所帮助。如果对这个话题感兴趣,那么敬请期待后续发布的更多系列文章,我们将继续从重试模式、断路器模式等角度深入探讨相关机制的实现思路和方法。更多精彩敬请期待!

    推荐阅读