本文系云原生应用实践杭州站活动演讲稿整理。杭州站活动邀请了 Apache APISIX 项目 VP 温铭、又拍云平台开发部高级工程师莫红波、蚂蚁金服技术专家王发康、有赞中间件开发工程师张超,分享云原生落地应用的经验心得,以下是张超《有赞统一接入层架构演进》分享内容。
张超,有赞中间件团队开发工程师,网关、Service Mesh 领域的专家,热衷技术,对 Golang、Nginx、Ruby 语言等有深入的研究。
大家好,我是来自有赞的张超,有赞中间件团队的开发工程师。今天给大家带来有赞接入层架构演进的分享。
先简单给大家介绍下有赞接入层,内部名为 YZ7,从概念来讲它与网关比较接近,是基于 OpenResty 和 Nginx 来实现的,主要是有标准 C 模块,自研发的 Nginx C 模块,以及基于 lua 实现的模块。它作为有赞业务流量的公网入口,提供 Traffic Shaping,包括限流、安全相关的像 WAF、请求路由等功能,请求路由包含标准的蓝绿发布、灰色发布功能,负载均衡等方面的功能。今天的分享,主要是从下面从三个方面来深入解析:
- 旧版接入层架构痛点
- 新架构设计分析
- 新架构设计总结
旧版接入层架构痛点
首先从旧版接入层架构的相关痛点出发,开始新架构的设计分析。
上图是旧版接入层架构的纵向切面,方案是早几年之前的。当时流行用 redis 做配置同步,它天然的主从同步协议确实非常适合。其中黄色箭头线是配置同步,数据从 redis master 同步到每个实例上的 redis slave,然后本级的 YZ7 会去轮巡本级的 redis,并把数据读到自身内存中。
为什么有右下方的 k8ssync controller 呢?因为前几年 K8S 逐渐的成为热门,很多应用都开始走向容器化的道路。
YZ7 是基于 OpenResty 来开发的,整个技术栈都是基于 lua,在 K8S 的生态里 lua 并不在其中。如果想要 watch K8S 里面的服务,需要实时知道它有哪些 endpoints。虽然通过 lua 也可以实现,但是需要重头做一个类似像 K8S 标准的 client-go 库,这就得不偿失了。因此会应用一个使用 GoLang 编写的 k8sssync controller,它负责向 K8S 获取它所感兴趣的后端服务 endpoints 数据,再通过 YZ7 配置的 API,再次写入到 redis master,由 redis master 分发到每个 YZ7 的实例上。
旧版接入层架构的缺点
- redis master 的单点问题:没有使用 redis closter 或者哨兵方案,只是简单的主从模式,出现问题时会导致配置无法下发。
- 当接入层是按照多机房的规模进行部署的,因为 redis master 是一个单点,它必然存在于一个机房中,从它所在的机房将数据同步到其他机房的 redis slave 时,容易受到机房之间专线稳定性的影响,稳定性差,配置同步的延时就高。
- 当 redis master 出现问题,这意味着从 k8ssync controller 同步过来的 K8S 内部服务 endpoints 数据无法实时同步到 YZ7 实例上。如果一些服务实例的 point 被清除了,接入层不能第一时间感知到。如此一来当请求进来,这边还在用已经下线的 point IP,导致请求会 502、504,引起服务不可用。还有一个缺点,因历史原因导致的 k8ssync controller 也是单点,如果它挂了,K8S server 会无法同步,同样会导致服务不可用,甚至引起大规模的故障。
- 配置不具备属性特征。无法在配置层面做多样化处理,包括配置的灰度下发。配置的灰度下发这个词是我个人提出来的,先保留这个疑问,后面会详细地揭开。
新架构设计三大组件
带着旧版接入层的种种缺陷,接下来需要设计出能够解决这些缺陷的新架构。当然,在设计新架构时需要遵循一些架构相关的要点。
- 首先就是解决基础的单点问题,为服务可用性提供保障。
- 组件的设计需要是无状态,可灰度、可回滚、可观测的。
- 无状态:意味着服务可以有弹性的进行扩缩容,应对弹性流量时非常的有帮助。
- 可灰度:服务某个组件的更新,它的影响面不能是整个集群或者是所有的流量,必须有可灰度的能力,只影响部分流量与部分实例。
- 可回滚:当服务更新发布后,出现一些连环的反映,可以单独的对它回滚。
- 可观测:从各个角度来增强组件的可观测性,包括日志、logging、metrics 甚至是 opentracing 等相关功能要做的更好,能把控到组件在线上的运行程度。
- 降低组件间的耦合程度。各组件职能独立,可独立测试部署。即使架构设计的再好,但是部署复杂,测试麻烦,就会加大成本。
遵循上述要点后,新架构方案细看有点像 Service Mesh 控制面、数据面分离和 APISIX 的控制面、数据面分离。中间虚线以上是控制面,下方则是数据面。控制面的核心组件叫 YZ7-manager,左边对接 K8S,右边对接 ETCD,ETCD 是它的配置存储中心,所有接入层的配置会存放在 ETCD 中,同时又会去 watch K8S。
虚线下方的数据面是每个 YZ7 的实例,每个实例上都有一个伴生进程,叫做 YZ7-agent,agent 会做一些杂活。YZ7 则是保留核心功能的网关,从下往上的红线箭头即是请求的方向。
控制面核心组件 manager
- manager 是一个配置提供者,类似于 Istio Pilot,Istio 1.5 版本之前是由多个组件组成,其中非常重要的就是 Pilot。配置保存在 ETCD 中,ETCD 的特点就是稳定可靠,所以选型用了 ETCD。
- manager 是无状态的,可以做到水平扩容。
- manager 接管了原来 k8ssync controller 的功能,由它去 watch K8S,代替了原 K8S-think 的功能。因为 manager 是无状态、可水平扩容的,解决了 YZ7 K8S-think 的单点问题。同时在原架构当中,YZ7 配置的 admin server 和现在的 APISIX 是非常相似的,它的 admin server 是和网关放在一起的,而在新架构中把网关 admin server 替掉,只放在控制面的 YZ7-manager 中。
- 核心功能是配置下发功能,从 YZ7-manager 的控制面,把数据下发到每个数据面。
数据面核心组件 agent
数据面的核心组件是 agent,是一个伴生服务,与每一个接入层的实例绑定。核心功能就是负责配置同步,包括配置注解的释义,这个和配置层面的灰度是相关的。还有配置间依赖管理,当有 A、B 两种配置时,可能 A 配置是依赖于 B 配置的,相当于 APISIX 里的 route 和 upstream。agent 的服务会把配置间的依赖管理做好。
接入层 YZ7
我们把原有配置的 admin server 去掉了,同时负责向 redis 获取数据的部分配置相关代码也去掉了,只留下了 http 接口。我们可以从外部将配置推送到 YZ7 实例中,保持在共享内存中。原来的网关功能全部保留,没有做很多的改造,仅保留核心功能,简化了组件。
新架构设计细节要点
讲完三个核心组件之后,再来聊一下新架构中几个比较重要的细节。
第一:从控制面的 YZ7-manager,到数据面的 YZ7-agent,配置下发协议怎么设计才能高效可靠?
第二:从 YZ7-agent 和 YZ7 之间,数据是用推模式还是拉模式?
第三:配置注解怎么实现?
第四:配置依赖怎么保证?
带着这四个问题,接下来会详细讲解,逐个击破:
控制面 YZ7-manager 到 数据面 YZ7-agent
首先,我们对于协议的要求一定是简单、可靠的,否则理解成本高,开发成本也会提高。
其次,协议必须支持服务端的主动推送,就像 APISIX 的配置生效时间很低,因为 ETCD 是支持 watch 功能。而 Kong 的配置时间相对比较高,是因为 kong 上对接的是 PostgreSQL 和 Cassandra,这两种关系数据库是不支持 watch 的。服务端有数据变更,客户端只能通过轮巡的方式获取。轮巡的间隔太长,配置生效时间就高;间隔太短,可以及时获取到数据变更,但是资源消耗会更高。
基于上述两点,我们以 gRPC 为基础,并参考 xDS,设计了一个新的协议。初次连接时,可以全量获取控制面的数据,后续一直保持长连接,可以增量地获取服务端的数据配置变更。
上图是 gRPC、XDS 的片段。第一行有一个 ConfigDiscoverService,这个 gRPC 就是做配置同步的核心,其中核心的两个 message 是 configrequest 与 configresponse。
configrequest 中,node 是带有某个数据链实例相关的数据,比如所在的集群,hostname,IP 等。resourcecondition 是在数据面声明感兴趣的配置,比如对路由配置,对 upstream 配置或对跨域配置感兴趣。在列表中把感兴趣的配置全部声明好,告诉服务端,控制面才能精准的把所感兴趣的配置推送到数据面。
configresponse 就是把响应码,包括 error detail 在出错的情况下,将包括错误码在内的信息,把 resource 全部放在 resource 列表里面然后推送给客户端。它的传输模型也比较简单,客户端会在连完之后发送 config request,然后服务端第一次会把所有的配置数据推送到客户端。
当一个接入层只是推送一些配置,它的配置量不会很大,几百兆就非常多了,因此全量的推送并不会带来特别多的带宽与内存上的开销,全量推送也是一个低频事件,不用过于担忧它的性能。
随着时间的推移,服务端会有新的配置变更,比如运维新增了配置或是发布业务应用,发布之后 pond 做了迁移,导致 pond 的 endpoints 变更了。控制面感知到这些变更,会将这些数据实时地推送到 Client 端,完成控制面到数据面的配置推送。
这跟 xDS 协议是很相似的,xDS 里的 discovery request 发送到服务端之后,如果有数据就把数据推回来,在 discover response,如果没有数据会其中加入一个 none 标志,告诉我们准备同步这个 discovery quest。没有数据时相当于是请求 ACQ 的功能。我们设计的有点类似 xDS 的简化版本,没有这方面的功能。
数据面 YZ7-agent 到 接入层 YZ7
从 YZ7-agent 到 YZ7 即数据面的 agent 到数据面的实例,其配置同步的抉择究竟是拉还是推?
首先来考虑拉,它的优点是按需加载,在需要时去加载对应的配置。缺点是如果配置提供方没有像 ECTD 的 watch 功能,就需要数据存在内存中必须要有淘汰的机制,否则就没有办法获取到同一个实例新的配置变更。而如果配置使用了淘汰策略,带来的问题就是配置生效时间高。生效时间高,对于一些静态配置像路由、host service 配置是无关痛痒,但是对于容器化业务的 endpoints 变更,它需要尽可能快的推送数据面,否则可能会出现 502、504等5XX的错误。因此拉的模式不适用于新的架构中。
其次是推模式,YZ7-agent 需要主动把数据推到 YZ7。优点是 YZ7 只需要做简单的保存动作即可,不需要考虑数据过期,而且组合的耦合程度会更低。这样的 YZ7 交付给测试,可以加几个接口,把需要用的测试数据推进去就行,而不需要额外部署 YZ7-agent,对交付测试比较有利。缺点是依赖于别人推会有一个问题,如果服务是刚刚起来或者 Nginx 刚刚完成热更新时,共享内存里是没有数据的,要采用推模式就必须解决这个问题。我们采用的方式是 agent 会定期的把数据缓存转储到磁盘上,当接入层 YZ7 实例热更新完或刚启动的时候,就会从磁盘上加载旧的数据,保证可以正常起来。再者是强制在此时要求 YZ7-agent 全量推送一次数据,就可以立刻达到新配置。
配置注解的实现
设计配置注解是为了做配置灰度。其作用是当新增了配置,但不希望对集群里所有的实例生效,只需要集群中的一两个小规模实例生效时方便进行验证。因为如果配置有误可能会带来大规模故障,而进行配置灰度可以有效降低故障的影响面。
上图是配置 payload 的片段,从上往下接入的是配置数据,里面只有一个 server,而 antotations 就是这个注解,里面的 canary 字段可以设计成灰度配置所需字段。这是按照 hostsname 来配置,这个配置只有 hosts2 或者 hosts3 才会生效。其中的 id、name、kind 是用来给配置做标识的,像 name、种类、UUID 之类的。其实 K8S 的声明配置也是如此的,具体的配置是放在 steak 面,外面会有像 laybol 等云数据相关的,图中的 antotations 就是效仿 K8S 声明式配置的 antotations。
有赞是一个 SaaS 服务提供者,域名非常多,配置非常复杂,比较依赖人为配置。为了降低因人为操作失误引起的故障面,需要有配置灰度这样的功能。操作流程也很简单,首先运维平台上创建一个配置,并标注为灰度配置,底层会创建出相关的配置注解。之后观察配置在相关实例上的表现,表现OK,就可以将该配置生效到所有的机器,去掉灰度配置注解,这时全部的接入层实例上也就生效了。如果出现问题,立刻删除灰度配置,也可避免引起其他激烈的反应。
△ 灰度配置操作流程图介绍
创建灰度配置,并携带灰度注解。通过 YZ7-manager 分发到每个 agent。agent 会判断该配置在机器上是 hit 还是 miss。如果是 miss 就会忽略掉这个配置,不会推过去。如果是 hit 就推送到本机中的 YZ7。
当灰度了一段时间,表现也正常,需要将其全部生效时就可以修改配置了,去掉灰度注解推送到 YZ7-manager 后会原封不动的再推到 YZ7 各个实例上。左下角这台是应用了灰度配置,由于 name 是相同的,这时稳定版本的配置就会把之前灰度版本的配置替换掉,所有接入层实例的配置也就都相同了。
当发现配置有问题,删除也会很简单。配置删除后,因为左下角这台已经灰度命中了,它会把删除配置的事件推到 YZ7,进而 YZ7 会主动删除内存中的副本。而左中、左下原本就没有命中灰度配置,会直接忽略,到此这三台YZ7的实例配置又恢复到了灰度配置应用之前的状态。
配置依赖管理
部分的配置间会有互相引用的关系。比如 host 配置,每一个 host 可配置一个标准的错误页,错误页又是一个单独的配置,在做 host 配置时,就必须先有错误页配置,否则会没办法下发。所以数据面的 agent 就需要保证好数据配置的推送关系,当 A 配置依赖于 B 配置,就不能先把 A 配置推送到接入层实例。因为 A 配置和 B 配置中间推送有时间窗口,会无法正确处理在 A、B 时间窗口之间进来的请求。
架构设计总结
走向云原生,需要我们在工作中学习更多的借鉴在云原生方面好的组件,像 K8S、Envoy 等都是值得学习的优秀范本。有赞接入层新架构遵循的控制面和数据面的职能分离设计原则,就是参考了 Service Mesh 的设计;配置下发协议是参考了 Envoy、xDS;加入注解的功能,设计上是参考了 K8S 的声明式配置的声明定义。
走向云原生的道路上我们应该多向前看,把云原生上所需要的功能、学到的新东西更好的融入到工作当中,把用到的组件能够更好的契合到云原生当中,走向云原生就会更有意义。
今天的分享就到这里,谢谢大家。