Kyle's Notebook

微服务概览与治理

Word count: 6.6kReading time: 23 min
2021/07/12

微服务概览与治理

微服务概述

单体架构

Browser <-> Gateway <-> Web Server <-> Database / Storage

对于单体架构项目,其项目结构可设计为模块化,但是最终还是会打包并部署为单体式应用。随着业务发展:

  • 应用变得复杂,以至于每位开发者都不能理解。

  • 应用难以扩展、可靠性低。

  • 无法实现敏捷开发和部署。

image-20210713153711034

SOA 与微服务架构

后端服务对外提供统一的一组接口,但完成请求处理所需的数据,由后端多个服务联动完成。

1
2
3
4
5
6
7
8
9
Web Interface
|
+-------+-------+-------+
svc1 svc2 svc3 svc4
+---+---+---+---+ |
svc5 +------+----+
| svc8
+---+---+
svc6 svc7

可以把微服务理解成 SOA(面向服务的架构)的实践:

  • 小即是美:小的服务代码少(bug 少),易测试、维护,容易不断迭代完善。单个服务可独立发版,提高网站可靠性(持续集成)。

  • 单一职责:每个服务可专注做好一件事。

  • 尽早创建原型:尽可能早地提供服务 API,建立服务契约,达成服务间沟通的一致性约定,后续再实现与完善。

  • 可移植性:服务间轻量级交互协议在性能和可移植性二者间,首要考虑兼容性和移植性。

由于运维的职责是控制变更,开发的职责是迭代功能上线,两者本质上有一定冲突。

随着系统复杂度提高,系统生产效率必然下降。对于微服务架构而言,下降的速度和幅度都比单体架构更小。

微服务定义

服务围绕业务功能构建,关注单一业务,相互之间采用轻量级通信机制,可全自动独立部署,可使用不同的编程语言和数据存储技术。微服务架构通过业务拆分实现服务组件化,通过组件组合快速开发系统,业务单一的服务组件可以独立部署,使整个系统清晰灵活。

  • 原子服务:针对单一应用场景、业务提供 API。

  • 独立进程:拆分独立的 Web 服务、数据库,可各自扩缩容、提高吞吐量。

  • 隔离部署:容器编排实现精细化的资源管理,且减少单台服务器故障带来的影响。

  • 去中心化治理

    • 数据去中心化,服务独享数据库,缓存等设施(也有个别情况由多个服务共享数据库,如面向用户的管理后台和面向管理员的管理后台)。

    • 治理去中心化,比如集中式负载均衡(统一的 LVS / Nginx) -> 去中心化治理(缓存、消息队列、RPC 负载均衡)。

    • 技术去中心化,每个服务可使用合适的技术实施,但是注意技术栈过于发散对于企业或团队也有不利。

微服务缺点

  • 分布式的复杂性:需要使用 RPC(同步)、消息队列(异步)来实现进程间通信;必须在代码层面处理 消息传递效率低服务不可用 等局部失效问题。

  • 分区的数据库架构:同时更新多个业务主体的事务很普遍,在微服务架构应用中更新不同服务所使用的不同的数据库涉及 分布式事务

  • 测试复杂:如集成测试、回归测试等,涉及多个服务多个分支。由于服务数量变多,构建稳定环境、确保每个服务正常交付难度加大。

  • 服务间依赖复杂,需要引入链路追踪等工具分析调用状况;且服务间调用(尤其当循环调用时)流量容易被放大,可设计为更粗粒度的接口。

  • 服务 模块间的依赖,应用的升级有可能会波及多个服务模块的修改。

  • 基础设施 建设复杂度高,运维难度大(自动化运维、日志采集、监控告警、持续集成与部署、容器编排)。

组件服务化

传统应用通过库(library)实现组件,库和应用一起运行,库的局部变化意味着整个应用重新部署。

通过服务来实现组件,将应用拆散为运行在不同的进程中的一系列服务(即多个微服务组合成完整的用户场景),单一服务局部变化只需重新部署对应的服务进程。

