微服务之服务治理:Envoy 全局 gRPC 限速服务 lyft/ratelimit 详解


Service Mesh 由 Data Panel、Control Panel 两部分组成,虽然目前的 Service Mesh 已经进入了以 Istio、Conduit 为代表的第二代 Control Panel。但是以 Istio 为例,它也没有自己去实现 Data Panel,而是仍然基于 Envoy 做了 Control Panel 来达成目标,可见 Envoy 在 Service Mesh 中地位的重要性。

Envoy 是一款由 Lyft 开源的 L7 代理和通信总线,目前也是 CNCF 旗下的开源项目,代码托管在 GitHub 上,由 C++ 语言实现,拥有强大的定制化能力——通过其提供的 Filter 机制,Envoy 基本可以对请求转发过程中超过 50% 的流程做定制化。

本文主要分析 Envoy ratelimit filter 机制和 lyft/ratelimit 提供的 gRPC 服务。

gRPC ratelimit service

Envoy 可以集成一个全局的 gRPC ratelimit 服务。Envoy ratelimit 服务支持如下两个特性:
  • 网络级别的速率限制过滤器:ratelimit 过滤器安装在 listener 上,Envoy 将为每个新连接调用 ratelimit 限速服务,这样能限制每秒该 listener 上建立的连接数
  • HTTP 级别速率限制过滤器:该过滤器安装在服务路由上,该过滤器可以限制到目标 upstream cluster 的所有请求速率,也可以限制不同来源的到目标 upstream cluster 的请求速率


下面描述的 external ratelimit 服务是基于 lyft/ratelimit 进行的。

ratelimit 架构

如果调用限速服务发生错误或限速服务返回了一个错误,并且 failure_mode_deny 设置为 true,则返回 500 状态码。

ratelimit 架构包含两部分,一部分是 Envoy 中 ratelimit filter,一部分是 ratelimit gRPC 服务:
1.jpg

其中,Envoy 中 ratelimit filter 又包含:
  • 配置 ratelimit service
  • 启用 ratelimit filter


Envoy ratelimit service 配置

限速服务配置描述的是 Envoy 使用的全局限速服务,Envoy 需要与该全局限速服务通信,从而做出全局速率限制的决定。

如果 Envoy 没有配置全局限速服务,将使用“NULL”限速服务,当 Envoy 使用“NULL”限速服务做出限速决定的时候,“NULL”限速服务总是返回 OK,表示仍然没有超过限制,实际上就是不限速。

Envoy ratelimit service 配置说明:
2.jpg

配置举例:
3.jpg

Envoy ratelimit filter 配置

4.jpg

外部限速服务

envoy 对 ratelimit service 的规定

ratelimit service client 和 server 的描述请见:
  • github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v2/rls.pb.go
  • envoyproxy/go-control-plane/envoy/service/ratelimit/v2/rls.proto


RateLimitServiceClient

RateLimitServiceClient 的定义如下:
5.png

ratelimit service client 定义了 ShouldRateLimit 方法,而该方法将调用 ratelimit service server 的 ShouldRateLimit 方法:
6.png


RateLimitServiceServer

RateLimitServiceServer 的定义如下:
7.png

RateLimitServiceServer 注册函数如下:
8.png

lyft/ratelimit

ratelimit 是一个 Go/gRPC 服务,旨在为各种应用程序提供通用速率限制方案。应用程序根据 domain 和一组 descriptor 向 ratelimit 服务请求限速决定(限速或不限速)。ratelimit 服务读取配置内容(配置文件通过 goruntime package 从磁盘中读取到内存),根据配置文件内容组成一个 cacheKey,并且通过该 cacheKey 访问 Redis 缓存,最后返回一个限速决定到限速服务调用方。

当前,ratelimit 服务支持每秒、每分、每小时或者每天都限速。

lyft/ratelimit 配置文件

config.yaml 配置文件举例:
9.jpg

ratelimit 把 Redis 作为其缓存层,支持两种操作模式:
  • 一个 Redis 实例,针对所有限速
  • 两个 Redis 实例,一个实例针对每秒限速,另一个实例针对其他限速


