cover_image

微盟技术 | APISIX在微盟开放平台的落地实践

王哲 微盟技术中心
2023年04月13日 10:10

微盟在一年多的使用过程中,体验到了APISIX的强劲性能,丰富的功能插件,同时也遇到了很多复杂的问题。目前APISIX网关在微盟开放平台上的功能和性能需求已日趋稳定,在此做一个阶段性的总结。

一、背景介绍

微盟开放平台作为微盟云内外交互的技术底座,对外提供了三大类开放能力,API ,消息,SPI ,并且每种开放能力都有对应的网关承载其业务流量。伴随着微盟业务的快速发展,为了支持业务更频繁的内外数据交互需求,微盟对三大网关进行了架构优化与性能优化,目标是使开发者有更好的使用体验,在提升平台整体稳定性的同时,提高并发能力,降低平台的响应延迟。

其中微盟开放平台的 API 网关在优化的道路上历经了三个大的版本分别是SpringMVC 的 1.0 版本,Webflux 的 2.0 版本和目前基于 APISIX 的 3.0 版本。目前平台的 APISIX 版本相比于前两代网关,在性能上,功能上都得到了很大的提升。

二、性能需求

选择 APISIX 开放网关前,针对主流的网关的 RT ,QPS 也做了横向的对比,以下结果是在 8c16g 的物理机上进行测试的,其中 Java 技术栈的已经进行了充分预热。

无代理

图片

springcloudgateway

图片

zuul1.x

图片

apisix

图片

以上,分别对无代理,springcloudgateway,zuul1.x,APISIX 进行了压测,平均 rt 分别为 3.5ms,10.3ms,23ms,6.3ms。从结果数据上看 APISIX 确实有更好的性能表现。其中负载状态下网关的响应延迟是比较关心的一个性能指标。作为 PaaS 平台,用户的一次操作会触发多次的内外数据交互,所以优化降低 RT 是一件非常有性价比的事情,用户的体验也会得到明显的提升。

三、性能分析

以下是对 APISIX 高性能部分的理解:

1.高性能基因

APISIX 是基于 OpenRestyNginx 实现的,一方面 APISIX 继承了 Nginx 对流量处理的高性能特性,比如多进程事件驱动模型,Sendfile 机制等等。另一方面OpenResty 的核心部分 LuaJIT 和 Resty 库也为 APISIX 的性能提供了强大的保障。LuaJIT 会尽量多地把 lua 脚本作为机器码执行而不是使用解释器来执行。Resty 库相比 Nginx 原生的 lua-resty-core 也做了优化(FFI)有更好的性能表现。下图就是 Resty 库支持的部分功能。

图片

2.网络优化

Resty 库除了FFI部分针对网络请求也做了非阻塞的优化,比如redis链接部分使用lua-resty-redis库代替nginx原生的redis2-nginx-module,其中lua-resty-redis中使用的是cosocketAPI实现了非阻塞的网络IO 。

图片

四、APISIX的部署

目前微盟 APISIX 网关使用了 NodePort 方式部署到了 K8s 中,但是不作为 K8s 的默认 ingress,同时 APISIX  相关的流量也不经过 K8s ingress(出于性能考虑,这样可以减少一个网络节点)。同时这样做是有历史原因的,一方面目前的 k8s ingress treafic 已承载了部分功能比如灰度流量,k8s crd 逻辑,另一方面直接使用 APISIX 替代 treafic 最开始也没有很强的信心。但是长期来看 APISIX支持云原生这个特性提供了一种可能,使微盟 K8s 集群网关和开放平台 API 网关得到统一,同时作为微盟内部服务和开放平台的流量入口。

五、扩展功能需求

图片

上图是微盟 API 网关的技术架构,API 网关作为微盟开放体系的一环要承担实现以下功能:

  1. 鉴权
  2. 路由
  3. 参数映射
  4. 数据加密
  5. 协议转换 http -> dubbo
  6. 熔断
  7. 租户商业化限流
  8. 调用链
  9. 调用明细

在实现以上这些功能需求的过程中,有一些需求使用了原生插件进行实现,但是都或多或少做了一些代码上的更改以适应平台的业务需求。其他完全个性化的功能通过创建新的 APISIX 插件来实现自定义的业务逻辑。以上这些要写一部分 lua 代码,对于 Java 开发者来说开始可能会比较复杂,但是实际这件事情却不困难,APISIX 丰富的原生插件提供了大量的使用技巧和实现案例可以供参考。

