Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cluster key rate limit #1002

Merged
merged 5 commits into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions plugins/wasm-go/extensions/cluster-key-rate-limit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# 功能说明

`key-cluster-rate-limit`插件实现了基于特定键值实现集群限流,键值来源可以是 URL 参数、HTTP 请求头、客户端 IP 地址



# 配置说明

| 配置项 | 类型 | 必填 | 默认值 | 说明 |
| ----------------------- | ------ | ---- | ------ | ---- |
| rule_name | string | 是 | - | 限流规则名称,根据限流规则名称和限流的客户端IP段来拼装redis key |
| limit_by_header | string | 否,`limit_by_header`,`limit_by_param`,`limit_by_per_ip` 中选填一项 | - | 配置获取限流键值的来源 http 请求头名称 |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以考虑加一个 limit_by_consumer, 现在通过 x-mse-consumer 这个request header可以取出 consumer 的名字

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以考虑加一个 limit_by_consumer, 现在通过 x-mse-consumer 这个request header可以取出 consumer 的名字

这个应该是要配合认证的插件一起使用对吧

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

是的,如果用户开了 key-auth 等认证插件,就可以配合基于 consumer 做流控了,在openapi场景下挺有用的

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

是的,如果用户开了 key-auth 等认证插件,就可以配合基于 consumer 做流控了,在openapi场景下挺有用的

好的

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以考虑加一个 limit_by_consumer, 现在通过 x-mse-consumer 这个request header可以取出 consumer 的名字

这个目前根据请求头限流的应该可以覆盖这个场景了,limit_by_header直接配置成x-mse-consumer就可以了,如果单独再实现一个limit_by_consumer会有点奇怪,在limit_by_header使用文档中添加下对x-mse-consumer的使用场景的说明会更好些

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit_by_consumer 更好一些,目前通过header传递consumer信息的方式后续可能会调整,有了limit_by_consumer,用户就不感知这个调整

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit_by_consumer 更好一些,目前通过header传递consumer信息的方式后续可能会调整,有了limit_by_consumer,用户就不感知这个调整

好的,limit_by_consumer实现的时候也会和limit_by_per_header、limit_by_per_param一样支持配置一个正则表达式

| limit_by_param | string | 否,`limit_by_header`,`limit_by_param`,`limit_by_per_ip` 中选填一项 | - | 配置获取限流键值的来源 URL 参数名称 |
| limit_by_per_ip | string | 否,`limit_by_header`,`limit_by_param`,`limit_by_per_ip` 中选填一项 | - | 配置获取限流键值的来源 IP 参数名称,从请求头获取,以`from-header-对应的header名`,示例:`from-header-x-forwarded-for`,直接获取对端socket ip,配置为`from-remote-addr` |
| limit_keys | array of object | 是 | - | 配置匹配键值后的限流次数 |
| show_limit_quota_header | bool | 否 | false | 响应头中是否显示`X-RateLimit-Limit`(限制的总请求数)和`X-RateLimit-Remaining`(剩余还可以发送的请求数) |
| rejected_code | int | 否 | 429 | 请求被限流时,返回的HTTP状态码 |
| rejected_msg | string | 否 | Too many requests | 请求被限流时,返回的响应体 |
| redis | object | 是 | - | redis相关配置 |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以进一步扩展一下,类似 per ip 这种,用于不可枚举场景的限流:
limit_by_per_header
limit_by_per_param

key 可以支持配置一个正则表达式,符合正则匹配的进行限制

Copy link
Collaborator Author

@hanxiantao hanxiantao Jun 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以进一步扩展一下,类似 per ip 这种,用于不可枚举场景的限流: limit_by_per_header limit_by_per_param

key 可以支持配置一个正则表达式,符合正则匹配的进行限制

key以regexp-开头的标识根据正则表达式匹配,示例如下:

rule_name: limit_by_param_x-ca-key
limit_by_header: x-ca-key
limit_keys:
- key: 102234
  query_per_second: 10
- key: regexp-\d+
  query_per_hour: 10

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regexp:\d+

用冒号分隔看上去自然一些


`limit_keys`中每一项的配置字段说明