lyft/ratelimit 利用 Redis 实现令牌桶(token bucket),主要使用如下两个 Redis 命令:
  • INCRBY:INCRBY key increment ,如果 key 存在,则将 key 中储存的数字加上指定的增量 increment;如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再加上指定的增量 increment
  • EXPIRE:EXPIRE key seconds 给定 key 设置生存时间,当 key 过期时(生存时间为 0),它会被自动删除


注意:Redis 所有单个命令的执行都是原子性的。

这点在代码中具体体现在:
10.png

lyft/ratelimit 实现分析

启动
11.jpg


ratelimit service server

通过上面的分析我们知道,ratelimit service server 实现了 RateLimitServiceServer 接口。

lyft/ratelimit 通过 RateLimitServiceServer 封装了 RateLimitServiceServer 定义。
12.jpg

13.jpg


配置文件加载

在创建 ratelimit service server 时创建了一个 config loader:config.NewRateLimitConfigLoaderImpl()。
14.jpg

reloadConfig() 利用 github.com/lyft/goruntime 加载 config 目录下所有配置文件:
15.jpg


ShouldRateLimit

ShouldRateLimit() 函数判断该 request 是继续还是由于限速而拒绝。
16.jpg


DoLimit

在详细分析 Dolimit 之前,先看看 lyft/ratelimit 是如何基于 Redis 进行 token bucket 设计的。

cacheKey 设计:

“domain_key_value_key_value_..._divider对齐的时间”组成了 cacheKey。注意,cacheKey 对应的 value 为在该时间段已经使用的 token 数。

其中 divider 对齐的时间(即 divider 倍数的时间)是这么算的:
17.png

divider 有以下几种:
18.jpg

比如以下配置为/demo路径的限流配置(限制每小时只有 500个 token,只能访问该路径 500 次):
19.png

20.jpg

当我们在 [15:00, 16:00) 时间段访问/demo路径时,ratelimit 服务将根据 domain、request.Descriptor、访问时间来生成 cacheKey:dev_version_v1_1577890800。

ratelimit 服务将通过 cacheKey 查找 Redis 中访问记录(已使用 token 值)。如果 Redis 中不存在该 cacheKey,则创建该 cacheKey(值为 1),并设置该 cacheKey 的过期时间为 1 小时。

下面我们来分析 generateCacheKey 函数。
21.jpg


pipelineAppend:

pipelineAppend 函数仅将 Redis CMD 添加到 Pipeline 中,并没有真正通过 Redis connection 去执行。
22.png

pipelineAppend 函数主要添加两个 Redis CMD 操作:
  • cacheKey 对应的值加上 hitsAddend(一般情况下为1,表示访问一次;当然我们可以 request 自行定义 hitsAddend 的值);如果 cacheKey 不存在则新增 cacheKey,初始值为 hitsAddend
  • 设置 cacheKey 的过期时间,过期 cacheKey 将从 Redis 中删除


在后面的文章中,大家会看到 pipelineAppend 和 pipelineFetch 才是一对,而不是 PipeResponse。

PipeResponse:

PipeResponse 通过 github.com/mediocregopher/radix.v2/redis/client.go 中 PipeResp 函数获取 pipeline 中下一个命令的执行结果。

PipeResp 函数会将 pipeline 中所有 Redis CMD 执行完并将各个 CMD 执行结果保存在数组中。后面会详细分析。

pipelineAppend 只会将 Redis CMD 放入 Pipeline,而 PipeResponse 会调用 PipeResp 执行 Pipeline 中所有命令。
23.png


PipeResp:

PipeResp 函数在 github.com/mediocregopher/radix.v2/redis/client.go 中,radix 是一个 Redis 的 client 库。

每次调用 PipeResp 函数时:
24.jpg

如果上次执行的 pipeline 命令结果还没有全部获取完,则接着返回上次 pipeline 中下一个命令的执行结果。

如果上次 pipeline 命令结果已经全部获取完,completed 数组为空,已经没有命令执行结果了,那么:
  • 第一步,将 Pipeline 中(pending 数组)命令全部执行完
  • 第二步,将所有命令执行结果取出来,保存到 completed 数组
  • 第三步,返回 Pipeline 中下一个命令的执行结果(这里其实为刚刚执行完的 pipeline 的第一个命令的结果)


pipelineFetch:

调用 PipeResponse 获取 pipeline 中下一个命令的执行结果。

