搞懂分布式技术9:Nginx负载均衡原理与实践
本篇摘自《亿级流量网站架构核心技术》第二章 Nginx负载均衡与反向代理 部分内容。
当我们的应用单实例不能支撑用户请求时,此时就需要扩容,从一台服务器扩容到两台、几十台、几百台。然而,用户访问时是通过如的方式访问,在请求时,浏览器首先会查询DNS服务器获取对应的IP,然后通过此IP访问对应的服务。
因此,一种方式是域名映射多个IP,但是,存在一个最简单的问题,假设某台服务器重启或者出现故障,DNS会有一定的缓存时间,故障后切换时间长,而且没有对后端服务进行心跳检查和失败重试的机制。
因此,外网DNS应该用来实现用GSLB(全局负载均衡)进行流量调度,如将用户分配到离他最近的服务器上以提升体验。而且当某一区域的机房出现问题时(如被挖断了光缆),可以通过DNS指向其他区域的IP来使服务可用。
可以在站长之家使用“DNS查询”,查询c.3.cn可以看到类似如下的结果。
即不同的运营商返回的公网IP是不一样的。
对于内网DNS,可以实现简单的轮询负载均衡。但是,还是那句话,会有一定的缓存时间并且没有失败重试机制。因此,我们可以考虑选择如HaProxy和Nginx。
而对于一般应用来说,有Nginx就可以了。但Nginx一般用于七层负载均衡,其吞吐量是有一定限制的。为了提升整体吞吐量,会在DNS和Nginx之间引入接入层,如使用LVS(软件负载均衡器)、F5(硬负载均衡器)可以做四层负载均衡,即首先DNS解析到LVS/F5,然后LVS/F5转发给Nginx,再由Nginx转发给后端Real Server。
对于一般业务开发人员来说,我们只需要关心到Nginx层面就够了,LVS/F5一般由系统/运维工程师来维护。Nginx目前提供了HTTP(ngx_http_upstream_module)七层负载均衡,而1.9.0版本也开始支持TCP(ngx_stream_upstream_module)四层负载均衡。
此处再澄清几个概念。
二层负载均衡是通过改写报文的目标MAC地址为上游服务器MAC地址,源IP地址和目标IP地址是没有变的,负载均衡服务器和真实服务器共享同一个VIP,如LVS DR工作模式。
四层负载均衡是根据端口将报文转发到上游服务器(不同的IP地址+端口),如LVS NAT模式、HaProxy
七层负载均衡是根据端口号和应用层协议如HTTP协议的主机名、URL,转发报文到上游服务器(不同的IP地址+端口),如HaProxy、Nginx。
这里再介绍一下LVS DR工作模式,其工作在数据链路层,LVS和上游服务器共享同一个VIP,通过改写报文的目标MAC地址为上游服务器MAC地址实现负载均衡,上游服务器直接响应报文到客户端,不经过LVS,从而提升性能。但因为LVS和上游服务器必须在同一个子网,为了解决跨子网问题而又不影响负载性能,可以选择在LVS后边挂HaProxy,通过四到七层负载均衡器HaProxy集群来解决跨网和性能问题。这两个“半成品”的东西相互取长补短,组合起来就变成了一个“完整”的负载均衡器。现在Nginx的stream也支持TCP,所以Nginx也算是一个四到七层的负载均衡器,一般场景下可以用Nginx取代HaProxy。
在继续讲解之前,首先统一几个术语。接入层、反向代理服务器、负载均衡服务器,在本文中如无特殊说明则指的是Nginx。upstream server即上游服务器,指Nginx负载均衡到的处理业务的服务器,也可以称之为real server,即真实处理业务的服务器。
对于负载均衡我们要关心的几个方面如下。
上游服务器配置:使用upstream server配置上游服务器。
负载均衡算法:配置多个上游服务器时的负载均衡机制。
失败重试机制:配置当超时或上游服务器不存活时,是否需要重试其他上游服务器。
服务器心跳检查:上游服务器的健康检查/心跳检查。
Nginx提供的负载均衡可以实现上游服务器的负载均衡、故障转移、失败重试、容错、健康检查等,当某些上游服务器出现问题时可以将请求转到其他上游服务器以保障高可用,并可以通过OpenResty实现更智能的负载均衡,如将热点与非热点流量分离、正常流量与爬虫流量分离等。Nginx负载均衡器本身也是一台反向代理服务器,将用户请求通过Nginx代理到内网中的某台上游服务器处理,反向代理服务器可以对响应结果进行缓存、压缩等处理以提升性能。Nginx作为负载均衡器/反向代理服务器如下图所示。
本章首先会讲解Nginx HTTP负载均衡,最后会讲解使用Nginx实现四层负载均衡。
Nginx的配置
第一步我们需要给Nginx配置上游服务器,即负载均衡到的真实处理业务的服务器,通过在http指令下配置upstream即可。
upstream backend {
//server ip:端口 weight=权重值;
server 192.168.61.1:9080 weight=1;
server 192.168.61.1:9090 weight=2;
}
upstream server主要配置。
IP地址和端口:配置上游服务器的IP地址和端口。
权重:weight用来配置权重,默认都是1,权重越高分配给这台服务器的请求就越多(如上配置为每三次请求中一个请求转发给9080,其余两个请求转发给9090),需要根据服务器的实际处理能力设置权重(比如,物理服务器和虚拟机就需要不同的权重)。
然后,我们可以配置如下proxy_pass来处理用户请求。
location / {
proxy_pass http://backend;
}
当访问Nginx时,会将请求反向代理到backend配置的Upstream Server。接下来我们看一下负载均衡算法。
负载均衡用来解决用户请求到来时如何选择Upstream Server进行处理,默认采用的是round-robin(轮询),同时支持其他几种算法。
round-robin
round-robin:轮询,默认负载均衡算法,即以轮询的方式将请求转发到上游服务器,通过配合weight配置可以实现基于权重的轮询。
ip_hash
ip_hash:根据客户IP进行负载均衡,即相同的IP将负载均衡到同一个Upstream Server。
upstream backend {
ip_hash;//ip_hash
server 192.168.61.1:9080 weight=1;
server 192.168.61.1:9090 weight=2;
}
hash key [consistent]
hash key [consistent]:对某一个key进行哈希或者使用一致性哈希算法进行负载均衡。使用Hash算法存在的问题是,当添加/删除一台服务器时,将导致很多key被重新负载均衡到不同的服务器(从而导致后端可能出现问题);因此,建议考虑使用一致性哈希算法,这样当添加/删除一台服务器时,只有少数key将被重新负载均衡到不同的服务器。
哈希算法:此处是根据请求uri进行负载均衡,可以使用Nginx变量,因此,可以实现复杂的算法。
upstream backend {
hash $uri;
server 192.168.61.1:9080 weight=1;
server 192.168.61.1:9090 weight=2;
}
一致性哈希算法:consistent_key动态指定。
upstream nginx_local_server {
hash $consistent_key consistent;
server 192.168.61.1:9080 weight=1;
server 192.168.61.1:9090 weight=2;
}
如下location指定了一致性哈希key,此处会优先考虑请求参数cat(类目),如果没有,则再根据请求uri进行负载均衡。
location / {
set $consistent_key $arg_cat;
if($consistent_key = "") {
set $consistent_key $request_uri;
}
}
而实际我们是通过lua设置一致性哈希key。
set_by_lua_file $consistent_key"lua_balancing.lua";
lua_balancing.lua代码。
local consistent_key = args.cat
if not consistent_key or consistent_key == '' then
consistent_key = ngx_var.request_uri
end
local value = balancing_cache:get(consistent_key)
if not value then
success,err = balancing_cache:set(consistent_key, 1, 60)
else
newval,err = balancing_cache:incr(consistent_key, 1)
end
如果某一个分类请求量太大,上游服务器可能处理不了这么多的请求,此时可以在一致性哈希key后加上递增的计数以实现类似轮询的算法。
if newval > 5000 then
consistent_key = consistent_key .. '_' .. newval
end
least_conn:将请求负载均衡到最少活跃连接的上游服务器。如果配置的服务器较少,则将转而使用基于权重的轮询算法。
Nginx商业版还提供了least_time,即基于最小平均响应时间进行负载均衡。
主要有两部分配置:upstream server和proxy_pass。
upstream backend { server 192.168.61.1:9080 max_fails=2 fail_timeout=10s weight=1; server 192.168.61.1:9090 max_fails=2 fail_timeout=10s weight=1; }
通过配置上游服务器的max_fails和fail_timeout,来指定每个上游服务器,当fail_timeout时间内失败了max_fails次请求,则认为该上游服务器不可用/不存活,然后将摘掉该上游服务器,fail_timeout时间后会再次将该服务器加入到存活上游服务器列表进行重试。
upstream server和proxy_pass
location /test { proxy_connect_timeout 5s; proxy_read_timeout 5s; proxy_send_timeout 5s; proxy_next_upstreamerror timeout; proxy_next_upstream_timeout 10s; proxy_next_upstream_tries 2; proxy_pass http://backend; add_header upstream_addr $upstream_addr; } }
然后进行proxy_next_upstream相关配置,当遇到配置的错误时,会重试下一台上游服务器。
详细配置请参考“代理层超时与重试机制”中的Nginx部分。
Nginx对上游服务器的健康检查默认采用的是惰性策略,Nginx商业版提供了health_check进行主动健康检查。当然也可以集成nginx_upstream_check_module(https://github.com/yaoweibin/nginx_upstream_check_module)模块来进行主动健康检查。
nginx_upstream_check_module支持TCP心跳和HTTP心跳来实现健康检查。
心跳检查
upstream backend { server 192.168.61.1:9080 weight=1; server 192.168.61.1:9090 weight=2; check interval=3000 rise=1 fall=3 timeout=2000 type=tcp; }
此处配置使用TCP进行心跳检测。
interval:检测间隔时间,此处配置了每隔3s检测一次。
fall:检测失败多少次后,上游服务器被标识为不存活。
rise:检测成功多少次后,上游服务器被标识为存活,并可以处理请求。
timeout:检测请求超时时间配置。
心跳检查
upstream backend { server 192.168.61.1:9080 weight=1; server 192.168.61.1:9090 weight=2; check interval=3000 rise=1 fall=3 timeout=2000 type=http; check_http_send "HEAD /status HTTP/1.0rnrn"; check_http_expect_alive http_2xx http_3xx; }
HTTP心跳检查有如下两个需要额外配置。
check_http_send:即检查时发的HTTP请求内容。
check_http_expect_alive:当上游服务器返回匹配的响应状态码时,则认为上游服务器存活。
此处需要注意,检查间隔时间不能太短,否则可能因为心跳检查包太多造成上游服务器挂掉,同时要设置合理的超时时间。
本文使用的是openresty/1.11.2.1(对应nginx-1.11.2),安装Nginx之前需要先打nginx_upstream_check_module补丁(check_1.9.2+.patch),到Nginx目录下执行如下shell:
patch -p0 < /usr/servers/nginx_upstream_check_module-master/check_1.9.2+.patch。
如果不安装补丁,那么nginx_upstream_check_module模块是不工作的,建议使用wireshark抓包查看其是否工作。
域名上游服务器
upstream backend { server c0.3.cn; server c1.3.cn; }
Nginx社区版,是在Nginx解析配置文件的阶段将域名解析成IP地址并记录到upstream上,当这两个域名对应的IP地址发生变化时,该upstream不会更新。Nginx商业版才支持动态更新。
不过,proxy_pass http://c0.3.cn是支持动态域名解析的。
备份上游服务器
upstream backend { server 192.168.61.1:9080 weight=1; server 192.168.61.1:9090 weight=2 backup; }
9090端口上游服务器配置为备份上游服务器,当所有主上游服务器都不存活时,请求会转发给备份的上游服务器。
如通过缩容上游服务器进行压测时,要摘掉一些上游服务器进行压测,但为了保险起见会配置一些备上游服务器,当压测的上游服务器都挂掉时,流量可以转发到备上游服务器,从而不影响用户请求处理。
不可用上游服务器
upstream backend { server 192.168.61.1:9080 weight=1; server 192.168.61.1:9090 weight=2 down; }
9090端口上游服务器配置为永久不可用,当测试或者机器出现故障时,暂时通过该配置临时摘掉机器。
配置Nginx与上游服务器的长连接
配置Nginx与上游服务器的长连接,客户端与Nginx之间的长连接可以参考位置“超时与重试”的相应部分。
通过keepalive指令配置长连接数量。
upstream backend { server 192.168.61.1:9080 weight=1; server 192.168.61.1:9090 weight=2 backup; keepalive 100;//LRU算法 }
通过该指令配置了每个Worker进程与上游服务器可缓存的空闲连接的最大数量。当超出这个数量时,最近最少使用的连接将被关闭。keepalive指令不限制Worker进程与上游服务器的总连接。
如果想要跟上游服务器建立长连接,则一定别忘了以下配置。
location / { #支持keep-alive proxy_http_version 1.1; proxy_set_header Connection ""; proxy_pass http://backend; }
如果是http/1.0,则需要配置发送“Connection: Keep-Alive”请求头。
上游服务器不要忘记开启长连接支持。
接下来,我们看一下Nginx是如何实现keepalive的(ngx_http_upstream_keepalive _module),获取连接时的部分代码。
ngx_http_upstream_get_keepalive_peer(ngx_peer_connection_t*pc, void *data) { //1.首先询问负载均衡使用哪台服务器(IP和端口) rc =kp->original_get_peer(pc, kp->data); cache =&kp->conf->cache; //2.轮询 “空闲连接池” for (q =ngx_queue_head(cache); q!= ngx_queue_sentinel(cache); q =ngx_queue_next(q)) { item = ngx_queue_data(q,ngx_http_upstream_keepalive_cache_t, queue); c =item->connection; //2.1.如果“空闲连接池”缓存的连接IP和端口与负载均衡到的IP和端口相同,则使用此连接 if (ngx_memn2cmp((u_char *)&item->sockaddr, (u_char *) pc->sockaddr, item->socklen,pc->socklen) == 0) { //2.2.从“空闲连接池”移除此连接并压入“释放连接池”栈顶 ngx_queue_remove(q); ngx_queue_insert_head(&kp->conf->free, q); goto found; } } //3.如果 “空闲连接池”没有可用的长连接,将创建短连接 return NGX_OK; 释放连接时的部分代码。 ngx_http_upstream_free_keepalive_peer(ngx_peer_connection_t*pc, void *data, ngx_uint_t state) { c = pc->connection;//当前要释放的连接 //1.如果“释放连接池”没有待释放连接,那么需要从“空闲连接池”腾出一个空间给新的连接使用(这种情况存在于创建连接数超出了连接池大小时,这就会出现震荡) if(ngx_queue_empty(&kp->conf->free)) { q =ngx_queue_last(&kp->conf->cache); ngx_queue_remove(q); item= ngx_queue_data(q, ngx_http_upstream_keepalive_cache_t, queue); ngx_http_upstream_keepalive_close(item->connection); } else {//2.从“释放连接池”释放一个连接 q =ngx_queue_head(&kp->conf->free); ngx_queue_remove(q); item= ngx_queue_data(q, ngx_http_upstream_keepalive_cache_t, queue); } //3.将当前连接压入“空闲连接池”栈顶供下次使用 ngx_queue_insert_head(&kp->conf->cache, q); item->connection = c;
总长连接数是“空闲连接池”+“释放连接池”的长连接总数。首先,长连接配置不会限制Worker进程可以打开的总连接数(超了的作为短连接)。另外,连接池一定要根据实际场景合理进行设置。
1.空闲连接池太小,连接不够用,需要不断建连接。
2.空闲连接池太大,空闲连接太多,还没使用就超时。
另外,建议只对小报文开启长连接。
缓存配置
反向代理除了实现负载均衡之外,还提供如缓存来减少上游服务器的压力。
1.全局配置(proxy cache)
proxy_buffering on; proxy_buffer_size 4k; proxy_buffers 512 4k; proxy_busy_buffers_size 64k; proxy_temp_file_write_size 256k; proxy_cache_lock on; proxy_cache_lock_timeout 200ms; proxy_temp_path /tmpfs/proxy_temp; proxy_cache_path /tmpfs/proxy_cache levels=1:2keys_zone =cache:512m inactive=5m max_size=8g; proxy_connect_timeout 3s; proxy_read_timeout 5s; proxy_send_timeout 5s;
开启proxy buffer,缓存内容将存放在tmpfs(内存文件系统)以提升性能,设置超时时间。
2.location配置
location ~ ^/backend/(.*)$ { \#设置一致性哈希负载均衡key set_by_lua_file $consistent_key "/export/App/c.3.cn/lua/lua_ balancing_backend.properties"; \#失败重试配置 proxy_next_upstream error timeout http_500 http_502 http_504; proxy_next_upstream_timeout 2s; proxy_next_upstream_tries 2; \#请求上游服务器使用GET方法(不管请求是什么方法) proxy_method GET; \#不给上游服务器传递请求体 proxy_pass_request_body off; \#不给上游服务器传递请求头 proxy_pass_request_headers off; \#设置上游服务器的哪些响应头不发送给客户端 proxy_hide_header Vary; \#支持keep-alive proxy_http_version 1.1; proxy_set_header Connection ""; \#给上游服务器传递Referer、Cookie和Host(按需传递) proxy_set_header Referer $http_referer; proxy_set_header Cookie $http_cookie; proxy_set_header Host web.c.3.local; proxy_pass http://backend /$1$is_args$args; }
我们开启了proxy_pass_request_body和proxy_pass_request_headers,禁止向上游服务器传递请求头和内容体,从而使得上游服务器不受请求头攻击,也不需要解析;如果需要传递,则使用proxy_set_header按需传递即可。
我们还可以通过如下配置来开启gzip支持,减少网络传输的数据包大小。
gzip on; gzip_min_length 1k; gzip_buffers 16 16k; gzip_http_version 1.0; gzip_proxied any; gzip_comp_level 2; gzip_types text/plainapplication/x-java text/css application/xml; gzip_vary on;
对于内容型响应建议开启gzip压缩,gzip_comp_level压缩级别要根据实际压测来决定(带宽和吞吐量之间的抉择)。