| 配置项 | 类型 | 必填 | 默认值 | 说明 |
| ---------------- | ------ | ------------------------------------------------------------ | ------ | ------------------ |
| key | string | 是 | - | 匹配的键值 |
| query_per_second | int | 否,`query_per_second`,`query_per_minute`,`query_per_hour`,`query_per_day` 中选填一项 | - | 允许每秒请求次数 |
| query_per_minute | int | 否,`query_per_second`,`query_per_minute`,`query_per_hour`,`query_per_day` 中选填一项 | - | 允许每分钟请求次数 |
| query_per_hour | int | 否,`query_per_second`,`query_per_minute`,`query_per_hour`,`query_per_day` 中选填一项 | - | 允许每小时请求次数 |
| query_per_day | int | 否,`query_per_second`,`query_per_minute`,`query_per_hour`,`query_per_day` 中选填一项 | - | 允许每天请求次数 |

`redis`中每一项的配置字段说明

| 配置项 | 类型 | 必填 | 默认值 | 说明 |
| ------------ | ------ | ---- | ---------------------------------------------------------- | --------------------------- |
| service_name | string | 必填 | - | 输入redis服务的注册名称 |
| service_port | int | 否 | 服务类型为固定地址(static service)默认值为80,其他为6379 | 输入redis服务的服务端口 |
| username | string | 否 | - | redis用户名 |
| password | string | 否 | - | redis密码 |
| timeout | int | 否 | 1000 | redis连接超时时间,单位毫秒 |



# 配置示例

## 识别请求参数 apikey,进行区别限流
```yaml
rule_name: limit_by_param_apikey
limit_by_param: apikey
limit_keys:
- key: 9a342114-ba8a-11ec-b1bf-00163e1250b5
query_per_second: 10
- key: a6a6d7f2-ba8a-11ec-bec2-00163e1250b5
query_per_minute: 100
redis:
service_name: redis.static
show_limit_quota_header: true
```

## 识别请求头 x-ca-key,进行区别限流
```yaml
rule_name: limit_by_param_x-ca-key
limit_by_header: x-ca-key
limit_keys:
- key: 102234
query_per_second: 10
- key: 308239
query_per_hour: 10
redis:
service_name: redis.static
show_limit_quota_header: true
```

## 根据请求头 x-forwarded-for 获取对端IP,进行区别限流

```yaml
rule_name: limit_by_per_ip_from-header-x-forwarded-for
limit_by_per_ip: from-header-x-forwarded-for
limit_keys:
# 精确ip
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里有一段空白符

- key: 1.1.1.1
query_per_day: 10
# ip段,符合这个ip段的ip,每个ip 100qps
- key: 1.1.1.0/24
query_per_day: 100
# 兜底用,即默认每个ip 1000qps
- key: 0.0.0.0/0
query_per_day: 1000
redis:
service_name: redis.static
show_limit_quota_header: true
```

## 对特定路由或域名开启

```yaml
# 使用_rules_字段进行细粒度规则配置
_rules_:
# 规则一:按路由名称匹配生效
- _match_route_:
- route-a
- route-b
rule_name: limit_rule1
limit_by_per_ip: from-header-x-forwarded-for
limit_keys:
# 精确ip
- key: 1.1.1.1
query_per_day: 10
# ip段,符合这个ip段的ip,每个ip 100qps
- key: 1.1.1.0/24
query_per_day: 100
# 兜底用,即默认每个ip 1000qps
- key: 0.0.0.0/0
query_per_day: 1000
redis:
service_name: redis.static
# 规则二:按域名匹配生效
- _match_domain_:
- "*.example.com"
- test.com
rule_name: limit_rule2
limit_by_param: apikey
limit_keys:
- key: 9a342114-ba8a-11ec-b1bf-00163e1250b5
query_per_second: 10
- key: a6a6d7f2-ba8a-11ec-bec2-00163e1250b5
query_per_minute: 100
redis:
service_name: redis.static
show_limit_quota_header: true
```
此例 `_match_route_` 中指定的 `route-a` 和 `route-b` 即在创建网关路由时填写的路由名称,当匹配到这两个路由时,将使用此段配置;
此例 `_match_domain_` 中指定的 `*.example.com` 和 `test.com` 用于匹配请求的域名,当发现域名匹配时,将使用此段配置;
配置的匹配生效顺序,将按照 `_rules_` 下规则的排列顺序,匹配第一个规则后生效对应配置,后续规则将被忽略
230 changes: 230 additions & 0 deletions plugins/wasm-go/extensions/cluster-key-rate-limit/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package main

