-
Notifications
You must be signed in to change notification settings - Fork 429
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
Changes from all commits
0f828be
6c07b58
55b5c04
d91c337
a335fc6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 请求头名称 | | ||
| 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相关配置 | | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 可以进一步扩展一下,类似 per ip 这种,用于不可枚举场景的限流: key 可以支持配置一个正则表达式,符合正则匹配的进行限制 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
key以regexp-开头的标识根据正则表达式匹配,示例如下:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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_` 下规则的排列顺序,匹配第一个规则后生效对应配置,后续规则将被忽略 |
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 | ||
} |
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 | ||
) |
There was a problem hiding this comment.
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 的名字
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
这个应该是要配合认证的插件一起使用对吧
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
是的,如果用户开了 key-auth 等认证插件,就可以配合基于 consumer 做流控了,在openapi场景下挺有用的
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
好的
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
这个目前根据请求头限流的应该可以覆盖这个场景了,limit_by_header直接配置成x-mse-consumer就可以了,如果单独再实现一个limit_by_consumer会有点奇怪,在limit_by_header使用文档中添加下对x-mse-consumer的使用场景的说明会更好些
There was a problem hiding this comment.
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,用户就不感知这个调整
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
好的,limit_by_consumer实现的时候也会和limit_by_per_header、limit_by_per_param一样支持配置一个正则表达式