APISIX插件开发之精细化限速插件

共 10200字,需浏览 21分钟

 ·

2021-11-05 11:08


需求背景

APISIX[1]当前版本(2.10.1)请求频率限制相关插件有 limit-count、limit-req、limit-conn 三种。都只能支持给固定的 key 来设置一个统一的限速,比如在 service 或 route 如下添加 limit-count 插件:

"limit-count": {
      "count"10,
      "key""consumer_name",
      "policy""local",
      "rejected_code"503,
      "time_window"1
}

那所有 consumer 都是一样的限速阈值,每秒 10 次。

再比如,在 Consumer 侧给指定的 Consumer 如下添加 limit-count 插件:

"limit-count": {
      "count"10,
      "key""service_id",
      "policy""local",
      "rejected_code"503,
      "time_window"1
}

那这个 Consumer 访问任意 service 都是一样的限速阈值。

在实际业务场景中,这个限速还太笼统,达不到业务要求。比如,业务 A 要求给 ConsumerA 限制 1 分钟能访问 100 次,给 ConsumerB 1 分钟限制能访问 1000 次,这个限速可以在 Consumer 侧给 A、B 分别设置限速,但是当 ConsumerA 和 ConsumerB 还需要访问业务 B,且业务 B 又有不同限速需求的时候,当前插件就玩不转了,除非客户端针对每个业务都要生成不同的 Consumer,那这个就太复杂了。

为了满足这个需求,我们一开始也APISIX[2]官方做了探讨,不过官方可能觉的不太通用,没后话了,感兴趣的可以看下issue[3]

解决方案

最后,我们自己在 limit-count 插件基础上进行改造,重新设计一个限速机制,能够针对不同的服务给不同的 Consumer 设置差异化的访问限速,满足生产环境更精细化的限速需求。

方案原理:将 limit-count 配置插入一个 table,在 table 里面支持定义更复杂的 key 和阈值,具体插件的配置 schema 如下:

{
    "scope""route_id",              # 标明插件添加位置,支持 route_id 和 service_id
    "default_count"1000,            # 设置默认的限速阈值
    "default_time_window"60,        # 设置默认的时间窗口
    "key""consumer_name",           # 设置要限速的客户端对象,支持 ["remote_addr""server_addr""http_x_real_ip""http_x_forwarded_for""consumer_name"]
    "map": {                          # 给每个限速对象分别设置不同的限速阈值和时间窗口
        "ConsumerA": {
            "count"300,
            "time_window"60
        },
        "ConsumerB": {
            "count"300,
            "time_window"60
        },
        "ConsumerC": {
            "count"300,
            "time_window"60
        }
    },
    "policy""local",
    "rejected_code"429              # 官方的限速插件超过限制访问返回的503,并不友好,这里改成更加直白的429 Too Many Requests
}

插件代码

--
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements.  See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License.  You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
local limit_local_new = require("resty.limit.count").new
local core = require("apisix.core")
local plugin_name = "limit-count-by-client"
local limit_redis_cluster_new
local limit_redis_new
do
    local redis_src = "apisix.plugins.limit-count.limit-count-redis"
    limit_redis_new = require(redis_src).new

    local cluster_src = "apisix.plugins.limit-count.limit-count-redis-cluster"
    limit_redis_cluster_new = require(cluster_src).new
end
local lrucache = core.lrucache.new({
    type = 'plugin', serial_creating = true,
})


local schema = {
    type = "object",
    properties = {
        key = {
            type = "string",
            enum = {"remote_addr""server_addr""http_x_real_ip",
                    "http_x_forwarded_for""consumer_name"},
            default = "remote_addr",
        },
        default_count = {type = "integer", exclusiveMinimum = 0},
        default_time_window = {type = "integer",  exclusiveMinimum = 0},
        scope = {
            type = "string",
            enum = {"route_id""service_id"},
            default = "route_id",
        },
        map = {
            type = "object",
            items = {
                type = "object",
                count = {type = "integer", exclusiveMinimum = 0},
                time_window = {type = "integer",  exclusiveMinimum = 0},
            }
        },
        rejected_code = {
            type = "integer", minimum = 200, maximum = 599, default = 429
        },
        policy = {
            type = "string",
            enum = {"local""redis""redis-cluster"},
            default = "local",
        }
    },
    dependencies = {
        policy = {
            oneOf = {
                {
                    properties = {
                        policy = {
                            enum = {"local"},
                        },
                    },
                },
                {
                    properties = {
                        policy = {
                            enum = {"redis"},
                        },
                        redis_host = {
                            type = "string", minLength = 2
                        },
                        redis_port = {
                            type = "integer", minimum = 1, default = 6379,
                        },
                        redis_password = {
                            type = "string", minLength = 0,
                        },
                        redis_database = {
                            type = "integer", minimum = 0, default = 0,
                        },
                        redis_timeout = {
                            type = "integer", minimum = 1, default = 1000,
                        },
                    },
                    required = {"redis_host"},
                },
                {
                    properties = {
                        policy = {
                            enum = {"redis-cluster"},
                        },
                        redis_cluster_nodes = {
                            type = "array",
                            minItems = 2,
                            items = {
                                type = "string", minLength = 2, maxLength = 100
                            },
                        },
                        redis_password = {
                            type = "string", minLength = 0,
                        },
                        redis_timeout = {
                            type = "integer", minimum = 1, default = 1000,
                        },
                        redis_cluster_name = {
                            type = "string",
                        },
                    },
                    required = {"redis_cluster_nodes""redis_cluster_name"},
                }
            }
        }
    }
}