import (
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
"github.com/zmap/go-iptree/iptree"
"strings"
)

// 限流规则类型
type limitRuleType string

const (
limitByHeaderType limitRuleType = "limitByHeader"
limitByParamType limitRuleType = "limitByParam"
limitByPerIpType limitRuleType = "limitByPerIp"

RemoteAddrSourceType = "remote-addr"
HeaderSourceType = "header"

DefaultRejectedCode uint32 = 429
DefaultRejectedMsg string = "Too many requests"

Second int64 = 1
SecondsPerMinute = 60 * Second
SecondsPerHour = 60 * SecondsPerMinute
SecondsPerDay = 24 * SecondsPerHour
)

type ClusterKeyRateLimitConfig struct {
ruleName string // 限流规则名称
limitType limitRuleType // 限流类型
limitByHeader string // 根据http请求头限流
limitByParam string // 根据url参数限流
limitByPerIp LimitByPerIp // 根据对端ip限流
limitItems []LimitItem // 限流配置 key为限流的ip地址或者ip段
showLimitQuotaHeader bool // 响应头中是否显示X-RateLimit-Limit和X-RateLimit-Remaining
rejectedCode uint32 // 当请求超过阈值被拒绝时,返回的HTTP状态码
rejectedMsg string // 当请求超过阈值被拒绝时,返回的响应体
redisClient wrapper.RedisClient
}

type LimitByPerIp struct {
sourceType string // ip来源类型
headerName string // 根据该请求头获取客户端ip
}

type LimitItem struct {
key string // 限流key
ipNet *iptree.IPTree // 限流key转换的ip地址或者ip段
count int64 // 指定时间窗口内的总请求数量阈值
timeWindow int64 // 时间窗口大小
}

func initRedisClusterClient(json gjson.Result, config *ClusterKeyRateLimitConfig) error {
redisConfig := json.Get("redis")
if !redisConfig.Exists() {
return errors.New("missing redis in config")
}
serviceName := redisConfig.Get("service_name").String()
if serviceName == "" {
return errors.New("redis service name must not be empty")
}
servicePort := int(redisConfig.Get("service_port").Int())
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
// use default logic port which is 80 for static service
servicePort = 80
} else {
servicePort = 6379
}
}
username := redisConfig.Get("username").String()
password := redisConfig.Get("password").String()
timeout := int(redisConfig.Get("timeout").Int())
if timeout == 0 {
timeout = 1000
}
config.redisClient = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
FQDN: serviceName,
Port: int64(servicePort),
})
return config.redisClient.Init(username, password, int64(timeout))
}

