三七互娱技术团队

带你玩玩接口网关

Image

1、API网关是什么?

    字面上意思是指将所有API的调用统一接入网关层,由网关层负责接入和输出。
    API网关的作用也很明显,所有业务接入网关层之后成为了主要的流量入口,统一处理和业务相关的请求,可以使请求更加安全、快速和准确地得到处理。

Image

    API网关功能相对来讲比较丰富,可以让服务只关心业务本身;而和业务实现无关的功能,就可以在独立的网关层面来解决。比如:反向代理、动态负载均衡、服务熔断、API健康检查、动态上游、动态限流限速、动态SSL证书...等等。

    功能如此强大那么可以拿来替代nginx吗?答案是可以,而且api网关还具备了nginx不具备的功能。

    下面带你使用开源API网关apisix框架实现一个动态限流功能。

2、apisix框架

    apisix是基于openresty+etcd实现的一个api网关框架。相比其他API网关kong,无论是功能、性能或代码二次开发优势明显。具体安装方式以及数据对比可以去github查阅。

2.1 apisix启动

安装完之后开启apisix,启动时主要处理了3个事情:

local openresty_args = [[openresty  -p ]] .. apisix_home .. [[ -c ]]                       .. apisix_home .. [[/conf/nginx.conf]]function _M.start(...)    init(...)    init_etcd(...)
local cmd = openresty_args -- print(cmd) os.execute(cmd)end

1、初始化Nginx配置文件并检查openresty版本

2、检查etcd可用性并初始化框架默认配置

3、启动openresty并挂载生成的Nginx配置

2.2 apisix插件

    Apisix的插件是框架灵魂所在,对于开发限流功能来讲,只要实现限流插件,并绑定到对应的路由上,即可完成需求。

    在开发限流插件前,我们先要了解apisix工作流。

Image

工作流可分为4部分:

1、匹配路由,根据请求的方法、uri、host、请求头等条件,去路由规则中进行匹配。如果命中了某条路由规则,就会从 etcd 中获取对应的插件列表。

2、匹配插件,本地开启的插件列表进行交集,得到最终可以运行的插件列表。

3、执行插件,根据插件的优先级,逐个运行插件。

4、分发上游,根据上游的健康检查和负载均衡算法,把这个请求发送给上游。

    通过API网关的工作流不难看出,主要的核心点在于3个,匹配路由规则、匹配插件规则以及负载均衡规则。而对于我们的业务实现,基本可以从路由重写跟开发插件,这两点出发去做对应的处理。

    重点讲下apisix插件热载特性,个人认为这是面向开发比较好的特性。使用过openresty都知道,只要发布了lua代码后,都需要对应服务器上openresty -s reload。有热更热载特性后,发布代码变得更加灵活并且可以自定发布机制。我们来看是apisix是怎么做到的。

Image

    拿请求工作流举例,再匹配到插件后,会从插件hash表上获取对应插件的对象并执行对应阶段的方法(如:access阶段执行access方法),插件hash表最先是在apisix启动时生成的,后续更新插件可以通过提供的http接口热载入,从而不需要openresty reload。有了热载接口后,就可以将热载的动作做在后台维护热载或自动热载。

local function load_plugin(name, plugins_list, is_stream_plugin)    local pkg_name = "apisix.plugins." .. name    if is_stream_plugin then        pkg_name = "apisix.stream.plugins." .. name    end    pkg_loaded[pkg_name] = nil
local ok, plugin = pcall(require, pkg_name) if not ok then core.log.error("failed to load plugin [", name, "] err: ", plugin) return end
if not plugin.priority then core.log.error("invalid plugin [", name, "], missing field: priority") return end
if not plugin.version then core.log.error("invalid plugin [", name, "] missing field: version") return end
plugin.name = name core.table.insert(plugins_list, plugin)
if plugin.init then plugin.init() end
returnend

看apisix源码具体热载插件代码,主要有2个核心点

1、pcall动态调用插件,并且将加载记录删除pkg_loaded[pkg_name]=nill

2、按插件名写入hash表,调用时从hash表读取

2.3 apisix开发插件规则

前面对apisix基本工作流以及插件热载都有了解,下面就到具体开发。

nginx本身就具备了限速limit_req模块,网关层可以利用该模块特性完成动态限流限速,可分为四步骤去现实。

第一、后台配置项

Image

1、rate:设置并发数阈值(单位:秒)

2、burst:设置缓冲队列数,达到并发阀值后延迟处理数量

3、key:设置限流维度,如:客户端IP,请求头,服务器ip...等

4、rejected_code:请求超过(rate+burst)后 ,请求的响应值

第二、注册插件

1、文件名跟插件名保持一致,分别对应路由以及hash表规则,存放路径apisix/plugins/limit-req.lua

2、将插件名称配置在conf/config.yaml的plugins字段下配置limit-req

3、请求热载插件,地址是

http://hosts/apisix/admin/plugins/reload -X PUT

第三、插件声明基本属性

local _M = {    version    = 0.1,    priority    = 1001,    name     = plugin_name,    schema   = schema,}

1、version 声明插件版本

2、priority 执行插件时优先级

3、name 插件名称

4、schema 校验参数表

重点讲下schema,它的作用主要是使用json schema数据格式完成对参数校验,而插件中可以使用Lua table对插件参数进行校验。