下面通过几个小功能简单介绍下微盟如果在apisix插件上做扩展的。

六、扩展功能实现

APISI的插件扩展是基于 Nginx 的 Http 请求生命周期的,如下图,可以选择合适的生命周期节点做自定义的插件逻辑。比如,可以在 lua 代码中完成function _M.access(conf, ctx) 方法,就可以实现在 access_by_lua 阶段对当前请求流量做一些扩展操作。

需要注意的是 APISIX 并没有使用或者说是实现全部的 Nginx 生命周期节点,具体能不能做扩展要参考对应版本的 APISIX 的官方文档,目前看到很多外部文档的描述都是错误的。常用的生命周期包括init, rewrite, access, balancer, header_filter, body_filter 以及 log。

图片

下面以两个场景为例子简单介绍下微盟通过 APISIX 自定义插件机制完成业务功能的。

1.开放API鉴权

说明

租户流量进入微盟开放平台之前 API 网关需要对请求中的 accesstoken 进行鉴权,判断当前应用是否有某个 API 的访问权限,也要判断当前应用是否有某个商家数据的访问权限。并且在向后续业务方请求的流量中添加应用,商家,权益相关的基础信息。

实现

通过自定义插件添加了这部分功能 wm-auth.lua。

wm-auth.lua的代码引用部分。

local http_util = require("apisix.weimob.utils.http-util")
local redis_util = require("apisix.weimob.utils.redis-util")

wm-auth.lua的部分代码。

  -- first load from redis
    local redis_key = "bidpid" .. accesstoken
    local red = redis_util:instance()
    local cache, err = red:get(redis_key)
    if err then
        core.log.error("failed to get redis key,ignore this err: ", redis_key .. "," .. err)
    end
    if cache ~= nil then
        local token_table = core.json.decode(cache)
        if token_table == nil or next(token_table) == nil then
            return 200, response_util.build_resp(err_constant.INVALID_ACCESS_TOKEN)
        end
        token_holder(ctx, token_table,conf)
        return
    end

    -- 授权中心授权token解析
    local analysis_token_url = conf.analysis_token_url
    local token_res, token_err = http.post(
            { uri = analysis_token_url .. "?accesstoken=" .. accesstoken }, {})
    if not token_res then
        wlog.log(ctx, "open-b analysis token err,accesstoken:" .. accesstoken, err_constant.PARSE_ACCESS_TOKEN_ERR)
        return 200, response_util.build_resp(err_constant.SERVER_ERROR)
    end
    core.wmlog.warn(ctx, "analysis_token:" .. accesstoken .. ",rs :", core.json.encode(token_res))
    local token_table = core.json.decode(token_res)
    if token_table == nil or next(token_table) == nil then
        return 200, response_util.build_resp(err_constant.INVALID_ACCESS_TOKEN)
    end
    token_holder(ctx, token_table,conf)

这段代码的逻辑比较简单,wm-auth.lua 通过 redis-util 判断当前 accesstoken 在缓存中是否存在,缓存未命中的情况下会通过 http-util 调用远程服务获取 accesstoken 绑定的相关信息。这段代码就参考了APISI的一些官方插件,APISI插件提供了很多例子告诉大家如何在扩展过程中调用外部服务。

http-util 的代码引用部分。

local http = require("resty.http")

redis-util 的代码引用部分。

local redis = require("resty.redis")

请求的底层还是使用官方推荐的 Resty 库进行网络IO。目前微盟开放 API 鉴权逻辑中大部分流量都会命中 redis,每次请求 redis 的耗时在 1ms 左右。

2.商业化限流

说明

开放 API 作为一种平台资源在微盟也完成了商业化资源控制,其中租户应用请求 API 的 QPS 和日调用量就是一种商业化资源。限流规则通过商业化和资源管理服务通知 APISIX 网关执行对应的限流规则。

实现

商业化限流也是通过自定义插件实现的,参考了官方提供的插件 limit-count-redis.lua,但是并不能直接使用,主要源于官方插件以下几点限制:

  • 官方的限流插件是 API 维度的,微盟开放平台商业化限流是 API + 应用 + 商户维度的。
  • 官方插件要配置固定的限流 Window,微盟开放 API 目前要同时支持 QPS 与日调用量限流。