比如用 Go 实现的微服务:

  • kit:微服务的基础库(框架)

  • service:业务代码 + kit 依赖 + 第三方依赖组成的业务微服务

  • RPC + message queue:轻量级通讯

按业务组织服务

服务提供的能力和业务功能对应,比如:订单服务和数据访问服务,前者反映了真实的订单相关业务,后者则是技术抽象服务,按微服务架构理念来划分服务时不应该存在数据访问服务。

传统应用设计架构的分层结构正反映了不同角色的沟通结构,若要按微服务的方式来构建应用,也需要调整对应团队的组织架构:每个服务背后的小团队组织是跨功能的,包含实现业务所需的全面的技能。

由开发团队对软件在生产环境运行担负全部责任,而不是由其他团队兜底:大前端(移动 / Web)-> 网关接入 -> 业务服务 -> 平台服务 -> 基础设施(PaaS / SaaS)

去中心化

去中心化包括数据、治理、技术几个层面,每个服务面临的业务场景不同,可针对性选择合适的技术解决方案。但也需要避免过度多样化,结合团队实际情况来选择取舍,要是每个服务都用不同的语言的技术栈来实现,维护成本就会很高。

区别于传统应用共享一套缓存和数据库,每个服务独享自身的数据存储设施(缓存,数据库等)有利于服务的独立性,隔离相关干扰。

image-20210713153651756

自动化

包括测试和部署的自动化,单一进程的传统应用被拆分为一系列的多进程服务后,开发、调试、测试、监控和部署的复杂度都会相应增大,必须有合适的自动化基础设施来支持微服务架构模式,否则开发运维成本将大大增加。

  • CI / CD:GitLab + GitLab Hooks + Kubernetes

  • Testing:测试环境、单元测试、API 自动化测试

  • 在线运行时:Kubernetes,以及一系列 Prometheus、ELK、 Control Panel

image-20210713153848181

可用性 & 兼容性设计

面向失败设计,微服务架构采用粗粒度的进程间通信,引入了额外的复杂性,如网络延迟、消息格式、负载均衡和容错,因此需要考虑:隔离、超时、负载保护、限流、降级、重试等。

在服务需要变更时要特别小心,可能引发服务消费者的兼容性破坏,时刻谨记 保持服务契约(接口)的兼容性

发送保守,接收开放。按照伯斯塔尔法则设计和实现服务时,发送保守即最小化地传送必要的信息,接收开放即要最大限度地容忍冗余数据,保证兼容性。

微服务设计

在 SOA 服务化演进过程中,按照垂直功能进行拆分,对外暴露一批微服务,缺乏统一出口将会面临以下困难:

  • 客户端到微服务直接通信,强耦合(比如用户一直持有较旧的 Android、iOS 客户端版本,旧的 API 不能下线,为应用升级带来困难)。

  • 客户端聚合数据 工作量巨大,需要多次请求,延迟高(一般前轻后重,如果由前端负责繁重的数据逻辑,将影响应用交付速度,无法快速迭代)。

  • 不利于协议统一,各部门间有差异,需要端来兼容(统一工作难度大)。

  • 面向端的 API 适配,耦合到了内部服务。

  • 多终端兼容 逻辑复杂,每个服务都需要处理。

  • 统一逻辑无法收敛,比如安全认证、限流(对外暴露 API 越少则越安全健壮)。

工作模型应该是内聚模式配合。

服务网关

通过新增了一个 app-interface 用于统一的协议出口,在服务内进行大量的 dataset join,按照业务场景来设计粗粒度的 API,为后续服务的演进带来很多优势:

  • 轻量交互:协议精简、聚合(通常提供较粗、单一粒度的接口)。

  • 差异服务:数据裁剪以及聚合、针对终端定制化 API(比如为弱网络环境提供的数据)。

  • 动态升级:原有系统兼容升级,更新服务而非协议。

  • 沟通效率提升,协作模式演进为移动业务 + 网关小组。

一个请求经过 BFF 会扇出(fan out)多个请求、并行访问下游多层 RPC 服务进行数据组装,其中需要考虑部分失败、异常处理等问题。

