分布式应用治理
分布式应用治理
正如在《分布式系统》中的讨论,现在当我们构建服务端应用程序时,随着业务逻辑复杂度以及协作者的增加,我们不可避免地会将其变为分布式应用。良好的分布式应用程序应该是无状态的、可扩展的、可配置的、独立发布的、容器化的、可自动化的,有时甚至是事件驱动的和 Serverless。创建之后,它们应该很容易进行升级,并且可以承受长期的维护。基于这些目标,借鉴《多运行时微服务架构实践》一文中的描述,我们从以下几个维度去概述分布式应用程序的需求:网络(Networking),生命周期管理(Lifecycle),状态管理(State)以及高可用(High Availability)。
构建微服务系统的意义无需赘述,但是很多时候我们虽然将业务拆分为了许多微小的模块,却需要在单个服务内添加诸多譬如流量管理、安全性、可观察性等等的控制代码,这也就导致了我们的服务不再微小;不仅是体积,还是开发的周期都会变得笨重。微服务系统也是天然的分布式系统,在《深入浅出分布式系统》》与《高可用架构》两个系列中我们讨论的构建高可用分布式系统中的很多挑战也是微服务系统所面对的,其中探讨的解决方案也来自于这里的微服务与云原生系列;这里我们介绍的微服务中的服务治理,更多是着眼于 RPC 服务注册与发现、网关负载均衡与流量控制、服务配置、弹性服务等几个方面。
网络(Networking)
分布式应用自然是被网络分隔的,其首要问题就是如何发现服务以及服务对应的实例;一般来说服务注册与发现机制是常用的思路,找到服务后,当前的请求应该选择发往服务的哪一个实例呢?一般来说,如果同一个服务的实例都是完全对等的(无状态),那么按负载均衡策略来处理就足够(轮询、权重、hash、一致性 hash,fair 等各种策略的适用场景);如果同一个服务的实例不是对等的(有状态),那么需要通过路由服务(元数据服务等)先确定当前要访问的请求数据做哪一个实例上,然后再进行访问。
事实证明,由 Kubernetes 提供的围绕服务发现的基本网络功能是一个良好的基础,但是,对于现代应用程序来说还不够。随着微服务数量的增加和部署的加快,无需接触服务就能实现更先进的发布策略、管理安全、指标、追踪、从错误中恢复、模拟错误等功能变得越来越有吸引力,并产生了一种新的软件类别,称为服务网格。
这里有一个更令人兴奋的趋势,那就是将网络相关的关注点从包含业务逻辑的服务转移出来,放到一个单独的运行时中,这可能是边车,也可能是节点级别的代理。今天,服务网格可以进行更高级的路由,有助于测试、处理安全的某些问题,甚至可以使用特定于应用程序的协议(例如,Envoy 支持 Kafka、MongoDB、Redis、MySQL 等)。尽管,服务网格作为一种解决方案,还没有得到广泛的采用,但是,它触及了分布式系统真正的痛点,我相信,它将找到适合自己生存的形式。
除了典型的服务网格外,还有其它项目,如 Skupper,它证实了把网络能力放入外部运行时代理的趋势。Skupper 通过第 7 层虚拟网络解决了多集群通信的难题,并提供了高级路由及连接功能。但是,它在每个 Kubernetes 命名空间运行一个实例,而不是把 Skupper 嵌入业务服务运行时。
总之,容器和 Kubernetes 在应用程序的生命周期管理中向前迈出了重要的一步。服务网格及相关技术触及了真正的痛点,并为把应用程序更多的责任向外转移到代理打下了基础。我们来看看接下来的事。
RPC,服务注册与发现
随着微服务的普及,应用之间的通信有了足够多的成熟方案。典型的服务注册与发现的解决方案有:
- Dubbo 在 2011 年开源之后,被大量的中小型公司采用;
- 在 Spring Boot 推出之后,Spring 逐渐焕发出第二春,随即 Spring Cloud 面世,逐渐占领市场,在中国市场中,和 Dubbo 分庭抗争;
- gRPC 是 Google 推出的基于 Http2 的端到端的通信工具,逐渐地在 Kubernetes 市场上占据统治地位,如 etcd,Istio 等都采用 gRPC 作为通信工具;
- Service Mesh 从开始概念上就火热,现在逐渐走向成熟,Istio + Envoy(其他 sidecar)逐渐开始走上舞台。
上图中的 6 个步骤的含义解释如下:
- 服务容器负责启动,加载,运行服务提供者。
- 服务提供者在启动时,向注册中心注册自己提供的服务。
- 服务消费者在启动时,向注册中心订阅自己所需的服务。
- 注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
- 服务消费者,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。
- 服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。
单体应用的服务是可数且可人工运维的,而对于基于微服务架构的应用而言,其服务数非常多,数不胜数。因此,微服务框架要具有服务发现的能力。一般情况下,服务发现是通过向注册中心注册服务实例的运行时标识以及对其进行监听并反向通知其状态变化来实现的。
内部服务之间的通信方式有两种:基于 HTTP 协议的同步机制(REST、RPC);基于消息队列的异步消息处理机制(AMQP-based message broker)。
-
Dubbo 是阿里巴巴开源的分布式服务框架,属于同步调用,当一个系统的服务太多时,需要一个注册中心来处理服务发现问题,例如使用 ZooKeeper 这类配置服务器进行服务的地址管理:服务的发布者要向 ZooKeeper 发送请求,将自己的服务地址和函数名称等信息记录在案;服务的调用者要知道服务的相关信息,具体的机器地址在 ZooKeeper 查询得到。这种同步的调用机制足够直观简单,只是没有“订阅——推送”机制。
-
AMQP-based 的代表系统是 Kafka、RabbitMQ 等。这类分布式消息处理系统将订阅者和消费者解耦合,消息的生产者不需要消费者一直在线;消息的生产者只需要把消息发送给消息代理,因此也不需要服务发现机制。
两种通信机制都有各自的优点和缺点,实际中的系统经常包含两种通信机制。例如,在分布式数据管理中,就需要同时用到同步 HTTP 机制和异步消息处理机制。
接口维度的注册
- 优点:服务查询按照接口维度查询非常方便,实现难度低。应用拆分或者合并的时候,Client 端(消费者)无需关心,做到了让用户无感
- 缺点:和 Kubernetes 等主流平台的模型对应关系不匹配。注册的数据量非常大,有一定的性能风险
应用维度的注册
- 优点:和 Kubernetes,Spring Cloud 等模型对应关系一致,性能上可以得到很大缓解
- 缺点:应用拆分或者合并的时候,Client 端需要感知(如果想做到不感知,需要框架开发者维护一份接口和应用映射关系的存储)。如果想对用户保持 Dubbo 原有的接口维度的查询,需要较多的工作量来保证。对用户透明度有所减少,需要在 OPS 上提供其他一些工具。如供应用开发者可以查看具体某个 IP 是否提供了某个服务等等。
接口调用
应用本身对外的 API 接口,又可以分为瘦客户端和富客户端两种。瘦客户端简洁方便,对使用方透明,只需要引入二方库,根据业务需求,配置对应的远程调用服务,便于升级维护,针对任意环境提供任意接口,使用方无感知。
富客户端则能包含业务逻辑(包含数据判定,不仅仅校验参数的合法性),数据存储(本地缓存,tair 等)前置,更充分利用客户端资源。随之而来的问题也比较严重:间接依赖其他业务的服务,会带来庞大的 maven 依赖,因为 jdk 版本,spring 版本的差异性全网升级成本非常高。
其他绑定
分布式系统的组件不仅要相互通信,而且要和现代的或遗留的外部系统集成。这需要连接器(connector)能够转换各种协议、支持不同的消息交换模式,如轮询、事件驱动、请求 / 答复、转换消息格式,甚至能够执行自定义的错误恢复程序和安全机制。
尽管我们一直在讨论从应用程序运行时解耦所有分布式需求这个主题,但是这种趋势也伴随着绑定。连接器、协议转换、消息转换、错误处理和安全中介都可以移出服务运行时。我们还没到那个阶段,但是,有几个项目在朝这个方向进行尝试,如:Knative 和 Dapr。把所有这些职责移出应用程序运行时将导致更小型的专注于业务逻辑的代码。这类代码将生存于独立于分布式系统需求的运行时中,可以作为预打包的功能使用。
Apache Camel-K 项目采用了另一种有趣的方法。该项目依赖于一个智能 Kubernetes 操作符(Kubernetes Operator,该操作符利用来自 Kubernetes 和 Knative 附加的平台能力构建应用程序运行时),而不是将运行时代理和主应用程序放到一起。在这里,该代理是负责应用程序要求的操作符,其中包括分布式系统原语。区别在于,有些分布式原语被添加到应用程序运行时中,而有些是在平台(也可能包括边车)中实现的。
网关负载均衡与流量控制
在巨石型架构下,客户端应用程序(Web 或者 App)通过向服务端发送 HTTP 请求;但是,在微服务架构下,原来的巨石型服务器被一组微服务替代,客户端为了完成一个业务逻辑,需要发起多个 HTTP 请求,从而造成系统的吞吐率下降,再加上无线网络的延迟高,会严重影响客户端的用户体验。
为了解决这个问题,一般会在服务器集群前面再加一个角色:API gateway,由它负责与客户度对接,并将客户端的请求转化成对内部服务的一系列调用。这样做还有个好处是,服务升级不会影响到客户端,只需要修改 API gateway 即可。
生命周期管理(Lifecycle)
传统的中间件通常只支持的一个语言运行时,(比如 Java),这就限定了软件该如何打包、哪些库可用、它们打补丁的频率等等。业务服务必须使用这些库,这些库与平台紧密耦合并使用相同的语言编写。在实践中,这会导致服务和平台的同步升级,从而妨碍服务和平台之间实现独立和定期的版本发布。
容器和 Kubernetes 把我们打包、分发和部署应用程序的方法演化成与编程语言无关的格式。关于 Kubernetes 模式和 Kubernetes 对程序开发人员的影响,有很多文章,这里我简单说一下。请注意,对 Kubernetes 来说,需要管理的最小原语是容器,并且,它专注于在容器级别和流程模型上交付分布式原语。这意味着,在管理应用程序的生命周期方面、健康检查、恢复、部署和扩展都做得很好,但是,在改进分布式应用程序的其他方面(如灵活的网络、状态管理和绑定)做得并不好,这些分布式应用程序生存于容器内部。
我们可能会指出,Kubernetes 拥有有状态的工作负载、服务发现、cron job 及其他功能。这是事实,但是,所有这些原语都在容器级别,并且在容器内部,开发人员仍然必须使用特定于编程语言的库来获取更细粒度的功能,这些功能我们已经在本文的开头部分列出。这就是驱动 Envoy、Linkerd、Consul、Knative、Dapr、Camel-K 等等项目的原因。
状态管理(State)
当我们在讨论状态时,通常是在讨论服务的状态以及为什么无状态是更好的方案。但是,管理我们服务的平台本身需要状态。进行可靠的服务编排和工作流、分布式单例、临时调度(即 cron jobs)、幂等性(idempotency)、状态化的错误恢复、缓存等等都需要它。这里所列出的功能都依赖于底层的状态。尽管实际状态管理不属于本文讨论的范围,但是,我们感兴趣的是分布式原语及其依赖于状态的抽象。
我们在前面列出了依赖于状态的主要集成原语。状态的管理比较困难,应该把它委派给专门的存储软件和托管服务。这不是本文的主题,但是,使用状态,以语言无关的抽象方式来帮助集成用例才是主题。如今,大家做了很多努力,试图在语言中立的抽象背后面提供状态化的原语。对于基于云的服务来讲,状态化工作流的管理是必备功能,例如 AWS 的 Step 函数、Azure Durable 函数等。在基于容器的部署中,CloudState 和 Dapr 都依赖边车模型以提供分布式应用程序中状态化抽象更好的解耦。
我希望,把前面列出的状态化功能也都抽象到一个独立的运行时。这意味着,工作流管理、单例、幂等、事务管理、cron job 触发器和状态化错误处理都在边车(或主机级别的代理)中可靠地发生,而不是存在于服务中。业务逻辑不需要在应用程序中包括这类依赖项和语义,它可以从绑定环境中声明式地请求这类行为。例如,边车可以充当 cron job 触发器、幂等消费者和工作流管理器,并且自定义业务逻辑可以作为回调调用或在工作流、错误处理、临时调用或唯一幂等请求等的某些阶段插入。
另一个状态化的用例是缓存。无论是服务网格层执行的请求缓存,还是用 Infinispan、Redis、Hazelcast 等进行的数据缓存,有一些把缓存功能推到应用程序运行时之外的示例。
配置中心
相比于集中式架构的属性文件配置方式,微服务架构更加倾向于使用集中化的配置中心来存储配置数据。配置中心不一定在任何时候都是 100%高可用的,大部分时间,配置是从客户端的缓存中读取的,如果配置中心恰好在配置修改时不可用,就会带来很大的影响,导致配置修改无法及时生效。配置修改要想及时生效,配置中心必须有推送配置变更事件的能力。如果配置中心是高可用的,也要慎重考虑如何保证多个配置中心间的数据一致性。
高可用(High Availability)
参阅 《HA Series》 以获取更多讨论。
对于一个分布式系统,如果我们不能很清楚地了解内部的状态,那么高可用是没有办法完全保障的,所以对分布式系统的监控(比如接口的时延和可用性等信息),分布式追踪 Trace,模拟故障的混沌工程,以及相关的告警等机制是一定要完善的;
系统雪崩是指故障的由于正反馈循序导致不断扩大规则的故障。一次雪崩通常是由于整个系统中一个很小的部分出现故障于引发,进而导致系统其它部分也出现故障。比如系统中某一个服务的一个实例出现故障,导致负载均衡将该实例摘除而引起其它实例负载升高,最终导致该服务的所有实例像多米诺骨牌一样一个一个全部出现故障。
避免雪崩总体的策略比较简单,只要是两个思路,一个是快速失败和降级机制(熔断、降级、限流等),通过快速减少系统负载来避免雪崩的发生;另一个为弹性扩容机制,通过快速增加系统的服务能力来避免雪崩的发生。这个根据不同的场景可以做不同的选择,或者两个策略都使用。一般来说,快速失败会导致部分的请求失败,如果分布式系统内部对一致性要求很高的话,快速失败会带来系统数据不一致的问题,弹性扩容会是一个比较好的选择,但是弹性扩容的实现成本和响应时间比快速失败要大得多。
弹性服务
负载均衡,与服务发现类似,大量的微服务应用实例无法通过静态修改负载均衡器的方式进行运维,因此需要反向代理或使用客户端负载均衡器配合服务发现动态调整负载均衡策略。
-
读写分离、分库分表:服务隔离与弹性扩缩容。这是集中式架构所不具备的能力,即能够在流量洪峰期通过增加应用实例的水平伸缩来增强服务的处理能力,并且能够在流量回归正常时简单地关闭应用实例,平滑地将多余的资源移出集群。
-
服务降级与容错:除了基础组件,在 DevOps 运维的角度看,我们还需要考虑分布式调用追踪、日志中心、系统自愈等方面。
-
分布式调用追踪。大量微服务应用的调用和交互,需要依靠一套完善的调用链追踪系统来实现,包括确定服务当前的运行状况,以及在出现状况时迅速定位相应的问题点。
-
日志中心。在微服务架构中,散落在应用节点上的日志不易排查,而且随着应用实例的销毁,日志也会丢失,因此需要将日志发送至日志中心统一进行存储和排查。
-
自愈能力。这是一个进阶功能,如果微服务应用可以通过健康检查感知各个服务实例的存活状态,并通过系统资源监控以及 SLA 分析获知应用当前的承载量,同时应用本身具有弹性扩缩容能力且微服务管控系统具有自动服务发现以及调整负载均衡的能力,那么便可以根据合理的调度策略配置通过调度系统来自动增加、关闭和重启应用实例,达到系统自愈的效果,使系统更加健壮。