func parseClusterKeyRateLimitConfig(json gjson.Result, config *ClusterKeyRateLimitConfig, log wrapper.Log) error {
ruleName := json.Get("rule_name")
if !ruleName.Exists() {
return errors.New("missing rule_name in config")
}
config.ruleName = ruleName.String()

// 根据配置区分限流类型
var limitType limitRuleType
limitByHeader := json.Get("limit_by_header")
if limitByHeader.Exists() && limitByHeader.String() != "" {
config.limitByHeader = limitByHeader.String()
limitType = limitByHeaderType
}

limitByParam := json.Get("limit_by_param")
if limitByParam.Exists() && limitByParam.String() != "" {
config.limitByParam = limitByParam.String()
limitType = limitByParamType
}

limitByPerIpResult := json.Get("limit_by_per_ip")
if limitByPerIpResult.Exists() && limitByPerIpResult.String() != "" {
limitByPerIp := limitByPerIpResult.String()
if strings.HasPrefix(limitByPerIp, "from-header-") {
headerName := limitByPerIp[len("from-header-"):]
if headerName == "" {
return errors.New("limit_by_per_ip parse error: empty after 'from-header-'")
}
config.limitByPerIp = LimitByPerIp{
sourceType: HeaderSourceType,
headerName: headerName,
}
} else if limitByPerIp == "from-remote-addr" {
config.limitByPerIp = LimitByPerIp{
sourceType: RemoteAddrSourceType,
headerName: "",
}
} else {
return errors.New("the 'limit_by_per_ip' restriction must start with 'from-header-' or be exactly 'from-remote-addr'")
}
limitType = limitByPerIpType
}
if limitType == "" {
return errors.New("only one of 'limit_by_header' and 'limit_by_param' and 'limit_by_per_ip' can be set")
}
config.limitType = limitType

// 初始化LimitItem
err := initLimitItems(json, config, log)
if err != nil {
return err
}

showLimitQuotaHeader := json.Get("show_limit_quota_header")
if showLimitQuotaHeader.Exists() {
config.showLimitQuotaHeader = showLimitQuotaHeader.Bool()
}

rejectedCode := json.Get("rejected_code")
if rejectedCode.Exists() {
config.rejectedCode = uint32(rejectedCode.Uint())
} else {
config.rejectedCode = DefaultRejectedCode
}
rejectedMsg := json.Get("rejected_msg")
if rejectedCode.Exists() {
config.rejectedMsg = rejectedMsg.String()
} else {
config.rejectedMsg = DefaultRejectedMsg
}
return nil
}

func initLimitItems(json gjson.Result, config *ClusterKeyRateLimitConfig, log wrapper.Log) error {
limitKeys := json.Get("limit_keys")
if !limitKeys.Exists() {
return errors.New("missing limit_keys in config")
}
if len(limitKeys.Array()) == 0 {
return errors.New("config limit_keys cannot be empty")
}
var limitItems []LimitItem
for _, item := range limitKeys.Array() {
key := item.Get("key")
if !key.Exists() || key.String() == "" {
return errors.New("limit_keys key is required")
}
var ipNet *iptree.IPTree
if config.limitType == limitByPerIpType {
var err error
ipNet, err = parseIPNet(key.String())
if err != nil {
log.Errorf("parseIPNet error: %v", err)
return err
}
} else {
ipNet = nil
}

qps := item.Get("query_per_second")
if qps.Exists() && qps.Int() > 0 {
limitItems = append(limitItems, LimitItem{
key: key.String(),
ipNet: ipNet,
count: qps.Int(),
timeWindow: Second,
})
continue
}
qpm := item.Get("query_per_minute")
if qpm.Exists() && qpm.Int() > 0 {
limitItems = append(limitItems, LimitItem{
key: key.String(),
ipNet: ipNet,
count: qpm.Int(),
timeWindow: SecondsPerMinute,
})
continue
}
qph := item.Get("query_per_hour")
if qph.Exists() && qph.Int() > 0 {
limitItems = append(limitItems, LimitItem{
key: key.String(),
ipNet: ipNet,
count: qph.Int(),
timeWindow: SecondsPerHour,
})
continue
}
qpd := item.Get("query_per_day")
if qpd.Exists() && qpd.Int() > 0 {
limitItems = append(limitItems, LimitItem{
key: key.String(),
ipNet: ipNet,
count: qpd.Int(),
timeWindow: SecondsPerDay,
})
continue
}
}
config.limitItems = limitItems
return nil
}
22 changes: 22 additions & 0 deletions plugins/wasm-go/extensions/cluster-key-rate-limit/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module github.com/alibaba/higress/plugins/wasm-go/extensions/key-cluster-rate-limit

go 1.19

replace github.com/alibaba/higress/plugins/wasm-go => ../..

require (
github.com/alibaba/higress/plugins/wasm-go v0.0.0
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc
github.com/tidwall/gjson v1.14.3
github.com/tidwall/resp v0.1.1
github.com/zmap/go-iptree v0.0.0-20210731043055-d4e632617837
)

require (
github.com/asergeyev/nradix v0.0.0-20170505151046-3872ab85bb56 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
)
Loading
Loading