pipelineAppend 和 pipelineFetch 才能算作一对,pipelineAppend 插入两条 Redis CMD 到 Pipeline:
  • Redis INCRBY 命令
  • Redis EXPIRE 命令


而 pipelineFetch 执行完 Pipeline 中命令,并获取 Redis INCRBY 命令的执行结果(即 cacheKey 更新后的值),同时将 Redis EXPIRE 命令执行结果 pop 出来。
25.png


DoLimit 分析:

DoLimit 判断 request 中每个 Descriptor 对应的请求次数是否超过限额:
26.jpg

Usecase

示例服务架构如下:
27.jpg

场景一:某 unit 内第一次成功访问服务

比如,2020 年 1 月 1 号 [15:00, 16:00) 时间段第一次访问业务,该请求到达 ratelimit service 的流程如下:
28.jpg

ratelimit service 通过 Redis /INCRBY dev_rate_v1_1577890800 1命令更新 Redis cacheKey dev_rate_v1_1577890800。由于 Redis 之前不存在 cacheKey dev_rate_v1_1577890800,所以新增该 cacheKey,并初始化其值为 1:
29.jpg

Redis /INCRBY dev_rate_v1_1577890800 1 命令执行完后,返回最新的cacheKey dev_rate_v1_1577890800 的值。ratelimit service 收到该值,发现访问次数并没有超过limit:300,所以访问请求到达 Envoy 后进入 router 过滤器到达业务服务,并成功返回业务结果。

场景二:相同 unit 内再一次成功访问服务

假设,2020 年 1 月 1 号 [15:00, 16:00) 时间段已经访问过该业务 299 次,现在进行第 300 次请求,该请求到达 ratelimit service 的流程如下:
30.jpg

ratelimit service 通过 Redis /INCRBY dev_rate_v1_1577890800 1命令更新 Redis cacheKey dev_rate_v1_1577890800,Redis 将cacheKey dev_rate_v1_1577890800的值加了 1 后,返回 300 给 ratelimit:
31.jpg

ratelimit service 收到该值,发现访问次数并没有超过limit:300,所以访问请求到达 Envoy 后进入 router 过滤器到达业务服务,并成功返回业务结果。

场景三:相同 unit 内由于 ratelimit 拒绝访问服务

假设,2020 年 1 月 1 号 [15:00, 16:00) 时间段已经访问过该业务 300 次,现在进行第 301 次请求,该请求到达 ratelimit service 的流程如下:
32.jpg

ratelimit service 通过 Redis /INCRBY dev_rate_v1_1577890800 1命令更新 Redis cacheKey dev_rate_v1_1577890800,Redis 将cacheKey dev_rate_v1_1577890800的值加了 1 后,返回 301 给 ratelimit:
33.jpg

ratelimit service 收到该值,发现访问次数已经超过limit:300,所以返回超过访问限制的响应结果给 Envoy,Envoy 然后拒绝了客户的请求,错误码为 429,拒绝原因为超过访问限制,类似下图结果:
34.png

思考

可用性和持久化问题

lyft/ratelimit 由于使用了 Redis 来缓存 cacheKey,所以生产上可能还需要部署一个高可用的 Redis 集群,Redis 官方推荐的是 Redis sentinel 方案,但是事实上部署和维护高可用 Redis 集群也是非常痛苦的事情。
35.jpg

另外,是否还需要考虑 Redis 的持久化呢?

如果业务对 ratelimit 没有非常实时的要求,那可以从 per_second_limits、per_minute_limits、per_hour_limits、per_day_limits 角度来分析 Redis 的可用性需求和持久化需求:
  • 首先,由于业务对 ratelimit 服务的需要,都要求 ratelimit 服务高可用性和 Redis 服务的高可用性
  • 但是,per_second_limits 对 Redis 可用性要求非常高,Redis 一旦不可用,那么从不可用恢复到可用或者 Redis 重启花费的时间,一般会达到或者超过秒级别,这样存储在 Redis 中的 cacheKey 都过期了,所以 Redis 持久化几乎是没用的;除非 Redis 从故障恢复的时间上限在毫秒级别
  • per_second_limits 对于 ratelimit 服务的可用性要求也非常高,类似于 Redis,因为 ratelimit 服务的故障也可能导致存储在 Redis 中的 cacheKey 都过期
  • per_minute_limits 对 Redis 可用性要求也很高,只有在 Redis 从故障恢复的时间上限在秒级别,Redis 持久化才有意义。同理,对 ratelimit 服务可用性要求也很高
  • 对于 per_hour_limits,在 Redis 故障恢复时间不超过 1 个小时的情况,Redis 持久化有意义。同理,ratelimit 服务也一样
  • 对于 per_day_limits,在 Redis 故障恢复时间不超过 1 天的情况,Redis 持久化有意义。同理,ratelimit 服务也一样


