背景
腾讯企点 Web 项目通过动态页面、静态页面、JSON API、实时推送 API 等方式对外提供核心业务能力,这涉及到很多业务系统的协作。各业务系统会面临一些共性问题,例如:如何让调用方快速接入、如何让业务安全地对外开放能力、如何应对和控制业务洪峰调用、安全防护等等。为了对外提供统一的、稳定的服务,我们需要一个隔离企业内部业务系统和外部系统调用的屏障 –– API 网关,它负责在上层抽象出各业务系统需要的通用能力,例如:鉴权、限流、ACL、降级、安全防护、灰度、版本控制、监控告警等。另外随着近年来微服务的流行,API 网关已经成为微服务架构中的标配组件之一。企点之前是通过 Nginx 作为 Web 接入层,对内反向代理至 HTTP 协议的 Web 服务层。各个项目组共同维护多个 Nginx 配置文件,单个 Nginx 配置单文件行数可达 5000 行之多,并含有一些 Nginx 扩展、Lua 脚本等。随着业务增长,需要对外提供的服务越来越多,逐渐显露出该接入方案的弊端:在这个背景下,企点迫切需要一个 API 网关来统一 Web 接入、抽象处理公共逻辑。API 网关架构设计
Apex 网关采用腾讯公司级接入层 STGW(Secure Tencent GateWay)做七层代理以及安全防护功能(如:DDOS、WAF 等),STGW 对安全协议进行卸载(如 HTTPS/WSS),这样 Apex 只需要重点关注和建设 HTTP/WS 等能力即可。Aepx 支持 HTTP/Websocket/Socket.io/SSE 多种常见 Web 接入方式,对内反向代理支持 HTTP/tRPC/gRPC/私有后台协议。
Apex 在架构上分为数据面和控制面。数据面是网关的核心,主要负责对上行流量的接入和反代处理,以及对下行流量的推送。控制面主要负责 API 管理、数据统计、动态配置、监控告警、系统运维等。网关从设计之初就想着借助开源力量快速成型,底层利用标准库和外部开源组件,上层依托公司内部开源生态和平台,我们只需要在中间实现自己的功能即可。有了架构设计,我们就可以开始定义 API 描述了。API 是网关的灵魂,它就像是一根线一样,把网关所有能力串联在一起。我们基于 Protobuf 描述,把 API 定义分为四个主要部分:基础信息、入站请求、反向代理、出站请求。这样分阶段的描述可以让 API 定义更语义化,在代码逻辑实现上更清晰,也方便和插件相结合。接下来我们看下插件的设计。依据 AOP 理念,业界很多知名产品都在使用 “洋葱模型” 来实现拦截器或插件机制。Apex 的插件机制也使用洋葱模型,它的原理是:依次从外向内传递请求,最内层处理业务逻辑后,再由内向外传递响应。让每次请求都可以经历 插件前置处理、业务逻辑处理 和 插件后置处理三个阶段,从而具备对输入和输出的修改能力。插件机制将网关的核心能力和扩展能力解耦,保证了网关的灵活性和可扩展性。有了上面这些基本的设计,我们就可以开始实现网关的功能了。动态路由
使用过 Nginx 的朋友都知道,在修改 Nginx 配置后,需要执行 reload 命令重新加载配置文件。这种方式不够动态,无法做到实时生效,于是我们设计一种基于配置中心订阅模式来实现网关路由的热加载能力。将路由配置信息托管在公司配置中心里,网关各节点订阅变更事件,当配置变动时,构建新路由动态替换原有路由对象,实现了毫秒级动态路由的目标,我们称之为“高速路上换轮胎”。正是由于该异步构建路由、动态更新机制,把对象所需数据尽可能提前构建好,存储在对象内,使得在请求阶段性能损耗极低。服务发现
在动态、精准地选择代理后端节点能力上,Apex 模型如下,调用 Selector 选取节点,Selector 内部包含 Registry 和 Strategy 组件,其中 Registry 用于注册服务和服务发现。Registry 支持多种寻址组件,公司内部采用 Polaris 名字服务,第三方部署可切换为其他组件。Strategy 组件负责按策略选择节点,如:Round-Robin、Random 等,还可以按照 Metadata 过滤节点,Sticky 等高级功能。另外大部分 Registry 均支持 Watcher 能力,能够异步实时监听服务节点变化,以更新内存数据。绝大部分寻址请求,均在内存完成,几乎无性能消耗。作为网关的核心能力,能够支持多种协议反向代理至关重要。Apex 通过抽象 Proxy 层,实现多种协议代理,如 HTTP、gRPC、tRPC(Tencent RPC)、企点内部协议等。针对 HTTP 协议使用 Go 基础库自带的 ReverseProxy 组件加以封装。其次 gRPC/tRPC 均采用 Protobuf 定义 RPC 接口,生成 Server 和 Client 调用桩代码。作为网关,为保证稳定性,需要尽可能少的发生变更。RPC 接口,协议变动非常频繁,网关不能跟着频繁发布。这块我们调研过两种方案:- 第一种是基于 protoc 命令生成二进制描述文件模式(–descriptor_set_out 参数),该功能会将所有依赖 proto 以二进制文件导出,网关动态加载该文件,达到实时更新目的。该方案存在一些致命缺陷,首先二进制文件没有可读性,一旦加载到内存中,发生错误难以排查。另一方面 proto 存在个版本兼容性问题(proto2 和 proto3)代码需要做大量处理。其次还是不可避免的进行 “配置“ 变更发布。
- 第二种方案是采用 jsonpb 请求,该模式 gRPC / tRPC 均支持。核心原理是 RPC 请求设置编解码类型为 application/jsonpb ,RPC 框架在 Codec 层检测到 jsonpb 类型,调用 jsonpb 编解码器对请求进行反序列化映射到 RPC 请求结构体中。该模式最大优点是网关不用感知接口描述定义,由客户端和服务端定义数据结构,要求客户端以 JSON 方式请求,网关负责将 HTTP JSON 请求转换成 RPC jsonpb 请求。另一个优点是 jsonpb 编解码支持解析以字符串表达 integer 和 float 值,这对不感知参数类型的网关来说,将 query 或 form 参数转换为 RPC 参数至关重要(也可以通过参数定义功能描述参数类型)当然最直观的缺陷是性能,jsonpb 相比 pb 二进制解析性能相差 97%。
基于反序列化性能相差在微秒范围内,在单个请求中占比微小,最终 Apex 网关采用第二种 jsonpb 请求模式。做到轻量级,不感知 RPC 具体定义实现 RPC 请求。同样内部私有协议也是类似的方式,通过增加中间层支持 jsonpb 调用兼容历史协议代理。$ benchstat jsonob.txt pb.txt
name old time/op new time/op delta
Deserialize-8 8.34µs ±11% 0.20µs ± 1% -97.62% (p=0.000 n=19+17)
请求参数合法性校验是 API 设计重要环节,关系到数据一致性及安全性。Apex 网关采用业界流行的 JSON Schema 模型定义校验模板,对请求参数进行校验,针对 POST/PUT/PATCH 请求校验 Body 请求参数, GET/HEAD/DELETE 请求校验 Query 参数。部分老业务因为历史原因可能没有做参数校验的代码逻辑,这部分接口接入网关后无需修改代码即可具备参数校验的能力。{
"code": 400,
"message": "validation failed: missing properties: 'name', 'job_title'",
"details": {
"/required": "missing properties: 'name', 'job_title'",
"/properties/age/exclusiveMinimum": "must be > 1 but found -1"
}
}
参数映射
除了对参数做校验,Apex 还支持对参数进行各种映射和转换。我们经常会遇到前后端接口参数不一致的情况,可能是因为新老版本 API 数据格式不兼容,也可能是需要在代理阶段向后端传递指定的值。以前解决这类问题,通常都是针对不同参数编写不同版本的后台接口。
有了 Apex 后,一切都变简单了。网关能获取到所有请求参数,用户可以配置转换规则,将请求参数映射到后台接口的任意位置上,这样一个后台接口,甚至可以包装成多个参数不同的对外接口,极大增强了后台接口的适配性。除了能解决新老版本 API 数据格式不兼容的问题,参数映射插件还能将入站请求插件处理过程中产生的中间数据代理到后端,可以通过这种形式实现防越权,也可以携带静态值到后端。前端跨域
除了对后台接口适配性的增强,网关对前端跨域也有很好的支持。我们常用的跨域方式有 CORS 和 JSONP 两种,CORS 最大的问题是配置不规范,容易产生安全风险,之前我们接口是直接配置在 Nginx 配置文件中,业务各配置各的,不方便标准化。JSONP 的使用问题是业务代码需要处理回调函数,形式上比较麻烦。
Apex 提供在统一的跨域名支持,用户只需要简单配置即可接入。利用跨域插件,消灭了跨域配置冗余和代码冗余,更加规范,更加安全。使用方式如下:数据脱敏
很多后台接口都需要同时被内部和外部调用,但有时候对外暴露的某些信息需要脱敏处理。以前的做法是针对不同的返回字段,封装成不不同的后台方法。Aepx 通过数据脱敏插件来解决这个问题,借助成熟的 jq 引擎,通过自定义 UDF 处理函数,可以实现对数据自由修改,用户只需要配置不同的处理规则就可以了。下图是生产环境的真实案例,在对外接口上配置了对手机号打码和对权限字段的擦除,以及脱敏前后的数据对比。服务编排
随着前后端分离架构、微服务架构、中台战略的实施,产生大量的各种协议的 API 服务,API 服务之间的相互调用和依赖关系也随之越来越复杂。接口聚合就是一个搬运工,帮助客户端聚合多个接口的返回数据,以适应不同场景需求。希望通过接口聚合这个功能,做到让客户端直接获取数据,而后端也能继续专心于提供基础业务领域的原子接口能力。服务编排可以聚合内部多个微服务调用,暴露单个 API 给客户端调用。这个能力可以很好的满足客户端需要调用多个不同的服务接口来实现逻辑的场景,过程如图所示:如果没有聚合服务,则客户端需要分别向 (2,3,4)三个服务发起调用,或者需要另一个后台服务做接口聚合。这可能会带来更多的网络和延时问题,还可能会增大客户端调用逻辑复杂性。
为了解决这些问题,可以使用聚合功能减少客户端与内服服务交互次数。网关接收到外部请求后,分发给内部多个服务,然后聚合结果返回给客户端。可以在高延迟网络上大大提高程序性能,改善用户体验。整个编排聚合功能被设计成 Pipeline 模型,一个 Pipeline 包含多个 Stage ,每个 Stage 包含多个 Task 请求任务。多个 Stage 之间串行执行,适合有前后依赖关系的请求。单个 Stage 内,多个 Task 之间并行执行,相互无依赖。每个 Task 等于一个后端请求,可单独配置前置、后置脚本用于处理数据。脚本中可以引用 collector 对象,通过 label 获取前置任意阶段 Task 数据。通过复用已有 Proxy 层,上层增加 Pipeline 调度器,可以轻量级实现该功能。BFF 逻辑无侵入业务代码;
可扩展性强;
前端友好,单次调用性能高;
快速新增聚合接口,无需开发、发布;
可以自定义并行,串行执行顺序;
自定义脚本执行
针对需要对数据进行自定义处理的业务需求,网关支持不同阶段执行自定义脚本功能。该功能基于集成 Lua VM 实现,可以直接执行 Lua 脚本对请求、响应阶段进行自定义处理。该阶段在请求后端之前执行,可以用于改写代理数据,计算附带指定数据等功能。
该阶段在后端返回后执行,可以用于改写后端原始返回数据、调试日志、上报等功能。
该阶段在 Pipeline 模式下,当所有 Stage 调用完毕后执行。在该脚本内可以通过引用 collector 对象获取每个 Task 响应数据进行计算、数据聚合等操作后,重写最终返回数据。
分布式限流
为了保障后端服务的可用性,降低级联故障,Apex 支持分布式限流能力。业界有多种常用限流算法,主流的就三大类。窗口类一般常用的是滑动窗口,它可以尽量保证限流平滑。滑动日志可以实现精准限流,但它需要详细记录每次请求的时间,资源占用大。漏桶一般用来流量整形,我们目前没有这种业务场景。令牌桶的好处是可以处理突发流量。结合我们的业务场景,Apex 选择实现分布式的令牌桶和滑动窗口两种方案。令牌桶方案我们是基于 Redis+Lua 实现的,抛开实现本身,考虑的更多的是可用性。分布式限流要依赖远程存储,而远程存储的状态和服务状态是分离的,它的状态是不可信任的。在实现方式上,当 Redis 不可用时,会自动降级成单机限流,并启动定时检测。当检测到 Redis 恢复时,自动恢复成分布式限流。这种方案每次判断都请求 Redis,计数精准,不过这对延时敏感型业务不友好,另外大流量的API会导致 Redis 热 key 问题。有些场景下令牌桶限流并不太适用,针对这类特殊场景,Apex 单独支持了 本地+远程滑动窗口的限流方案,它稳定、高性能,不过限流精度有所降低。请求会先在本地计数,到达一定时间间隔或者计数到达一定阈值后,将本地计数累加到远程,然后将远程最新的计数同步回本地。这种本地计数的方式性能高,适用于延时敏感型业务以及请求量大的业务。业界有多套用于实现熔断的算法,最典型的是 Hystrix 熔断算法,其算法核心是:当请求失败比率达到一定阈值之后,熔断器开启,并休眠一段时间(由配置决定),这段休眠期过后,熔断器将处于半开状态,在此状态下将试探性的放过一部分流量,如果这部分流量调用成功后,再次将熔断器关闭,否则熔断器继续保持开启并进入下一轮休眠周期。这个熔断算法有一个问题:过于一刀切。是否可以做到在熔断器开启状态下仍然可以放行少部分流量呢?当然,这里有个前提,需要看后端此时还能够接受多少流量。Apex 使用 Google SRE Client-Side Throttling 自适应熔断算法。核心原理是:通过请求总数和请求成功数,按算法计算出主调方丢弃请求的概率。成功数越低,丢弃概率越高。- 在通常情况下(无错误发生时) requests == accepts;
- 当后端出现异常情况时,accepts 的数量会逐渐小于 requests;
- 当后端持续异常时,客户端可以继续发送请求,直到 requests = K ∗ accepts,一旦超过这个值,客户端就启动自适应限流机制,新产生的请求在本地会以 p 概率被拒绝;
- 当客户端主动丢弃请求时,requests 值会一直增大,在某个时间点会超过 K ∗ accepts,使概率 p 计算出来的值大于 0,此时客户端会以此概率对请求做主动丢弃;
- 当后端逐渐恢复时,accepts 增加,(同时 requests 值也会增加,但是由于 K 的关系,K ∗ accepts 的放大倍数更快),使得计算结果变为负数,从而概率 p == 0,客户端自适应限流结束;
Apex 支持按机器机器节点、服务、RPC方法等多维度设置熔断规则,尽可能减小熔断的作用范围和影响范围。如果这个范围控制不好,如熔断粒度过大的话,会导致同节点或同服务下的其他接口不能被正常调用。缓存可以降低请求延时、缓解后端服务的压力,是提升性能的重要机制。以前业务侧如果要引入缓存,需要在代码中感知缓存的读写和配置,但现在通过Apex管理,业务可以集中在核心逻辑的开发上。Apex参考HTTP缓存机制,为GET请求提供了缓存管理能力,对于开启了缓存配置的请求,优先使用本地缓存,如果本地缓存不存在,则尝试使用远程缓存,通过两级缓存最大限度提高性能。灵活的缓存配置项能够满足业务的各种需求,Apex 支持请求级别和指定参数级别的缓存,可以对每个请求单独缓存,也可以根据指定参数缓存。另外还可以配置缓存时间、需进行缓存的状态码、是否仅在上游宕机时启用缓存抵挡等。自动化测试
网关上托管了大量的 API,如何保证 API 的质量和稳定性是一个挑战。以前我们手动为每个接口编写测试用例,如下图中的示例,这种手写的方式维护麻烦且效果不好。最近测试部门搞了一个智能测试平台(名叫 Nemean),业务方只需要将请求流量复制到平台上就可以分析请求特征和参数,自动生成测试用例。针对未接入 Apex 的流量,我们通过 Nginx mirror module 实现了流量复制。同时,Apex 参考 Nginx Mirror Module 的思路,实现了自己的流量复制插件,在测试环境对请求进行采样,并异步上报到测试平台。
流量复制方案自动生成测试用例,覆盖全面,场景丰富,无需人工参与,把人力资源从接口测试用例中解放了出来!服务端推送
企点有很多的业务场景依赖于服务端推送来更新页面数据,比如上图的融合工作台,它里面的的客户列表、资料变更等都需要通过推送去更新。这样的业务场景还有很多,比如消息提醒、消息监控等等。因为业务历史原因,企点内部各团队维护了多套实现不同的推送系统,分散在不同工程中,它们推送协议不同、后台技术栈不同、业务协议、传输协议也不尽相同,并且各系统与业务强耦合,难复用。为了解决上面的一系列问题,我们将长连接统一接入在 Apex 网关上,由网关实现统一的推送能力,屏蔽不同推送协议的通信细节,做到和业务解耦。我们来看下网关推送系统是怎么设计的:大家关注上图中蓝色部分,分别是网关和推送服务。系统的核心流程是:用户和网关建立连接,网关将连接信息保存到 Redis,推送服务去 Redis 中查找用户连接的网关节点,然后将消息经由对应的网关推送出去。推送系统和网关配合在一起,可以复用网关的各种能力,比如说限流。用户建立长连接后,理论上就可以任意发送消息了,但是加上网关的限流后,就可以做到对长连接上的流量进行控制了。推送服务和网关是解耦的,中间通过连接池进行RPC通信,这样的好处是两个系统可以分别修改和部署,不会互相影响。推送服务提供两种推送模式,同步推送适用于需要及时感知到推送结果的场景,异步推送则相反。在开发过程中,我们发现推送是否高效,很大程度上取决于能否快速找到用户的网络连接在哪里。如果每条推送都要去 Redis 中查询,不仅 Redis 压力大,而且推送延时也高,所以对这个场景做了特殊优化。在推送服务节点上,将连接信息存储一份到本地缓存。那这个缓存怎么保持更新呢?在用户和网关建连和断连的时候,通过 Redis 的 PubSub 发送一条变更通知,推送服务各个节点都订阅这个通知,更新自己的缓存数据。这样在推送的时候直接查询内存就好了,如果内存查询不到,再回源 Redis,查到后再回塞缓存。这个优化效果非常好,可以降低 95% 的 Redis 查询率,推送延时平均降低了 2ms。而且如果 Redis 不可用了,内存中的缓存数据还可以做最后的容灾兜底,保证现有连接的推送依然正常。在推送服务新节点启动前,会先进行本地缓存预热,尽可能减少 Redis 查询。推送系统为业务方提供了多语言、多协议的接口和 SDK,业务方只需要简单调用发送函数就可以推送了,几分钟就可以完成接入,非常方便。除了基础能力,还支持了很多其他特性,比如单用户多连接、防止用户恶意建连等功能。API 文档生成
API 最终需要交付给前端开发人员或第三方客户,需要完善的 API 文档。我们都知道开发人员最不喜欢书写文档。前面提过网关后台可以设置请求、响应参数定义,不仅可以用于参数校验,还可以自动化生成 API 文档。以下是 API 文档自动化生成流程图,Apex 网关内包含路由各个维度配置以及参数定义,自动化生成 Swagger JSON 格式,对内导入统一文档管理平台(接口契约平台 Tolstoy),实时更新。对外可以离线 JSON + Swagger UI 交付 API 文档,大大减少开发人员书写文档的时间以及文档的准确性。业务接入 API 网关后,迫切希望了解业务接口健康状态。通过接入自定义告警平台,接入方可以自行设置告警策略,关注自身业务相关接口异常状态,包括:失败数、延迟、失败率、QPS 数等。安全性、可用性
作为业务入口,网关每天承接海量业务请求,其中不乏一些恶意请求,安全防护显得尤为重要。对于基础防护如:CSRF 跨站脚本攻击、CORS 跨域安全、上传文件漏洞等,可以在网关管理后台一键开启。针对 SQL 注入、DDOS、 XSS 等高级防护,这块采用公司级别门神+WAF服务,可实时对恶意流量进行过滤、拦截。另外 API 设计、测试阶段网关实时提交至安全扫描平台,进行安全漏洞扫描。上线后安全团队对接口进行不定期扫描,尽可能发现潜在安全隐患,最大程度保护业务和后端服务。可观测性
Apex 通过监控数据了解系统运行情况,通过日志和 trace 排查分析问题,通过公司 BI 平台统计、发送接口调用日报。指标监控:Metircs (Prometheus & Grafana)链路追踪:Traces (TpsTelemetry)
UI 管理后台
为了让用户更方便地接入 API,Apex 提供了操作简单、功能丰富的 UI 管理后台。除了对 API 进行统一的查看和配置外,还可以在后台进行接口调试、查看单个 API 实时访问数据。
性能数据
Apex 作为企点流量入口,高性能设计至关重要,在 HTTP 代理模式下单机(4c8g)压测达到 11k/s,tRPC 模式下高达 20k/s。目前每日承载数亿次请求,业务峰值 QPS 10w+,日均 QPS 2w+,上线以来一直保持零宕机的高稳定运行。其服务了包括企点协同、客服、待办、工作台、客户库、WebIM、B2B 等大量核心业务场景。
tRPC 短连接 vs 长连接压测性能
HTTP 代理压测性能
未来规划
业务在发展,Apex 也会越来越完善,我们期待并积极努力地建设以下能力:支持自定义条件路由、灰度
协议即接口,支持挂载基于 Google API Protobuf 描述的 RESTful 接口
完善多语言 SDK(提供签名、调用、结果上报、错误处理等能力)
探索和使用自适应限流和熔断
业务级资源隔离(网关部署、限流和缓存使用的 Redis等)
支持中心化部署和私有化部署多种模式
公司内部开源和外部开源
参考链接
https://stackoverflow.com/questions/34782493/difference-between-csrf-and-x-csrf-token
Google SRE Breaker 算法
https://sre.google/sre-book/handling-overload/#eq2101
GopherLua: VM and compiler for Lua in Go.
https://github.com/yuin/gopher-lua