本文是对Rate Limiting with NGINX and NGINX Plus的主要内容(去掉了关于NGINX Plus相关内容)的翻译。
限流(rate limiting)是NGINX众多特性中最有用的,也是经常容易被误解和错误配置的,特性之一。该特性可以限制某个用户在一个给定时间段内能够产生的HTTP请求数。请求可以简单到就是一个对于主页的GET请求或者一个登陆表格的POST请求。
限流也可以用于安全目的上,比如减慢暴力密码破解攻击。通过限制进来的请求速率,并且(结合日志)标记出目标URLs来帮助防范DDoS攻击。一般地说,限流是用在保护上游应用服务器不被在同一时刻的大量用户请求湮没。
下面介绍NGINX限流的基本用法。
NGINX限流是如何工作的
NGINX限流使用漏桶算法(leaky bucket algorithm),该算法广泛应用于通信和基于包交换计算机网络中,用来处理当带宽被限制时的突发情况。和一个从上面进水,从下面漏水的桶的原理很相似;如果进水的速率大于漏水的速率,这个桶就会发生溢出。
图片来自于NGINX BLOG
在请求处理过程中,水代表从客户端来的请求,而桶代表了一个队列,请求在该队列中依据先进先出(FIFO)算法等待被处理。漏的水代表请求离开缓冲区并被服务器处理,溢出代表了请求被丢弃并且永不被服务。
配置基本的限流功能
有两个主要的指令可以用来配置限流:limit_req_zone和limit_req,例子:
1 | limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s; #基于毫秒:1秒=1000毫秒 1000/10=100毫秒 |
当limit_req 在它出现的环境中启用了限流(在上面的例子中,作用在所有对于/login/的请求上),则limit_req_zone指令定义了限流的参数。
limit_req_zone指令一般定义在http块内部,使得该指令可以在多个环境中使用。该指令有下面三个参数:
- Key — 在限流应用之前定义了请求的特征。在上面例子中,它是$binary_remote_addr(NGINX变量),该变量代表了某个客户端IP地址的二进制形式。这意味着我们可以将每个特定的IP地址的请求速率限制为第三个参数所定义的值。(使用这个变量的原因是因为它比用string代表客户端IP地址的$remote_addr变量消耗更少的空间。)
- Zone — 定义了存储每个IP地址状态和它访问受限请求URL的频率的共享内存区域。将这些信息保存在共享内存中,意味着这些信息能够在NGINX工作进程之间共享。定义有两个部分:由zone=关键字标识的区域名称,以及冒号后面的区域大小。约16000个IP地址的状态信息消耗1M内存大小,因此我们的区域(zone)大概可以存储约160000个地址。
当NGINX需要添加新的记录时,如果此时存储耗尽了,最老的记录会被移除。如果释放的存储空间还是无法容纳新的记录,NGINX返回503 (Service Temporarily Unavailable)状态码。此外,为了防止内存被耗尽,每次NGINX创建一个新的记录的同时移除多达两条前60秒内没有被使用的记录。 - Rate — 设置最大的请求速率。在上面的例子中,速率不能超过10个请求每秒。NGINX事实上可以在毫秒级别追踪请求,因此这个限制对应了1个请求每100毫秒。因为我们不允许突刺(bursts,短时间内的突发流量,详细见下一部分。),这意味着如果某个请求到达的时间离前一个被允许的请求小于100毫秒,它会被拒绝。
1秒=1000毫秒 1000/10=100毫秒
limit_req_zone指令设置限流和共享内存区域的参数,但是该指令实际上并不限制请求速率。为了限制起作用,需要将该限制应用到某个特定的location或server块(block),通过包含一个limit_req指令的方式。在上面的例子中,我们将请求限制在/login/上。
所以现在对于/login/,每个特定的IP地址被限制为10个请求每秒— 或者更准确地说,不能在与前一个请求间隔100毫秒时间内发送请求。
处理流量突刺(Bursts)
如果在100毫秒内得到2个请求会怎么样?对于第2个请求,NGINX返回503状态码给客户端。这可能不是我们想要的,因为事实上,应用是趋向于突发性的。相反,我们想要缓存任何过多的请求并且及时地服务它们。下面是我们使用limit_req的burst参数来更新配置:
1 | location /login/ { |
burst参数定义了一个客户端能够产生超出区域(zone)规定的速率的请求数量(在我们示例mylimit区域中,速率限制是10个请求每秒,或1个请求每100毫秒)。一个请求在前一个请求后的100毫秒间隔内达到,该请求会被放入一个队列,并且该队列大小被设置为20.
这意味着如果从某个特定IP地址来的21个请求同时地达到,NGINX立即转发第一个请求到上游的服务器组,并且将剩余的20个请求放入队列中。然后,NGINX每100毫秒转发一个队列中的请求,并且只有当某个新进来的请求使得队列中的请求数目超过了20,则返回503给客户端。
无延迟排队
带有burst的配置产生平滑的网络流量,但是不实用,因为该配置会使得你的网站表现的很慢。在上面的例子中,队列中第20个数据包等待2秒才能被转发,这时该数据包的响应可能对于客户端已经没有了意义。为了处理这种情况,除了burst参数外,添加nodelay参数。
1 | location /login/ { |
带有nodelay参数,NGINX仍然会按照burst参数在队列中分配插槽(slot)以及利用已配置的限流,但是不是通过间隔地转发队列中的请求。相反,当某个请求来的太快,只要队列中有可用的空间(slot),NGINX会立即转发它。该插槽(slot)被标记为“已使用”,并且不会被释放给另一个请求,一直到经过适当的时间(在上面的例子中,是100毫秒)。
像之前一样假设有20个插槽的队列是空的,并且来自于给定的IP地址的21个请求同时地到达。NGINX立即转发这21个请求以及将队列中的20个插槽标记为“已使用”,然后每隔100毫秒释放一个插槽。(相反,如果有25个请求,NGINX会立即转发25个中的21个请求,标记20个插槽为“已使用”,并且用503状态拒绝4个请求。)
现在假设在转发第一个请求集合之后的101毫秒,有另外的20个请求同时地到达。队列中只有1个插槽被释放,因此NGINX转发1个请求,并且用503状态拒绝其它的19个请求。相反,如果在这20个新请求到达之前过去了501毫秒,则有5个插槽被释放,因此NGINX立即转发5个请求,并且拒绝其它15个请求。
效果等同于10个请求每秒的限流。如果你想利用请求之间的无限制性间隔的限流,nodelay选项则是非常有用的。
注意:对于大多数的部署,我们推荐在limit_req指令中包含burst和nodelay参数。
高级设置的例子
通过结合基本的限流和其它的NGINX特性,你可以实现更多的细微的流量限制。
白名单
下面的例子展示了如何将限流作用在任何一个不在“白名单”中的请求上。
1 | geo $limit { |
这个例子同时使用了geo和map指令。对于IP地址在白名单中的,geo块分配0值给$limit;其它所有不在白名单中的IP地址,分配1值。然后我们使用一个map去将这些值映射到某个key中,例如:
- 如果$limit是0,$limit_key被设置为空字符串
- 如果$limit是1,$limit_key被设置为客户端的IP地址的二进制格式
这个两个结合起来,对于白名单中的IP地址,$limit_key被设置为空字符串;否则,被设置为客户端的IP地址。当limit_req_zone指令的第一个参数是一个空字符串,限制不起作用,因此白名单的IP地址(在10.0.0.0/8和192.168.0.0/24子网中)没有被限制。其它所有的IP地址都被限制为5个请求每秒。
limit_req指令将限制作用在/定位中,并且允许在没有转发延迟的情况下,转发多达10个数据包。
在一个定位中包含多个limit_req指令
可以在单个定位(location)中包含多个limit_req指令。匹配给定的请求限制都会被使用,这意味着采用最严格的限制。例如,如果多于一个的指令使用了延迟,最终使用最长的延迟。类似地,如果某个指令使得请求被拒绝,即使其它的指令允许请求通过,最终还是被拒绝。
我们可以在白名单中的IP地址上应用某个限流来扩展之前的例子:
1 | http { |
在白名单上的IP地址不匹配第一个限流(req_zone),但是能匹配第二个(req_zone_wl),因此这些IP地址被限制为15个请求每秒。不在白名单上的IP地址两个限流都能匹配上,因此最严格的那个限流起作用:5个请求每秒。
配置相关的特性
日志(Logging)
默认,NGNIX记录由于限流导致的延迟或丢弃的请求的日志,如下面的例子:
1 | 2015/06/13 04:20:00 [error] 120315#0: *32086 limiting requests, excess: 1.000 by zone "mylimit", client: 192.168.1.2, server: nginx.com, request: "GET / HTTP/1.0", host: "nginx.com" |
该日志记录包含的字段:
- limiting requests — 日志条目记录了某个限流的标志
- excess — 超过这个请求代表的配置的速率的每毫秒请求数目
- zone — 定义了启用了限流的区域
- client — 产生请求的客户端IP地址
- server — 服务器的IP地址或主机名
- request — 客户端产生的实际的HTTP请求
- host — HTTP头部主机名的值
默认,NGINX日志在error级别拒绝请求,如上面例子中的[error]所示。(它在低一个级别上记录延迟的请求,因此默认是info。)用limit_req_log_level指令来改变日志级别。下面我们设置在warn级别上记录被拒绝的请求的日志:
1 | location /login/ { |
发送给客户端的错误码
默认,当某个客户端超过它的限流,NGINX用503(Service Temporarily Unavailable)状态码来响应。使用limit_req_status指令设置一个不同的状态码(在下面的例子是444):
1 | location /login/ { |
拒绝对特定位置的所有请求
如果你想拒绝对于某个特定URL的所有请求,而不是仅仅的限制它们,可以为这个URL配置一个location块,并且在其中包含deny all指令:
1 | location /foo.php { |
Nginx有ngx_http_limit_req_module可用于限制请求处理速率,但大多数人似乎只使用其基本功能:通过远程地址限制请求率,如下所示:1
2
3
4
5
6
7
8http {
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
...
server{
...
location/search/{
limit_req zone=one burst=5;
}
这是从Nginx的官方文档中获取的示例配置,limit_req_zone指令将变量$binary_remote_addr作为限制传入请求的键。密钥填充名为one的区域,该区域由zone参数定义,它可以使用最多10m的内存。而rate参数表示每个$binary_remote_addr的最大请求率是每秒1。在搜索位置块中,我们可以使用limit_req指令来引用一个区域,突发不超过5个请求。
一切看起来都很棒,我们已经配置了Nginx来对抗流氓机器人/蜘蛛,对吧?
没有!该配置在现实生活中不起作用,它永远不应该在您的生产环境中使用!以下列情况为例:
- 当用户在NAT后访问您的网站时,他们共享相同的公共IP,因此Nginx将仅使用一个$binary_remote_addr来执行限制请求。总共有数百名用户每天只能访问您的网站1次!
- 僵尸网络用于抓取您的网站,每次使用不同的IP地址。同样,在这种情况下,限制$binary_remote_addr是完全没用的。
那么我们应该使用什么配置呢?我们需要使用不同的变量作为键,或者甚至将多个变量组合在一起(从版本1.7.6开始,limit_req_zone的键可以采用多个变量)。而不是远程地址,最好使用请求HTTP标头来区分用户,例如User-Agent,Referer,Cookie等。这些标头在Nginx中很容易访问,它们作为内置变量公开,如$http_user_agent,$http_referer,$cookie_ name等
例如,这是定义区域的更好方法:1
2
3http {
limit_req_zone $binary_remote_addr$http_user_agent zone=two:10m rate=90r/m;
}
它将$binary_remote_addr和$http_user_agent组合在一起,因此可以区分NATed网络后面的不同用户代理。但它仍然不完美,多个用户可以使用相同的浏览器,相同的版本,因此他们发送相同的User-Agent标头!另一个问题是$http_user_agent变量的长度不固定(与$binary_remote_addr不同),长标头可能会使用该区域的大量内存,可能超过它。
为了解决第一个问题,我们可以在那里使用更多变量,cookie会很棒,因为不同的用户发送他们独特的cookie,比如$cookie_ userid,但这仍然是我们的第二个问题。答案是使用变量哈希代替。
Thers是一个名为set-misc-nginx-module的第三方模块,我们可以用它来从变量生成哈希值。如果您使用的是Openresty,则已包含此moule。所以配置是这样的:1
2
3
4
5
6
7
8
9
10
11http {
...
limit_req_zone $binary_remote_addr$cookie_hash$ua_hash zone=3:10m rate=90r/m;
...
server{
...
set_md5 $cookie_hash $cookie_userid;
set_md5 $ua_hash $http_user_agent;
...
}
}
我们可以在http块中使用$cookie_hash和$ua_hash,然后在server块中定义它们。这个配置现在很棒。
现在让我们继续解决分布式僵尸网络问题,我们需要从密钥中取出$binary_remote_addr,因为这些机器人通常不会发送Referer标头(否则你可以自己找到它的独特之处),我们可以利用它。这个配置应该照顾它:1
2
3
4
5
6
7
8
9
10
11
12http {
...
limit_req_zone $cookie_hash $referer_hash $ua_hash zone=3:10m rate=90r/m;
...
server{
...
set_md5 $cookie_hash$cookie_userid;
set_md5 $referer_hash $http_referer;
set_md5 $ua_hash $http_user_agent;
...
}
}
爬虫限流1
2
3
4
5
6
7
8
9
10
11
12
13
14
15http {
map $http_user_agent $limit_bots {
default '';
~*(google|bing|yandex|msnbot) $binary_remote_addr;
}
limit_req_zone $limit_bots zone=bots:10m rate=1r/m;
server {
location / {
limit_req zone=bots burst=5 nodelay;
}
}
}
https://rayandas.in/blogs/rate-limit-with-nginx/
https://www.jianshu.com/p/2cf3d9609af3
Nginx(或Openresty)的高级限制请求