image-20210713161340504

面向资源的接口设计:即 RESTful 接口,不进行过多的数据处理,返回原始数据。无法承担复杂的业务逻辑,不适用于面向外网、复杂、多终端的接口,相比之下提供针对某一业务场景而统一的粗粒度接口更高效(封装容错、统一协议等)。

BFF(Backend For Frontend,服务于前端的后端)是一种对后端微服务的适配服务(包括聚合裁剪和格式适配等逻辑),向无线端设备暴露友好和统一的 API,方便无线设备接入访问后端服务。

网关不必为所有前端提供无差别服务,而应针对不同前端聚合不同服务,提供不同接口和网络访问协议支持。

避免单点

如果所有微服务都接到同一个 app-interface 上,会有单点故障风险,严重代码缺陷或者流量洪峰可能引发集群宕机。而且单个模块也会导致后续业务集成复杂度高,根据康威法则,单块的无线 BFF 和多团队之间就出现不匹配问题,团队之间沟通协调成本高,交付效率低下,因此可拆解出多个 interface(如上图中的 BFF-View、BFF-Account、BFF-Space)。

关注分离

当跨横切面逻辑(Cross-Cutting Concerns 比如安全认证,日志监控,限流熔断等)代码变得越来越复杂,需要协调更新框架升级发版(路由、认证、限流、安全)。因此全部上升,引入 API Gateway 把业务集成度高的 BFF 层和通用功能服务层进行分层处理。

网关用于解耦拆分和后续升级迁移,在网关配合下单块 BFF 实现解耦拆分,各业务线团队独立开发和交付各自的微服务,大大提升研发效率。把跨横切面逻辑从 BFF 剥离到网关上,BFF 的开发人员可以更加专注业务逻辑交付,实现了架构上的关注分离(Separation of Concerns)。

业务流量实际为:移动端 -> API Gateway -> BFF -> Microservices。

在 FE Web业务中,BFF 可以是 Node.js 来做服务端渲染(SSR,Server-Side Rendering),此处忽略了上游的 CDN、ELB。

安全

对于外网请求,通常在 API Gateway 进行统一的认证拦截。认证成功则使用 JWT 方式通过 RPC metadata 传递的方式带到 BFF 层,BFF 校验 Token 完整性后把身份信息注入到应用的 Context 中。而 BFF 到其他下层的微服务建议直接在 RPC Request 中带入用户身份信息(userId)请求服务。

  • API Gateway -> BFF -> Service

  • Biz Auth -> JWT -> Request Args。

对于服务内部,一般要区分身份认证和授权。安全级别:

  • Full Trust:假定服务间安全。

  • Half Trust:服务间需要认证鉴权,但非所有接口都加密。

  • Zero Trust:所有请求通过身份认证鉴权后,还需要经过安全加密,防止被嗅探。

服务划分

可通过 业务职能(Business Capability)或是 DDD 界限上下文(Bounded Context)划分服务边界。

  • 业务职能:基于公司内部不同部门提供的职能。例如客户服务部门提供客户服务的职能。

  • 界限上下文:DDD 中用来划分不同业务边界的元素,业务边界即“解决不同业务问题”的问题域和对应的解决方案域,为了解决某种类型的业务问题,贴近领域知识(业务)。

划分时从性能、便捷性、业务领域去思考和抽象。

CQRS

将应用程序分为两部分:命令端(处理创建、更新和删除请求,并在数据更改时发出事件) 和 查询端(针对一或多个物化视图执行查询来处理查询,物化视图通过订阅数据更改时发出的事件流而保持最新)。

参考 Bilibili 案例:在稿件服务演进过程,围绕创作稿件、审核稿件、最终发布稿件有大量逻辑耦合,其中稿件本身的状态也有非常多种。但是最终前台用户只关注稿件能否查看,当审核条件变复杂,直接访问数据库查询结果时查询和变更容易相互影响。

针对审核稿件的数据状态与查询操作解耦:使用中间件 Canal(订阅稿件数据库 binlog)将审核结果发布到 Kafka 中,消费数据并组建稿件查阅结果数据库,对外提供独立查询服务来拆分复杂架构和业务。架构从 Polling publisher -> Transaction log tailing 实现了演进(Pull vs Push)。