格式如下:

local schema = {    type = "object",    properties = {        rate  = {type = "number", minimum = 0},        burst = {type = "number",  minimum = 0},        key   = {type = "string",            enum = {"remote_addr", "server_addr", "http_x_real_ip",                    "http_x_forwarded_for"},        },        rejected_code = {type = "integer", minimum = 200},    },    required  = {"rate", "burst", "key", "rejected_code"}}function _M.check_schema(conf)    local ok, err = core.schema.check(schema, conf)    if not ok then        return false, err    end
return trueend

从代码schema表中看出主要有3个配置:

1、type 声明校验的类型

2、propertise 校验参数列表,拿rate配置来讲,type限制参数类型,并且mininum限制最小值

3、Required 必传的参数

    最后check_schema完成校验工作。

    上面只是展示了jsoschema一小部分校验规则,实际上还支持更多校验方式。其思想无语言的边界,以一种规范形式替代纯粹的if-else。

第四、完成限流限速功能

function _M.rewrite(conf, ctx)    -- TODOend
function _M.access(conf, ctx) -- TODOend
function _M.balancer(conf, ctx) -- TODOend

    插件主要工作在两个阶段,分别是access_by_lua_block阶段以及balancer_by_lua_block阶段,并调用阶段对应的方法以及传入配置项、共享变量。

    其主要的作用:

1、access_by_lua_block阶段会执行rewrite与access 方法,两个方法本质上并没有太大区别,都是处理请求并实现插件逻辑。只是rewrite处理uri重写或api鉴权会比较直观 。

2、balancer_by_lua_block阶段执行balancer方法,在这里实现负载均衡规则。

    结合每个工作区的意义,限流放在access阶段处理比较合适。

local function create_limit_obj(conf)    core.log.info("create new limit-req plugin instance")    -- 实例化限流库    return limit_req_new("plugin-limit-req", conf.rate, conf.burst)end
function _M.access(conf, ctx) -- 实例化限流库并将对象缓存 local lim, err = core.lrucache.plugin_ctx(plugin_name, ctx, create_limit_obj, conf) if not lim then core.log.error("failed to instantiate a resty.limit.req object: ", err) return 500 end
local key = (ctx.var[conf.key] or "") .. ctx.conf_type .. ctx.conf_version core.log.info("limit key: ", key)
-- 调用限流并返回是否触发 local delay, err = lim:incoming(key, true) if not delay then -- 超过阈值,就返回指定的状态码 if err == "rejected" then return conf.rejected_code end
core.log.error("failed to limit req: ", err) return 500 end
-- 达到并发数,缓冲队列延迟处理 if delay >= 0.001 then sleep(delay) endend

    核心代码主要有2个,实例化resty-limit-req对象完成调用并做对应处理,代码中我也做了相应的注释。除上述三个工作区外,还有一些不常用工作区。这些方法可以不去实现它,但在你需要时能让你写出更合理的插件逻辑。

function init()    -- TODOend
function header_filter(conf, ctx) -- TODOend
function body_filter(conf, ctx) -- TODOend
function log(conf, ctx) -- TODOend

1、init方法在插件热载后执行,可以对插件进行初始化操作。

2、header_filter_by_lua_block阶段执行header_filter方法, 处理响应头操作。

3、body_filter_by_lua_block阶段执行body_filter方法, 处理响应体操作。

4、log_by_lua_block阶段执行log方法,处理访问日志操作。

2.4 路由匹配规则

    最后将限流插件绑定对应路由配置,再路由匹配成功后会执行插件。

    路由匹配规则,它是多维度匹配规则并且支持模糊匹配,传统的key-value数据结构已无法满足当前需求,而使用关系型数据库则多一层依赖关系。

    apisix用的是lua的FFI库实现调用C的radixtree,使用的是基数树数据结构来存储并进行匹配。

local radix = require("resty.radixtree")local rx = radix.new({    {         paths        = "/aa",        hosts        = "foo.com",        method       = {"GET", "POST"},        remote_addrs = "127.0.0.1",        metadata     = "1",    },    {         paths        = "/bb*",        hosts        = {"*.bar.com", "gloo.com"},        method       = {"GET", "POST", "PUT"},        remote_addrs = "fe80:fe80::/64",        vars         = {"arg_name", "jack"},        metadata     = "2",    }})ngx.say(rx:match("/aa", {    host = "foo.com",    method = "GET",    remote_addr = "127.0.0.1"}))

    从上面的代码例子中可以看出,resty-radixtree可以支持根据uri、host、method、参数、IP地址等多个维度进行匹配,并且匹配成功将返回metadata字段值,利用该特性将匹配项以及插件ID绑定。基数树的时间复杂度是O(K),性能比“遍历+hash缓存”的方式更加高效。

3、效果展示

    为了方便效果呈现,我配置访问前端地址(/test)1秒处理1个请求并且缓冲1个,触发后返回503状态码。

Image


这是利用apisix框架搭建的在线demo,有兴趣的同学可以去体验下。

demo后台:http://47.106.211.125:9080/apisix/dashboard

前端地址:http://47.106.211.125:9080/test

负载均衡分发至本机8001、8002、8003端口,8003端口间隔1分钟断开1次

THE END

编辑:chuanrui

Image

Image