“从一次常见的发布说起,在云上某个系统应用发布时,重启阶段会导致较大数量的 OpenAPI、上游业务的请求响应时间明显增加甚至超时失败。随着业务的发展,用户数和调用数越来越多,该系统又一直保持一周发布二次的高效迭代频率,发布期间对业务的影响越来越无法接受,微服务下线的治理也就越来越紧迫。”
云原生架构的发展给我们微服务系统带来了自动的弹性伸缩、滚动升级、分批发布等原生能力,让我们享受到了资源、成本、稳定性的最优解,但是在应用的缩容、发布等过程中,由于实例下线处理得不够优雅,将会导致短暂的服务不可用,短时间内业务监控会出现大量 io 异常报错;如果业务没做好事务,那么还会引起数据不一致的问题,那么需要紧急手动订正错误数据;甚至每次发布,都需要发告示停机发布,我们的用户会出现一段时间服务不可用。
微服务下线有损问题分析
减少不必要的 API 报错,是最好的用户体验,也是最好的微服务开发体验。如何解决这个在微服务领域内让人头疼的问题呢?在这之前我们先来了解一下为什么我们的微服务在下线的过程中会有可能出现流量损失。
如上图所示,是一个微服务节点下线的正常流程
- 下线前,消费者根据负载均衡规则调用服务提供者,业务正常。
- 服务提供者节点 A 准备下线,先对其中的一个节点进行操作,首先是触发停止 Java 进程信号。
- 节点停止过程中,服务提供者节点会向注册中心发送服务节点注销的动作。
- 服务注册中心接收到服务提供者节点列表变更的信号后会,通知消费者服务提供者列表中的节点已下线。
- 服务消费者收到新的服务提供者节点列表后,会刷新客户端的地址列表缓存,然后基于新的地址列表重新计算路由与负载均衡。
- 最终,服务消费者不再调用已经下线的节点
微服务下线的流程虽然比较复杂,但整个流程还是非常符合逻辑的,微服务架构是通过服务注册与发现实现的节点感知,自然也是通过这条路子实现节点下线变化的感知,整个流程没有什么问题。
参考我们这边给出的一些简单的实践数据,我想你的看法可能就会变得不同。从第 2 步到第 6 步的过程中,Eureka 在最差的情况下需要耗时 2 分钟,即使是 Nacos 在最差的情况下需要耗时 50 秒;在第 3 步中,Dubbo 3.0之前的所有版本都是使用的是服务级别的注册与发现模型,意味着当业务量过大时,会引起注册中心压力大,假设每次注册/注销动作需要花费20~30ms,五六百个服务则需要注册/注销花费掉近 15s 的时间;在第 5 步中, Spring Cloud 使用的 Ribbon 负载均衡默认的地址缓存刷新时间是 30 秒一次,那么意味着及时客户端实时地从注册中心获取到下线节点的信号,依旧会有一段时间客户端会将请求负载均衡至老的节点中。
如上图所示,只有到客户端感知到服务端下线并且使用最新的地址列表进行路由与负载均衡时,请求才不会被负载均衡至下线的节点上。那么在节点开始下线的开始到请求不再被打到下线的节点上的这段时间内,业务请求都有可能出现问题,这段时间我们可以称之为服务调用报错期。
在微服务架构下,面对每秒上万次请求的流量洪峰,即使服务调用报错期只有短短几秒,对于企业来说都是非常痛的影响。在一些更极端的情况下,服务调用报错期可能会恶化到数分钟,导致许多企业不敢发布,最后不得不每次发版都安排在凌晨两三点。对于研发来说每次发版都是心惊胆颤,苦不堪言。
无损下线技术
通过对微服务下线的流程分析,我们理解了解决微服务下线问题的关键就是:保证每一次微服务下线过程中,尽可能缩短服务调用报错期,同时确保待下线节点处理完任何发往该节点的请求之后再下线。
那么如何缩短服务调用报错期呢?我们想到了一些策略:
- 将步骤 3 即节点向注册中心执行服务下线的过程提前到步骤 2 之前,即让服务注销的通知行为在应用下线前执行,考虑到 K8s 提供了Prestop接口,那么我们就可以将该流程抽象出来,放到 K8s 的 Prestop 中进行触发。
- 如果注册中心能力不行,那么我们是否有可能服务端在下线之前绕过注册中心直接告知客户端当前服务端节点下线的信号,该动作也可以放在K8s的Prestop接口中触发。
- 客户端在收到服务端通知后,是否可以主动刷新客户端的地址列表缓存。
如何尽可能得保证服务端节点在处理完任何发往该节点的请求之后再下线?站在服务端视角考虑,在告知了客户端下线的信号后,是否可以提供一种等待机制,保证所有的在途请求以及服务端正在处理的请求被处理完成之后再进行下线流程。
如上图所示,我们通过以上这些策略可以确保服务消费者端尽可能早实时地感知到服务提供者节点下线的行为,同时服务提供者会确保所有在途请求以及处理中的请求处理完成之后,再进行服务下线。这些想法看起来没什么问题,接下来看一下我们是如何在 Spring Cloud 跟 Dubbo 服务框架中实现的。
首先我们需要在服务提供者进程中内置一个 HttpServer 向外暴露 /offline 接口,用于接受主动注销的通知。我们可以在 K8s 的 Prestop 中配置 curl http://localhost:20001/offline
触发主动注销接口。该接口收到 offline 命令后,通过触发调用注册中心中下线实例的接口或者通过调用微服务程序中的 ServiceRegistration.stop 接口执行服务注销动作,使得我们在停止微服务之前完成向注册中心进行节点地址的下线动作。
我们在 Prestop 接口中还要实现一个能力就是主动通知的能力。在 Dubbo 框架中是比较好实现的,因为 Dubbo 本身就是长连接的模型,我们可以发现 Dubbo 在服务提供者中维护了与所有服务消费者连接的 Channel 集合,在收到 offline 命令后,向所有维护中的 Channel 发送一个 ReadOnly 信号,标记该 Channel 为只读状态,Dubbo 的消费者收到 ReadOnly 信号后,将不再往该服务提供者发送请求,从而实现主动通知的效果。对于 Spring Cloud 框架而言实现思路也是类似的,由于 Spring Cloud 调用的请求是没有 Channel 的模型,因此我们在收到 offline 命令后,我们在请求的 Response Header 中带上 ReadOnly 标签,服务消费者收到 ReadOnly 标签后,会主动刷新负载均衡 Ribbon 缓存,保证不再有新的请求访问下线过程中的服务提供者。
我们服务提供者端需要等待所有在途请求处理完成之后,再进行应用停止流程。由于业务的不确定性,请求处理时长是不确定的,那么服务提供者端需要等待多久才可以等到所有在途请求处理完成呢?为了解决这个问题,我们设计了一种自适应等待的策略。我们让应用在下线前会有一段自适应等待的时期,我们对所有进入服务提供者以及调用完成的流量进行统计与计算。在这个过程中应用会一直等待,直到应用处理完成所有流向当前应用的流量之后再进行停机下线流程。
通过服务提前注销、主动通知以及自适应等待这三种策略,我们实现了微服务无损下线的能力。从而避免微服务节点下线过程中存在较长的服务报错期,解决了发布过程中业务流量损失的问题。
大规模下无损下线实践
到目前为止,以上的一系列解决思路与策略都看起来非常的完美,但是当我们面对云上客户时,特别是在面对大规模的微服务场景下,无损下线方案在落地的过程中依旧碰到了许多问题。云上某客户生产环境的 Spring Cloud 应用在接入我们的方案之后,发布的过程中依旧出现了大量的错误ErrorCode: ServiceUnavailable
。我们跟客户一起分析排查之后,我们定位到问题的根因是有些 Consumer 没能及时收到 Provider 的下线通知,即使服务端节点已经下线了,依旧有流量访问下线的服务端节点。在大规模之下,注册中心的通知及时性是不能保证的,我们还意识到 “在收到 offline 命令后,我们可以在收到请求的返回值中带上 ReadOnly 标签” 这个方式的及时性也不能保证,特别是在QPS不大、RT较长、应用的节点数量过多的情况下,有许多 Consumer 是没能收到 Provider 下线通知的。我们排查了各个 Consumer 节点收到 ReadOnly 标签的日志,发现确实有不少 Consumer 没有日志记录,证明了我们的怀疑。
主动通知
为了解决大规模实践中的问题,我们必须有一种更加实时可靠的主动通知方案。考虑到 Spring Cloud 调用的请求是没有 Channel 的模型,那么我们就需要在 Spring Cloud 服务提供者端维护最近一段时间内调用过该实例的服务消费者地址列表。在收到 offline 命令后,服务提供者将会遍历缓存在内存中的服务消费者地址列表,并对每一个 Consumer 发起一次 GoAway 的 Http 调用。当然我们需要在服务消费者对外暴露的 HttpServer 中增加接收 GoAway 通知的接口。当服务消费者收到调用后,服务消费者会主动刷新当前节点的负载均衡 Ribbon 缓存,并且在流量路由的过程中隔离掉发送 GoAway 请求的 Provider 节点,这样当前服务消费者就不会再向对应的 Provider 节点发起请求。当 Provider 对每一个 Consumer 节点进行 GoAway 调用后,则表示该服务提供者已经将“下线中”的信号通知至所有活跃的消费者,通过这个方式我们实现了大规模下相对可靠的主动通知的能力。
可观测性建设
无损下线的流程非常复杂,同时还涉及到多个节点之间的通知机制,特别是在大规模之下,下线流程的完整性以及可靠性的确认变得非常复杂与繁琐。我们需要一种完善的可观测能力,帮助我们观测下线的流程有无任何问题。当出现问题的时候,需要可观测能力帮助我们快速定位问题以及根因。
如何判断我们每次发布应用的无损下线是否生效?
最直观的方式那就是看业务的流量,我们需要站在 Provider 视角上看业务流量是否在 Provider 下线前停止,并且在这个过程中业务流量没有损失。想到这一块,那我们就应该提供 Provider 节点的流量情况,并且需要关联无损下线的事件。这样就可以直观地看到先是触发了无损下线,在没有业务流量之后再停止应用的完整无损下线流程。
Metrics 流量视图
我们借助可观测 Metrics 能力,对于每个Pod的业务流量进行统计与展示,同时在流量执行的过程中关联无损下线事件,这样就可以直观地看到到微服务节点下线过程有无问题,一目了然。
如何判断无损下线的流程执行符合我们的预期?
按照我们主动通知的逻辑,我们微服务节点下线过程中,需要对每一个 Consumer 节点进行 GoAway 调用。设想一下,在大规模场景下,假设当前应用有 5 个消费者应用,且每个应用有 50 个节点,那么我们如何确保 GoAway 通知到了这 250 个 Consumer 中的每一个 Consumer ?无损下线流程本身就非常复杂,特别在大规模场景下,无损下线可观测问题的复杂度急剧上升。我们想到可以借助 Tracing 能力提升无损下线的可观测能力。
依赖 Tracing 的无损下线可观测新思路
如上图该场景,我们对 108 节点进行缩容操作,我们就可以得到一条 Tracing 链路,其中包含主动通知、服务注销、应用停止等几个步骤,并且我们可以在每个步骤中看到所需的信息。
在主动通知环节我们可以看到当前 Provider 节点对哪些 Consumer 进行 GoAway 请求的调用,如下图所示我们将主动通知 10.0.0.90、10.0.0.176 两个 Consumer 节点。
当 Consumer 收到 GoAway 调用后,会进行负载均衡列表的刷新以及路由的隔离,我们将在负载均衡地址列表中显示最新抓到的当前 Consumer 对于当前服务缓存的最新地址列表,我们可以在下图中看到,地址列表中只剩下 10.0.0.204 这个服务提供者节点的调用地址。
我们也可以看到 Spring Cloud 向 Nacos(注册中心)执行服务下线的调用结果,注销成功。
我们发现通过将无损下线的 workflow 抽象成 Tracing 结构的策略,可以帮助我们降低大规模场景、复杂链路下无损下线问题的排查成本,帮助我们更好地解决大规模下微服务下线时流量无损的问题。
总结
软件迭代过程中,除了风险控制,在微服务领域还有一个常见的问题就是应用上下线过程中的流量治理,目的也比较明确,确保应用在发布、扩缩容、重启等场景时,不会损失任何业务流量损失。无损下线技术正是在这样的背景下应运而生的,他解决了变更过程中的业务流量损失问题,也是流量治理体系中非常重要的一个环节。无损下线功能有效地保证我们业务流量的平滑,提升了微服务开发的幸福度。
MSE 无损下线功能也随着客户场景的丰富在不断演进与完善。值得一提的是,我们在实践微服务治理的过程中开源了 OpenSergo 这个项目,旨在推动微服务治理从生产实践走向标准。欢迎感兴趣的同学们一起参与讨论与共建,一起来定义微服务治理的未来。