image-20210713160016914

RPC 与服务发现

gRPC

gRPC 是一种高性能的开源统一 RPC 框架:

  • 基于 Proto 的请求响应,支持多种语言。

  • 轻量级、高性能:序列化支持 Protocol Buffer 和 JSON。

  • 可插拔:支持多种插件扩展。

  • IDL:基于文件定义服务,通过 proto3 生成指定语言的数据结构、服务端接口以及客户端 Stub。

  • 移动端基于标准 HTTP/2 设计,支持双向流、消息头压缩、单 TCP 多路复用、服务端推送等特性,使得 gRPC 在移动端设备上更加省电和网络流量(传输层透明,便于升级到 HTTP/3、QUIC)。

1
2
3
4
5
6
7
8
9
10
11
12
13
syntax = "proto3";

package rpc_package;

service HelloWorldService {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
1
2
3
protoc --go_out=.--go_opt=paths=source_relative \ 
--go-grpc_out=.--go-grpc_opt=paths=source_relative \
helloworld/helloworld.proto

设计原则:

  • 服务而非对象,消息而非引用:促进微服务系统间粗粒度消息交互设计理念。

  • 负载无关:不同服务使用不同的消息类型和编码,例如 protocol buffers、JSON、XML、Thrift。

  • :Streaming API。

  • 阻塞/非阻塞:支持异步和同步处理在客户端和服务端间交互消息序列。

  • 元数据交换:常见的横切关注点,如认证或跟踪,依赖数据交换。

  • 标准化状态码:客户端以有限方式响应 API 调用返回的错误(优先使用标准的 HTTP 状态码)。

设计时不要过早关注性能问题,先实现标准化。

Health Check

gRPC 有标准的 健康检测协议,在所有语言实现中基本都提供了 生成代码设置运行状态 功能。

主动健康检查 可在服务不稳定时被消费者所感知,临时从负载均衡中摘除,减少错误请求。当服务提供者重新稳定、health check 成功后再重新加入到消费者的负载均衡、恢复请求。

health check 同样也被用于外挂方式的容器健康检测,或者流量检测(k8s liveness & readiness)。

Kubernetes 结合 health check 实现平滑发布:

  • Kubernetes 向 discovery(注册中心)发起注销请求。

  • Kubernetes 向 APP 发送 SIGTER 信号,进入优雅退出过程(待 Inflight 请求处理结束)。

  • 其他客户端在最多 2 个心跳周期内(一般是实时)退出。

  • Kubernetes 退出超时(一般 10-60s),强制退出 SIGKILL。

1
2
3
4
5
6
7
8
          [Discovery]
register | poll
+--- > ----+---- > ----+
| call |
[Provider] ----------> [Consumer]
| |
+----------------------+
health check

服务发现

客户端发现

服务实例被启动时,网络地址会被写到注册表上,当服务实例终止时再从注册表中删除。

服务注册表通过心跳机制动态刷新,客户端使用负载均衡算法选择可用的服务实例响应请求(Smart Client)。

由于客户端直连,比服务端服务发现少一次网络跳转,Consumer 需要内置特定的服务发现客户端。

由于微服务的核心是去中心化,更建议使用客户端发现模式。

服务端发现

客户端通过负载均衡器向服务发送请求,由负载均衡器查询服务注册表,将请求路由到可用的服务实例上。服务实例在服务注册表上被注册和注销,比如 Consul Template + Nginx,Kubernetes + etcd。

Consumer 无需关注服务发现具体细节,只需知道 DNS 域名。支持异构语言开发,需要基础设施支撑,由于多一次网络跳转可能有性能损失。

image-20210713172437446

服务网格(Service Mesh):介于两者之间的模型,服务网格部署在 pod 中、在同一台物理机上(同组的两个容器),两个容器通过本地进程间通信(IPC)。流量先发送给 Proxy,再由 Proxy 转发给直连的下游服务提供者。相当于把服务发现、负载均衡等功能转移到 Proxy 上,避免 Client 过重,同时由于 Proxy 非集中式部署,也实现去中心化(避免热点请求)。然而除非服务体量、规模足够大,或者服务涉及多种语言(框架层面不统一),使用 Service Mesh 带来的收益很有限。

服务发现演进

早期常用 ZooKeeper 实现,但在海量服务场景下可妥协为弱一致性,使用 ZK 反而带来以下问题:

  • ZK 可提供非必要的强一致性,但也因此牺牲了可用性。

  • 当出现网络抖动或分区(Java FGC 等),Master 节点失联会导致重新选举,当超过半数不可用会导致整体瘫痪。

  • 节点数量多时大量服务长连接导致性能瓶颈。

AP 式的发现服务(注册、注销的事件延迟)。基本思路:

  • 通过 Family(appid)和 Addr(IP:Port)定位实例(使用三段式命名,business.service.xxx。还能附加权重、染色标签、集群等元数据)。

  • Provider 注册后定期(30s)发送心跳,注册、下线都需要同步,数据通过长轮询推送(新启动时 load cache、JVM 预热,故障时 Provider 不建议重启和发布)。

  • Consumer 启动时拉取实例,发起长轮询(30s ,故障时需要 client 侧 cache 节点信息)

  • Server 定期(60s)检测并剔除失效(90s)的实例。短时间里丢失了大量的心跳连接(15分钟内心跳低于期望值 * 85%)则开启自我保护,保留过期服务不删除。

image-20210713174424012

全量复制:当数据每次都全部复制到所有 Discover 节点(比如 Eureka),随着规模变大,一旦涉及扩容操作,同样大规模的数据量被广播,写放大严重(可以使用读写分离、一致性哈希来优化,减少广播压力)。

Poll 模型的无效请求太多,更建议用 Push 实现,节点状态变化时推送到 Consumer 端,传播速度更快。

多集群,多租户

多集群

对于重要级别较高的服务(L0),如果只有一套大集群,一旦故障影响范围巨大。需要考虑:

  • 对于单一集群,多个节点保证可用性,通常使用 N+2 的方式来冗余节点。

  • 从单一集群故障带来的影响面角度考虑冗余多套集群。

  • 单个机房内的机房故障导致的问题。

利用 PaaS 平台给某个 appid 服务建立多套集群(物理上相当于两套资源,逻辑上维护 cluster 的概念)。在不同集群服务启动后从环境变量获取当下服务 cluster,在服务发现注册时带入这些元信息,不同集群可隔离使用不同缓存资源等。

  • 多套冗余的集群对应多套独占缓存,带来更好的性能和冗余能力。

  • 尽量避免业务隔离使用或者 sharding 带来的 cache hit 影响(按照业务划分集群资源)。

1
2
3
4
             svcA
+-------+---+---+-------+
svcB svcC svcD svcE
[pod..] [pod..] [pod..] pod[...]

业务隔离集群可造成缓存命中率下降,不同业务形态数据正交,可统一为一套逻辑集群(物理上多套资源池),即 gRPC 客户端默认忽略服务发现 cluster 信息,按照全部节点全部连接(利用负载均衡为多套集群预热,确保缓存都生效),存在问题:

  • 长连接导致的内存和 CPU 开销,health check 频率太高。

  • 短连接极大的资源成本和延迟。

优化:从全集群中选取一批节点,划分子集限制连接池大小。

  • 挑选部分后端建立连接(通常 20-100 个,减少连接、health check 开销。部分场景需要大子集,比如大批量读写)。

  • 后端平均分给客户端。

  • 客户端重启并保持重新均衡,同时对后端重启保持透明、连接的变动最小。

Provider 和 Consumer 是动态变化的,算法如果不能感知其变化并再均衡,则会导致资源消耗不均匀;但再均衡操作也不能过于敏感,会导致节点频繁重连加大开销。

1
2
3
4
5
6
7
8
9
10
11
12
def subset(backend, client_id, subset_size):
subset_count = len(backends) / subset_size

# Group clients into rounds; each round uses the same shuffled list:
round = client_id / subset_count
random.seed(round)
random.shuffle(backends)

# The subset id corresponding to the current client
subset_id = client_id % subset_count
start = subset_id * subset_size
return backends[start:start + subset_size]

多租户

多租户(multi-tenancy):建立多套共存的系统,便于实现微服务稳定性和模块化。租户可针对测试、金丝雀发布、影子系统(shadow systems)、服务层或者产品线来建立,可保证代码隔离性并基于流量租户做路由决策。

对于 传输中的数据(data-in-flight),例如消息队列中请求或者消息、静态数据(data-at-rest)、存储或者持久化缓存,租户都能够保证隔离性和公平性,以及基于租户的路由机会。

如果对服务 B 做出改变,需要确保它仍然能够和服务 A,C,D 正常交互。要实现测试和该系统中其他服务的交互,有两种基本的方式:并行测试和生产环境测试。

并行测试

需要和生产环境一样的过渡(staging)环境,只用于处理测试流量。当完成生产服务的变动,将代码部署到测试栈。可在不影响生产环境的情况下让开发者稳定地测试服务,同时在发布前更容易识别和控制 bug。存在以下问题:

  • 混用环境导致的不可靠测试。

  • 多套环境带来的硬件成本。

  • 难以做负载测试,仿真线上真实流量情况。

生产测试

在生产环境测试(称为染色发布),待测试的服务 B’ 在隔离的、可访问集成环境(UAT)C 和 D 的沙盒环境中启动。把测试流量路由到服务 B’,保持生产流量正常流入到集成服务,此时服务 B’ 仅处理测试流量、不处理生产流量。同时要确保集成流量不要被测试流量影响。

image-20210713215913283

两个基本要求作为多租户体系结构的基础:

  • 流量路由:基于流入栈中的流量类型做路由(请求数据打上 tag)。

  • 隔离性:可靠地隔离测试和生产中的资源,保证对于关键业务微服务没有副作用。

灰度测试成本代价很大,影响 1/N 的用户。其中 N 为节点数量。

比如为入站请求绑定上下文(如 HTTP Header),in-process 使用 context 传递,跨服务使用 metadata 传递(如 opentracing baggage item)。在架构中每个基础组件都能够理解租户信息,并基于租户路由隔离流量。

在平台中允许对运行不同的微服务有更多的控制(指标和日志),典型的基础组件是日志、指标、存储、消息队列、缓存以及配置,基于租户信息隔离数据需要分别处理基础组件。

多租户架构本质上描述为:跨服务传递请求携带上下文(context),数据隔离的流量路由方案。利用服务发现注册租户信息,注册成特定的租户。

image-20210713215917434

参考

CATALOG
  1. 1. 微服务概览与治理
    1. 1.1. 微服务概述
      1. 1.1.1. 单体架构
      2. 1.1.2. SOA 与微服务架构
      3. 1.1.3. 微服务定义
      4. 1.1.4. 微服务缺点
      5. 1.1.5. 组件服务化
      6. 1.1.6. 按业务组织服务
      7. 1.1.7. 去中心化
      8. 1.1.8. 自动化
      9. 1.1.9. 可用性 & 兼容性设计
    2. 1.2. 微服务设计
      1. 1.2.1. 服务网关
        1. 1.2.1.1. 避免单点
        2. 1.2.1.2. 关注分离
      2. 1.2.2. 安全
      3. 1.2.3. 服务划分
        1. 1.2.3.1. CQRS
    3. 1.3. RPC 与服务发现
      1. 1.3.1. gRPC
      2. 1.3.2. Health Check
      3. 1.3.3. 服务发现
        1. 1.3.3.1. 客户端发现
        2. 1.3.3.2. 服务端发现
        3. 1.3.3.3. 服务发现演进
    4. 1.4. 多集群,多租户
      1. 1.4.1. 多集群
      2. 1.4.2. 多租户
        1. 1.4.2.1. 并行测试
        2. 1.4.2.2. 生产测试
    5. 1.5. 参考