Nginx源码入门指南

李乐

引子

  我们为什么需要学习Nginx呢?高性能,高稳定,优雅的模块化编程等就不提了,就说一个理由:Nginx是目前最受欢迎的web服务器,据统计,全球平均每3个网站,就有一个使用Nginx。如果你不懂Nginx,日常很多工作可能都无法开展。

  比如,最近看到过这么一句话:"Nginx master进程接收到客户端请求,转发给worker进程处理"。如果你不懂Nginx,就有可能也闹出类似的笑话。

  比如,当你需要搭建一套webserver时,可能基本的一些配置都一头雾水。location匹配规则你清楚吗,正则匹配、最大前缀匹配和精确匹配的顺序以及优先级你能记得住吗?反向代理proxy_pass以及fastcgi_pass你知道怎么配置吗?限流负载均衡等基本功能又怎么配置呢?一大堆配置有时真的让人脑仁疼。

  比如,线上环境Nginx曾出现"no live upstreams"。顾名思义,Nginx认为所有上游节点都挂掉了,此时Nginx直接向客户端返回502,而不会请求上游节点。这时候你该想想,Nginx是依据什么判断上游节点都挂掉了?出现这种错误后,Nginx又是怎么恢复的呢?

  再比如,线上环境高峰期Nginx还出现过这种错误:"Resource temporarily unavailable",对应的错误码是EAGAIN。非阻塞读写socket遇到EAGAIN不是通常稍等再尝试吗?其实通过源码里的一句注释就能瞬间明白了:"Linux returns EAGAIN instead of ECONNREFUSED for unix sockets if listen queue is full"。原来如此,Nginx和FPM之间是通过域套接字建立连接的,监听队列满了,系统直接返回的是EAGAIN,而不是我们平时了解的ECONNREFUSED;而Nginx在发起连接connect时,如果返回EAGAIN直接结束请求返回502。

  本篇文章旨在让你对Nginx能够有个系统的认识,了解其核心功能的实现思路,以及如何切入Nginx源码的学习。在遇到问题时,至少让你直到可以去哪寻找你想要的答案。主要涉及以下几个方面:

如何开始Nginx源码的学习; 模块化编程; master与worker进程模型; Nginx事件驱动模型; HTTP处理流程之11个阶段; location匹配规则; upstream与负载均衡; proxy_pass; fastcgi_pass与FPM; 限流; 案例分析:502问题分析。

如何开始Nginx源码的学习

  作为一名初学者,如何去上手阅读Nginx源码呢?这还不简单,从main方法入手,一行一行看呗。如何你这么做了,也坚持了一段时间,我给你点个赞,至少我是做不到的。不过,我相信大部分人是坚持不了几天的。数十万行Nginx源码,岂是短时间就能研究透的?如果长时间都没有取得明显成效,大多数人都会选择放弃吧。

  我一般是怎么阅读源码的呢?

  1)动手GDB,动手GDB,动手GDB;重要的话说三遍;

  逻辑比较晦涩,各种判断分支太复杂,回调handler不知道是什么。GDB调试其实真的很简单,b打断点,p命令看变量名称,bt命令看调用栈,c继续执行至下一个断点,n执行到下一行。笔者一般常用的也就这几个命令。

  2)带着问题去阅读,最好能带着答案去阅读。

  带着问题去阅读,就有了一条主线,只需要关注你需要关注的。比如Nginx是一个事件驱动程序,那么第一个问题就是去探索他的事件循环,事件循环中无非就是通过epoll_wait()等待事件的发生,然后执行事件回调handler。其余逻辑都可不必过多关注。

  有了答案,你就能更容易的切入到源码中,去探索他的实现思路。去哪里寻找答案呢?官网的开发者指南就比较详细的介绍了Nginx诸多功能的实现细节 ,包括代码布局介绍,基本数据结构介绍,事件驱动模型介绍,
HTTP处理流程基本概念介绍等等。通过这些介绍我们就能得到一些问题的答案。比如,事件循环的切入点是ngx_process_events_and_timers()函数,那你是不是就能在这个函数打断点,跟踪事件循环执行链路(http://nginx.org/en/docs/dev/...)。

  配置不明白,也可以查看官方文档 ,注意配置是按照模块分类的。我们以配置"keepalive_timeout"为例,官方文档有很清楚的介绍,http://nginx.org/en/docs/http... 。

  英文难以阅读怎么办?还是尽量阅读官方文档吧,更新及时,准确性高。或者,Nginx作为目前使用最广泛的web服务器,网络上相关博客文档也是非常多的,搜索"Nginx 事件循环",立刻就能得到你想要的答案。不过需要注意的是,网络上找到的答案,无法保证正确性,最好自己验证下。

  3)从点,到线,再到面,再到点

  同样的以事件循环为例,你从官方文档或者博客得到切入点是函数ngx_process_events_and_timers(),只有这一点信息怎么办?全局搜索代码,查看该函数的调用链路,比如我通过understand可以很容易得出其调用链,如下图:

image

  从一个切入点,你就能得到一条执行链路;不断的去探索新的问题,逐步你就能掌握了整个系统。

  待你对整个系统有了一定了解,还需要再度回归到具体的点上。毕竟,第一阶段阅读源码时,由于整体的掌握度不够,跳过了很多实现细节。

  比如,事件结构体ngx_event_s包括数十个标识类字段,当初都没有深究具体含义;比如,当我配置了N多个location匹配规则时,Nginx是从头到尾一个个遍历匹配吗?效率是不是优点低呢?比如,Nginx的多进程模型,master进程是如何管理以及监控work进程的,Nginx的平滑升级又是怎么实现的?进程间通信以及信号处理你了解吗?再比如,Nginx通过锁来解决多个worker的惊群效应,那么锁的实现原理是什么呢?

模块化编程

  在学习Nginx源码之前,最好了解一下其模块块编程思想。一个模块实现一个小小的功能,所有模块组合成了强大的Nginx。比如下表几个功能模块:

模称 功能 ngx_epoll_module 基于epoll的事件处理模块 ngx_http_limit_req_module 按请求qps限流 ngx_http_proxy_module 按HTTP协议转发请求到上游 ngx_http_fastcgi_module 按fastcgi协议转发请求到上游 ngx_http_upstream_ip_hash_module iphsh负载均衡 …… ……

  Nginx模块被划分为几大类,比如核心模块NGX_CORE_MODULE,事件模块NGX_EVENT_MODULE,HTTP模块NGX_HTTP_MODULE。结构体ngx_module_s定义了Nginx模块,我们重点需要关注这几个(类)字段:

钩子函数,比如init_master/exit_master在master进程启动/退出时回调;init_process/exit_process在work进程启动/退出时回调;init_module在模块初始化时候调用; 指令数组commands,其定义了该模块可以解析哪些配置;这个很好理解,功能由各个模块实现,与功能对应的配置也应该由各个模块解析处理; 模块上下文ctx,查看源码的话你会发现其类型为void*,那是因为不同类型的模块ctx定义不一样。ctx结构通常都定义了配置创建以及初始化回调;另外,事件模块还会定义事件处理(添加事件,修改事件,删除事件,事件循环)回调;HTTP模块还定义了HTTP处理流程的回调,这些回调会在HTTP流程 11个执行阶段调用。

  总结一句话,Nginx的框架已定义,主流程已知,各个模块只需要实现并注册流程中的回调即可,Nginx会在合适的时机执行这些回调。

  最后再来一副脑图简单列一下重点知识,还需要读者去进一步研究探索:

image

master/worker进程模型

  在讲解master/worker进程模型之前,我们先思考这么一个问题:假如配置worker_processes=1(work进程数目),执行下面几条命令后,Nginx还能否正常提供服务。

//master_pid即master进程id,work_pid即work进程pid

kill master_pid
kill -9 master_pid
kill work_pid
kill -9 work_pid

  注意,kill默认 发送SIGTERM(15)信号,用于通知进程需要被关闭,目标进程可以捕获该信号并做相应清理任务后退出;kill - 9表示强制杀死该进程,信号不能被捕获或忽略,同时接收该信号的进程在收到这个信号时不能执行任何清理。

  好了,请短暂的思考一分钟,或者自己动手验证下。下面我们揭晓答案:

------ 实验一:kill master_pid -----------
#ps aux | grep nginx
root      2314  0.0  0.0  25540  1472 ?        Ss   Aug18   0:00 nginx: master process /nginx/nginx-1.15.0/output/sbin/nginx-c /nginx/nginx-1.15.0/conf/nginx.conf
nobody   15243  0.0  0.0  31876  5880 ?        S    22:40   0:00 nginx: worker process

#kill 2314

#curl http://127.0.0.1/test -H "Host:proxypass.test.com"
curl: (7) Failed to connect to 127.0.0.1 port 80: Connection refused

#ps aux | grep nginx
//空,没有进程输出

------ 实验二:kill -9 master_pid -----------
# ps aux | grep nginx
root     15911  0.0  0.0  20544   680 ?        Ss   22:50   0:00 nginx: master process /nginx/nginx-1.15.0/output/sbin/nginx-c /nginx/nginx-1.15.0/conf/nginx.conf
nobody   15914  0.0  0.0  26880  5156 ?        S    22:50   0:00 nginx: worker process

kill -9 15911

# curl http://127.0.0.1/test -H "Host:proxypass.test.com"
hello world

#ps aux | grep nginx
nobody   15914  0.0  0.0  26880  5652 ?        S    22:50   0:00 nginx: worker process

------ 实验三:kill work_pid -----------
# ps aux | grep nginx
root     15995  0.0  0.0  20544   676 ?        Ss   22:52   0:00 nginx: master process /nginx/nginx-1.15.0/output/sbin/nginx-c /nginx/nginx-1.15.0/conf/nginx.conf
nobody   15997  0.0  0.0  26880  5152 ?        S    22:52   0:00 nginx: worker process

# kill 15997

# curl http://127.0.0.1/test -H "Host:proxypass.test.com"
hello world

# ps aux | grep nginx
root     15995  0.0  0.0  20544   860 ?        Ss   22:52   0:00 nginx: master process /nginx/nginx-1.15.0/output/sbin/nginx-c /nginx/nginx-1.15.0/conf/nginx.conf
nobody   16021  0.0  0.0  26880  5648 ?        S    22:52   0:00 nginx: worker process

------ 实验四:kill -9 work_pid -----------
# ps aux | grep nginx
root     15995  0.0  0.0  20544   860 ?        Ss   22:52   0:00 nginx: master process /nginx/nginx-1.15.0/output/sbin/nginx-c /nginx/nginx-1.15.0/conf/nginx.conf
nobody   16021  0.0  0.0  26880  5648 ?        S    22:52   0:00 nginx: worker process

# kill -9 16021

# curl http://127.0.0.1/test -H "Host:proxypass.test.com"
hello world

# ps aux | grep nginx
root     15995  0.0  0.0  20544   860 ?        Ss   22:52   0:00 nginx: master process /nginx/nginx-1.15.0/output/sbin/nginx-c /nginx/nginx-1.15.0/conf/nginx.conf
nobody   16076  0.0  0.0  26880  5648 ?        S    22:54   0:00 nginx: worker process
实验一:kill master_pid,结果是Connection refused,只因master和work进程都退出了,没有进程监听80端口;kill的是master进程,为什么work进程也退出了呢?其实是master进程让work进程退出的。 实验二:kill -9 master_pid,Nginx还能正常提供HTTP服务,此时master进程退出,work进程还在;到这里就能说明,HTTP请求由work进程处理,与master进程无关;另外,为什么这里master进程没有让work进程退出呢?这就是kill -9的特点了,master进程不能捕获该信号做清理工作; 实验三:kill work_pid,发现Nginx还是能正常提供HTTP服务,而且work进程竟然还健在?再仔细看看,kill之后的work进程是16021,之前是15997;原来是Nginx启动了新的work进程,其实是master进程在监听到work进程退出后,会拉起新的work进程; 实验四:kill -9 work_pid同实验三。

  通过上面的四个实验,我们可以初步得到以下结论:1)master进程在监听到work进程退出后,会拉起新的进程,而master进程在退出时(kill方式),会销毁所有work进程;其实就一句话,master进程主要用于管理work进程。2)work进程用于接收并处理HTTP请求。

  下面我们将简要介绍master/work进程处理流程。

  master进程被fork后,会执行主处理函数ngx_master_process_cycle函数,主要工作如下图所示:

image

  注意我们上面的描述『master进程被fork后』,被谁fork呢?其实这是标准的daemon守护进程启动方式:两次fork+setsid。

  可以看到,master进程在主循环中等待信号的到达,信号处理函数为ngx_signal_handler。另外需要清楚的是,子进程在退出时,会向父进程发送信号SIGCHLD;master进程就是通过该信号来监听work进程的异常退出,从而拉起新的work进程。

  最后还有一个问题,master进程在接收到退出信号时,如何告知work进程退出呢?这里可以使用信号吗?其实这里是通过socketpair向work进程发送消息的。至于为什么不用信号呢,就和work进程事件处理框架有关了。

  最后总结下,操作Nginx时,常用的信号如下列表:

信号 Nginx内置shell 说明 SIGUSR1 nginx -s reopen 重新打开文件,可配合mv实现日志切割 SIGHUP nginx -s reload 重新加载配置 SIGQUIT nginx -s quit 平滑退出 SIGTERM nginx -s stop 强制退出 SIGUSR2 + SIGWINCH + SIGQUIT 无 平滑升级二进制程序

  work进程用于接收并处理HTTP请求,主函数为ngx_worker_process_cycle。需要注意,master进程创建socket并启动监听,work进程只是将listen_fd加入到自己的监听列表(epoll_ctl)。问题来了,如果多个work进程同时监听listen_fd的可读事件,新的连接请求到达时,Linux内核会唤醒哪个work进程并交由其处理呢?这就是所谓的『惊群』效应了。

  Nginx是通过『锁』实现的,work进程在监听listen_fd的可读事件之前,需要获取到锁;没有锁,work进程会将listen_fd从自己的监听列表中移除。读者可以阅读函数ngx_process_events_and_timers(事件循环主函数),
加锁函数为为ngx_trylock_accept_mutex,基于共享内存 + 原子操作实现。

  在加锁的时候,Nginx还有一些负载均衡策略;每个work进程启动的时候,都会初始化若干ngx_connection_t结构,连接数可通过worker_connections配置,如果当前work进程的空闲连接数小于总数的1/8,则会在近几次事件循环中不获取锁。

  新版本Linux内核已经解决了惊群效应,不需要加锁了;是否加锁也可以通过accept_mutex配置,官方解释如下:

There is no need to enable accept_mutex on systems that support the EPOLLEXCLUSIVE flag (1.11.3) or when using reuseport.

  好了,到这里你对master/worker进程模型也有了一定了解了,我们可以简单画个示意图:

image

  最后,脑图奉上:

image

Nginx事件驱动模型

  Nginx作为webserver,就是接收客户端请求,转发到上游,再转发上游响应结果给客户端,其中必然伴随着大量的IO交互,没有一个高效的IO复用模型如何能行?另外在等待客户端请求,等待上游响应时,通常还伴随着一些超时事件(时间事件)。

  我们所说的事件驱动,就是指IO事件以及时间事件驱动,没有事件时候服务通常会阻塞等待事件的发生。不止是Nginx,一般事件驱动程序都是如下类似的套路:

for {
    //查找最近的时间事件timer
    
    //epoll_wait等待IO事件的发生(最大等待时间timer)
    
    //处理IO事件
    
    //处理时间事件
}

  1)查找最近的时间事件:一般肯定存在N多个时间事件,那么这些时间事件最好是按照触发时间排好序的,不然每次都需要遍历。通常会选择红黑树或者最小堆实现。

  2)等待IO事件的发生:目前都是基于IO多路复用模型,比如Linux系统使用epoll实现;对于epoll,最好研究下其红黑树+双向链表+水平触发/边缘触发。epoll的使用还是非常简单的,就3个API:

//创建epoll对象
int epoll_create(int size);

//往epoll添加需要监听的fd(这时候就依赖红黑树了)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

//阻塞等待事件发生,最大等待timeout时间
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

  上一小节讲过,worker进程的主函数是ngx_worker_process_cycle,事件循环主函数是ngx_process_events_and_timers,从这两个函数中很容易找到其事件循环逻辑。

static void ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data){
    for ( ;; ) {
        //事件循环
        ngx_process_events_and_timers(cycle);
        
        //信号处理,如reopen,quit等
    }
}

void ngx_process_events_and_timers(ngx_cycle_t *cycle){
    //查找最近的时间事件
   timer = ngx_event_find_timer();
   
   //抢锁,成功后才监听listen_fd
   //(参照函数ngx_enable_accept_events,基于epoll_ctl将listen_fd加入epoll)
   ngx_trylock_accept_mutex(cycle)
   
   //阻塞等待IO事件的发生,底层基于epoll_wait实现
   (void) ngx_process_events(cycle, timer, flags);
   
   //accept处理连接事件
   ngx_event_process_posted(cycle, &ngx_posted_accept_events);
   
   //处理时间事件
   ngx_event_expire_timers();
   
   //处理普通读写事件
   ngx_event_process_posted(cycle, &ngx_posted_events);
}

  事件循环我们有一定了解了,还需要关注几个核心结构体,比如连接对象ngx_connection_t,事件对象ngx_event_t。worker进程在启动时刻,就会创建若干个连接对象以及事件对象,创建的数目可通过worker_connections配置。连接对象与事件对象示意图如下:

image

  最后,脑图奉上:

image

HTTP处理流程之11个阶段

  Nginx处理客户端连接请求事件的handler是ngx_event_accept,同时对于listen_fd,还设置有连接初始化函数ngx_http_init_connection,该函数开始了HTTP请求的解析工作:

void ngx_http_init_connection(ngx_connection_t *c)
{
    c->read = ngx_http_wait_request_handler;
    c->write->handler = ngx_http_empty_handler;
 
    ngx_add_timer(rev, c->listening->post_accept_timeout);
}

  解析HTTP请求的处理流程如下图所示:

image

  下面就开始处理HTTP请求了。Nginx将HTTP请求处理流程分为11个阶段,绝大多数HTTP模块都会将自己的handler添加到某个阶段(将handler添加到全局唯一的数组phases中),nginx处理HTTP请求时会挨个调用每个阶段的handler。需要注意的是其中有4个阶段不能添加自定义handler。11个阶段定义如下:

typedef enum {
    NGX_HTTP_POST_READ_PHASE = 0, 
  
    NGX_HTTP_SERVER_REWRITE_PHASE,  //server块中配置了rewrite指令,重写url
  
    NGX_HTTP_FIND_CONFIG_PHASE,   //查找匹配的location配置;不能自定义handler;
    NGX_HTTP_REWRITE_PHASE,       //location块中配置了rewrite指令,重写url
    NGX_HTTP_POST_REWRITE_PHASE,  //检查是否发生了url重写,如果有,重新回到FIND_CONFIG阶段;不能自定义handler;
  
    NGX_HTTP_PREACCESS_PHASE,     //访问控制,比如限流模块会注册handler到此阶段
  
    NGX_HTTP_ACCESS_PHASE,        //访问权限控制,比如基于ip黑白名单的权限控制,基于用户名密码的权限控制等
    NGX_HTTP_POST_ACCESS_PHASE,   //根据访问权限控制阶段做相应处理;不能自定义handler;
  
    NGX_HTTP_TRY_FILES_PHASE,     //只有配置了try_files指令,才会有此阶段;不能自定义handler;
    NGX_HTTP_CONTENT_PHASE,       //内容产生阶段,返回响应给客户端
  
    NGX_HTTP_LOG_PHASE            //日志记录
} ngx_http_phases;
NGX_HTTP_POST_READ_PHASE:第一个阶段,ngx_http_realip_module模块会注册handler到该阶段(nginx作为代理服务器时有用,后端以此获取客户端原始IP),而该模块默认不会开启,需要通过--with-http_realip_module启动; NGX_HTTP_SERVER_REWRITE_PHASE:server块中配置了rewrite指令时,该阶段会重写url; NGX_HTTP_FIND_CONFIG_PHASE:查找匹配的location配置;该阶段不能自定义handler; NGX_HTTP_REWRITE_PHASE:location块中配置了rewrite指令时,该阶段会重写url; NGX_HTTP_POST_REWRITE_PHASE:该阶段会检查是否发生了url重写,如果有,重新回到FIND_CONFIG阶段,否则直接进入下一个阶段;该阶段不能自定义handler; NGX_HTTP_PREACCESS_PHASE:访问控制,比如限流模块ngx_http_limit_req_module会注册handler到该阶段; NGX_HTTP_ACCESS_PHASE:访问权限控制,比如基于ip黑白名单的权限控制,基于用户名密码的权限控制等; NGX_HTTP_POST_ACCESS_PHASE:该阶段会根据访问权限控制阶段做相应处理,不能自定义handler; NGX_HTTP_TRY_FILES_PHASE:只有配置了try_files指令,才会有此阶段,不能自定义handler; NGX_HTTP_CONTENT_PHASE:内容产生阶段,用于产生响应结果;ngx_http_fastcgi_module模块就处于该阶段; NGX_HTTP_LOG_PHASE:该阶段会记录日志;

  HTTP模块通常都有回调postconfiguration,用于注册本模块的handler到某个处理阶段,Nginx在解析完成http配置块后,会遍历所有HTTP模块并注册handler到相应阶段,后续处理HTTP请求时只需遍历执行该所有handler即可。

  注意,这里的图是二维数组,后续还会将其转化微一维数组,便于遍历执行所有handler。

image

  在这11个阶段里,我们需要重点关注内容产生阶段NGX_HTTP_CONTENT_PHASE,这是HTTP请求处理的第10个阶段,用于产生响应结果;一般情况有3个模块注册handler到此阶段:ngx_http_static_module、ngx_http_autoindex_module和ngx_http_index_module。

  但是当我们配置了proxy_pass和fastcgi_pass时,执行流程会有所不同。此时会设置请求的回调content_handler,当Nginx执行到内容产生阶段时,如果content_handler不为空,则执行此回调,不再执行其他handler。

ngx_int_t ngx_http_core_content_phase(ngx_http_request_t *r,
    ngx_http_phase_handler_t *ph)
{
    if (r->content_handler) {  //如果请求对象的content_handler字段不为空,则调用
        r->write_event_handler = ngx_http_request_empty_handler;
        ngx_http_finalize_request(r, r->content_handler(r));
        return NGX_OK;
    }
 
    rc = ph->handler(r);  //否则执行内容产生阶段handler
}

  HTTP处理流程就简单介绍到这里,还有很多细节需要读者继续探索。最后,脑图奉上。

image

location匹配规则

  location用于匹配特定的请求URI,配置解析见文档http://nginx.org/en/docs/http...。基本语法为:

location [=|~|~*|^~] uri{……}
location @name { ... }

其中:

“=”用于定义精确匹配规则,请求URI与配置的uri模式完全匹配才能生效; “ ~ ”和“ ~* ”分别定义区分大小写的正则匹配规则和不区分大小写的正则匹配规则,正则匹配成功时,立即结束location查找过程; “^~”用于定义最大前缀匹配规则,该类型location即使匹配成功也不会结束location查找过程,依然会查找匹配长度更长的location。另外,只包含uri的location依然为最大前缀匹配。 “@”用于定义命令location,此类型location不能匹配常规客户端请求,只能用于内部请求重定向。

  以“ ^~ ”开始的匹配模式与只包含uri的匹配模式都表示最大前缀匹配规则,这两者有什么区别呢?以“ ^~ ”开始的location在匹配成功时,不会再执行后续的正则匹配,直接选择该location配置。只包含uri的location在匹配成功时,依然会执行后续的正则匹配,只有当正则匹配不成功时,才会选择该location;否则,会选择正则类型location。

  另外,通常使用“location /”用于定义通用匹配,任何未匹配到其他location的请求都会匹配到。如果某请求URI未匹配到任何location,Nginx会返回404。

  每个虚拟server都可以配置多个location块;客户端请求到达时,需要遍历所有location,检测请求URI是否与location配置相匹配。那么当location配置数目较多时,匹配效率如何保障?遍历方式显然是不可行的。解析完成location配置时,多个location的存储结构是双向链表,该结构需要进行再处理,优化为查找匹配效率更高的结构。

  思考下,正则匹配只能逐个遍历,没有更优的查找匹配算法或数据结构;因此,所有正则匹配的location不需要特殊处理,只是从双向链表中裁剪出来,另外存储在regex_locations数组。

  双向链表中最后只剩下精确匹配与最大前缀匹配,该类型location查找只能基于字符串匹配。Nginx将剩余的这些location存储为树形结构(三叉树),每个节点node都有三个子节点,left、tree和right。left小于node;right大于node;tree与node前缀相同,且tree节点的uri长度大于node节点的uri长度。三叉树的转换过程由函数ngx_http_init_static_location_trees实现,这里不做过多介绍。三叉树结构类似于下图所示:

image

  location匹配过程处于HTTP处理流程第3阶段NGX_HTTP_FIND_CONFIG_PHASE,location匹配逻辑由函数ngx_http_core_find_location实现,有兴趣的读者可以查看学习。

  最后,脑图奉上:

image

upstream与负载均衡

  Nginx反向代理是其最重要最常用的功能:Nginx转发客户端请求到上游服务,接手上游服务响应并转发给客户端。而upstream使得Nginx可以成为一台反向代理服务器,并且upstream还提供了负载均衡能力,可以将请求按照某种策略均匀的分发到上游服务。

  提到upstream,就不得不提一个很重要的结构体ngx_http_upstream_t,该结构主要包括Nginx与上游的连接对象,读写事件,缓冲区buffer,请求创建/请求处理等回调函数,等等,如下所示:

struct ngx_http_upstream_s {
    //读写事件处理handler
    ngx_http_upstream_handler_pt     read_event_handler;
    ngx_http_upstream_handler_pt     write_event_handler;

    //该结构封装了连接获取/释放handler,不同负载均衡策略对应不同handler
    ngx_peer_connection_t            peer;

   //各种缓冲区buffer

   //回调handler:创建请求,处理上游返回头等的回调。
   //proxypass与fastcgipass都实现了这些回调
    ngx_int_t                      (*create_request)(ngx_http_request_t *r);
    ngx_int_t                      (*reinit_request)(ngx_http_request_t *r);
    ngx_int_t                      (*process_header)(ngx_http_request_t *r);
    void                           (*abort_request)(ngx_http_request_t *r);
    void                           (*finalize_request)(ngx_http_request_t *r,
                                         ngx_int_t rc);
    ngx_int_t                      (*rewrite_redirect)(ngx_http_request_t *r,
                                         ngx_table_elt_t *h, size_t prefix);
    ngx_int_t                      (*rewrite_cookie)(ngx_http_request_t *r,
                                         ngx_table_elt_t *h);
};

  upstream流程主要包含的步骤以及处理函数如下表:

步骤 处理函数 初始化upstream ngx_http_upstream_create()、 ngx_http_upstream_init() 与上游建立连接 ngx_http_upstream_connect() 发送请求到上游 ngx_http_upstream_send_request() 处理上游响应头 ngx_http_upstream_process_header() 处理上游响应体(边接收边转发) ngx_http_upstream_send_response() 结束请求 ngx_http_upstream_finalize_reques() 可能的重试 ngx_http_upstream_next()

  针对upstream处理流程,我们需要思考以下几个问题:

proxypass与fastcgipass处理回调是在什么时候设置的?我们已proxypass为例,在配置proxy_pass指令后,内容产生阶段处理函数为ngx_http_proxy_handler;该处理函数开启了upstream的主流程,创建并初始化upstream,同时设置了请求处理回调。 负载均衡对应的处理回调是在什么时候确定的?同样的,也是在解析配置文件的时候,根据不同的配置指令,初始化不同的负载均衡策略。

  默认的负载均衡策略为加权轮询,其初始化函数为ngx_http_upstream_init_round_robin;我们也可以通过配置指令设置指定的负载均衡策略,如下表:

指令 初始化函数 默认 ngx_http_upstream_init_round_robin hash key ngx_http_upstream_init_hash ip_hash ngx_http_upstream_init_ip_hash least_conn ngx_http_upstream_init_least_conn Nginx在转发请求包体到上游服务时候,

是需要接收到完整的请求体之后转发,还是可以边接收边转发呢?可以通过指令proxy_request_buffering配置:

Syntax:    proxy_request_buffering on | off;
Default:    
proxy_request_buffering on;
Context:    http, server, location
This directive appeared in version 1.7.11.

When buffering is enabled, the entire request body is read from the client before sending the request to a proxied server.

When buffering is disabled, the request body is sent to the proxied server immediately as it is received. In this case, the request cannot be passed to the next server if nginx already started sending the request body.

  这里要特别注意,当proxy_request_buffering设置为off时,如果请求转发给上游出错(或者上游处理错误等),该请求将不能再转发给其他上游进行重试。因为Nginx没有缓存请求体。

Nginx处理完上游响应头部后,就可以开始将返回结果转发给客户端,并不需要等到完整接收上游响应体,只需要边接受响应体,边返回给客户端即可。详细的逻辑可以参考函数ngx_http_upstream_send_response()。 Nginx在某个上游服务器不可用的情况下,可以重新选择一个上游服务器,并建立连接,将请求转发给新的上游服务器。具体在这几个阶段出现错误时,都可以进行重试:1)连接upstream失败;2)发送请求到上游服务器失败;3)处理上游响应头失败。需要注意,如果Nginx已经开始处理上游响应体,此时出现错误,则会直接结束这次与上游服务器的交互,并结束这次请求,不会再选择新的上游服务器重试;这里是因为Nginx可能已经发送了部分数据到客户端。

  另外还需要关注这两个配置,用于配置在什么错误下重试,以及重试次数:

Syntax:    proxy_next_upstream_tries number;
Default:    
proxy_next_upstream_tries 0;
Context:    http, server, location
This directive appeared in version 1.7.5.

Limits the number of possible tries for passing a request to the next server. The 0 value turns off this limitation.


Syntax:    proxy_next_upstream error | timeout | invalid_header | http_500 | http_502 | http_503 | http_504 | http_403 | http_404 | http_429 | non_idempotent | off ...;
Default:    
proxy_next_upstream error timeout;
Context:    http, server, location

Specifies in which cases a request should be passed to the next server:
线上环境,Nginx与上游服务器通常建立的是长连接,与长连接紧密相关的有三个配置:1)keepalive connections,限制最大空闲连接数(不是最大可建立的连接数),高并发情况下实际建立的连接数可能比这多;2)keepalive_requests number,Nginx版本需高于1.15.3,每个连接上最多可处理请求数;3)keepalive_timeout timeout,Nginx版本需高于1.15.3,空闲连接超时时间。

  长连接由模块ngx_http_upstream_keepalive_module实现,配置后会替换上面介绍的负载均衡初始化函数为ngx_http_upstream_init_keepalive_peer。

将Nginx作为反向代理服务器时,还需要注意与上游服务器交互的一些超时配置;默认的超时时间是60秒,对于大部分业务来说都是过长的。次如:
Syntax:    proxy_connect_timeout time;
Default: proxy_connect_timeout 60s;

Syntax:    proxy_send_timeout time;
Default: proxy_send_timeout 60s;

Syntax:    proxy_read_timeout time;
Default: proxy_read_timeout 60s;

  曾经线上环境就看到过一起非常不合理的配置,超时时间都是60秒,重试次数proxy_next_upstream_tries不限制。业务高峰期出现突发慢请求,处理时间超过60秒,网关记录HTTP状态504;同时又选择下一台上游服务器重试,直到遍历完所有的上游服务器为止。最终这个请求的响应时间为900秒!

最后不得不提的是Nginx的上游服务器剔除机制;当同一台上游服务器失败次数过多时,Nginx会短暂认为该上游服务器不可用,在一定时间内不会再将请求转发到该上游服务器。该策略由两个配置决定(详情:http://nginx.org/en/docs/http...):
Syntax:    server address [parameters];
Default:    —
Context:    upstream

max_fails=number
sets the number of unsuccessful attempts to communicate with the server that should happen in the duration set by the fail_timeout parameter to consider the server unavailable for a duration also set by the fail_timeout parameter. 
By default, the number of unsuccessful attempts is set to 1. 
The zero value disables the accounting of attempts. 
What is considered an unsuccessful attempt is defined by the proxy_next_upstream, fastcgi_next_upstream, uwsgi_next_upstream, scgi_next_upstream, memcached_next_upstream, and grpc_next_upstream directives.

fail_timeout=time
sets the time during which the specified number of unsuccessful attempts to communicate with the server should happen to consider the server unavailable;
and the period of time the server will be considered unavailable.
By default, the parameter is set to 10 seconds.

  需要特别注意,一旦Nginx认为所有上游服务器都不可用,在接收到客户端请求时,会直接返回502,不会尝试请求任何一台上游服务起。此时Nginx会记录日志"no live upstreams"。所以,线上环境,最好将非核心或者一些慢接口隔离到不同的upstream,以防这些接口的错误影响其他核心接口。

  upstream的主流程以及一些注意点上面也介绍了,对于想了解一些实现细节的,可以参照下面的脑图,去探索源码:

image

fastcgi_pass与FPM

  当我们配置了fastcgi_pass指令后,Nginx会将请求转发给上游FPM处理。fastcgi协议用于Nginx与FPM之间的交互,不同于HTTP协议(以"\r\n"作为分隔符解析),fastcgi协议在发送请求之前,先发送固定结构的头部信息,包含该请求数据的类型以及长度等等。

  fastcgi协议消息头定义如下:

typedef struct {
    u_char  version; //FastCGI协议版本
    u_char  type;    //消息类型
    u_char  request_id_hi; //请求ID
    u_char  request_id_lo;
    u_char  content_length_hi; //内容长度
    u_char  content_length_lo;
    u_char  padding_length;    //内容填充长度
    u_char  reserved;          //保留
} ngx_http_fastcgi_header_t;

  Nginx对fastcgi消息类型定义如下:

#define NGX_HTTP_FASTCGI_BEGIN_REQUEST  1
#define NGX_HTTP_FASTCGI_ABORT_REQUEST  2
#define NGX_HTTP_FASTCGI_END_REQUEST    3
#define NGX_HTTP_FASTCGI_PARAMS         4
#define NGX_HTTP_FASTCGI_STDIN          5
#define NGX_HTTP_FASTCGI_STDOUT         6
#define NGX_HTTP_FASTCGI_STDERR         7
#define NGX_HTTP_FASTCGI_DATA           8

  一般情况下,最先发送的是BEGIN_REQUEST类型的消息,然后是PARAMS和STDIN类型的消息;当FastCGI响应处理完后,将发送STDOUT和STDERR类型的消息,最后以END_REQUEST表示请求的结束。

  fastcgi协议的请求与响应结构示意图如下:

image

  配置fastcgi_pass指令后,会设置内容产生阶段处理函数为ngx_http_fastcgi_handler;函数ngx_http_fastcgi_create_request创建fastcgi请求;
函数ngx_http_fastcgi_process_record解析FPM的响应结果。

  我们可以GDB调试打印输出/输入数据,便于更好的理解fastcgi协议。添加断点如下:

Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000418f05 in ngx_process_events_and_timers at src/event/ngx_event.c:203 inf 3, 2, 1
    breakpoint already hit 17 times
2       breakpoint     keep y   0x000000000045b7fa in ngx_http_fastcgi_create_request at src/http/modules/ngx_http_fastcgi_module.c:735 inf 3, 2, 1
    breakpoint already hit 4 times
3       breakpoint     keep y   0x000000000045c2af in ngx_http_fastcgi_create_request at src/http/modules/ngx_http_fastcgi_module.c:1190 inf 3, 2, 1
    breakpoint already hit 4 times
4       breakpoint     keep y   0x000000000045a573 in ngx_http_fastcgi_process_record at src/http/modules/ngx_http_fastcgi_module.c:2145 inf 3, 2, 1
    breakpoint already hit 1 time

  执行到ngx_http_fastcgi_create_request函数结尾(断点3),打印r->upstream->request_bufs三个buf:

(gdb) p *r->upstream->request_bufs->buf->pos@1000
$18 =
\001\001\000\001\000\b\000\000                  //8字节头部,type=1(BEGIN_REQUEST)
\000\001\000\000\000\000\000\000                //8字节BEGIN_REQUEST数据包
\001\004\000\001\002\222\006\000                //8字节头部,type=4(PARAMS),数据内容长度=2*256+146=658(不是8字节整数倍,需要填充6个字节)
\017\025SCRIPT_FILENAME/home/xiaoju/test.php    //key-value,格式为:keylen+valuelen+key+value
\f\000QUERY_STRING\016\004REQUEST_METHODPOST
\f!CONTENT_TYPEapplication/x-www-form-urlencoded
\016\002CONTENT_LENGTH19
\v\tSCRIPT_NAME/test.php
\v\nREQUEST_URI//test.php
\f\tDOCUMENT_URI/test.php
\r\fDOCUMENT_ROOT/home/xiaoju
\017\bSERVER_PROTOCOLHTTP/1.1
\021\aGATEWAY_INTERFACECGI/1.1
\017\vSERVER_SOFTWAREnginx/1.6.2
\v\tREMOTE_ADDR127.0.0.1
\v\005REMOTE_PORT54276
\v\tSERVER_ADDR127.0.0.1
\v\002SERVER_PORT80
\v\tSERVER_NAMElocalhost
\017\003REDIRECT_STATUS200
\017dHTTP_USER_AGENTcurl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.27.1 zlib/1.2.3 libidn/1.18 libssh2/1.4.2
\t\tHTTP_HOSTlocalhost
\v\003HTTP_ACCEPT*/*
\023\002HTTP_CONTENT_LENGTH19
\021!HTTP_CONTENT_TYPEapplication/x-www-form-urlencoded
\000\000\000\000\000\000           //6字节内容填充
\001\004\000\001\000\000\000\000   //8字节头部,type=4(PARAMS),表示PARAMS请求结束
\001\005\000\001\000\023\005\000   //8字节头部,type=5(STDIN),请求体数据长度19个字节
 
 
(gdb) p *r->upstream->request_bufs->next->buf->pos@20
$19 = "name=hello&gender=1"   //HTTP请求体,长度19字节,需填充5个字节
 
 
(gdb) p *r->upstream->request_bufs->next->next->buf->pos@20
$20 =
\000\000\000\000\000            //5字节填充
\001\005\000\001\000\000\000    //8字节头部,type=5(STDIN),表示STDIN请求结束

  执行到方法ngx_http_fastcgi_process_record,打印读入请求数据:

p *f->pos@1000
$26 =
\001\006\000\001\000\377\001\000  //8字节头部,type=6(STDOUT),返回数据长度为255字节(需要填充1个字节)
Set-Cookie: PHPSESSID=3h9lmb2mvp6qlk1rg11id3akd3; path=/\r\n    //返回数据内容,以换行符分隔
Expires: Thu, 19 Nov 1981 08:52:00 GMT\r\n
Cache-Control: no-store, no-cache, must-revalidate\r\n
Pragma: no-cache\r\n
Content-type: text/html; charset=UTF-8\r\n
\r\n
{\"ret-name\":\"ret-hello\",\"ret-gender\":\"ret-1\"}
\000
\001\003\000\001\000\b\000\000   //8字节头部,type=3(END_REQUEST),表示fastcgi请求结束,数据长度为8
\000\000\000\000\000\000\000\000    //8字节END_REQUEST数据

proxy_pass

  当我们配置了proxy_pass指令后,Nginx会将请求转发给上游HTTP服务处理;此时设置内容产生阶段处理函数为ngx_http_proxy_handler。

  这里我们简单介绍下长连接的配置以及注意事项。下面配置使得Nginx与上游HTTP服务保持长连接:

upstream  proxypass.test.com{
        server 127.0.0.1:8080;
        keepalive 10;
}
server {
      listen 80;
      server_name proxypass.test.com ;

      access_log /home/nginx/logs/proxypass.test.com_access.log main;
      location /  {
          proxy_pass http://proxypass.test.com;
          proxy_http_version 1.1;
          proxy_set_header Connection "keep-alive";
      }
}

  注意upstream配置块里的配置指令keepalive,官方文档如下:

Syntax:    keepalive connections;
This directive appeared in version 1.1.4.

The connections parameter sets the maximum number of idle keepalive connections to upstream servers that are preserved in the cache of each worker process.

It should be particularly noted that the keepalive directive does not limit the total number of connections to upstream servers that an nginx worker process can open. The connections parameter should be set to a number small enough to let upstream servers process new incoming connections as well.

  每个work都有长连接缓存池,而keepalive配置的就是缓存池忠最大空闲连接的数目,注意是空闲连接,并不是限制Nginx与上游HTTP建立的连接总数目。

  proxy_http_version配置使用1.1版本HTTP协议;proxy_set_header配置HTTP头部Connection为keep-alive(Nginx默认Connection:close,即使使用的是1.1版本HTTP协议)。

  在使用长连接时,一定要特别注意;如果上游主动关闭连接,而此时恰好Nginx发起请求,可能会出现502(线上曾经出现过偶发502现象),而且出现502的概率较低,现场难以捕获。

  长连接上游为什么会主动关闭连接呢?比如,在Golang服务中,通常会配置IdleTimeout,当长连接长时间空闲时后,Golang会主动关闭该长连接。如下面的抓包实例,前两次请求公用同一个TCP连接,但是当长时间没有新的请求到达时,Golang会主动关闭该长连接。如果此时恰好Nginx发起新的请求,就有可能造成异常情况。

//建立连接
10:24:16.240952 IP 127.0.0.1.31451 > 127.0.0.1.8080: Flags [S], seq 388101088, win 43690, length 0
10:24:16.240973 IP 127.0.0.1.8080 > 127.0.0.1.31451: Flags [S.], seq 347995396, ack 388101089, win 43690, length 0
10:24:16.240994 IP 127.0.0.1.31451 > 127.0.0.1.8080: Flags [.], ack 1, win 86, length 0

//第一次请求
10:24:16.241052 IP 127.0.0.1.31451 >127.0.0.1.8080: Flags [P.], seq 1:111, ack 1, win 86, length 110: HTTP: GET /test HTTP/1.1
10:24:16.241061 IP 127.0.0.1.8080 > 127.0.0.1.31451: Flags [.], ack 111, win 86, length 0
10:24:17.242332 IP 127.0.0.1.8080 > 127.0.0.1.31451: Flags [P.], seq 1:129, ack 111, win 86, length 128: HTTP: HTTP/1.1 200 OK
10:24:17.242371 IP 127.0.0.1.31451 > 127.0.0.1.8080: Flags [.], ack 129, win 88, length 0

//隔几秒第二次请求,没有新建连接
10:24:24.536885 IP 127.0.0.1.31451 > 127.0.0.1.8080: Flags [P.], seq 111:221, ack 129, win 88, length 110: HTTP: GET /test HTTP/1.1
10:24:24.536914 IP 127.0.0.1.8080 > 127.0.0.1.31451: Flags [.], ack 221, win 86, length 0
10:24:25.537928 IP 127.0.0.1.8080 > 127.0.0.1.31451: Flags [P.], seq 129:257, ack 221, win 86, length 128: HTTP: HTTP/1.1 200 OK
10:24:25.537957 IP 127.0.0.1.31451 > 127.0.0.1.8080: Flags [.], ack 257, win 90, length 0

//上游主动断开长连接
10:25:25.538408 IP 127.0.0.1.8080 > 127.0.0.1.31451: Flags [F.], seq 257, ack 221, win 86, length 0
10:25:25.538760 IP 127.0.0.1.31451 > 127.0.0.1.8080: Flags [F.], seq 221, ack 258, win 90, length 0
10:25:25.538792 IP 127.0.0.1.8080 > 127.0.0.1.31451: Flags [.], ack 222, win 86, length 0

  Nginx在与上游建立长连接时,也有一个配置,用于设置长连接超时时间:

Syntax:    keepalive_timeout timeout;
Default:    
keepalive_timeout 60s;
Context:    upstream
This directive appeared in version 1.15.3.

Sets a timeout during which an idle keepalive connection to an upstream server will stay open.

  需要注意,这是配置在upstream配置块的,而且必须Nginx版本高于1.15.3。

  http/server/location配置块还有一个配置keepalive_timeout,其配置的是与客户端的长连接超时时间:

Syntax:    keepalive_timeout timeout [header_timeout];
Default:    
keepalive_timeout 75s;
Context:    http, server, location

The first parameter sets a timeout during which a keep-alive client connection will stay open on the server side. The zero value disables keep-alive client connections. 

限流

  限流的目的是通过对并发访问/请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务(定向到错误页)、排队等待(秒杀)、或者降级(返回兜底数据或默认数据)。通常限流策略有:限制瞬时并发数(如Nginx的ngx_http_limit_conn_module模块,用来限制瞬时并发连接数)、限制时间窗口内的平均速率(如Nginx的ngx_http_limit_req_module模块,用来限制每秒的平均速率)。另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。

  常用的限流算法有计数器(简单粗暴,升级版是滑动窗口算法)漏桶算法,以及令牌桶算法。下面简单介绍令牌桶算法。如下图所示,令牌桶是一个存放固定容量令牌的桶,按照固定速率r往桶里添加令牌;桶中最多存放b个令牌,当桶满时,新添加的令牌被丢弃;当一个请求达到时,会尝试从桶中获取令牌;如果有,则继续处理请求;如果没有则排队等待或者直接丢弃。如下图:

image

  限流模块会注册handler到HTTP处理流程的NGX_HTTP_PREACCESS_PHASE阶段。如ngx_http_limit_req_module模块注册ngx_http_limit_req_handler;函数ngx_http_limit_req_handler执行限流算法,判断是否超出配置的限流速率,从而进行丢弃或者排队或者通过。

  ngx_http_limit_req_module模块提供以下配置指令,供用户配置限流策略:

//每个配置指令主要包含两个字段:名称,解析配置的处理方法
static ngx_command_t  ngx_http_limit_req_commands[] = {
 
    //一般用法:limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
    //$binary_remote_addr表示远程客户端IP;
    //zone配置一个存储空间(需要分配空间记录每个客户端的访问速率,超时空间限制使用lru算法淘汰;注意此空间是在共享内存分配的,所有worker进程都能访问)
    //rate表示限制速率,此例为1qps
    { ngx_string("limit_req_zone"),
      ngx_http_limit_req_zone,
     },
 
    //用法:limit_req zone=one burst=5 nodelay;
    //zone指定使用哪一个共享空间
    //超出此速率的请求是直接丢弃吗?burst配置用于处理突发流量,表示最大排队请求数目,当客户端请求速率超过限流速率时,请求会排队等待;而超出burst的才会被直接拒绝;
    //nodelay必须与burst一起使用;此时排队等待的请求会被优先处理;否则假如这些请求依然按照限流速度处理,可能等到服务器处理完成后,客户端早已超时
    { ngx_string("limit_req"),
      ngx_http_limit_req,
     },
 
    //当请求被限流时,日志记录级别;用法:limit_req_log_level info | notice | warn | error;
    { ngx_string("limit_req_log_level"),
      ngx_conf_set_enum_slot,
     },
 
    //当请求被限流时,给客户端返回的状态码;用法:limit_req_status 503
    { ngx_string("limit_req_status"),
      ngx_conf_set_num_slot,
    },
};

  Nginx限流算法依赖两个数据结构:红黑树和LRU队列。红黑树提供高效率的增删改查,LRU队列用于实现数据淘汰。

  我们假设限制每个客户端IP($binary_remote_addr)请求速率。当用户第一次请求时,会新增一条记录,并以客户端IP地址的hash值作为key存储在红黑树中,同时存储在LRU队列中;当用户再次请求时,会从红黑树中查找这条记录并更新,同时移动记录到LRU队列首部。存储空间(limit_req_zone配置)不够时,会淘汰记录,每次都是从尾部删除。

  下面我们通过两个实验进一步理解Nginx限流策略的配置以及限流现象。

实验一:

  配置限速1qps,允许请求被排队处理,配置burst=5,即最多允许5个请求排队等待处理。

http{
    limit_req_zone $binary_remote_addr zone=test:10m rate=1r/s;
 
    server {
        listen       80;
        server_name  localhost;
        location / {
            limit_req zone=test burst=5;
            root   html;
            index  index.html index.htm;
        }
}

  使用ab并发发起10个请求,ab -n 10 -c 10 http://xxxxx;

  查看服务端access日志;根据日志显示第一个请求被处理,2到5四个请求拒绝,6到10五个请求被处理;为什么会是这样的结果呢?

xx.xx.xx.xxx - - [22/Sep/2018:23:41:48 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [22/Sep/2018:23:41:48 +0800] "GET / HTTP/1.0" 503 537 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [22/Sep/2018:23:41:48 +0800] "GET / HTTP/1.0" 503 537 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [22/Sep/2018:23:41:48 +0800] "GET / HTTP/1.0" 503 537 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [22/Sep/2018:23:41:48 +0800] "GET / HTTP/1.0" 503 537 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [22/Sep/2018:23:41:49 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [22/Sep/2018:23:41:50 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [22/Sep/2018:23:41:51 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [22/Sep/2018:23:41:52 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [22/Sep/2018:23:41:53 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"

  查看ngx_http_log_module,注册handler到NGX_HTTP_LOG_PHASE阶段(HTTP请求处理最后一个阶段);因此实际情况应该是这样的:10个请求同时到达,第一个请求到达直接被处理,第2到6个请求到达,排队延迟处理(每秒处理一个);第7到10个请求被直接拒绝,因此先打印access日志;

  ab统计的响应时间见下面,最小响应时间87ms,最大响应时间5128ms,平均响应时间为1609ms:

              min  mean[+/-sd] median   max
Connect:       41   44   1.7     44      46
Processing:    46 1566 1916.6   1093    5084
Waiting:       46 1565 1916.7   1092    5084
Total:         87 1609 1916.2   1135    5128
试验二

  实验一配置burst后,虽然突发请求会被排队处理,但是响应时间过长,客户端可能早已超时;因此添加配置nodelay,使得Nginx紧急处理等待请求,以减小响应时间:

http{
    limit_req_zone $binary_remote_addr zone=test:10m rate=1r/s;
 
    server {
        listen       80;
        server_name  localhost;
        location / {
            limit_req zone=test burst=5 nodelay;
            root   html;
            index  index.html index.htm;
        }
}

  同样ab发起请求,查看服务端access日志;第一个请求直接处理,第2到6个五个请求排队处理(配置nodelay,Nginx紧急处理),第7到10四个请求被拒绝:

xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 200 612 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 503 537 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 503 537 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 503 537 "-" "ApacheBench/2.3"
xx.xx.xx.xxx - - [23/Sep/2018:00:04:47 +0800] "GET / HTTP/1.0" 503 537 "-" "ApacheBench/2.3"

  ab统计的响应时间见下面,最小响应时间85ms,最大响应时间92ms,平均响应时间为88ms:

              min  mean[+/-sd] median   max
Connect:       42   43   0.5     43      43
Processing:    43   46   2.4     47      49
Waiting:       42   45   2.5     46      49
Total:         85   88   2.8     90      92

  ngx_http_limit_conn_module限流模块比较简单,这里不再介绍。

  最后,脑图奉上:

image

案例分析:502问题介绍

  生产环境通常存在各种各样的异常HTTP状态码,比如499,504,502,500,400,408等,每个状态码的含义还是需要清楚。这里我们简单介绍下最常见的502问题排查。

  502的含义是NGX_HTTP_BAD_GATEWAY,即网关错误。通常的原因是上游没有监听,或者上游主动断开连接。在出现502时候,通常Nginx都会记录错误日志;比如:

2020/11/02 21:35:24 [error] 20921#0: *40 upstream prematurely closed connection while reading response header from upstream, client: 127.0.0.1, server: proxypass.test.com, request: "GET /test HTTP/1.1", upstream: "http://127.0.0.1:8080/test", host: "proxypass.test.com"

  通过日志瞬间就明白了,在Nginx等待上游返回结果时候,上游主动关闭连接了。上游为什么会主动关闭连接呢?这原因就复杂了,比如上游是golang服务时,假如配置了WriteTimeout=3秒,当请求处理时间超过3秒时,Golang服务会主动关闭连接。如下面抓包实例:

//三次握手建立连接
21:57:13.586604 IP 127.0.0.1.31519 > 127.0.0.1.8080: Flags [S], seq 574987506, win 43690, length 0
21:57:13.586627 IP 127.0.0.1.8080 > 127.0.0.1.31519: Flags [S.], seq 3599212930, ack 574987507, win 43690, length 0
21:57:13.586649 IP 127.0.0.1.31519 > 127.0.0.1.8080: Flags [.], ack 1, win 86, length 0

//发送请求
21:57:13.586735 IP 127.0.0.1.31519 > 127.0.0.1.8080: Flags [P.], seq 1:111, ack 1, win 86, length 110: HTTP: GET /test HTTP/1.1
21:57:13.586743 IP 127.0.0.1.8080 > 127.0.0.1.31519: Flags [.], ack 111, win 86, length 0

//请求处理5秒超时;没有响应,上游直接断开连接
21:57:18.587918 IP 127.0.0.1.8080 > 127.0.0.1.31519: Flags [F.], seq 1, ack 111, win 86, length 0
21:57:18.588169 IP 127.0.0.1.31519 > 127.0.0.1.8080: Flags [F.], seq 111, ack 2, win 86, length 0
21:57:18.588184 IP 127.0.0.1.8080 > 127.0.0.1.31519: Flags [.], ack 112, win 86, length 0

  再比如Nginx错误日志:

connect() to unix:/tmp/php-fcgi.sock failed (11: Resource temporarily unavailable) while connecting to upstream

  表明Nginx在通过域套接字连接上游FPM进程时,返回EAGAIN(11);查看Nginx源码中的注释:

Linux returns EAGAIN instead of ECONNREFUSED for unix sockets if listen queue is full

  Linux域套接字在队列溢出时,接收到连接请求会返回EAGAIN,而此时Nginx直接结束该请求,并返回502。

总结

  想一篇文章完全介绍清楚Nginx源码是不可能的,因此本文针对Nginx部分知识点,做了一个概括介绍,主要告诉读者一些基本的设计思想,以及可以去源码哪里寻找你想要的答案。当然,限于篇幅以及能力问题,还有很多知识点或者细节是没有介绍到的。结合这些脑图,剩下的就需要读者自己去探索研究了。