基于OpenResty的限流系统的开发
基于 OpenResty 的限流系统的开发
1 背景
随着业务请求量越来越大,目前很多系统都是将数据流量分摊到多个服务器处理,用多台服务器或集群共同完成工作任务,以减轻单台服务器的压力,我们广告服务亦如此。但是,在面对 618 或双 11 的突发流量时,单台机器可能仍然无法承受突然遽增的流量。此时就需要做限流以为服务扩容争取时间,同时保证当前容量下广告服务的平稳运行。
下图是我们广告服务的当前的负载均衡架构:
针对我们广告服务来讲,限流还可以解决以下两个问题:
- 当服务自身出现性能问题时,限制流量进入可以让问题暂时得到缓解保证系统不被拖垮,为定位问题或者回滚争取时间。
- 针对一些确实有问题的流量,我们通过限流功能可以避免这部分流量进入后面的服务集群,避免浪费宝贵的系统资源处理它们,可以达到一定的降本增效的目的。
2 现成的限流方案及其不足
2.1 什么是现成的限流方案
Nginx 作为流量入口同时处于服务边缘,所以限流一般选在 Nginx 上实现。传统上,限流都是针对特定 IP 的,比如一个典型的防止 DDoS 的限流配置如下 :
http {
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
server {
location /search/ {
limit_req zone=one burst=5 nodelay;
}
}
上面配置处于 nginx.conf,其中几个主要配置的含义如下:
limit_req_zone
是用于限流的模块, 参数$binary_remote_addr
表示使用请求 IP 作为限流条件,参数zone=one:10m
表示声明一个名字为 one 大小为 10m 的空间存储 IP 请求计数;rate=1r/s
表示允许特定 IP 每秒访问一次,即限流力度burst=5
表示设置特定 IP 的请求缓冲区为 5 个;nodelay
表示请求超过rate
和burst
时不会延迟等待而是直接丢弃请求。
2.2 现成的限流方案的不足
Nginx 提供的现成限流效果很好,但不能满足我们业务的一些需求,主要体现在以下几个方面:
- 限流力度 ( rate ) 是静态的,需要手动修改,广告流量平时波动就比较大,很难根据经验得出一个合理的阈值。
- 限流方案比较少,常用的 IP 和 UA QPS 限制功能略单薄。和广告业务相关的定制功能无法实现,比如如果我们想限制来自某个广告位或者媒体的流量就很难实现了。
3 基于业务特点定制的限流方案
同样是在 Nginx 所在位置完成限流,但组件要由 Nginx 换成 OpenResty。
OpenResty 是一个基于 Nginx 与 Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。OpenResty 可以用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。
OpenResty 可以让我们在不修改底层 C 代码而是通过编写 Lua 脚本的方式来实现我们想要的功能。这样实现目标就方便多了,我们既不用担心修改 Nginx 底层代码导致不可预知的问题,也不用发愁所用 Nginx 与官方版本不一致导致后期升级出问题。而且围绕 OpenResty 有非常丰富的第三方脚本库,通过复用这些成熟的模块,我们可以节省很多工作。
基于 OpenResty 提供的脚本库,我们可以编写代码让限流更加灵活多变:
- 动态的限流力度——实时检测服务的健康状态并据此调整限流力度,保证服务资源充分利用;
- 定制化的限流标准——比如限制低权限用户的流量,定制与业务相关的流量功能;
- 动态修改负载均衡权重——根据服务的健康状态实现流量定向,类似智能路由。
4 限流模块设计
4.1 系统流程
如上图所示,限流系统通过实时检测后端每个服务节点的健康状态来控制每个节点的流量负载和入口的总流量,所以我们需要知道:
- 每个服务节点的健康状况 f
- 根据 f 计算出每个服务节点的负载均衡权重 w
- 汇集 f 来确定 Nginx 的限流力度 g
根据服务的健康状况 f 得出限流力度 g 后,针对进来个每个请求,我们就可以根据 g 来决定是否让其通行;如果允许其通过,再根据权重 w 决定将该请求发送到哪个后端服务节点。
有了这三个关键值,我们就可以将其计算过程和使用逻辑程序化并嵌入到 OpenResty 中去执行,从而实现我们的限流模块。实现相关说明见第 5 节。
4.2 如何计算 $f$, $g$ 与 $w$
服务节点健康状态值 $f$
单个服务节点健康状态通常可以用下述参数反映
- 平均响应时间,该时间为一段时间内服务处理时间与网络传输时间加和的平均值;
- 还可以使用后端服务节点所在机器的物理资源使用量,如内存使用量、CPU 负载等。
- 建立数学模型 $f(x,y,z...)$,其中参数为后端服务节点的各项指标,具体模型可以根据实际情况来确定。这样就可以衡量服务节点健康状况了。
单个服务节点动态的负载均衡权重 $w$
- 单个服务节点健康状况 $f$ 作为输入参数;
- 建立数学模型 $w(f)$, 通过数学模型实时确定该服务节点的负载均衡权重 $w$。
限流力度值 $g$
- 限流力度使用 QPS 作为单位
- 每个服务节点健康状况 $f1,f2,f3...$ 作为参数;
- 建立数学模型 $g(f1,f2,f3...)$, 通过数学模型和每个服务节点健康状况确定实时限流力度值。
5 基于 OpenResty 实现限流逻辑
OpenResty 在 Nginx 基础上整合了很多精良的第三方 Lua 脚本模块,使得 Lua 开发更加简单快捷,下面介绍如何基于 OpenResty 开发限流系统。
5.1 与 OpenResty 限流相关的功能简介
OpenResty 本身其实就是一个 Nginx,所以 Nginx 的配置文件完全可以用于 OpenResty,启停与配置重载的命令也都是通用的。
5.1.1 如何实现多进程间的数据同步?
OpenResty 也是 Master-Workers 运行模式,每个 worker 都是独占一个进程来处理请求,这对限流模块所用的数据结构提出了要求。
为了计算单台 OpenResty 的 QPS 和 平均会话时间,一些计数变量需要被不同进程读写。OpenResty 提供了共享字典 lua_shared_dict
来实现不同进程之间的数据共享,该字典使用共享内存和原子锁实现。压力测试表明(所用硬件为 32 cores,128GB 内存的过保刀片机),不使用任何同步数据结构前提下,OpenResty 最大 QPS 在 140,000 左右;对此类同步数据节构的单次访问会导致 OpenResty QPS 降低 10,000 左右,所以应该尽量减少对共享字典不必要的读写操作。
5.1.2 限流脚本如何介入 OpenResty HTTP 请求处理?
在 OpenResty 中,一个 HTTP 请求的处理分为很多个不同的阶段,下表是对这些阶段的总结。我们限流脚本是在 ngx_lua 的相关处理阶段的基础上进行介入处理的。
chaoslawful 和 agentzh 将 Lua 解释器集成进 Nginx 中,开发了 ngx_lua 模块,从而使得开发者可以使用 lua 语言开发业务逻辑。
ngx_lua 模块有 11 个处理阶段,这些阶段建立自 HTTP 处理阶段基础上,两者并不完全等同。
HTTP 处理阶段 | 描述 | 限流脚本介入的阶段 |
---|---|---|
NGX_HTTP_POST_READ_PHASE | 接收到完整的HTTP头部后处理的阶段 | |
NGX_HTTP_SERVER_REWRITE_PHASE | URI与location匹配前,修改URI的阶段,用于重定向 | |
NGX_HTTP_FIND_CONFIG_PHASE | 根据URI寻找匹配的location块配置项 | |
NGX_HTTP_REWRITE_PHASE, | 上一阶段找到location块后再修改URI | |
NGX_HTTP_POST_REWRITE_PHASE | 防止重写URL后导致的死循环 | |
NGX_HTTP_PREACCESS_PHASE | 下一阶段之前的准备 | |
NGX_HTTP_ACCESS_PHASE | 让HTTP模块判断是否允许这个请求进入Nginx服务器,限流关键阶段 | access_by_lua |
NGX_HTTP_POST_ACCESS_PHASE | 向用户发送拒绝服务的错误码,用来响应上一阶段的拒绝 | |
NGX_HTTP_TRY_FILES_PHASE | 为访问静态文件资源而设置 | |
NGX_HTTP_CONTENT_PHASE | 处理HTTP请求内容的阶段,大部分HTTP模块介入这个阶段 | content_by_lua |
NGX_HTTP_LOG_PHASE | 处理完请求后的日志记录阶段 | log_by_lua |
上表中 access_by_lua
、content_by_lua
、log_by_lua
是 ngx_lua 模块的其中三个处理阶段。
限流相关功能涉及的 ngx_lua 阶段如下:
- init_by_lua:在配置文件加载时调用脚本,允许阻塞。
- init_worker_by_lua:在工作进程初始化时调用脚本。
- access_by_lua:在请求进入 OpenResty 之前生效,我们可以在这里限制流量进入后端服务。
- content_by_lua:用于生成响应内容。
- log_by_lua:请求(包括响应码为 500、404 等的请求)结束阶段生效。
5.2 限流过程需要解决的几个问题
- 如何进行请求计数:某个度量时间段内进入 OpenResty 的总请求数,当该值满足限流力度 $g$ 的条件时,该度量时间段内后续进入的请求将不被允许进入后端服务。
- 如何执行定时任务:由于很多统计数据往往和选取的度量时间段相关,所以某个度量时间段结束后需要及时将相关数据重置。
- 如何控制流量进入 upstream,实现限流效果
- 如何实现动态负载均衡:让性能好的节点接收更多流量,让性能差的节点收到较少的流量,即如何使用 $w$ 选择服务节点。
- 如何计算每个服务节点的平均会话时间:平均会话时间是构成服务节点健康值 $f$ 的核心指标
- 如何将限流效果可视化:直观展现限流是否生效
下面几个章节将会对上面提到的问题进行针对性的解决。
5.3 如何进行请求计数
- 1 在 nginx.conf 的
http
中定义一个名字为 count_dict 大小为 1MB 的共享字典配置lua_shared_dict count_dict 1M;
- 2 在 nginx.conf 的
location
中加入脚本配置access_by_lua_file lua/count.lua;
,该脚本包含了请求技术逻辑。 3 编写脚本 count.lua
local count_dict = ngx.shared.count_dict local count = count_dict:get("count") or 0 count = count + 1 count_dict:set("count",count) ngx.log(ngx.NOTICE,"count = ", count)
5.4 如何执行定时任务
统计数据往往和特定时间段相关,比如 QPS、平均会话时间,这时往往需要定时任务来及时将其重置。
- 1 在 nginx.conf 的
http
中加入初始化任务脚本配置init_worker_by_lua_file lua/timer.lua;
,该脚本负责定时任务相关逻辑。 2 编写脚本 timer.lua
local function task() local count_dict = ngx.shared.count_dict -- 将请求计数重置为 0 count_dict:set("count",0) local timer = 1 -- 开启 1s 后的任务 local ok,err = ngx.timer.at(timer,task) if not ok then ngx.log(ngx.ERR,"fail to start task,err=",err) end end -- 只在一个工作进程开启定时器 if 0 == ngx.worker.id() then task() end
5.5 如何控制流量进入 upstream
根据服务的健康状况 $f$ 来动态确定限流力度 $g$,此处限流力度为 max,如果超过这个限流阈值,后面到达的请求将被返回 503 ,此时流量就不会被转发到 upstream。
修改 count.lua
local count_dict = ngx.shared.count_dict local count = count_dict:get("count") or 0 -- 需要根据服务的健康状况动态修改 max local max = g(f) -- 如果请求数超过阈值 if max < count then ngx.ctx.traffic = true -- 限流标记 ngx.exit(503) -- 直接返回 503 ,阻止进入 upstream end count = count + 1 count_dict:set("count",count)
5.6 如何实现动态负载均衡
- 1 在 nginx.conf 的
upstream
中加入脚本配置balancer_by_lua_file lua/balancer.lua;
,该脚本负责计算动态权重 $w$。 2 编写 balancer.lua
local balancer = require 'ngx.balancer' -- 服务节点列表,weight 需要根据节点健康状态动态修改 local nodes = { {ip = "127.0.0.1", port = 8080, weight = w(f1)}, {ip = "127.0.0.1", port = 8082, weight = w(f1)} } -- 根据节点动态权重计算选择某个后端节点 local index = select_node(nodes) ngx.log(ngx.NOTICE,"index = ", index) local target_node = nodes[index] -- 设置要发送的 upstream local ok,err = balancer.set_current_peer(target_node.ip, target_node.port) if not ok then ngx.log(ngx.ERR,"set_current_peer : ", err) ngx.exit(500) end
5.7 如何计算每个服务节点的平均会话时间
服务节点的平均会话时间是计算该服务节点的健康值 $f$ 所必须的。注意,可以通过下述类似的方法计算其他和时间相关的变量。
1 在 nginx.conf 的
http
中加入共享字典配置lua_shared_dict upstream_dict 1M; lua_shared_dict time_dict 1M;
- 2 在 Nginx 配置文件
location
中加入脚本配置log_by_lua_file lua/sum_time.lua;
3 编写脚本 sum_time.lua
if nil ~= ngx.ctx.traffic then -- 被限流的请求不计入 return end local upstream_dict = ngx.shared.upstream_dict -- upstream 响应时间 local resp_time = tonumber(ngx.var.upstream_response_time) * 1000 -- upstream 地址和端口 local upstream_addr = ngx.var.upstream_addr local time_sum_key = upstream_addr.."time_sum" local req_count_key = upstream_addr.."req_count" local sum = upstream_dict:get(time_sum_key) or 0 sum = sum + resp_time upstream_dict:set(time_sum_key,sum) local count = upstream_dict:get(req_count_key) if nil == count then upstream_dict:set(req_count_key,0) count = 0 end upstream_dict:incr(req_count_key,1) count = count + 1 -- 平均会话时间 local avg_session_time = sum / count local time_dict = ngx.shared.time_dict time_dict:set(upstream_addr,avg_session_time) ngx.log(ngx.NOTICE,"avg_session_time = ", avg_session_time)
4 修改 timer.lua 脚本, 添加重置时间任务
local function task() local count_dict = ngx.shared.count_dict count_dict:set("count",0) local timer = 5 local ok,err = ngx.timer.at(timer,task) if not ok then ngx.log(ngx.ERR,"fail to start task,err = ",err) end end local function reset_time() -- 重置平均会话时间数据为 0 local upstream_dict = ngx.shared.upstream_dict local keys = upstream_dict:get_keys() for k,v in ipairs(keys) do upstream_dict:set(v,0); end local time_dict = ngx.shared.time_dict keys = time_dict:get_keys() for k,v in ipairs(keys) do time_dict:set(v,0); end local timer = 5 local ok,err = ngx.timer.at(timer,reset_time) if not ok then ngx.log(ngx.ERR,"fail to start reset time,err = ",err) end end if 0 == ngx.worker.id() then task() reset_time() end
5.8 如何将限流效果可视化
限流有没有奏效可以通过观察日志来判断,但更好的方式是将 QPS 直观的绘制在图上。
本文可视化使用的是基于 Prometheus 和 Grafana 的监控系统。OpenResy、Prometheus 与 Grafana 的关系如下图所示:
其中 Prometheus 从 Nginx 刮取数据并保存数据,Grafana 从 Prometheus 拉取数据并绘制图像
具体的 Prometheus 和 Grafana 可以参考官方文档,这里不再赘述。下面主要介绍一下如何使用第三方的 lua-nginx-prometheus 模块,开发供 Prometheus 刮取数据的 target。
5.8.1 lua-nginx-prometheus 模块下载和解压
- 下载 lua-nginx-prometheus 模块
- 解压下载的上述模块以提取 prometheus.lua 文件,然后将该文件拷贝到 Openresty 安装目录(默认 /usr/local/openresty)的 lualib 目录下
5.8.2 配置和编写 Prometheus 刮取数据脚本
1 在 nginx.conf 的
http
中加入 Prometheus 共享字典和脚本配置-- Prometheus 字典 lua_shared_dict prometheus_metrics 10M; -- Prometheus 全局变量定义脚本,其他脚本可以访问 init_by_lua_file lua/init_prometheus.lua;
2 在 Nginx 配置文件中加入刮取服务配置
server { listen 9981; location /metrics { deny ... # 访问权限控制 allow .. # 访问权限控制 content_by_lua_file lua/scrape_prometheus.lua; } }
3 编写脚本 init_prometheus.lua
-- 定义全局变量 prometheus = require("prometheus").init("prometheus_metrics") -- 声明全局 gauge metric_upstream_avg_session_time = prometheus:gauge( "upstream_avg_session_time", "upstream avg session time", {"upstream"})
4 编写脚本 scrape_prometheus.lua
local time_dict = ngx.shared.time_dict local keys = time_dict:get_keys() -- 遍历所以服务节点的平均会话时间 for i=1,#keys do local upstream_addr = keys[i] local avg_session_time = time_dict:get(upstream_addr) or 0 metric_upstream_avg_session_time:set(avg_session_time,{upstream_addr}) end -- Prometheus 将数据汇集成刮取格式 prometheus:collect()
5 测试 Prometheus 刮取请求
- 执行
curl localhost:9981/metrics
命令 - 可以得到类似响应
# HELP nginx_metric_errors_total Number of nginx-lua-prometheus errors # TYPE nginx_metric_errors_total counter nginx_metric_errors_total 0 # HELP upstream_avg_session_time upstream avg session time # TYPE upstream_avg_session_time gauge upstream_avg_session_time{upstream="127.0.0.1:8080"} 0 upstream_avg_session_time{upstream="127.0.0.1:8082"} 0
- 执行
- 6 Grafana 效果图
- x 轴表示时间, y 轴表示允许进入服务的请求数
- 如图所示,12 : 20 开启限流,14 : 00 修复 bug 以后,流量稳定在 4k 以下
6 系统开发注意事项
6.1 内存字典使用
内存字典的大小配置
内存字典使用的是红黑树实现的,如果要存储大量的数据,建议合理分析以确定大小。比如 prometheus 所用字典内存不够时会打印日志到 error.log 中。
内存字典和性能
内存字典为了实现多进程访问,内部使用共享内存实现,在不同进程竞争内存变量时,OpenResty 吞吐量会受到很大影响,所以在使用内存字典时,要消除不必要的字典读写。
6.3 热更新与全局变量
热更新
- Lua 脚本在 reload 命令后都会重新进入内存,可以使用 reload 对脚本更新。
- 内存字典在 reload 命令后不会被清空,需要手动编写脚本 (调用
flush_all
和flush_expired
函数)清空。
全局变量
init_by_lua 阶段可以定义全局变量,上面的示例也是定义在此阶段,但是不建议在这个阶段定义。倘若需要使用全局变量,请定义自己的模块 module
(Lua 5.1 就已经支持) ,在模块内定义全局变量,然后通过 require
引入模块
6 未来展望
6.1 集群化
- 将限流集群化,让字典数据在不同 Nginx 上共享 。目前服务使用三个 Nginx 分发流量,限流系统只能运行在各个 Nginx 上,互相之间没有联系,不能共享数据。
- 让配置文件集群化,减少运维人员配置工作量。
6.2 和其他新技术结合
与 Service Mesh 的限流相互补充相互融合
7 参考
- [1] 李明江.Nginx Lua 开发实践[M].北京:机械工业出版社,2018
- [2] http://openresty.org
- [3] https://www.cnblogs.com/biglittleant/p/8979915.html