根据上述分析,我们可以得出如下对比表格:
36.png

从另外一个角度来分析,如果业务对 ratelimit 不是要求非常实时,对于 per_hour_limits 和 per_day_limits,Redis 即使不使用持久化存储,Redis 故障恢复后数据丢失也不会对业务造成什么影响。

ratelimit 是无状态的,状态都在 Redis 上,所以 ratelimit 可以部署多个实例,访问相同的 Redis;Redis 虽然是有状态的,但是如果我们可以接受状态丢失,那么多个 ratelimit 服务访问一个单实例 Redis 也足够了。

Redis 性能

Intel(R) Xeon(R) CPU E5520 @ 2.27GHz (with pipelining)
37.jpg

Redis 的瓶颈最有可能是机器内存的大小或者网络带宽,而现在生产环境的服务器内存都比较大,所以最影响 Redis 性能的因素就是网络带宽了。下面是一个官方测试结果:


当客户端通过以太网访问 Redis 服务器,并且操作的数据大小一直小于以太网数据包的大小(大约 1500 字节)时,通过管道机制聚合多个命令在一次请求中处理能够明显提高效率。实际上,处理 10 字节、100 字节或 1000 字节的查询,几乎会产生相同的吞吐量。
38.png

官方推荐网络配置:如果要在单个服务器上整合多个高吞吐量 Redis 实例,可以考虑配置一个 10 Gbit/s NIC 或多个 TCP/IP 绑定的 1 Gbit/s NIC。

如何设计可扩展的速率限制算法

一种好的方式是使用“set-then-get”方法,依靠以非常高效的方式实现锁的原子操作,使你可以快速增加并检查计数器值,而不会阻塞原子操作。

在高度分布式系统中,你可能希望完全避免使用集中式数据存储来存储速率限制之类的快速变化的数据。CRDT(无冲突复制数据类型)和存储桶加权可能是一种更有效的策略。

跟踪每个节点可能会导致竞争条件出现。如果节点集群知道其他节点和集群的相对负载,则可以使用此值对隔离的速率限制器进行加权,并且可以用发布/订阅机制在节点之间广播仅需要共享的数据。

如果允许存在一定的方差,则让节点根据集群的大小同步其“令牌权重”意味着节点可以在内存中管理速率限制,甚至都不需要数据存储来跟踪。

至少在我们看来,在大多数限速场景中,极端精确度通常并不那么重要,我们更关心限速服务的可扩展性,这个扩展性不用考虑扩展数据层去处理计数器。

参考文献:
  • ratelimit filter configuration
  • global ratelimiting
  • envoy config rate limit service
  • Rate limit service v2 api proto
  • Common rate limit components proto
  • Understanding EnvoyProxy’s Rate Limiting
  • Using Envoy as Sidecar Proxy’s Microservice Mode-5.rate limiter
  • Envoy gRPC and Rate Limiting
  • Scaling your API with rate limiters
  • 服务接口 API 限流 Rate Limit
  • Envoy 的配置文件完全展开介绍
  • 高可用 Redis 服务架构分析与搭建
  • Redis 哨兵模式(sentinel)
  • Redis benchmarks
  • How to Design a Scalable Rate Limiting Algorithm (Reddit)
  • How to Design a Scalable Rate Limiting Algorithm (Blog)
  • Envoy Custom Auth Ratelimiter Example
  • SRE Resiliency Bolt on Rate Limiting using Envoy
  • Envoy gRPC and Rate Limiting
  • 知其所以然 redis 的原子性
  • 一文看懂 Redis 的持久化原理


原文链接:https://mp.weixin.qq.com/s/2gR0md3IEhEnQFZ4Qu4ZEA

0 个评论

要回复文章请先登录注册