Bug解决之路-Nginx502BadGateway前言事实证明,阅读Linux内核源码确实有很大的好处,尤其是在处理问题的时候。当你看到错误报告的那一刻,你可以在脑海中闪过现象/原因/和解决方案。甚至一些角落和角落也能很快反应出为什么。笔者阅读了一些LinuxTCP协议栈的源代码,在解决下面的问题时感觉非常流畅。bug场景首先这个问题不难解决,但是这个问题引起的现象还是挺有意思的。让我们先描述一下这个现象。笔者想对自研的dubbo协议隧道网关进行压力测试(这个网关的设计也蛮有意思的,打算放在后面的博客中)。先来看压测的拓扑结构:为了对笔者网关的单机性能进行压测,两端只保留一个网关,即gateway1和gateway2。当压力达到一定程度时,就会报错,导致压力测试停止。很自然地认为网关处理不了。在Gateway2机器上检查了网关的情况,没有报错。而Gateway1有大量的502错误。502是BadGateway,一个经典的Nginx错误报告。首先想到的是Gateway2在Upstream被Nginx压垮踢掉了。然后,先看Gateway2的负载,查看监控,发现Gateway2在4核8G的机器上只用了一个核,完全没有瓶颈。IO有问题吗?看着又小又差的网卡流量打消了这个猜测。Nginx所在机器CPU使用率接近100%。这时候发现一个有趣的现象,Nginx确实是CPU占满了!再次压测,到Nginx所在的机器顶层,发现Nginx的4个Worker各占一个coreCPU满-_-!什么,号称强大的Nginx这么弱,事件驱动的epoll边沿触发是纯C搭建的?一定是姿势不对!去掉Nginx直接通信没有压力。既然猜测是Nginx的瓶颈,那我们就去掉Nginx吧。Gateway1和Gateway2直连,所以压测中TPS飙升,而Gateway2的CPU最多只消耗2核,无压力。去Nginx查看日志。由于我没有Nginx机器的权限,所以一开始没有关注日志。现在联系相应的运维看看。accesslog中发现大量502错误,确实是Nginx的。再次阅读错误日志,发现有大量的Cannotassignrequestedaddress。因为笔者看了TCP源码,立马意识到端口号用完了!由于Nginxupstream和Backend默认是短连接,当有大量请求流量进来时,会产生大量的TIME_WAIT连接。而这些TIME_WAIT会占用端口号,被Kernel回收需要1分钟左右.cat/proc/sys/net/ipv4/ip_local_port_range3276861000也就是说只要一分钟内产生28232(61000-32768)个TIME_WAIT套接字,端口号就会被耗尽,即470.5TPS(28232/60),这只是一个容易达到的压力测量。其实这个限制是在client端的,在server端是没有这个限制的,因为server端的端口号只有8080这样的知名端口号。在上游,Nginx扮演的是Client的角色,Gateway2扮演Nginx的角色。为什么Nginx的CPU是100%笔者很快就搞清楚了为什么Nginx会占满机器的CPU。问题出在端口号的查找过程中。让我们看一下性能最密集的函数:int__inet_hash_connect(...){//注意,这里是静态变量staticu32hint;//hint帮助不从0开始搜索,而是从下一个分配的端口号搜索开始等待u32offset=hint+port_offset;.....inet_get_local_port_range(&low,&high);//remaining这里是61000-32768remaining=(high-low)+1......for(i=1;i<=remaining;i++){port=low+(i+offset)%remaining;/*端口是否占用检查*/....gotook;}....ok:提示+=i;......}看上面的代码,如果没有可用的端口号,需要循环剩余次数宣告端口号用完,共28232次。但是按照正常情况,因为hint的存在,所以每次查找都是从下一个要分配的端口号开始,按个位数查找就可以找到端口号。如下图所示:所以当端口号耗尽时,Nginx的Worker进程就沉浸在上面的for循环中无法自拔,CPU被占满。为什么Gateway1调用Nginx没有问题很简单,因为作者在Gateway1调用Nginx的时候设置了Keepalived,所以使用的是长连接,端口号耗尽没有限制。如果Nginx后面有多台机器,由于端口号搜索导致CPU占用100%,只要有可用的端口号,因为hint,搜索的次数可能是1和28232的差值。因为端口号限制是针对特定的远程服务器:端口。因此,只要Nginx的后端有多台机器,甚至同一台机器上有多个不同的端口号,只要不超过临界点,Nginx就不会有任何压力。比较无脑的增加端口号范围的方案当然是增加端口号范围,这样可以抵抗更多的TIME_WAIT。同时减少tcp_max_tw_bucket,tcp_max_tw_bucket是内核中TIME_WAIT的最大数量,只要端口范围-tcp_max_tw_bucket大于一定值,那么就会一直有端口可用,这样就可以避免继续当值再次增加时打破临界点。.cat/proc/sys/net/ipv4/ip_local_port_range2276861000cat/proc/sys/net/ipv4/tcp_max_tw_buckets20000Enabletcp_tw_reuse这个问题Linux其实早就有解决办法了,那就是参数tcp_tw_reuse。echo'1'>/proc/sys/net/ipv4/tcp_tw_reuse其实TIME_WAIT太多的原因是恢复时间需要1分钟。这个1分钟其实就是TCP协议中规定的2MSL时间,但是在Linux中是固定为1分钟的。#defineTCP_TIMEWAIT_LEN(60*HZ)/*等待多长时间销毁TIME-WAIT*状态,大约60秒*/2MSL的原因是为了排除网络上剩余的数据包影响新的相同5-的Sockettuple,也就是说在2MSL(1min)内重用这个五元组是有风险的。为了解决这个问题,Linux采取了一系列的措施来防止这种情况的发生,使得在大多数情况下1s内的TIME_WAIT可以被重用。下面这段代码就是检测这个TIME_WAIT是否被重用。__inet_hash_connect|->__inet_check_establishedstaticint__inet_check_established(...){....../*首先检查TIME-WAIT套接字。*/sk_nulls_for_each(sk2,node,&head->twchain){tw=inet_twsk(sk2);//如果time_wait中找到匹配的端口,则判断是否可以重用转到唯一;否则转到not_unique;}}......}而核心函数是twsk_unique,其判断逻辑如下:inttcp_twsk_unique(...){......if(tcptw->tw_ts_recent_stamp&&(twp==NULL||(sysctl_tcp_tw_reuse&&get_seconds()-tcptw->tw_ts_recent_stamp>1))){//设置write_seq为snd_nxt+65536+2//这样可以保证数据传输速率<=80Mbit/s不会被wrappedtp->write_seq=tcptw->tw_snd_nxt+65535+2......返回1;}返回0;}上面代码的逻辑如下:当tcp_timestamp和tcp_tw_reuse开启时,Connect搜索一个端口时,只要这个端口TIME_WAIT状态的Socket记录的最新时间戳早>1s就可以重用此端口将之前的1分钟缩短为1秒。为了防止潜在的序号冲突,write_seq直接加在65537上。这样当单个Socket传输速率小于80Mbit/s时,序号不会重叠(冲突)。同时这个tw_ts_recent_stamp设置的时序如下图所示:所以如果Socket进入TIME_WAIT状态,如果一直有对应的包发送,会影响这个TIME_WAIT对应的端口的时间可用的。启用该参数后,由于从1分钟缩短为1秒,Nginx单对单上游可承受的TPS从原来的470.5TPS(28232/60)跃升至28232TPS,增加了60次。如果还是觉得性能不够,可以增加上述端口号的范围,减小tcp_max_tw_bucket,继续提升tps。但是,如果减小tcp_max_tw_bucket,可能会有序号重叠的风险。毕竟,Socket没有经过2MSL阶段就被重用了。不要启用tcp_tw_recycle。启用tcp_tw_recyle会对NAT环境产生很大的影响。建议不要启用它。具体可以看作者的另一篇博客:https://my.oschina.net/alchemystar/blog/3119992Nginxupstream改成longConnection其实上面这一系列的问题都是Nginx短连接到Backend造成的。从1.1.4开始,Nginx实现了对后端机器的长连接支持功能。Upstream中的这个配置可以开启长连接功能:upstreambackend{server127.0.0.1:8080;#需要特别注意的是keepalive指令不限制一个nginxworker进程可以打开的upstream服务器的连接总数。connections参数应设置为足够小的数字,以使上游服务器也能处理新的传入连接。keepalive32;keepalive_timeout30s;,大家又可以玩得开心了。由此产生的风险点是,由于单个远程ip:port耗尽,CPU将被占满。所以在Nginx中配置Upstream时需要格外小心。假设一个PE扩展一个Nginx的情况。为防止出现问题,先配置一个Backend,查看一下情况。压力,毕竟临界值是470.5TPS(28232/60)),甚至在同一个Nginx上请求非这个域名,也会因为CPU耗尽而得不到响应。多配置几个Backends/启用tcp_tw_reuse可能是个不错的选择。总结再强大的应用程序,仍然承载在内核之上,永远逃不出Linux内核的牢笼。所以调优Linux内核本身的参数是非常有意义的。如果你看过一些内核源码,无疑对我们排查线上问题有很大的帮助,也可以指导我们避免一些坑!关注作者公众号获取更多干货文章:
