导语:本文围绕服服务调用模式、一致性取舍、服务提供者的健康检查模式等方面,讨论了服务发现的技术选型和设计的各种优缺点,希望能够帮助大家在选择或者使用服务发现系统的时候更加顺畅。
01
服务发现系统的背景
不知道大家刚接触微服务治理的时候是否有这样的疑惑:为什么一定需要一个服务发现系统呢?服务启动的时候直接读取一个本地配置,然后通过远程配置系统,动态推送下来不行吗?实际上,当服务节点规模较小时,该方案也行得通,但如果遇到以下的场景呢?
1. 在微服务的世界中,服务节点的扩缩容、服务版本的迭代是常态,服务消费端需要能够快速及时的感知到节点信息的变更(网络地址、节点数量)。
2. 当服务节点规模巨大时,节点的不可用也会变成常态,服务提供者要能够及时上报自己的健康状态,从而做到及时剔除不健康节点(或降低权重)。
3. 当服务部署在多个可用区时,需要将多个可用区的服务节点信息互相同步,当某个可用区的服务不可用时,服务消费者能够及时切换到其他可用区(通过负载均衡算法自动切换或手动紧急切换),从而做到多活和高可用。
4. 服务发现背后的存储应该是分布式的,这样当部分服务发现节点不可用的时候,也能提供基本的服务发现功能
5. 除了ip、port我们需要更多的信息,比如节点权重、路由标签信息等等。
使用文件配置或DNS等传统方式无法同时满足上述几点要求,因此我们需要重新设计一个能够匹配上微服务架构的服务发现系统。
02
服务间调用模式
客户端发现模式
由客户端负责向服务发现系统(可以认为是一个数据库,存储了所有服务提供者的所有节点位置信息)询问某个服务提供者的所有实例的ip、port信息,并采用某种负载均衡策略,直接发起对服务实例的访问。
其中一个经典代表就是Netflix提供的解决方案:Netflix Eureka 提供服务发现功能, Netflix Ribbon 作为一个通讯SDK库与客户端集成在一起提供负载均衡与故障转移。
这种模式去除了对中心化单点(API Gateway or Load Balancer)的依赖,可以避开单点造成的性能瓶颈与故障问题,同时由于负载均衡的逻辑在客户端,它可以根据自身的配置选择负载均衡算法,比如一致性Hash算法。不过这种模式也存在缺陷,由于客户端的负载均衡逻辑是分布式的,各自为政,没有全局统一视角,在某些情景下会因为客户端的高度竞争而导致后端服务提供者节点的负载不均衡。同时客户端的业务逻辑和服务发现的逻辑耦合在一起,不同的服务使用了不同的编程语言,那么就需要有不同语言的SDK,如果未来某天服务发现的逻辑变更了,也需要重新发布所有的客户端节点。
服务端发现模式
把原本客户端执行的服务列表拉取&负载均衡&熔断&故障转移这部分逻辑抽象变成一个专属的服务。不过跟传统的 load balancer 不大一样的地方是: 这个的 load balancer会跟服务发现系统密切的配合,实时订阅服务发现系统中服务提供者节点列表信息,扮演反向代理的角色,将请求分发到合适的 Endpoint。
这块的一个代表是kubernetes的服务发现解决方案:运行在每个Node节点的kube-proxy会实时的watch Services和 Endpoints对象。每个运行在Node节点的kube-proxy感知到Services和Endpoints的变化后,会在各自的Node节点设置相关的iptables或IPVS规则,方便后面用户通过Service的ClusterIP去访问该Service下的服务。当kube-proxy把需要的规则设置完成之后,用户便可以在集群内的Node或客户端Pod上通过ClusterIP经过iptables或IPVS设置的规则进行路由和转发,最终将客户端请求发送到真实的后端Pod。
这种模式对于客户端来说是透明的,所有细节都被隔离在 load balancer 跟服务发现系统之间, 因此也沒有前面跨语言等相关问题,更新相关逻辑也只要統一部署 load balancer & service registry 就足够了。很明显,这种模式下服务的架构等于多了一层转发,延迟事件会增加;整个系统也多了一个故障点,整体系統的运维难度会提高;另外这个load balancer 也可能会成为性能瓶颈。
基本上服务端发现模式我们平常接触到的机会比较少,但是由于是无任何入侵的,比较适合旧系统上微服务架构的一个过渡方案。
03
服务发现的一致性取舍
我们先回顾一下CAP定律:在一个分布式系统中,Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性),不能同时成立。
- 一致性:它要求在同一时刻点,分布式系统中的所有数据备份都处于同一状态。
- 可用性:在系统集群的一部分节点宕机后,系统依然能够响应用户的请求。
- 分区容错性:在网络区间通信出现失败,系统能够容忍。
对基于raft、paxos算法的CP服务发现系统比如Consul、Zookeeper、Etcd等,为了保证数据的线性强一致性(Linearizable),必然会牺牲掉高可用性,比如在网络分区的情况下心跳、注册、反注册这些操作都会超时并失败。同时由于一致性算法的要求,所有的写请求都会重定向至leader节点,那么这样无法做到写的水平扩展。而AP服务发现比如Eureka则强调最终一致性(在有限的时间内(例如3s内)将数据收敛到一致状态),在牺牲数据一致性的情况下最大程度保障服务的可用性。
zookeeper服务发现
可以考虑上图的跨机房容灾的情景,此时满足强一致要求的Zookeeper作为服务发现。如果机房 1 和机房 2 由于某些不稳定的原因发生网络断开,provider B 去往 Zookeeper Follower 的注册是无法实现的。因为 Zookeeper Follower 所有的请求是强一致,都有同步到 ZK Leader,这时机房 2 就无法注册了,但此时其实 Consumer B 和 Provider B 之间的网络是正常的,互相调用没有问题,可Provider B不能注册导致Consumer B无法访问Provider B。所以我们可以发现,服务发现系统首先应当保证的服务可用性,为了保证数据一致性却不能提供注册功能,在生产实践中是不能接受的。 当然我们也可以在两个机房独立的部署两套Zookeeper,然后再写一个工具互相同步数据,使得两个机房的Zookeeper互为Master Slave,但这样不仅引入了新的复杂度,同时还得花大力气保证数据同步的一致性。
那么引入一个最终一致的Netflix Eruka的最终一致性设计是否就满足所有的场景万事大吉了呢?让我们设想这种情景:
Eruka serverA和Eruka serverB之前互相同步数据,但此时Eruka serverC和Eruka serverB、Eruka serverA之间的网络发生了故障,无法顺利同步信息。
ProviderB向Eruka serverA注册了服务信息,并维持上报心跳,这样服务节点ProvderB的信息Eruka serverA和Eruka serverB中都是存在的,但是由于信息复制的问题,没办法同步到Eruka serverC中。这样当ConsumerA先向eruka serverA发起请求的时候,会得到一个正确的节点信息,但是当下次访问到Eruka serverC的时候又会得到一个错误的节点信息,这样之前正确的信息就被覆盖了。
那么为了避免上述的情况,我们需要改造上面的逻辑,Client SDK需要同时去访问三个eruka server节点,再拿到三个节点返回providerB的节点信息中的的dirty time(dirty time由ProviderB维护,心跳上报的时候夹带,这样可以保证单调自增)后,通过比较选取dirty time最新的那个信息,这样就可以保证访问到正确的信息。当然上述情景是在生成环境中很难遇到,因为大多数情况下eruka server和Provider、Consumer都部署在同一个机房,如果eruka serverC和其他eruka server节点网络通信有问题的话,ConsumerA大概率也是访问不到eruka serverC的;又如果eruka serverC是跨机房部署的,那么正常情况下ConsumerA也是不会主动跨机房访问eruka serverC的。
04
服务提供者的健康检查模式
客户端心跳
- 客户端每隔一定时间主动发送“心跳”的方式来向服务端表明自己的服务状态正常,心跳可以是 TCP 的形式,也可以是 HTTP 的形式。
- 也可以通过维持客户端和服务端的一个 socket 长连接自己实现一个客户端心跳的方式。
但是客户端心跳中,长连接的维持和客户端的主动心跳都只是表明链路上的正常,不一定是服务状态正常。
服务端主动探测
- 服务端调用服务发布者某个 HTTP 接口来完成健康检查。
- 对于没有提供 HTTP 服务的 RPC 应用,服务端调用服务发布者的接口来完成健康检查。
- 可以通过执行某个脚本的形式来进行综合检查。
服务端主动调用服务进行健康检查是一个较为准确的方式,返回结果成功表明服务状态确实正常。但是服务端主动探测也存在问题。服务注册中心主动调用 RPC 服务的某个接口无法做到通用性;在很多场景下服务注册中心到服务发布者的网络是不通的,服务端无法主动发起健康检查,那么往往需要在宿主机器上部署一个agent来代替服务端的接口探测,比如Consul的健康检查机制就是这么实现的。
05
消费端的订阅机制
- Push推送:Push 的经典实现有两种,基于socket长连接的推送,典型的实现如 zookeeper;另一种为HTTP连接所使用的 Long Polling,这两种形式都保证了消息变更能够第一时间送达。但是基于 socket 长连接的推送和基于 HTTP 协议的 Long Polling 都会存在notify消息丢失的问题和代码实现复杂度过高的问题。
- 定时轮询:比如eruka,客户端每隔一段时间(默认30秒)会去服务端拉取注册表信息,保证注册表是最新的,这样的基于http短链接的订阅模式实现起来是最简单、最通用的。但也很容易导致一个问题,就是服务节点信息会有30s的延迟,在这30s内有可能会有请求打到已下线的节点上去。
- 推拉结合的方式:比如Consul,客户端和consul server之间会建立起一个最长30s的http长链接,如果期间有任何变更,则会立即推送,如果没有变更等到30s过后,客户端又会立即建立起新的连接,继续开始新的一轮订阅。这种模式的既吸收了http短链接方便通用的好处,又享受到消息即时推送的优势。
06
服务的上线与下线
优雅上线
1. 服务提供一个通用的Health check接口(比如spring boot actuator模块自带/actuator/health 接口,grpc也提供了health checking的标准模型),服务发现的sdk通过检查该接口来确定服务是否准备好接流,只有准备好节流才可将该节点注册上去。
2. SDK也可以提供一个回调接口,服务一切都准备就绪后再调用这个接口通知sdk去注册。
优雅下线
服务发现SDK接收到系统发出的SigTerm或者SigInt信号后,需要先主动反注册本身的实例,此时如果服务框架提供了graceful shutdown能力,就可以直接调用该方法,此时会阻塞住直到当前的所有inflight请求都处理完成或者超时才真正退出(不通)(grpc server提供了直接graceful shutdown方法,spring web应用则可以通过java提供的ThreadPoolExecutor.awitTermination来实现此能力)。如果没有graceful shutdown的能力,则需要主动sleep一定时间以确保所有http、rpc请求都处理完成后再退出。
07
服务发现的容灾与高可用
服务端
- 服务节点信息原本是分布式存储的,少数节点挂了,不会影响整体可用性。
- 当大多数节点挂了的时候,如果是强一致的系统此时会进入只读不可写的模式(比如Zookeeper和开启了stale read的consul。如果是最终一致的系统,此时客户端 sdk会自动重试并切换到正常节点上去,读和写都不受影响。(缺少后括号,但不知道在哪加)。
- 当服务端所有的节点都挂了时候,此时需要服务端能够持久化存储之前注册的Provider节点信息,并在重启之后进入保护模式一段时间,在此期间先不剔除不健康的Provider节点(因为宕机过程中心跳没办法成功上报),否则可能会导致在一个ttl内大量Provider节点失效。
- 网络闪断保护,监测到大面积出现服务提供者节点心跳没有上报,则自动进入保护模式,该模式下不会剔除因为心跳上报失败的服务提供者节点
客户端
- 客户端SDK需要有不可用节点剔除能力,当服务端某个节点不可用的时候,能够立即切换到下一个节点尝试(切换的时候随机sleep 0-3s防止重试风暴打垮某个节点)。这里要注意客户端SDK每次请求的超时时间是否设置正确,我们发现部分服务发现官方SDK的默认超时时间过长,比如java的consul sdk中默认超时是10分钟,在生产实践中如果发生了网络闪断导致response包回不来就会导致sdk的心跳请求一致阻塞住,没办法进行下次的心跳上报,从而导致节点从注册中心中异常下线。
- 当所有的服务端节点都不可用的时候,SDK能够使用内存中的缓存继续提供服务
- 如果客户端重启了,内存中的数据不存在了,则走本地配置降级。
服务注册的Metadata
服务注册的时候除了携带serviceName、ip、port这些信息就足够了呢?在一个大型为微服务系统中,服务支持的协议、服务的标签(比如Abtest、蓝绿发布的时候需要筛选这些tag作为服务路由信息)、服务的健康状态、服务的调度权重等信息可能都需要传递给消费者感知到。不过在生产实践中,一般不推荐将过多的信息放入注册中心,以免导致性能下降,比如swagger生成的api信息最好单独存储。
08
总结
以上一些浅见便是我们团队在腾讯云微服务框架TSF中的服务发现系统开发和维护时所踩过的坑以及留下的经验和总结,如果大家不想再淌这些坑,可以直接使用腾讯云微服务框架TSF,其中提供了服务发现等微服务治理功能。