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 服务。
Envoy 可以集成一个全局的 gRPC ratelimit 服务。Envoy ratelimit 服务支持如下两个特性:
下面描述的 external ratelimit 服务是基于 lyft/ratelimit 进行的。
如果调用限速服务发生错误或限速服务返回了一个错误,并且 failure_mode_deny 设置为 true,则返回 500 状态码。
ratelimit 架构包含两部分,一部分是 Envoy 中 ratelimit filter,一部分是 ratelimit gRPC 服务:
其中,Envoy 中 ratelimit filter 又包含:
限速服务配置描述的是 Envoy 使用的全局限速服务,Envoy 需要与该全局限速服务通信,从而做出全局速率限制的决定。
如果 Envoy 没有配置全局限速服务,将使用“NULL”限速服务,当 Envoy 使用“NULL”限速服务做出限速决定的时候,“NULL”限速服务总是返回 OK,表示仍然没有超过限制,实际上就是不限速。
Envoy ratelimit service 配置说明:
配置举例:
envoy 对 ratelimit service 的规定
ratelimit service client 和 server 的描述请见:
RateLimitServiceClient
RateLimitServiceClient 的定义如下:
ratelimit service client 定义了 ShouldRateLimit 方法,而该方法将调用 ratelimit service server 的 ShouldRateLimit 方法:
RateLimitServiceServer
RateLimitServiceServer 的定义如下:
RateLimitServiceServer 注册函数如下:
ratelimit 是一个 Go/gRPC 服务,旨在为各种应用程序提供通用速率限制方案。应用程序根据 domain 和一组 descriptor 向 ratelimit 服务请求限速决定(限速或不限速)。ratelimit 服务读取配置内容(配置文件通过 goruntime package 从磁盘中读取到内存),根据配置文件内容组成一个 cacheKey,并且通过该 cacheKey 访问 Redis 缓存,最后返回一个限速决定到限速服务调用方。
当前,ratelimit 服务支持每秒、每分、每小时或者每天都限速。
config.yaml 配置文件举例:
ratelimit 把 Redis 作为其缓存层,支持两种操作模式:
lyft/ratelimit 利用 Redis 实现令牌桶(token bucket),主要使用如下两个 Redis 命令:
注意:Redis 所有单个命令的执行都是原子性的。
这点在代码中具体体现在:
启动
ratelimit service server
通过上面的分析我们知道,ratelimit service server 实现了 RateLimitServiceServer 接口。
lyft/ratelimit 通过 RateLimitServiceServer 封装了 RateLimitServiceServer 定义。
配置文件加载
在创建 ratelimit service server 时创建了一个 config loader:config.NewRateLimitConfigLoaderImpl()。
reloadConfig() 利用 github.com/lyft/goruntime 加载 config 目录下所有配置文件:
ShouldRateLimit
ShouldRateLimit() 函数判断该 request 是继续还是由于限速而拒绝。
DoLimit
在详细分析 Dolimit 之前,先看看 lyft/ratelimit 是如何基于 Redis 进行 token bucket 设计的。
cacheKey 设计:
“domain_key_value_key_value_..._divider对齐的时间”组成了 cacheKey。注意,cacheKey 对应的 value 为在该时间段已经使用的 token 数。
其中 divider 对齐的时间(即 divider 倍数的时间)是这么算的:
divider 有以下几种:
比如以下配置为/demo路径的限流配置(限制每小时只有 500个 token,只能访问该路径 500 次):
当我们在 [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 函数。
pipelineAppend:
pipelineAppend 函数仅将 Redis CMD 添加到 Pipeline 中,并没有真正通过 Redis connection 去执行。
pipelineAppend 函数主要添加两个 Redis CMD 操作:
在后面的文章中,大家会看到 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 中所有命令。
PipeResp:
PipeResp 函数在 github.com/mediocregopher/radix.v2/redis/client.go 中,radix 是一个 Redis 的 client 库。
每次调用 PipeResp 函数时:
如果上次执行的 pipeline 命令结果还没有全部获取完,则接着返回上次 pipeline 中下一个命令的执行结果。
如果上次 pipeline 命令结果已经全部获取完,completed 数组为空,已经没有命令执行结果了,那么:
pipelineFetch:
调用 PipeResponse 获取 pipeline 中下一个命令的执行结果。
pipelineAppend 和 pipelineFetch 才能算作一对,pipelineAppend 插入两条 Redis CMD 到 Pipeline:
而 pipelineFetch 执行完 Pipeline 中命令,并获取 Redis INCRBY 命令的执行结果(即 cacheKey 更新后的值),同时将 Redis EXPIRE 命令执行结果 pop 出来。
DoLimit 分析:
DoLimit 判断 request 中每个 Descriptor 对应的请求次数是否超过限额:
示例服务架构如下:
场景一:某 unit 内第一次成功访问服务
比如,2020 年 1 月 1 号 [15:00, 16:00) 时间段第一次访问业务,该请求到达 ratelimit service 的流程如下:
ratelimit service 通过 Redis /INCRBY dev_rate_v1_1577890800 1命令更新 Redis cacheKey dev_rate_v1_1577890800。由于 Redis 之前不存在 cacheKey dev_rate_v1_1577890800,所以新增该 cacheKey,并初始化其值为 1:
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 的流程如下:
ratelimit service 通过 Redis /INCRBY dev_rate_v1_1577890800 1命令更新 Redis cacheKey dev_rate_v1_1577890800,Redis 将cacheKey dev_rate_v1_1577890800的值加了 1 后,返回 300 给 ratelimit:
ratelimit service 收到该值,发现访问次数并没有超过limit:300,所以访问请求到达 Envoy 后进入 router 过滤器到达业务服务,并成功返回业务结果。
场景三:相同 unit 内由于 ratelimit 拒绝访问服务
假设,2020 年 1 月 1 号 [15:00, 16:00) 时间段已经访问过该业务 300 次,现在进行第 301 次请求,该请求到达 ratelimit service 的流程如下:
ratelimit service 通过 Redis /INCRBY dev_rate_v1_1577890800 1命令更新 Redis cacheKey dev_rate_v1_1577890800,Redis 将cacheKey dev_rate_v1_1577890800的值加了 1 后,返回 301 给 ratelimit:
ratelimit service 收到该值,发现访问次数已经超过limit:300,所以返回超过访问限制的响应结果给 Envoy,Envoy 然后拒绝了客户的请求,错误码为 429,拒绝原因为超过访问限制,类似下图结果:
lyft/ratelimit 由于使用了 Redis 来缓存 cacheKey,所以生产上可能还需要部署一个高可用的 Redis 集群,Redis 官方推荐的是 Redis sentinel 方案,但是事实上部署和维护高可用 Redis 集群也是非常痛苦的事情。
另外,是否还需要考虑 Redis 的持久化呢?
如果业务对 ratelimit 没有非常实时的要求,那可以从 per_second_limits、per_minute_limits、per_hour_limits、per_day_limits 角度来分析 Redis 的可用性需求和持久化需求:
根据上述分析,我们可以得出如下对比表格:
从另外一个角度来分析,如果业务对 ratelimit 不是要求非常实时,对于 per_hour_limits 和 per_day_limits,Redis 即使不使用持久化存储,Redis 故障恢复后数据丢失也不会对业务造成什么影响。
ratelimit 是无状态的,状态都在 Redis 上,所以 ratelimit 可以部署多个实例,访问相同的 Redis;Redis 虽然是有状态的,但是如果我们可以接受状态丢失,那么多个 ratelimit 服务访问一个单实例 Redis 也足够了。
Intel(R) Xeon(R) CPU E5520 @ 2.27GHz (with pipelining)
Redis 的瓶颈最有可能是机器内存的大小或者网络带宽,而现在生产环境的服务器内存都比较大,所以最影响 Redis 性能的因素就是网络带宽了。下面是一个官方测试结果:
当客户端通过以太网访问 Redis 服务器,并且操作的数据大小一直小于以太网数据包的大小(大约 1500 字节)时,通过管道机制聚合多个命令在一次请求中处理能够明显提高效率。实际上,处理 10 字节、100 字节或 1000 字节的查询,几乎会产生相同的吞吐量。
官方推荐网络配置:如果要在单个服务器上整合多个高吞吐量 Redis 实例,可以考虑配置一个 10 Gbit/s NIC 或多个 TCP/IP 绑定的 1 Gbit/s NIC。
一种好的方式是使用“set-then-get”方法,依靠以非常高效的方式实现锁的原子操作,使你可以快速增加并检查计数器值,而不会阻塞原子操作。
在高度分布式系统中,你可能希望完全避免使用集中式数据存储来存储速率限制之类的快速变化的数据。CRDT(无冲突复制数据类型)和存储桶加权可能是一种更有效的策略。
跟踪每个节点可能会导致竞争条件出现。如果节点集群知道其他节点和集群的相对负载,则可以使用此值对隔离的速率限制器进行加权,并且可以用发布/订阅机制在节点之间广播仅需要共享的数据。
如果允许存在一定的方差,则让节点根据集群的大小同步其“令牌权重”意味着节点可以在内存中管理速率限制,甚至都不需要数据存储来跟踪。
至少在我们看来,在大多数限速场景中,极端精确度通常并不那么重要,我们更关心限速服务的可扩展性,这个扩展性不用考虑扩展数据层去处理计数器。
参考文献:
原文链接: https://mp.weixin.qq.com/s/2gR0md3IEhEnQFZ4Qu4ZEA