Rate Limit
在众多的反向代理服务器中,实现请求次数限制的功能往往称作 rate-limit。 Rate-limit 在公网环境下实际上主要是为了防止 DoS 攻击。Rate-limit 实现了对每个客户端单位时间内请求次数的限制,例如 5 r/s,就是限制每个客户端 1s 之内针对该服务端只能发起 5 个 Http 请求。其原理往往是基于客户端请求中的某些标志性信息(一般是 IP)使用某种限流算法在服务端做出相应处理:如果该客户端的单位时间请求次数超出限制,服务端直接返回状态码为 429 的响应,在 Http 规范中,状态码 429 含义为“请求次数过多”。
限流算法
常用的限流算法有两种:漏桶和令牌桶。
漏桶算法:请求先进入到漏桶中,漏桶以一定的速率将请求转给后端处理, 当请求速率过大会直接溢出即返回状态码为 429 的响应。漏桶对以不规则速率进入的请求进行了整形,使其以固定的速率被处理,往往被反向代理服务器用于保护真正的后端服务。
令牌桶算法:系统会按固定时间向桶中放入令牌, 如果桶已经满了就不再加了。新请求来临时,会各自拿走一个令牌并被转给后端服务处理, 如果没有令牌可拿就拒绝服务即返回状态码为 429 的响应。
假设漏桶和令牌桶均处于最佳状态(漏桶为空,令牌桶已满),当出现突发大流量时,对于漏桶,一部分请求被置于桶中,超出桶容量的请求被拒绝服务,而桶中的请求会以固定速率转给后端处理,因此必然存在排队情况,也就意味着请求可能存在较大的响应延时,这对后端服务来说是友好的,因为大流量经过漏桶已经被整形为平滑的请求序列,对后端压力较小,只不过压力较小可能意味着资源利用不充分;对于令牌桶来说,一部分请求立即获得令牌并且被立即转给服务后端处理,超出桶容量的请求一样被拒绝服务,转给后端服务的请求会被立即处理,因此响应延时会小很多,但是后端服务器会直接受到大流量的冲击,因此对后端服务不是很友好,不过这个问题并不严重,后端服务器的处理能力往往是很强的,只要设定的桶容量适宜,这些流量冲击一般不构成威胁,反而能够更充分的利用服务端资源。
除了应对突发流量时的较大差别,漏桶和令牌桶对于一般情况下的请求限速效果是类似的。
Nginx 的 Http 限速模块 ngx_http_limit_req_module 使用的是漏桶算法。OpenResty 的限速模块 lua-resty-limit-traffic 支持配置使用漏桶或者令牌桶,配置方法可参考这篇文章。
针对 S3 请求的限速方案
进行限速的第一步是要确定使用什么信息来区分不同的客户端,一般来讲客户端 IP 使用的较为广泛,在 Nginx 和 OpenResty 的限速模块中都有现成的可获取客户端 IP 的变量 ,分别是 $binary_remote_addr 和 ngx.var.binary_remote_addr。当然还有一些其它的信息也可以用以区分客户端从而分别进行限速,例如 Http 请求头中的 Authorization 信息等。这里需要对 S3 进行限速,区分不同客户端使用的是 s3 的 accesskey。 根据 s3 协议文档,这个 accesskey 包含在请求信息的 Authorization 请求头中,但需要去提取出来才能用,因此需要对限速的逻辑进行定制以实现从 Authorization 中提取 accesskey 并基于此实现限速。无论对 Nginx 还是 OpenResty 的限速模块进行定制开发都是可以实现这个功能,但最终选择使用另外一个反向代理服务器 Envoy 来进行定制开发,这是因为:Envoy 对扩展开发原生就有非常友好的支持,扩展模块和 Envoy 本身的核心模块是完全解耦的,它们通过 grpc 进行通信,使用 grpc 的另一个好处就是可以使用任何开发语言实现扩展模块而不用关心 Envoy 本身使用的开发语言,因此完全可以采用一种开发人员最熟悉的语言去实现扩展模块逻辑,相比之下, Nginx 或者 OpenResty 的扩展开发只能使用 C 语言或者 Lua 。由于这种扩展模块的解耦,对扩展模块的更改并不需要重新编译打包 Envoy 本身的镜像,而 OpenResty 就必须重新打包整个镜像。因此,最终决定采用 Envoy 实现对 S3 的请求限速,其基本原理如下图所示,所有的客户端请求都要经过 envoy 的处理,针对每个请求 envoy 将其请求头中的 Authorization 信息作为参数向 rate-limiter 发起 rpc 调用以确定该请求是否应该被接受,如果是则将该请求转发给后端 rgw 服务进一步处理,如果否则直接返回状态码为 429 的响应。rate-limiter(采用令牌桶算法) 从 Authorization 信息中提取出 accesskey 并以此为 key 去检索出相应的桶中是否有令牌存在,进而决定是否应该接受该请求。
Envoy 的限速服务 gRPC 接口定义:
https://github.com/envoyproxy/envoy/blob/master/api/envoy/service/ratelimit/v2/rls.proto
Envoy go 版本控制平面生成的代码:
https://github.com/envoyproxy/go-control-plane/blob/master/envoy/service/ratelimit/v2/rls.pb.go
Rate-limiter
这里采用 Go 语言实现 Rate-limiter 扩展模块。为每个 accesskey 维护一个令牌桶,令牌发放速率可调节,accesskey 和令牌桶的对应关系通过 map 结构维护。accesskey 和其限速值均存于数据库中,Rate-limiter 定期从数据库中同步限速信息并依次调节令牌桶发放速率,存储数据库中的限速值可通过控制平面 API 进行调整。添加一个全局限速值,存于数据库中,Rate-limiter 服务启动时从数据库中读取,该值可以通过控制平面 API 进行设置,值为 0 时涵义是不进行全局限速,当有携带新的 accesskey 的请求到来时,只记录下该 accesskey 的存在,不自动进行限速处理,除非用户指定要对其限速,非零值则意味着对新接入的 accesskey 自动添加限速规则,创建令牌桶进行限速。
初始状态下每个令牌桶中令牌数量为 0 ,随着时间推移,令牌数量逐渐增加,直到令牌桶满,令牌数量保存在内存中,一旦 Rate-limiter 重启则令牌数量重新初始化为 0. 若需要在分布式环境下保存令牌数量,则最好使用 Redis 作为存储后端。
Envoy 限流方案的实现参考
- 一个较为完整的实现案例:https://venilnoronha.io/envoy-grpc-and-rate-limiting
- Lyft 贡献了一个开源项目,同样是实现了一个 gRPC Server 供 Envoy 调用,使用 Redis 存储数据,可对数据库、消息队列等多种后端应用进行限速:https://github.com/lyft/ratelimit
不使用 Envoy 的方案
由于 Go 社区官方提供了一种可以创建简单反向代理服务器的工具包,也可以通过直接编写一个反向代理工具进行简单的 Http 限速,从而避免使用 Envoy 限速要引入 Envoy 本身和一个 rate-limiter grpc server 两个容器的问题,其基本的结构如下:
其中 rate-limiter 不再是一个 grpc server , 而是一个 http 反向代理服务器,直接接收来自客户端的请求,并通过限流算法决定是否应该接受该请求,对于接受的请求转给后端 rgw 进行处理,对于不接受的请求直接返回状态码为 429 的 Http 响应。
rate-limiter 和 rgw 作为两个容器部署于 K8S 同一个 Pod 中,同时还可以实现带宽限制和请求数限制同时使用。
控制平面 API
这里控制平面也使用 grpc 协议,主要实现添加 accesskey,设置限速值等操作。