local _M = {
    version = 0.4,
    priority = 1002,
    name = plugin_name,
    schema = schema,
}


function _M.check_schema(conf)
    local ok, err = core.schema.check(schema, conf)
    if not ok then
        return false, err
    end

    return true
end


local function create_limit_obj(conf, ctx)
    core.log.info("create new limit-count plugin instance")

    local req_key = ctx.var[conf.key]
    local item_count = 0
    local item_time_window = 0
    if conf.map[req_key] ~= nil then
        item_count = conf.map[req_key].count
        item_time_window = conf.map[req_key].time_window
    else
        item_count = conf.default_count
        item_time_window = conf.default_time_window

    end

    if not conf.policy or conf.policy == "local" then
        return limit_local_new("plugin-" .. plugin_name, item_count,
                               item_time_window)
    end

    if conf.policy == "redis" then
        return limit_redis_new("plugin-" .. plugin_name,
                               item_count, item_time_window, conf)
    end

    if conf.policy == "redis-cluster" then
        return limit_redis_cluster_new("plugin-" .. plugin_name, item_count,
                                       item_time_window, conf)
    end

    return nil
end


function _M.access(conf, ctx)
    core.log.info("ver: ", ctx.conf_version)
    local lim, err = core.lrucache.plugin_ctx(lrucache, ctx, conf.policy, create_limit_obj, conf, ctx)
    if not lim then
        core.log.error("failed to fetch limit.count object: ", err)
        return 500
    end
    local req_key = ctx.var[conf.key]
    local limit_key = req_key .. conf.scope
    local key = (limit_key or "") .. ctx.conf_type .. ctx.conf_version
    core.log.info("limit key: ", key)

    local delay, remaining = lim:incoming(key, true)
    if not delay then
        local err = remaining
        if err == "rejected" then
            return conf.rejected_code
        end

        core.log.error("failed to limit req: ", err)
        return 500, {error_msg = "failed to limit count: " .. err}
    end
    local item_count = 0
    local item_time_window = 0
    if conf.map[req_key] ~= nil then
        item_count = conf.map[req_key].count
    else
        item_count = conf.default_count

    end
    core.response.set_header("X-RateLimit-Limit", item_count,
                             "X-RateLimit-Remaining", remaining)
end


return _M

启用方法

将插件代码保存为 limit-count-by-client,拷贝到apisix/plugins,然后在 config.yaml 插件位置启用,如下,包括 2 个配置:

# 前面略...
nginx_config:
  http:
    lua_shared_dicts:
      plugin-limit-count-by-client: 10m # 插件的policy使用local模式的时候,需要用到共享内存

# 内容略...

plugins:
  - # 内容略..
  - limit-count-by-client

## 后面内容略...

具体配置,这里贴一个结合 HMAC 认证插件,实现对具体用户进行限频的路由配置,仅供参考:

{
    "uris": [
        "/hello"
    ],
    "plugins": {
        "hmac-auth": {
            "disable"false
        },
        "limit-count-by-client": {
            "default_count"1000,
            "default_time_window"60,
            "key""consumer_name",
            "map": {
                "consumer_A": {
                    "count"1000,
                    "time_window"60
                },
                "consumer_B": {
                    "count"500,
                    "time_window"60
                }
            },
            "policy""local",
            "rejected_code"429,
            "scope""route_id"
        }
    },
    "service_id""service_foo",
    "status"1
}

至于需要在官方 dashboard 启用,则需要更新一下 dashboard 的 schema.json,这里就不细说了。

注意事项

这个插件改造后只能加到 service 或 router,而不能加到 consumer 位置,所以取名叫 limit-count-by-client。

引用链接

[1]

APISIX: https://zhang.ge/tag/apisix/

[2]

APISIX: https://zhang.ge/tag/apisix/

[3]

issue: https://zhang.ge/goto/aHR0cHM6Ly9naXRodWIuY29tL2FwYWNoZS9hcGlzaXgvaXNzdWVzLzQ3NjA=

原文链接:https://zhang.ge/5158.html


你可能还喜欢

点击下方图片即可阅读

Earthly 一个更加强大的镜像构建工具

云原生是一种信仰 🤘

关注公众号

后台回复◉k8s◉获取史上最方便快捷的 Kubernetes 高可用部署工具,只需一条命令,连 ssh 都不需要!



点击 "阅读原文" 获取更好的阅读体验!


发现朋友圈变“安静”了吗?

浏览 99
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报