插件在 access 阶段对逻辑进行了扩展,部分代码如下:


local script = [=[
    if ARGV[1] and ARGV[1]~=-1 and ARGV[1]~='-1' then
         if redis.call('ttl', KEYS[1]) < 0 then
            redis.call('set', KEYS[1], 1, 'EX', 1)
        else
            local qt = redis.call('incrby', KEYS[1], 1)
            local remain = ARGV[1]-tonumber(qt)
            if remain<0 then
              return -1;
            end
        end
    end
    if ARGV[2] and ARGV[2]~=-1 and ARGV[2]~='-1' then
        if redis.call('ttl', KEYS[2]) < 0 then
            redis.call('set', KEYS[2], 1, 'EX', 259200)
        else
            local dt = redis.call('incrby', KEYS[2], 1)
            local remain = ARGV[2]-tonumber(dt)
            if remain<0 then
              return -2;
            end
        end
    end
    return 1;
]=]

function _M.access(conf, ctx)

   
  ...
  部分逻辑省略
  ...
  
    local res, err = red:eval(script, 2, qps_cnt, qpd_cnt, s_limit, d_limit)
    if err then
        core.log.error("eval redis quota limit script err:", err)
        -- redis报错降级,忽略err
        return
    end

    -- 1.未达到限流阈值
    -- -1 qps限流  -2 日调用限流
    if res == '1' or res == 1 then
        ctx.var.execute_quota_limit = true
        return
    end
    if res == '-1' or res == -1 then
        ctx.var.execute_quota_limit = true
        return 200, response_util.build_resp(err_constant.CALL_QPS_LIMIT)
    end
    if res == '-2' or res == -2 then
        ctx.var.execute_quota_limit = true
        return 200, response_util.build_resp(err_constant.CALL_LIMIT_EXCEED)
    end
end

限流部分微盟使用了 Redis 的 Resty 库来操作 Redis IO,再通过  Redis Lua 脚本完成原子功能的请求量计数和限流判断。

七、其他问题

在使用 APISIX 的过程中会遇到的很多问题,下面列举一些比较重要的问题,同时这部分的问题在目前最新版本的 APISIX 上还是存在的。

1.json序列化

微盟 APISIX 网关在代理流量过程中会对请求参数,响应数据进行解析处理。APISIX 默认是使用 cjson 库完成对 json 类型数据的序列化,反序列化功能的。其中针对数字类型,cjson 的默认精度是14位长度,超过这个长度会使数字类型变成科学计数法。APISIX 可以通过 cjson.encode_number_precision 来设置更大的数字类型精度,但是最多也只能设置到16位。如果后端服务是 Java 项目的话,这个时候就比较难受了,因为 Java long 类型的最长精度是19位,对于这部分数据是比较难处理的。

2.Content phase扩展不支持resty网络请求

在 APISIX 的使用中,开发了很多自定义插件。其中有些插件需要在 Content phase 阶段(body_filter,head_filter 参考nginx生命周期)完成对 response 数据的扩展操作。如果此时的数据操作依赖外部服务数据,也就是说要在此插件的 Content phase 阶段进行 IO 操作(包括请求外部服务,Redis 数据查询...)APISIX 是禁止此类操作的。查阅了部分文档,对这个禁止操作的解释是为了性能优化,但是对于性能优化的原理并没有更多的解释,这其实对业务功能实现有了比较大的限制。

以上这些问题,都通过一些其他方式找到了临时的解决方案,但是这些方案明显没有很遵循 APISX  的框架设计思路,所以肯定也不是官方推荐的解决方案,在这里就不详细介绍了。希望上述问题 APISX 官方能提供更优雅的解决方案。

八、总结

APISX 在微盟的落地的过程伴随着惊喜和艰难,惊喜于 APISX 的性能表现和大量丰富的原生插件,艰难则是因为 APISX 在功能上还存在一些硬性的问题。好在最终拿到了很好结果。希望未来微盟的开放生态可以随着 APISX 的发展使用APISX 技术体系更加完善,更加强大的功能,推进开放平台交互体验的进步。


推荐阅读
Elasticsearch 慢查询自动化巡检实践
微盟移动端组件库 Titian Mobile 对外开源
全链路灰度在微盟的落地
本地缓存的数据一致性和可观测性的实践

继续滑动看下一个
微盟技术中心
向上滑动看下一个