卷确 2019-11-04
2019 年 7 月 6 日,OpenResty 社区联合又拍云,举办 OpenResty × Open Talk 全国巡回沙龙·上海站,美团基础架构部技术专家张志桐在活动上做了《美团 HTTP 服务治理实践》的分享。
OpenResty x Open Talk 全国巡回沙龙是由 OpenResty 社区、又拍云发起,邀请业内资深的 OpenResty 技术专家,分享 OpenResty 实战经验,增进 OpenResty 使用者的交流与学习,推动 OpenResty 开源项目的发展。活动将陆续在深圳、北京、武汉、上海、成都、广州、杭州等城市巡回举办。
首先做下自我介绍,我叫张志桐,毕业于哈尔滨工业大学,2015 年加入美团,目前在美团主要负责 Oceanus 七层负载均衡网关、Mtrace 分布式链路跟踪系统以及 KMS 密钥管理服务等。
美团是 Nginx 的老用户,从创业初期就使用 Nginx,直到 2013 年迁到了阿里的 Tengine,再到今年三四月份,全站服务迁到了 OpenResty 上。从 Tengine 迁到 OpenResty 最根本的原因是升级困难,随着 Nginx 的版本迭代越来越快,导致 Tengine 很难合到官方 Nginx 最新版本上,但是使用 OpenResty 可以平滑地升级整个 Nginx 的社区版本。
Oceanus,单词的含义是海神。它是整个美团接入层的七层负载均衡网关,每天有千亿级别的调用量,部署了几千个服务站点,近万个注册应用服务。Oceanus 最核心的功能是提供 HTTP 服务治理功能,主要包括服务的注册与发现,健康检查,以及完全的可视化管理,同时也提供了像 Session 复用、动态 HTTPS、监控、日志、WAF、反爬虫、限流等网关功能。
这里补充一个限流方面的小问题,目前美团是通过全局 Redis Cluster 来实现的,也简单的做了一些优化,实现了完全基于 OpenResty 的 Redis Cluster,因为官方的 OpenResty 版本只支持单实例的 Redis 调用。同时我们不是每次请求都会去做 Redis Incr 的操作,每次会设置一个阈值,设置越大,本机加的代价就越小,因为不需要远程调用了,但出现的误差也会对应增大。基本的思路就是本地加一个步长,定期的把步长同步到 Redis Cluster 上来实现集群限流的功能。
上图是当前 Oceanus 的系统架构,底层的引擎核心是基于 OpenResty 的。在每个 OpenResty 节点上会部署了一个 Agent 的进程,主要是为了做逻辑的解耦,我们不希望整个 Nginx 或者是 OpenResty 上有过重的逻辑和请求无关,于是把很多的逻辑都下沉到 Agent 上,实现与 OpenResty 的解耦,比如用 MNS 拉取服务列表,再通过 Agent 灌入到 OpenResty。站点管理,落地文件配置,统一由前端管理平台 Tethys 进行管理,之后会实时落地到 mysql 里,Agent 通过 mysql 的同步,再落地到本地到 Server block 文件,通过 reload 方式实现站点的重新加载。右边是 Oceanus 体系之外的模块,第一个是 MNS,是公司内部统一的命名服务。另一个 Scanner,主要负责的是健康检查。
如上图配置 Nginx 反向代理会遇到几个问题:
我们怎么解决这三个问题?第一个动态的服务注册,第二个是不需要 reload 动态配置生效,第三个文件化配置变成一个结构化管理。
服务注册目前是基于美团内部的 MNS 统一命名服务,上图是整个服务注册的前端界面。它后端还是依托如 ETCD、ZK 服务注册的基础组件,主要用于缓存服务的信息,实现批量拉取、注册服务功能,可以根据 Nginx 集群选择拉取与这一类集群相关的所有站点信息,同时通过推拉结合的方式保证数据实时和准确。并定期的把所有数据都拉到本地,依靠 ZK 的 watcher 方式来保证数据的实时到达。
Nginx 主动健康检查有一些开源模块,但这些主动的健康检查会遇到一些问题。假设有一个站点 http://xxx.meituan.com,配在 upstream 里做健康检查,每个 proxy 的服务器的每个 worker 都会定期向后端服务发起健康检查。假如每秒检查一次,整个 Nginx 集群数量是 100,每个单机实例上部署了 32 个 worker,健康检查的请求 QPS 就是 100×32,而实际服务器每天的 QPS 不到 10,加上健康检查机制就变成 3000 多了。所以我们摒弃了在内部主动去做健康检查的方式,选择了 Scanner 去做周期性健康检查。此外, Scanner 支持自定义心跳,可以检查端口是否通畅、HTTP 的 url 是否准确,并且支持快慢线程的隔离。
美团实现动态 upstream 用的是业内比较成熟的方式:Tengine 提供的 dyups 模块。它提供一个 dyups API,通过这个 API 添加、删除、创建服务节点,之后通过一个 worker 处理这一次修改请求,把请求放到了一个共享内存的队列中,各个 worker 会从这个队列把这次变更拉取出来在本地生效,然后落到本地的内存中,实现整个步骤。其中,第一次调用时是需要加锁,然后同步内存中还没有被消费的数据,同步完之后才会更新操作,保证了数据的串性。
1.持久化
最大的问题是内存生效,因为它走的是本地 worker 进程内部的内存,所以下一次 reload 时,整个服务列表会丢失。我们的解决方案是通过本地 Agent 来托管这个节点的更新和文件落地。当 Agent 定期感知到服务列表变化时,首先把本地生成的 upstream 文件更新,之后再去调用 dyups API,把这一次变更的节点实时同步到内存中,实现了服务节点不仅落地到本地文件做持久化存储,同时还灌入到了 Nginx worker 内存中来保证服务的实施。
其中需要注意的是 reload 调用 dyups API 并发的问题。假如出现一种特殊的场景,Agent 感知到服务节点变化时,还没来得及落地 upstream 文件,这时候 Nginx 出现了一次 reload,更新的还是旧的 upstream 文件。此时 dyups API 调用过来,通知需要更新服务节点,更新服务节点之后会把更新的信息放到共享内存中,类似于一个接收器,每一个 worker 拿到更新之后才会把消息删除掉。这里可能出现一个问题,当 reload 的时候,出现了六个 worker 进程,有可能这一次更新被旧的 worker 进程拿掉了,导致新的 worker 没有更新,进而导致了新的 worker 里有部分是更新成功,有部分是更新不成功的。
我们目前是把 Nginx 所有的 reload、start、stop 包括一些灌入的节点都统一交给 Agent 进行处理,保障了 reload 和 dyups API 调用的串行化。
2.流量倾斜
每台机器同一时刻更新节点,初始序列是一样的,导致流量倾斜。比如线上有 100 个服务节点,每 25 个节点一个机房,当灌入节点时顺序是一致的。从最开始选节点,第一个选的节点都是一样的,导致一次请求筛选的节点都是请求列表里的第一个,所以同一时刻所有的流量都到了同一台后端机器上。
我们的解决方案是在 Nginx 内部加权轮训时的初始化节点,做了内部的 random,来保证每个 worker 选的第一个节点都是随机化的节点,而不是根据原来的动态 upstream 加权轮训的方式保证的稳定的序列去选节点。
如上图,创建站点可以直接在 Oceanus 平台上配置,提交后相当于建立了一个 Nginx 的 server 配置。同时支持导入功能,Nginx server 的配置文件可以实时导入,落到集群的机器上。
建完站点之后,可以直接配置映射规则,左侧是的 location,右侧对应的 pool 在美团内部是 appkey,每个服务都有一个名字。之后会通过一些校验规则来验证配置的规则从 location 到 appkey 是否合法,或者是否超出预期。 当 location 配置规则非常复杂,中间出现一些正则时,作为一名业务 RD 在平台上配置规则时是很容易出问题,因为你不知道配置的规则是否正确,是否真的把原来想引流的流量导到了 appkey 上,还是把错误地把不该导入这个服务的请求导到了 appkey 上。因此需要做很多的前置校验,目前美团内部使用的校验规则是模拟生成已有路径下的正则匹配的 url,用于测试哪些流量到了新部署的 appkey上做校验。这种校验也是有一定的不足,比如配置了很多正则匹配的方式,我们模拟出来的 url 其实不足以覆盖所有的正则 url ,会导致校验不准确。目前我们的规划是获取到所有的后端服务,比如 Java 的服务,后面会有 Controller,Controller 上有指定业务的 url,我们可以针对业务的 url 去离线的日志里筛选出来它们历史上每个路径下匹配真实的 url,用真实的 url 做一次回放,看是否匹配到了应该匹配的服务上去。
我们也支持所有的 Nginx 上的指令配置,包括设置 Header、设置超时、rewrite、自定义指令等,或者我们封装好的一些指令。 同时也支持一些服务的性能统计,比如说 QPS,HTTPS QPS,以及服务内部的 4XX、5XX。
精细化分流项目的背景是美团在线上的一些需求,比如在线上希望实现对某一个地域的用户做灰度的新功能特性更新,或者按百分比引流线上的流量,以及对固定流量的特征,选择让它落到固定后端的服务器上,保证这一部分的用户和其他的用户的物理隔离。
举个例子,上图右边是三台服务器都是服务 A,把其中两台服务器作为一个分组 group-G,Agent 获取到这个服务信息后,会把它实时落地到 upstream 文件里。如果是 group-G ,可以落到Upstream A_GR_G 的 upstream 文件中;如果是 upstream A,就和普通的服务一样落地好,3 个 server 同时落到一个服务上。此时前端有用户 ID 的请求进来,需要选择一种分流的策略,比如希望用户的 ID 的 mod100 如果等于 1 的请求,路由到灰度的分组 groupG 上,通过这种策略的计算,把 1001 用户请求路由到 upstream A-GR-G 服务上,然后剩下的其他的用户都通过策略的筛选,路由到服务 A 上 。
精细化分流具体实现的逻辑,首先在一个 worker 进程嵌入 timer,它会定期拉取策略配置,同时 DB 配置结构化写入共享内存的双 buffer,worker数据请时候,会从共享内存中读取策略进行匹配。策略匹配的粒度是 Host+Location+appkey,策略分为公共策略和私有策略,公共策略是整个全网都需要采用的一个策略,私有策略是可以针对自己的服务做一些定制化。
当请求来临的时候,获取请求的上下文,通过 Host+Location 来查找它需要使用的策略集合,如果是匹配公共策略就直接生效,如果是私有策略就会按 appkey 查找策略。以上图为例,请求来了之后,获取到请求的上下文,之后通过请求上下文里的 Host+Location 去找相应的策略集合,然后可能找到了左下角的策略集合。
分流转发的过程是在 rewrite 阶段触发的,请求进入到 rewrite 阶段以后会解析策略数据,实时获取请求来源中的参数,通过参数和表达式渲染成表达式串:
if (ngx.var.xxx % 1000 = 1) ups = ups + target_group;
通过执行这段命令,看是否命中分流策略,如果命中则改写路由的 ups 到指定的 ups group,否则不对 upstream 做修改。
微服务框架下服务个数多、调用链路较长,其中一个服务出问题会影响到整条链路。举个的例子,QA 提测往往需要该条链路上的多个服务配套测试,甚至是同时测试一个服务的多个演进版本,测试的科学性是不完善的,为了解决线下 QA 实现稳定的并发测试,我们提出了泳道的概念。
如上图,有两个 QA。第一个 QA 可以建立属于自己的泳道 1,第二个 QA 可以建立属于自己的泳道 2。QA 1 测试的功能在 B、C、D 服务上,它只需要建立一个有关于这次测试特性的 B、C、D 的服务,就可以复用原来的骨干链路。比如骨干链路的请求通过泳道的域名进来,首先会路由到骨干链的 A 服务上,之后他会直接把这次请求转发给泳道 1 上的 B、C、D 服务,之后 D 服务因为没有部署和他不相干的服务,所以它又会回到骨干链路的 E 服务和 F 服务。
QA2 测试的功能主要是集中在 A 和 B 服务,它只需要单独部署一个 A 和 B 服务相关于本次测试特性服务就可以了。当请求进来,在泳道 2 上 A、B 服务流经结束,就会回到主干链路 C、D、E 和 F 服务上,从而实现并发测试的效果,同时保证了骨干链路的稳定,因为这个过程中骨干链路是一直没有动的,唯一动的是要测试的那部分的内容。
同时多泳道并存可以保证多服务和多版本的并行测试,并做错误的隔离,极大的提高了的服务上线的流程。
泳道的实现基于精细化分流就很简单了。例如给服务 A 一个标签,它属于泳道 S,用同样的原理可以把它落地成 upstream A-SL-S,同时把泳道 IP 放到 upstream 里面,此时 A 服务上里没有泳道的机器。美团内部一般使用通过服务镜像的方式做服务的测试,通过 Docker 直接创建泳道的链路,自动化生成一个泳道的域名,通过测试域名访问就会直接把请求转发到泳道域名上。实现方案就是通过 Lua 泳道模块判断 Host 的命名规则和 Header 里是否有泳道,从而判断是否需要转发到后端的 upstream 节点上。
随着公司规模的不断扩大,我们实现了第三套的负载均衡方案——单元化。首先先介绍一些问题,你的服务是否真的做到了水平的扩展?你的服务是否真的做到了物理隔离?
举个例子,如上图,一条业务线上有两套集群,服务 A 和服务 B,同时下面有数据库,数据库做了分库分表,并且服务也是分布式服务,它到底是不是一个水平扩展的服务呢?
服务集群 A 和 B 的服务节点都有 N 个,当在服务集群 B 加一个节点时,所有服务集群 A 的节点都会与服务集群 B 中新加的节点建立一条连接,做长连接的连接池。长连接的资源其实是不可水平扩展的,因为每加一台机器,承受的长连接的数量都是 N。同理这个问题最严重的是在 DB 上,DB 的主库一般都是单点的,即使分了库,所有的写请求都会放到主库上,它的长连接其实是受限的,你如何怎么保证它的长连接一直在一个可控的范围内呢?
另一个问题是任意节点有异常都可能影响所有的用户,服务集群 B 的 N 节点出现问题,此时服务集群 A 里的所有请求,都有可能转发给 B 集群的 N 服务节点,也就是说任意一个用户的请求都可能会受到影响。所以看似你做的整个的分布式的系统能做到水平扩展,但其实不是这样。
为了解决上面的问题,我们提出了单位化的操作。按用户的流量特征把所有的请求都框到一个服务单元内,通常服务单元都是按地域划分的。此时每个单元内的服务是互相分布式调用的,但是跨单元的服务之间是没有关系的。原来服务集群 A 里的服务节点对服务集群 B 里的每一个节点都建立连接,变成了只针对自己服务单元内的服务做长连接,这样连接数量就降到原来的 N 分之一。同时用户的流量会在某个单元内做闭环,实现了完全的隔离。当然现实中单元化还有一些前提,比如说 DB 的数据分布,如果 DB 不能按单元划分,那单位化还是实现不了。
Oceanus 网关层实现单元化的路由,复用了报文转换的功能模块,支持根据某个Header或者Get参数来修改、删除、新加 Header 或者 Get 参数。
如上图的例子,假如从 App 端上来的请求,会带有地域特征,北京的用户可能带的 Location ID 是 01001、01002、01003。当它上来以后,我们有一个 Map 映射表,它跟前面的精细化分流不太一样,而是通过路由表做路由筛选的,前面的可能是基于表达式的。假如 01001 的Location 的路由表,它对应 Set ID 是 SET1,那么就直接在 01001 的用户请求里加一个 header,这个 header 的名称就是 SET1,这样就实现了报文的转换,也就是北京的用户在网关层都会新加一个 SET1 标识。之后就可以复用前面的精细化分流的方案,当遇到 SET1 的请求就转发到 SET1 的分组,从而实现了前端的单位化的路由方案。
Oceanus 未来主要在配置动态化上做进一步优化,尤其是 location 动态化,因为通过文件配置 location 的方式,每次 reload 的操作,对线上的集群还是有损的。同时希望做到插件的管理动态化,它的热部署与升级,以及自动化运维。美团线上近千台机器,做自动化运维是很解放人效的操作,如何去快速搭建一个集群以及迁移各个集群的站点,是一个比较关键的任务。
演讲视频及PPT下载: