cover_image

高途自建网关实践之路:前端网关

王鑫煜 高途技术
2023年10月25日 01:01

01

前言

高途于2022年初完成了后端服务的容器化改造,在同年6月份,需要支持前端服务的容器化改造,为此我们需要一个前端的网关来实现前端的南北向流量路由。我们调研了美团、阿里等众多厂商的实践方案,参考了k8s社区的热点项目,选用了目前热门的k8s Ingress的技术栈来实现。

初期,为了节省开发和运维成本,我们想用阿里云的ALB,但是在详细调研后发现,阿里云的ALB的路由规则,主要是基于序号和创建先后来决定的优先级,而不是基于请求域名、路径、头、cookie等请求属性。因为我们公司并没有为每个基建都设置专门的运维岗,所以,维护这样的路由优先级就需要建设复杂的系统能力,这个研发成本就太高了;同时,我们也认为基于序号的优先级不是我们理想中的选路形态,最终放弃了这个方案。

图片


在经过了一段时间调研后,我们选用了社区热门的 Ingress Nginx Controller 来作为前端网关。在项目投产后,业务研发不断提出了很多需求,比如根据请求属性来选路,比如在子环境选路失败后兜底到主环境等。这些需求,Ingress Nginx Controller 还没有能力去支持。除了业务诉求无力支撑外,作为基建,对我们的发布有较大的制约,也存在着较大的维护成本。在维护上,因为我们是直接部署的开源项目,基建和我们的日志、metric、trace能力不匹配,存在着监控缺失、请求链路网关节点缺失等问题。在发布上,这个项目当时还不具备多集群的能力,每个网关服务都绑定了一个k8s集群,这导致了我们很难实现前端服务的多k8s集群部署,限制了我们的运营策略。

经过一段时间的思考,我们发现在流量路由上,我们是不能照搬任何一个公司方案的,因为我们公司有我们自己历史情况以及未来的选路规划。我们的选路诉求,也不是任何一个开源组件都能够完美支撑的。2023年初,我们决定采用社区热门的Apisix来作为前端网关,通过二次开发的形式,满足我们公司自己的选路诉求。


02

目标

  • 理想路由。构建一个理想的路由模型,能够支撑高途目前以及未来的路由诉求。

  • 和内部监控系统打通,做稳定性保障。


03

Apisix落地

在Apisix上,我们期望它能作为流量路由的终极中间件,不仅肩负起南北向流量的路由,将来也可以肩负起东西向流量的路由。在南北向流量上,不仅可以做流量网关,也可以做业务网关。当前,我们先迈出第一步,实现业务网关。但仅是这一步,我们就遇到了诸多始料未及的问题。


3.1 路由建模

为了满足业务同服务多泳道流量隔离,灵活的线上灰度选路和线下多版本选路,我们构建了理想路由模型。


图片


上图是我们期望的路由模型,大致流程是,用户发起的请求 -> 流量网关 -> 业务网关 -> 业务服务实例。这个南北向选路模型,不仅适用于前端也适用于后端,足够应对我们业务未来的选路诉求。


1. 流量网关:负责安全,SSL/TLS安全协议卸载,日志等能力


2. 业务网关:

Router:根据请求域名和请求路径,选出流量要访问到哪个业务服务上。

User Route:根据请求的头、cookie、url参数,来选择流量打到业务服务的哪个服务组上。这里的服务组和服务的关系是,一个服务可以部署在多个服务组上,从而实现针对服务组的细粒度的流量路由。

System Route:根据在头、cookie、url中的流量标记,来决定是访问正常pod、还是灰度pod。

Near Route:这一层实现就近路由。


3. 业务服务实例:网关可以支持多种发布类型的实例,支持各种主流的服务发现中间件,比如eureka等。



3.2 Apisix Ingress改造

图片


上图是Ingress-APISIX的路由变更、服务发现的大致流程。


1. 创建K8s Service,作为业务服务的一组实例的标识,用来做路由和负载均衡。

2. 创建Apisix CRD或者Apisix Ingress,用来配置域名-路径到K8s Service的对应关系

3. Apisix Ingress Controller监听Apisix CRD/Ingress,K8s Service,Pod等K8s Event,来生成Apisix资源Upstream和Route,将资源变更上报到Apisix网关中

4. Apisix的某个实例收到上报的资源变更数据,经过校验后写入到Etcd

5. Etcd将变更后的数据同步到Apisix的所有实例中


上述设计有以下问题:


1. Apisix模型和理想模型不匹配。Apisix的核心模型Upstream是K8s Namespace粒度的,根据我们理想路由模型,我们期望Upstream是服务粒度的。我们的一个服务是可能会部署到多个服务组,每个服务组都是一个不同的K8s Namespace,也就会对应着不同的K8s Service,这个是跟我们的设计是冲突的。


2. Apisix Ingress Controller不支持多K8s集群。也就是说,只能部署到一个K8s集群上,而一个Apisix Ingress Controller是只能对应一个Apisix集群的,因为如果多对一,很可能会出现Route和Upstream命名冲突,从而导致不同集群的Route和Upstream相互覆盖,出现严重的路由问题。


3. Apisix Ingress Controller数据同步效率低下。Apisix Ingress Controller生成的数据是先通过Apisix,然后再写入到Etcd,同一时间也只会有一个实例的一个线程在生成数据。我们实测,在4c8g Pod上,对于Upstream和Route资源创建写入的qps是10,也就是说,差不多每秒只能处理10次写入操作,这个在K8s Endpoint列表较大下,会出现明显的排队情况。


4. Apisix Ingress Controller数据同步稳定性差。Apisix Ingress Controller生成的数据会走比较重的校验逻辑。对于Route,会校验Upstream是否存在;对于Upstream,会校验K8s Service和K8s Endpoint是否存在。同时,在Apisix侧,会根据Key查询已有资源,来回写create_time。在频繁变更下,这些资源会出现短暂的一致性问题,进而导致期间的Apisix资源变更失败。


对Ingress Controller的改造:


1. 多集群改造。我们修改了Route和Upstream Key的命名规则,从一个16位的hash值,修改为了name,同时,name后面通过"_"符号拼接了K8s集群的唯一标识和我们的环境标识,用来区分不同环境不同K8s集群下的资源。从而解决了多K8s集群下命名冲突的问题,进而可以将多个K8s集群下的Ingress Controller都指向一个Apisix集群。


2. 简化数据生成逻辑。我们通过修改Apisix的代码将Route和Upstream解耦,在变更Route的时候,不再校验Upstream;在变更Upstream的时候,不再查询K8s Service。通过约定的形式,我们让监听ApisixRoute生成的upstream_id和监听K8s Endpoint生成的upstream_id保持一致。同时我们也不再维护create_time字段,进而我们不需要在写入的时候反查Etcd数据。这样,Upstream和Route的同步链路不会再有对其他资源的查询逻辑,解决了Apisix资源变更失败的问题。


3. 缩短数据同步链路。qps低的主要问题是Ingress Controller向Apisix同步数据时使用的是http短连接,这块会很耗时。我们将Ingress Controller -> Apisix -> Etcd这一链路修改为了Ingress Controller -> Etcd链路,这样一来,中间少了一个IO节点,网络通信也变成了长链接,qps从十位数提升为了千位数,完全满足了高途的要求。


图片


对于Apisix模型和理想模型不匹配的问题,我们当时没有解决它,而是针对Ingress类型的选路单独做了一套基于多Router的选路模型来应对。一是因为我们当时初次接触Ingress Controller技术栈和OpenResty技术栈,我们没有足够的信心能够cover住它;二,我们也想尽量保持开源项目原有的主逻辑,不做过大的修改,从而方便吃到社区迭代红利;三是因为当时还没有实现后端的选路逻辑,模型不统一带来的痛感还没有清晰的感受到。但是随着我们对Apisix掌握度的提高,随着我们开始着手实现后端选路,这块确实对我们造成了不小的阻碍。我们目前正在将 Upstream 修改为服务粒度,从而实现前后端选路逻辑一致。具体方案等我们改造完成投产后,会发一篇文章来讲。


3.3 Apisix 改造

对于前端选路,我们的当务之急是尽快实现前端容器化,亟需实现的是路由模型中Router和System Route部分,我们先只围绕这两个功能做了设计。(在写文章的时间,我们正在着手实现理想模型,并且前后端流程合一。)对于当时前端选路,因为原生设计Upstream和Route是K8s Namespace粒度的,我们并没有选择大改,所以我们必须去处理一个host、path对应多个Route的情况,通过对多Route的筛选选出最终的Upstream,即某个K8s Namespace下的pod,来完成选路。这里需要提及的是,对于灰度和正常的pod,我们是通过在不同K8s Namespace下发布Deployment来实现的。正常pod是在一个叫prod的K8s Namesapce,灰度pod是在一个叫gray-test的K8s Namespace下。这样Router和System Route的逻辑,就可以映射成多Router的逻辑。



图片


上图是一次灰度发布流程,细节如下。

  1. 研发或测试做一次灰度发布,发布系统会在gray-test的K8s Namespace下创建灰度Deployment,创建灰度的K8s Service,创建多个灰度ApisixRoute。我们主要通过ApisixRoute中exprs规则,来构建不同场景下的路由。

    1. 流量标ApisixRoute:对于携带流量标的请求,会命中当前路由,然后会指向灰度Upstream。

    2. 参数灰度ApisixRoute:如果请求中的header、cookie、param命中了当前路由的exprs规则,就意味着命中的参数灰度,然后会指向灰度Upstream。

    3. 流量灰度ApisixRoute:exprs是空,我们在ApisixRoute的label中添加了一个weight字段,表示灰度权重,如果流量灰度规则存在,那么会按权重判断,是指向正常Upstream,还是灰度Upstream。

    4. 正常ApisixRoute:exprs是空,指向正常Upstream。

    5. 以上规则的优先级是 流量标 > 灰度参数 > 流量灰度 > 正常。

  2. Apisix Ingress Controller在监听到ApisixRoute和K8s Endpoint变化后,生成Apisix Route和Upstream,并添加上当前环境和当前K8s集群标识后缀,同步Etcd,同步Apisix。

  3. Apisix逻辑改造:

    1. "router.router_http.match(api_ctx)"改造。这个方法是Apisix通过请求选Route的入口方法,我们移除了方法里面通过header、cookie、param选路的逻辑,并且将只选一个的逻辑修改为选择多个。这样一来,我们可以通过这个方法选出请求host、path命中的同host、path不同vars的多个Route(K8s ApisixRoute中的exprs会转成Apisix Route中的vars)。

    2. 我们对选出的多个Route,按照 流量标 -> 参数灰度 -> 流量灰度 -> 正常 的顺序做细粒度的筛选。当选出一个Route后,再去做原生的后续的Upstream处理逻辑,如果处理失败,则走fallback逻辑,再次选Route。


在对Apisix主逻辑改造中,我们没有修改config_etcd.lua的数据同步逻辑,基本上没有修改Upstream处理逻辑。我们除了在Route选路逻辑上做了较大的修改外,还修改了skywalking trace上报逻辑,添加了请求日志,优化了server_picker缓存更新逻辑,移除了traffic-split插件逻辑等零零碎碎的多处改动,这里就不再赘述了。


04

稳定性保障


我们非常关心在 K8s -> Etcd -> Apisix内存 这个链路中资源会出现不一致的问题,所以我们花了比较大的时间去做对账和预案。K8s -> Etcd 这个链路的对账和预案是比较容易实现的,但是 Etcd -> Apisix内存 这个对账和预案实现是比较困难的。因为Apisix是一个标准的Nginx线程模型,即多进程单线程。我们需要比对的是每个进程中的那份内存数据,这就意味着,我们不能通过原生的管理接口查询内存数据,因为原生的管理接口是查的随机的某个进程下数据。所以在 Etcd -> Apisix内存 比对上我们花了比较多的时间去实现。我们认为,这种每个进程单独去同步Etcd数据的逻辑,并不优雅和稳定。Nginx是一个高效的中间件,而过多的Etcd数据同步、服务发现中间件数据的同步,会干扰Nginx的请求处理效率。幸运的是,我们看到社区有边车的解决方案,我们目前也在探索这块如何去改造。


K8s -> Etcd 这个链路的对账逻辑是一目了然的,就是查询K8s数据和Etcd数据去做比对即可。预案这块,我们幸运的发现在Apisix Ingress Controller的启动流程中,有一块逻辑是查询Apisix Etcd数据和K8s数据做全量比对的逻辑,这个逻辑只是把不一致的数据以error日志输出。我们对这个逻辑做了改造,在全量比对中,如果发现Apisix Etcd比K8s数据多,那么删除多余的Apisix Etcd数据,其余的按照原生逻辑做重新覆盖。这样,如果出现了不一致的情况,我们只需要重启Apisix Ingress Controller即可修复。


图片


Etcd -> Apisix内存 这个链路比较麻烦。我们在Apisix route.lua和upstream.lua中,添加了一个Nginx的定时器,这个定时器每分钟执行一次,会将对应资源的内存数据全量写入一个专门的日志文件,通过日志数据的上报消费,来触发对账,这样实现了Etcd和Apisix内存数据的全量比对。预案这块,直接重启Apisix即可。


上线至今,我们还没有发现 Etcd -> Apisix内存 这个链路出现不一致的情况,但在 K8s -> Etcd 这个链路偶尔会有几例。


05

总结和展望


Apisix是一个非常优秀的中间件,相比于SpringCloud Gateway,在稳定性、资源利用率、吞吐量、响应时间等性能指标上都表现优异;相比于Java,基于C的技术,不需要预热,可以快速扩缩容;相比于基于序号的路由,路由规则更贴合Nginx规则,更加通用,易于理解。但遗憾的是,Apisix在模型上,是在为Nginx模型建模,而不是为网关业务来建模,这导致Apisix作为网关,使用上并不顺手。


我们在Apisix落地项目中,支撑了高途前端容器化服务的网关路由需求,后续我们将支撑后端容器化服务的网关路由需求,并实现本文谈到的路由模型,统一前后端逻辑。


我们期望在Apisix实践中,能够打造出标准的、易用的下一代网关,为行业贡献一份来自高途的智慧。




继续滑动看下一个
高途技术
向上滑动看下一个