陶辉 《深入理解Nginx:模块开发与架构解析》作者 极客时间《Nginx核心知识100讲》讲师
浏览 2.03 W+
文章 15
订阅 162
10分钟快速认识Nginx 陶辉 发表于 : 2020-06-08 21:27

Nginx是当下最流行的Web服务器,通过官方以及第三方C模块,以及在Nginx上构建出的Openresty,或者在Openresty上构建出的Kong,你可以使用Nginx生态满足任何复杂Web场景下的需求。Nginx的性能也极其优秀,它可以轻松支持百万、千万级的并发连接,也可以高效的处理磁盘IO,因而通过静态资源或者缓存,能够为Tomcat、Django等性能不佳的Web应用扛住绝大部分外部流量。 但是,很多刚接触Nginx的同学,对它的理解往往失之偏颇,不太清楚Nginx的能力范围。比如:你可能清楚Nginx对上游应用支持Google的gRPC协议,但对下游的客户端是否支持gRPC协议呢?Openresty中的Nginx版本是单号的,而Nginx官网中的stable稳定版本则是双号的,我们到底该选择哪个版本的Nginx呢?安装Nginx时,下载Nginx docker镜像,或者用yum/apt-get安装,都比下载源代码再编译出可执行文件要简单许多,那到底有必要基于源码安装Nginx吗?当你下载完Nginx源码后,你清楚每个目录与文件的意义吗? 本文是《从头搭建1个静态资源服务器》系列文章中的第1篇,也是我在6月4日晚直播内容的文字总结,在这篇文章中我将向你演示:Nginx有什么特点,它的能力上限在哪,该如何获取Nginx,Nginx源代码中各目录的意义又是什么。 Nginx到底是什么?  Nginx是一个集静态资源、负载均衡于一身的Web服务器,这里有3个关键词,我们一一来分析。 l    Web我爱把互联网服务的访问路径,与社会经济中的供应链放在一起做类比,这样很容易理解“上下游”这类比较抽象的词汇。比如,购买小米手机时,实体或者网上店铺是供应链的下游,而高通的CPU则是上游。类似地,浏览器作为终端自然是下游,关系数据库则是上游,而Nginx位于源服务器和终端之间,如下图所示:                                                弄明白了上下游的概念后,我们就清楚了“Web服务器”的外延:Nginx的下游协议是Web中的HTTP协议,而上游则可以是任意协议,比如python的网关协议uwsgi,或者C/C++爱用的CGI协议,或者RPC服务常用的gRPC协议,等等。 在Nginx诞生之初,它的下游协议仅支持HTTP/1协议,但随着版本的不断迭代,现在下游还支持HTTP/2、MAIL邮件、TCP协议、UDP协议等等。 Web场景面向的是公网,所以非常强调信息安全。而Nginx对TLS/SSL协议的支持也非常彻底,它可以轻松的对下游或者上游装载、卸载TLS协议,并通过Openssl支持各种安全套件。 l    静态资源 Web服务器必须能够提供图片、Javascript、CSS、HTML等资源的下载能力,由于它们多数是静态的,所以通常直接存放在磁盘上。Nginx很擅长读取本机磁盘上的文件,并将它们发送至下游客户端!你可能会觉得,读取文件并通过HTTP协议发送出去,这简直不要太简单,Nginx竟然只是擅长这个?这里可大有文章! 比如,你可能了解零拷贝技术,Nginx很早就支持它,这使得发送文件时速度可以至少提升一倍!可是,零拷贝对于特大文件很不友好,占用了许多PageCache内存,但使用率却非常低,因此Nginx用Linux的原生异步IO加上直接IO解决了这类问题。再比如,小报文的发送降低了网络传输效率,而Nginx通过Nagle、Cork等算法,以及应用层的postpone_out指令批量发送小报文,这使得Nginx的性能远远领先于Tomcat、Netty、Apache等竞争对手,因此主流的CDN都是使用Nginx实现的。 l    负载均衡 在分布式系统中,用加机器扩展系统,是提升可用性的最有效方法。但扩展系统时,需要在应用服务前添加1个负载均衡服务,使它能够将请求流量分发给上游的应用。这一场景中,除了对负载均衡服务的性能有极高的要求外,它还必须能够处理应用层协议。在OSI网络体系中,IP网络层是第3层,TCP/UDP传输层是第4层,而HTTP等应用层则是第7层,因此,在Web场景中,需求量最大的自然是7层负载均衡,而Nginx非常擅长应用层的协议处理,这体现在以下4个方面: 1.      通过多路复用、事件驱动等技术,Nginx可以轻松支持C10M级别的并发;2.      由C语言编写,与操作系统紧密结合的Nginx(紧密结合到什么程度呢?Nginx之父Igor曾经说过,他最后悔的就是让Nginx支持windows操作系统,因为它与类Unix系统差异太大,这使得紧密结合的Nginx必须付出很大代价才能实现),能够充分使用CPU、内存等硬件,极高的效率使它可以同时为几十台上游服务器提供负载均衡功能;3.      Nginx的架构很灵活,它允许任何第三方以C模块的形式,与官方模块互相协作,给用户提供各类功能。因此,丰富的生态使得Nginx支持多种多样的应用层协议(你可以在Github上搜索到大量的C模块),你也可以直接开发C模块定制Nginx。4.      Nginx使用了非常开放的2-clause BSD-like license源码许可协议,它意味着你在修改Nginx源码后,还可以作为商业用途发布,TEngine就受益于这一特性。当Lua语言通过C模块注入Nginx后,就诞生了Openresty及一堆Lua语言模块,这比直接开发C语言模块难度下降了很多。而在Lua语言之上,又诞生了Kong这样面向微服务的生态。 从上述3个关键词的解释,我相信你已经明白了Nginx的能力范围。接下来,我们再来看看如何安装Nginx。 怎样获取Nginx? Nginx有很多种获取、安装的方式,我把它们分为以下两类: l    非定制化安装 主要指下载编译好的二进制文件,再直接安装在目标系统中,比如:u    拉取含有Nginx的docker镜像;u    在操作系统的应用市场中直接安装,比如用apt-get/yum命令直接安装Nginx;u    获取到网上编译好的Nginx压缩包后,解压后直接运行; l    定制化安装 在http://nginx.org/en/download.html上或者https://www.nginx-cn.net/product上下载Nginx源代码,调用configure脚本生成定制化的编译选项后,执行make命令编译生成可执行文件,最后用make install命令安装Nginx。 非定制化安装虽然更加简单,但这样的Nginx默认缺失以下功能: u    不支持更有效率的HTTP2协议;u    不支持TCP/UDP协议,不能充当4层负载均衡;u    不支持TLS/SSL协议,无法跨越公网保障网络安全;u    未安装stub_status模块,无法实时监控Nginx连接状态;你可以通过configure –help命令给出的--with-XXX-module说明,找到Nginx默认不安装的官方模块,例如:(dynamic是动态模块,在后续文章中我会演示其用法)--with-http_ssl_module enable ngx_http_ssl_module   --with-http_v2_module enable ngx_http_v2_module   --with-http_realip_module enable ngx_http_realip_module   --with-http_addition_module enable ngx_http_addition_module   --with-http_xslt_module enable ngx_http_xslt_module   --with-http_xslt_module=dynamic enable dynamic ngx_http_xslt_module   --with-http_image_filter_module enable ngx_http_image_filter_module   --with-http_image_filter_module=dynamic enable dynamic ngx_http_image_filter_module   --with-http_geoip_module enable ngx_http_geoip_module   --with-http_geoip_module=dynamic enable dynamic ngx_http_geoip_module   --with-http_sub_module enable ngx_http_sub_module   --with-http_dav_module enable ngx_http_dav_module   --with-http_flv_module enable ngx_http_flv_module   --with-http_mp4_module enable ngx_http_mp4_module   --with-http_gunzip_module enable ngx_http_gunzip_module   --with-http_gzip_static_module enable ngx_http_gzip_static_module   --with-http_auth_request_module enable ngx_http_auth_request_module   --with-http_random_index_module enable ngx_http_random_index_module   --with-http_secure_link_module enable ngx_http_secure_link_module   --with-http_degradation_module enable ngx_http_degradation_module   --with-http_slice_module enable ngx_http_slice_module   --with-http_stub_status_module enable ngx_http_stub_status_module 因此,从功能的全面性上来说,我们需要从源码上安装Nginx。 你可能会想,那为什么不索性将所有模块都编译到默认的Nginx中呢?按需编译模块,至少有以下4个优点: u    执行速度更快。例如,通过配置文件关闭功能,就需要多做一些条件判断。u    减少nginx可执行文件的大小。u    有些模块依赖项过多,在非必要时启用它们,会增加编译、运行环境的复杂性。u    给用户提供强大的自定义功能,比如在configure时设定配置文件、pid文件、可执行文件的路径,根据实际情况重新指定编译时的优化参数等等。 当然,最重要的还是可以通过configure --add-module选项任意添加自定义模块,这赋予Nginx无限的可能。 由于Nginx有许多分支和版本,该如何选择适合自己的版本呢?这有两个技巧,我们先来看mainline和stable版本的区别,在http://nginx.org/en/download.html上你会看到如下页面: 这里,mainline是含有最新功能的主线版本,它的迭代速度最快。另外,你可能注意到mainline是单号版本,而Openresty由于更新Nginx的频率较低,所以为了获得最新的Nginx特性,它通常使用mainline版本。 stable是mainline版本稳定运行一段时间后,将单号大版本转换为双号的稳定版本,比如1.18.0就是由1.17.10转换而来。 Legacy则是曾经的稳定版本。如果从头开始使用Nginx,那么你只需要选择最新的stable或者mainline版本就可以了。但如果你已经在使用某一个Legacy版本的Nginx,现在是否把它升级到最新版本呢?毕竟在生产环境上升级前要做完整的功能、性能测试,成本并不低。此时,我们要从CHANGES变更文件中,寻找每个版本的变化点。点开CHANGES文件,你会看到如下页面: 这里列出了每个版本的发布时间,以及发布时的变更。这些变更共分为以下4类:u    Feature新功能,比如上图HTTP框架新增的auth_delay指令。u    Bugfix问题修复,我们尤其要关注一些重大Bug的修复。u    Change已知特性的变更,比如之前允许HTTP请求头部中出现多个Host头部,但在1.17.9这个Change后,就认定这类HTTP请求非法了。u    Security安全问题的升级,比如1.15.6版本就修复了CVE-2018-16843等3个安全问题。 从Feature、Bugfix、Change、Security这4个方面,我们就可以更有针对性的升级Nginx。 认识Nginx的源码目录 当获取到Nginx源码压缩包并解压后,你可能对这些目录一头雾水,这里我对它们做个简单说明。比如1.18.0版本的源代码目录是这样的: 其中包含5个文件和5个目录,我们先来看单个文件的意义: u    CHANGES:即上面介绍过的版本变更文件。u    CHANGES.ru:由于Igor是俄罗斯人,所以除了上面的英文版变更文件外,还有个俄文版的变更文件。u    configure:如同其他Linux源码类软件一样,这是编译前的必须执行的核心脚本,它包含下面4个子功能:Ø  解析configure脚本执行时传入的各种参数,包括定制的第三方模块;Ø  针对操作系统、体系架构、编译器的特性,生成特定的编译参数;Ø  生成Makefile、ngx_modules.c等文件;Ø  在屏幕上显示汇总后的执行结果。u    LICENSE:这个文件描述了Nginx使用的2-clause BSD-like license许可协议。u    README:它只是告诉你去使用http://nginx.org官网查询各模块的用法。 再来看各个目录的意义: u    auto:configure只是一个简单的入口脚本,真正的功能是由auto目录下各个脚本完成的。u    conf:当安装完Nginx后,conf目录下会有默认的配置文件,这些文件就是从这里的conf目录复制过去的。u    contrib:包含了Nginx相关的周边小工具,比如下一讲将要介绍vim中如何高亮显示Nginx语法,就依赖于其中的vim子目录。u    html:安装完Nginx并运行后,会显示默认的欢迎页面,以及出现错误的500页面,这两个页面就是由html目录拷贝到安装目录的。u    man:目录中仅包含nginx.8一个文件,它其实是为Linux系统准备的man帮助文档,使用man -l nginx.8命令,可以看到Nginx命令行的使用方法: u    src:放置所有Nginx源代码的目录。关于src下的子目录,后续我分析源码时再详细介绍它们。 以上就是官方压缩包解压后的内容,当你执行完configure脚本后,还会多出Makefile文件以及objs目录,在下一篇文章中我会介绍它们。 小结最后,对《从头搭建静态资源服务器》系列第1篇做个总结。 Nginx是集静态资源与负载均衡与一身的Web服务器,它支持C10M级别的并发连接,也通过与操作系统的紧密结合,能够高效的使用系统资源。除性能外,Nginx通过优秀的模块设计,允许第三方的C模块、Lua模块等嵌入到Nginx中运行,这极大丰富了Nginx生态。 下载源码编译安装Nginx,可以获得定制Nginx的能力。这样不仅用助于性能的提升,还通过各类模块扩展了Nginx的功能。 Nginx源代码中有5个文件和5个一级目录,其中configure脚本极为关键,在它执行后,还会生成Makefile文件和objs目录,它们与定制化的模块、系统的高性能参数密切相关,此后才能正式编译Nginx。 下一篇,我将在《如何configure定制出属于你的Nginx》一文中介绍configure脚本的用法,配置文件的语法格式,以及如何配置出静态资源服务。 

点赞 16
5 条评论
《Nginx核心知识100讲》课件 陶辉 发表于 : 2020-06-03 14:01

PDF课件是放在github上的,地址是https://github.com/russelltao/geektime-nginx  因为github对国内网络不太稳定,有同学在微信群里请我发下课件,干脆我把6个PDF课件放到这篇文章的附件里,需要的同学请下载取用。   Nginx核心知识100讲-第一部分课件.pdf  Nginx核心知识100讲-第二部分课件.pdf  Nginx核心知识100讲-第三部分课件.pdf  Nginx核心知识100讲-第四部分课件.pdf  Nginx核心知识100讲-第五部分课件.pdf  Nginx核心知识100讲-第六部分课件.pdf 

点赞 17
12 条评论
《深入理解NGINX:模块开发与架构解析》示例代码下载 陶辉 发表于 : 2020-05-20 09:11

第三章示例源代码:chapter3第四章示例源代码:chapter4第五章示例源代码:chapter5第六章示例源代码:chapter6

点赞 10
1 条评论
如何用NGINX实现UDP四层反向代理? 陶辉 发表于 : 2020-05-20 09:09

在实时性要求较高的特殊场景下,简单的UDP协议仍然是我们的主要手段。UDP协议没有重传机制,还适用于同时向多台主机广播,因此在诸如多人会议、实时竞技游戏、DNS查询等场景里很适用,视频、音频每一帧可以允许丢失但绝对不能重传,网络不好时用户可以容忍黑一下或者声音嘟一下,如果突然把几秒前的视频帧或者声音重播一次就乱套了。使用UDP协议作为信息承载的传输层协议时,就要面临反向代理如何选择的挑战。通常我们有数台企业内网的服务器向客户端提供服务,此时需要在下游用户前有一台反向代理服务器做UDP包的转发、依据各服务器的实时状态做负载均衡,而关于UDP反向代理服务器的使用介绍网上并不多见。本文将讲述udp协议的会话机制原理,以及基于nginx如何配置udp协议的反向代理,包括如何维持住session、透传客户端ip到上游应用服务的3种方案等。UDP协议简介许多人眼中的udp协议是没有反向代理、负载均衡这个概念的。毕竟,udp只是在IP包上加了个仅仅8个字节的包头,这区区8个字节又如何能把session会话这个特性描述出来呢?图1 UDP报文的协议分层在TCP/IP或者 OSI网络七层模型中,每层的任务都是如此明确:物理层专注于提供物理的、机械的、电子的数据传输,但这是有可能出现差错的;数据链路层在物理层的基础上通过差错的检测、控制来提升传输质量,并可在局域网内使数据报文跨主机可达。这些功能是通过在报文的前后添加Frame头尾部实现的,如上图所示。每个局域网由于技术特性,都会设置报文的最大长度MTU(Maximum Transmission Unit),用netstat -i(linux)命令可以查看MTU的大小:  而IP网络层的目标是确保报文可以跨广域网到达目的主机。由于广域网由许多不同的局域网,而每个局域网的MTU不同,当网络设备的IP层发现待发送的数据字节数超过MTU时,将会把数据拆成多个小于MTU的数据块各自组成新的IP报文发送出去,而接收主机则根据IP报头中的Flags和Fragment Offset这两个字段将接收到的无序的多个IP报文,组合成一段有序的初始发送数据。IP报头的格式如下图所示:图2 IP报文头部IP协议头(本文只谈IPv4)里最关键的是Source IP Address发送方的源地址、Destination IP Address目标方的目的地址。这两个地址保证一个报文可以由一台windows主机到达一台linux主机,但并不能决定一个chrome浏览的GET请求可以到达linux上的nginx。4、传输层主要包括TCP协议和UDP协议。这一层最主要的任务是保证端口可达,因为端口可以归属到某个进程,当chrome的GET请求根据IP层的destination IP到达linux主机时,linux操作系统根据传输层头部的destination port找到了正在listen或者recvfrom的nginx进程。所以传输层无论什么协议其头部都必须有源端口和目的端口。例如下图的UDP头部:图3 UDP的头部TCP的报文头比UDP复杂许多,因为TCP除了实现端口可达外,它还提供了可靠的数据链路,包括流控、有序重组、多路复用等高级功能。由于上文提到的IP层报文拆分与重组是在IP层实现的,而IP层是不可靠的所有数组效率低下,所以TCP层还定义了MSS(Maximum Segment Size)最大报文长度,这个MSS肯定小于链路中所有网络的MTU,因此TCP优先在自己这一层拆成小报文避免的IP层的分包。而UDP协议报文头部太简单了,无法提供这样的功能,所以基于UDP协议开发的程序需要开发人员自行把握不要把过大的数据一次发送。对报文有所了解后,我们再来看看UDP协议的应用场景。相比TCP而言UDP报文头不过8个字节,所以UDP协议的最大好处是传输成本低(包括协议栈的处理),也没有TCP的拥塞、滑动窗口等导致数据延迟发送、接收的机制。但UDP报文不能保证一定送达到目的主机的目的端口,它没有重传机制。所以,应用UDP协议的程序一定是可以容忍报文丢失、不接受报文重传的。如果某个程序在UDP之上包装的应用层协议支持了重传、乱序重组、多路复用等特性,那么他肯定是选错传输层协议了,这些功能TCP都有,而且TCP还有更多的功能以保证网络通讯质量。因此,通常实时声音、视频的传输使用UDP协议是非常合适的,我可以容忍正在看的视频少了几帧图像,但不能容忍突然几分钟前的几帧图像突然插进来:-)UDP协议的会话保持机制有了上面的知识储备,我们可以来搞清楚UDP是如何维持会话连接的。对话就是会话,A可以对B说话,而B可以针对这句话的内容再回一句,这句可以到达A。如果能够维持这种机制自然就有会话了。UDP可以吗?当然可以。例如客户端(请求发起者)首先监听一个端口Lc,就像他的耳朵,而服务提供者也在主机上监听一个端口Ls,用于接收客户端的请求。客户端任选一个源端口向服务器的Ls端口发送UDP报文,而服务提供者则通过任选一个源端口向客户端的端口Lc发送响应端口,这样会话是可以建立起来的。但是这种机制有哪些问题呢?问题一定要结合场景来看。比如:1、如果客户端是windows上的chrome浏览器,怎么能让它监听一个端口呢?端口是会冲突的,如果有其他进程占了这个端口,还能不工作了?2、如果开了多个chrome窗口,那个第1个窗口发的请求对应的响应被第2个窗口收到怎么办?3、如果刚发完一个请求,进程挂了,新启的窗口收到老的响应怎么办?等等。可见这套方案并不适合消费者用户的服务与服务器通讯,所以视频会议等看来是不行。有其他办法么?有!如果客户端使用的源端口,同样用于接收服务器发送的响应,那么以上的问题就不存在了。像TCP协议就是如此,其connect方的随机源端口将一直用于连接上的数据传送,直到连接关闭。这个方案对客户端有以下要求:不要使用sendto这样的方法,几乎任何语言对UDP协议都提供有这样的方法封装。应当先用connect方法获取到socket,再调用send方法把请求发出去。这样做的原因是既可以在内核中保存有5元组(源ip、源port、目的ip、目的端口、UDP协议),以使得该源端口仅接收目的ip和端口发来的UDP报文,又可以反复使用send方法时比sendto每次都上传递目的ip和目的port两个参数。对服务器端有以下要求:不要使用recvfrom这样的方法,因为该方法无法获取到客户端的发送源ip和源port,这样就无法向客户端发送响应了。应当使用recvmsg方法(有些编程语言例如python2就没有该方法,但python3有)去接收请求,把获取到的对端ip和port保存下来,而发送响应时可以仍然使用sendto方法。 接下来我们谈谈nginx如何做udp协议的反向代理。Nginx的stream系列模块核心就是在传输层上做反向代理,虽然TCP协议的应用场景更多,但UDP协议在Nginx的角度看来也与TCP协议大同小异,比如:nginx向upstream转发请求时仍然是通过connect方法得到的fd句柄,接收upstream的响应时也是通过fd调用recv方法获取消息;nginx接收客户端的消息时则是通过上文提到过的recvmsg方法,同时把获取到的客户端源ip和源port保存下来。我们先看下recvmsg方法的定义:ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);相对于recvfrom方法,多了一个msghdr结构体,如下所示:struct msghdr {             void         *msg_name;       /* optional address */             socklen_t     msg_namelen;    /* size of address */             struct iovec *msg_iov;        /* scatter/gather array */             size_t        msg_iovlen;     /* # elements in msg_iov */             void         *msg_control;    /* ancillary data, see below */             size_t        msg_controllen; /* ancillary data buffer len */             int           msg_flags;      /* flags on received message */ };其中msg_name就是对端的源IP和源端口(指向sockaddr结构体)。以上是C库的定义,其他高级语言类似方法会更简单,例如python里的同名方法是这么定义的:(data, ancdata, msg_flags, address) = socket.recvmsg(bufsize[, ancbufsize[, flags]])其中返回元组的第4个元素就是对端的ip和port。配置nginx为UDP反向代理服务以上是nginx在udp反向代理上的工作原理。实际配置则很简单:# Load balance UDP-based DNS traffic across two servers stream {             upstream dns_upstreams {                         server 192.168.136.130:53;                         server 192.168.136.131:53;             }                  server {                         listen 53 udp;                         proxy_pass dns_upstreams;                         proxy_timeout 1s;                         proxy_responses 1;                         error_log logs/dns.log;             } }在listen配置中的udp选项告诉nginx这是udp反向代理。而proxy_timeout和proxy_responses则是维持住udp会话机制的主要参数。UDP协议自身并没有会话保持机制,nginx于是定义了一个非常简单的维持机制:客户端每发出一个UDP报文,通常期待接收回一个报文响应,当然也有可能不响应或者需要多个报文响应一个请求,此时proxy_responses可配为其他值。而proxy_timeout则规定了在最长的等待时间内没有响应则断开会话。如何通过nginx向后端服务传递客户真实IP最后我们来谈一谈经过nginx反向代理后,upstream服务如何才能获取到客户端的地址?如下图所示,nginx不同于IP转发,它事实上建立了新的连接,所以正常情况下upstream无法获取到客户端的地址:图4 nginx反向代理掩盖了客户端的IP上图虽然是以TCP/HTTP举例,但对UDP而言也一样。而且,在HTTP协议中还可以通过X-Forwarded-For头部传递客户端IP,而TCP与UDP则不行。Proxy protocol本是一个好的解决方案,它通过在传输层header之上添加一层描述对端的ip和port来解决问题,例如:但是,它要求upstream上的服务要支持解析proxy protocol,而这个协议还是有些小众。最关键的是,目前nginx对proxy protocol的支持则仅止于tcp协议,并不支持udp协议,我们可以看下其代码:可见nginx目前并不支持udp协议的proxy protocol(笔者下的nginx版本为1.13.6)。虽然proxy protocol是支持udp协议的。怎么办呢?方案1:IP地址透传可以用IP地址透传的解决方案。如下图所示:图5 nginx作为四层反向代理向upstream展示客户端ip时的ip透传方案这里在nginx与upstream服务间做了一些hack的行为:nginx向upstream发送包时,必须开启root权限以修改ip包的源地址为client ip,以让upstream上的进程可以直接看到客户端的IP。server {              listen 53 udp;          proxy_responses 1;          proxy_timeout 1s;          proxy_bind $remote_addr transparent;               proxy_pass dns_upstreams; }upstream上的路由表需要修改,因为upstream是在内网,它的网关是内网网关,并不知道把目的ip是client ip的包向哪里发。而且,它的源地址端口是upstream的,client也不会认的。所以,需要修改默认网关为nginx所在的机器。# route del default gw 原网关ip # route add default gw nginx的ipnginx的机器上必须修改iptable以使得nginx进程处理目的ip是client的报文。# ip rule add fwmark 1 lookup 100 # ip route add local 0.0.0.0/0 dev lo table 100  # iptables -t mangle -A PREROUTING -p tcp -s 172.16.0.0/28 --sport 80 -j MARK --set-xmark 0x1/0xffffffff这套方案其实对TCP也是适用的。方案2:DSR(上游服务无公网)除了上述方案外,还有个Direct Server Return方案,即upstream回包时nginx进程不再介入处理。这种DSR方案又分为两种,第1种假定upstream的机器上没有公网网卡,其解决方案图示如下:图6 nginx做udp反向代理时的DSR方案(upstream无公网)这套方案做了以下hack行为:1、在nginx上同时绑定client的源ip和端口,因为upstream回包后将不再经过nginx进程了。同时,proxy_responses也需要设为0。server {         listen 53 udp;     proxy_responses 0;         proxy_bind $remote_addr:$remote_port transparent;          proxy_pass dns_upstreams; }2、与第一种方案相同,修改upstream的默认网关为nginx所在机器(任何一台拥有公网的机器都行)。3、在nginx的主机上修改iptables,使得nginx可以转发upstream发回的响应,同时把源ip和端口由upstream的改为nginx的。例如:# tc qdisc add dev eth0 root handle 10: htb # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.11 match ip sport 53 action nat egress 172.16.0.11 192.168.99.10 # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.12 match ip sport 53 action nat egress 172.16.0.12 192.168.99.10 # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.13 match ip sport 53 action nat egress 172.16.0.13 192.168.99.10 # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.14 match ip sport 53 action nat egress 172.16.0.14 192.168.99.10方案3:DSR(上游服务有公网)DSR的另一套方案是假定upstream上有公网线路,这样upstream的回包可以直接向client发送,如下图所示:图6 nginx做udp反向代理时的DSR方案(upstream有公网)这套DSR方案与上一套DSR方案的区别在于:由upstream服务所在主机上修改发送报文的源地址与源端口为nginx的ip和监听端口,以使得client可以接收到报文。例如:# tc qdisc add dev eth0 root handle 10: htb # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.11 match ip sport 53 action nat egress 172.16.0.11 192.168.99.10结语以上三套方案皆可以使用开源版的nginx向后端服务传递客户端真实IP地址,但都需要nginx的worker进程跑在root权限下,这对运维并不友好。从协议层面,可以期待后续版本支持proxy protocol传递客户端ip以解决此问题。在当下的诸多应用场景下,除非业务场景明确无误的拒绝超时重传机制,否则还是应当使用TCP协议,其完善的流量、拥塞控制都是我们必须拥有的能力,如果在UDP层上重新实现这套机制就得不偿失了。 

点赞 12
2 条评论
巧用 NGINX 实现大规模分布式集群的高可用性 陶辉 发表于 : 2020-05-19 15:54

本文是我对2019年GOPS深圳站演讲的文字整理。这里我希望带给各位读者的是,如何站在整个互联网背景下系统化地理解Nginx,因为这样才能解决好大流量分布式网络所面临的高可用问题。标题里有“巧用”二字,何谓巧用?同一个问题会有很多种解决方案,但是,各自的约束性条件却大不相同。巧用就是找出最简单、最适合的方案,而做到这一点的前提就是必须系统化的理解Nginx!本文分四个部分讲清楚如何达到这一目的:首先要搞清楚我们面对的是什么问题。这里会谈下我对大规模分布式集群的理解;Nginx如何帮助集群实现可伸缩性;Nginx如何提高服务的性能;从Nginx的设计思路上学习如何用好它。1. 大规模分布式集群的特点互联网是一个巨大的分布式网络,它有以下特点:多样化的客户端。网络中现存各种不同厂商、不同版本的浏览器,甚至有些用户还在使用非常古老的浏览器,而我们没有办法强制用户升级;多层代理。我们不知道用户发来的请求是不是通过代理翻墙过来的;多级缓存。请求链路上有很多级缓存,浏览器、正反向代理、CDN等都有缓存,怎么控制多级缓存?RFC规范中有明确的定义,但是有些Server并不完全遵守;不可控的流量风暴。不知道用户来自于哪些地区,不知道他们会在哪个时间点集中访问,不知道什么事件会触发流量风暴;网络安全的高要求:信息安全问题要求通信数据必须加密;快速迭代的业务需求:BS架构使软件开发方式发生了巨大变化,我们可以通过快速迭代、发布来快速验证、试错。上图是典型的REST架构,图中包括客户端、正反向代理、源服务器,$符号代表缓存可以服务于上游,也可以服务于下游。通过IP地址标识主机,通过域名系统简化使用,URI则指向具体资源,每种资源有许多种表述,而服务器通过HTTP协议将表述转移至客户端上展示。这便是REST名为表述性状态转移的缘由,我在极客时间《Web协议详解与抓包实战》课程第7、8节课中对此有详细的介绍。设计架构时有许多关注点,与本文主题相关的有4个要点:可伸缩性。核心点在于如何有效的、动态的、灰度的均衡负载。可扩展性指功能组件的独立进化。可以理解为某个Nginx模块独立升级后,并不影响Nginx整体服务的属性。网络效率,也就是如何提升信息传输的效率。HTTP协议功能的全面支持。HTTP1的RFC规范非常多,毕竟它经历了20多年的变迁,而这20多年里互联网的巨大变化是HTTP1的设计者无法预料到的,这些规范也并不被所有Server、Client支持。 当然HTTP2和HTTP3相对情况会好很多。Nginx有优秀的可插拔模块化设计,它基于统一管道架构。其中有一类模块我称它为upstream负载均衡模块,官方Nginx便提供了最小连接、RoundRobin、基于变量控制的hash、一致性hash等负载均衡策略,而大量的第三方模块更提供了许多定制化的负载均衡算法。基于Lua语言的Openresty有自己的生态,这些Lua模块也提供了更灵活的实现方式。Nginx在性能优化上做得非常极致,大家知道最近F5收购了Nginx公司,为什么要收购?因为Nginx的性能可以与基于硬件的、价格昂贵的F5媲美!Nginx对HTTP协议的支持是比较全面的,当我们使用一些小众的替代解决方案时,一定要明确自己在HTTP协议有哪些独特需求。优秀的可配置性,在nginx.conf配置文件里我们可以使用脚本指令与变量实现复杂的功能。2. Nginx与scalability在讨论Nginx的负载均衡策略前,我们先来了解AKF扩展立方体,它能使我们对此建立整体思维。AKF扩展立方体有X、Y、Z轴,这三个轴意味着可以从3个角度实现可伸缩性:X轴指只需要增加应用进程,不用改代码就能水平的扩展。虽然最方便 ,但它解决不了数据不断增长的问题。Y轴按功能切分应用,它能解决数据增长的问题,但是,切分功能意味着重构代码,它引入了复杂性,成本很高。Z轴基于用户的属性扩展服务,运维Nginx时这招我们最常用,通常我们基于变量取到用户的IP地址、URL或者其他参数来执行负载均衡。当然,这三个轴可以任意组合以应对现实中的复杂问题。当然,要想解决可伸缩性问题,还必须在功能上支持足够多的协议。面向下游客户端主要是HTTP协议,当然Nginx也支持OSI传输层的UDP协议和TCP协议。受益于Nginx优秀的模块化设计,对上游服务器Nginx支持非常多的应用层协议,如grpc、uwsgi等。上图是Nginx执行反向代理的流程图,红色是负载均衡模块,任何一个独立的开发者都可以通过开发模块来添加新的LB策略。Nginx必须解决无状态HTTP协议带来的信息冗余及性能低下问题,而Cache缓存是最重要的解决手段,我们需要对Cache在反向代理流程中的作用有所了解。当下游是公网带宽并不稳定,且单用户信道较小时,通常Nginx应缓存请求body,延迟对上游应用服务建立连接的时间;反之,若上游服务的带宽不稳定,则应缓存响应body。理解nginx配置文件的3个关键点是:多级指令配置。通过大括号{},我们可以层层嵌套指令,借用父子关系来模块化的配置代码。变量,这是我们实现复杂功能,且不影响Nginx模块化设计的关键。变量是不同模块间低耦合交互的最有效方式!脚本引擎。脚本指令可以提供应用编程功能。很多人说Nginx的if指令是邪恶的,比如上图中的代码,其实我们只有理解if指令是如何影响父子嵌套关系后,才能正确的使用if。在《Nginx核心知识150讲》第141课我有详细介绍。Nginx官方迭代速度很快,在前两年差不多是两周一个版本,现在是一个月一个版本。频繁的更新解决了Bug也推出了新功能。但我们更新Nginx时却不能像更新其他服务一样,因为Nginx上任一时刻处理的TCP连接都太多了,如果升级Nginx时不能很好的应对就会出现大规模的用户体验问题。Nginx采用多进程结构来解决升级问题。它的master进程是管理进程,为所有worker进程保留住Syn半连接队列,所以升级Nginx时不会导致大规模三次握手失败。相反,单进程的HAProxy升级时就会出现连接建立失败问题。3. Nginx与集群performance缓存有两个实现维度:时间与空间。基于空间的缓存需要基于信息来预测,提前把用户可能请求的字节流准备好。而基于时间的缓存如上图所示,蓝色线条的请求触发了缓存(public share cache),这样红色线条的第二次请求可以直接命中缓存。浏览器中的是私有缓存,私有缓存只为一个用户服务。Nginx上实现了共享缓存,同时Nginx也可以控制浏览器中私有缓存的有效时间。RFC规范定义了许多缓存相关的头部,如果我们忽略了这些规则会很难理解Nginx如何基于下游的请求、上游的响应控制私有缓存及共享缓存,而且不了解这些规则其实不容易读懂nginx.conf中缓存相关指令的说明文档。在《Web协议详解与抓包实战》课程第29到32课我详细的介绍了缓存相关的规则。有些同学会问我,为什么部署Nginx之后没有看到上图中的Cache Loader和Cache Manger进程呢?因为我们没有启用Nginx的缓存。当然,即使我们开启缓存后,Cache Loader进程可能还是看不到的。为什么呢?因为Nginx为了高性能做了很多工作。当重启Nginx时,之前保存在磁盘上的缓存文件需要读入内存建立索引,但读文件的IO速度是很慢的,读缓存文件(文件很大很多)这一步骤可能耗时非常久,对服务器的负载很大,这会影响worker进程服务用户请求的能力。CL进程负责每次只读一小部分内容到共享内存中,这大大缓解了读IO慢的问题。CM进程负责淘汰过期缓存。当下游有一份过期资源时,它会来询问Nginx时:此资源还能用吗?能用的话,通过304告诉我,不要返回响应body(可能很大!)了。当Nginx缓存的资源可能过期时,它也可以问上游的web应用服务器:缓存还能用吗?能用的话通过304告诉我,我来更新缓存Age。RFC7033文档详细定义了这一过程,我在《Web协议详解与抓包实战》第28课有详细介绍。Nginx的not_modified过滤模块便负责执行这一功能。我在《Nginx核心知识150讲》课程第97、98课对此有详细介绍。如果我们突然发布了一个热点资源,许多用户请求瞬间抵达访问该资源,可是该资源可能是一个视频文件尺寸很大,Nginx上还没有建立起它的缓存,如果Nginx放任这些请求直达上游应用服务器(比如可能是Tomcat),非常可能直接把上游服务器打挂了。因为上游应用服务器为了便于功能的快速迭代开发,性能上是不能与Nginx相提并论的。这就需要合并回源请求。怎么合并回源请求呢?第一个请求过来了,放行!第二个请求也到了,但因为第1个请求还没有完成,所以上图中的请求2、4、5都不放行,直到第6步第1个请求的响应返回后,再把缓存的内容作为响应在第8、9、10中返回。这样就能缓解上游服务的压力。减少回源请求是一个解决方案,但如果Nginx上有过期的响应,能不能先将就着发给用户?当然,同时也会通过条件请求去上游应用那里获取最新的缓存。我们经常提到的互联网柔性、分级服务的原理与此是相同的。既然最新内容暂时由于带宽、性能等因素不能提供,不如先提供过期的内容,当然前提是不对业务产生严重影响。Nginx中的proxy_cache_use_stale指令允许使用stale过期缓存,上图中第1个请求放行了,第2、3请求使用旧缓存。从这里可以看出Nginx应对大流量有许多成熟的方案。我们在网页上会使用播放条拖动着看视频,这可以基于Http Range协议实现。但是,如果不启用Slice模块Nginx就会出现性能问题,比如现在浏览器要访问一个视频文件的第150-249字节,由于满足了缓存条件,Nginx试图先把文件拉取过来缓存,再返回响应。然而,Nginx会拉取完整的文件缓存!这是很慢的。怎么解决这个问题呢?使用Nginx的slice模块即可,如果配置100字节作为基础块大小,Nginx会基于100-199、200-299产生2个请求,这2个请求的应用返回并存入缓存后再构造出150-249字节的响应返回给用户。这样效率就高很多!通常,Nginx作为CDN使用时都会打开这一功能。互联网解决信息安全的方案是TLS/SSL协议,Nginx对其有很好的支持。比如,Nginx把下游公网发来的TLS流量卸载掉TLS层,再转发给上游;同时,它也可以把下游传输来的HTTP流量 ,根据配置的证书转换为HTTPS流量。在验证证书时,在nginx.conf中我们可以通过变量实现证书或者域名验证。虽然TLS工作在OSI模型的表示层,但Nginx作为四层负载均衡时仍然可以执行同样的增、删TLS层功能。Nginx的Stream模块也允许在nginx.conf中通过变量验证证书。Nginx处理TLS层性能非常好,这得益于2点:Nginx本身的代码很高效,这既因为它基于C语言,也由于它具备优秀的设计。减少TLS握手次数,包括:session缓存。减少TLS1.2握手中1次RTT的时间,当然它对集群的支持并不好,而且比较消耗内存。Ticket票据。Ticket票据可应用于集群,且并不占用内存。当然,减少TLS握手的这2个策略都面临着重放攻击的危险,更好的方式是升级到TLS1.3。我在《Web协议详解与抓包实战》第80课有详细介绍。4. 巧用NginxNginx模块众多,我个人把它分为四类,这四类模块各自有其不同的设计原则。请求处理模块。负责生成响应或者影响后续的处理模块,请求处理模块遵循请求阶段设计,在同阶段内按序处理。过滤模块。生成了HTTP响应后,此类模块可以对响应做再加工。仅影响变量的模块。这类模块为其他模块的指令赋能,它们提供新的变量或者修改已有的变量。负载均衡模块。它们提供选择上游服务器的负载均衡算法,并可以管理上游连接。请求处理模块、过滤模块、负载均衡模块均遵循unitform pipe and filter架构,每个模块以统一的接口处理输入,并以同样的接口产生输出,这些模块串联在一起提供复杂的功能。Nginx把请求处理流程分为11个阶段,所有请求处理模块必须隶属于某个阶段,或者同时在多个阶段中工作。每个处理阶段必须依次向后执行,不可跳跃阶段执行。同阶段内允许存在多个模块同时生效,这些模块串联在一起有序执行。当然,先执行的模块还有个特权,它可以决定忽略本阶段后续模块的执行,直接跳跃到下一个阶段中的第1个模块执行。每个阶段的功能单一,每个模块的功能也很简单,因此该设计扩展性很好。上图中的灰色模块Nginx框架中的请求处理模块。上图中右边是Openresty默认编译进Nginx的过滤模块,它们是按序执行的。图中用红色框出的是关键模块,它们是必须存在的,而且它们也将其他模块分为三组,开发第三方过滤模块时必须先决定自己应在哪一组,再决定自己应在组内的什么位置。Nginx中的变量分为:提供变量的模块和使用变量的模块。其含义我在《Nginx核心知识150讲》第72课有介绍,关于框架提供的变量在第73、74课中有介绍。无论我们使用了哪些模块,Nginx框架中的变量一定是默认提供的,它为我们提供了基础功能,理解好它们是我们使用好Nginx变量的关键。框架变量分为5类:HTTP 请求相关的变量TCP 连接相关的变量Nginx 处理请求过程中产生的变量发送 HTTP 响应时相关的变量Nginx 系统变量最后我们来谈谈Openresty,它其实是Nginx中的一系列模块构成的,但它由于集成了Lua引擎,又延伸出Lua模块并构成了新的生态。看看Openresty由哪些部分组成:Nginx,这里指的是Nginx的框架代码。Nginx官方模块,以及各类第三方(非Openresty系列)C模块。Openresty生态模块,它包括直接在Nginx中执行的C模块,例如上图中的绿色模块,也包括必须运行在ngx_http_lua_module模块之上的Lua语言模块。当然,Openresty也提供了一些方便使用的脚本工具。Openresty中的Lua代码并不用考虑异步,它是怎么在Nginx的异步C代码框架中执行的呢?我们知道,Nginx框架由事件驱动系统、HTTP框架和STREAM框架组成。而Openresty中的ngx_http_lua_module和ngx_stream_lua_module模块给Lua语言提供了编程接口,Lua语言通过它们编译为C代码在Nginx中执行。我们在nginx.conf文件中嵌入Lua代码,而Lua代码也可以调用上述两个模块提供的SDK调动Nginx的功能。Openresty的SDK功能强大,我个人把它分为以下8大类:Cosocket提供了类似协程的网络通讯功能,它的性能优化很到位,许多其他Lua模块都是基于它实现的。基于共享内存的字典,它支持多进程使用,所有worker进程间同步通常通过Shared.DICT。定时器。基于协程的并发编程。获取客户端请求与响应的信息修改客户端请求与响应,包括发送响应子请求,官方Nginx就提供了树状的子请求,用于实现复杂功能,Openresty用比C简单的多的Lua语言又实现了一遍。工具类,大致包含以下5类:正则表达式日志系统配置编解码时间类最后做个总结。在恰当的时间做恰当的事,听起来很美好,但需要我们有大局观。我们要清楚大规模分布式网络通常存在哪些问题,也要清楚分布式网络的常用解决方案,然后才能谈如何用Nginx解决上述问题。而用好Nginx,必须系统的掌握Nginx的架构与设计原理,理解模块化设计、阶段式设计,清楚Nginx的核心流程,这样我们才能恰到好处地用Nginx解决掉问题。

点赞 10
4 条评论
HTTP请求是如何关联Nginx server{}块的? 陶辉 发表于 : 2020-05-19 14:23

Nginx是企业内网的对外入口,它常常同时对接许多应用,因此,Nginx上会同时监听多个端口、为多个域名提供服务。然而,匹配多级域名并不简单,Nginx为此准备了字符串精确匹配、前缀通配符、后缀通配符、正则表达式,当它们同时出现时,弄清楚HTTP请求会被哪个server{ }下的指令处理,就成了一件困难的事。这是因为基于域名规范,请求匹配server{ }配置块时,并不会按照它们在nginx.conf文件中的出现顺序作为选择依据。而且对于不支持Host头部、没有域名的HTTP/1.0请求和无法匹配到合适server{ }的异常请求,我们都要区别对待。另外,为了加快匹配速度,Nginx将字符串域名、前缀通配符、后缀通配符都放在了哈希表中,该设计充分使用了CPU的批量载入主存功能。如果不了解这些流程,既有可能导致请求没有被正确的server{ }块处理,也有可能降低了原本非常高效地哈希表查询性能。本文将沿着Nginx处理HTTP请求的流程,介绍一个请求是如何根据listen、server_name等配置关联到server{ }块的。我们将从TCP连接的建立、Nginx从哪些字段取出域名、域名是怎样与server_name匹配的,讲清楚Nginx如何为请求找到处理它的server{ }块。在实际运维中,大部分问题都是由于请求匹配指令错误造成的,搞清楚这一匹配流程,对我们掌握Nginx非常重要。listen指令对server{ }块的第1次关联为了让一台服务器可以处理访问多个域名的不同请求,我们用“虚拟主机”来定义一种域名的处理方式,在Nginx中这对应着一个server{ }块。因此,HTTP请求到达时,Nginx首先要找到处理它的server{ }配置块。请求关联server{ }块时主要依据listen和server_name这两个指令,其中listen指令发生在TCP连接建立完成时,它对server{ }块进行首次匹配,等到接收HTTP请求头部时,server_name再进行第二次匹配,这样就可以决定请求由哪个server{ }块中的指令处理。我们先来看listen指令是如何匹配请求的。Nginx启动时创建socket并监听listen指令告知的端口(包括绑定IP地址)。当运行在TCP协议之上的HTTP请求到达服务器时,操作系统首先收到了TCP三次握手请求。我们知道,TCP这种传输层协议是由内核实现的,因此,由内核完成TCP的三次握手后,就会通过“读事件”经由Linux的Epoll通知到Nginx的worker进程以及具体监听的socket。 比如,我们在nginx.conf中配置了以下两个server:server {    listen 192.168.1.5:80; }server {    listen 127.0.0.1:80; }如果是本机进程发来的HTTP请求(在Linux中可以用curl或者telnet发起请求),它的IP报文头部目的IP地址就是127.0.0.1,而TCP报文头部的目的端口就是80。这样,Linux内核就找到了相应的socket,进而通过epoll_wait函数唤醒Nginx进程,而Nginx也就找到了对应的listen指令以及其所属的server{ }块。版本号头部长度服务类型总长度标识标志位分片偏移TTL生存时间上层协议首部校验和源IP地址127.0.0.1源端口号80序列号确认序列号首部长度保留位UAPRSF窗口大小校验和紧急指针 你可能注意到,有些server{ }块没有listen指令也可以正常的工作。这是因为Nginx认为每个server{}都应该监听TCP端口,当你没有显式的配置listen指令时,Nginx会默认帮你打开80端口。 Nginx是怎样从HTTP请求中取出域名的?Nginx允许多个server{ }块监听相同的端口,所以当访问相同端口、不同域名的请求到达时,还需要根据请求中的域名做第2次匹配,以决定最终关联的server{ }块。这里我们先要搞清楚域名是怎么从HTTP请求中取出来的。在HTTP/1.0协议中并没有Host头部,这是因为互联网起步时,HTTP的设计者并没有考虑到域名的数量会远多于服务器。对于HTTP/1.0请求而言,只能从absolute URL中携带域名。举个例子,下面这个没有携带Host头部的请求可以取到www.taohui.pub域名:GET http://www.taohui.pub/index.html HTTP/1.0如果你不清楚HTTP协议的格式,建议你先观看下我在极客时间上的视频课程《Web协议详解与抓包实战》第12课《详解HTTP的请求行》。互联网业务的推动导致一台服务器必须要处理大量域名,于是HTTP/1.1协议推出了描述访问域名的Host头部。对于不含有Host头部的HTTP/1.1请求,RFC规范要求服务器必须返回400错误码(Nginx也正是这么做的)。当Host头部与上述absolute URL中的域名同时出现时,将会以后者为准。例如对于下面这个请求,Nginx会取出www.taohui.tech作为匹配域名:GET http://www.taohui.tech/index.html HTTP/1.0Host: www.taohui.pub另外,对于使用了TLS/SSL协议的HTTPS请求来说,还可以从TLS握手中获取到域名。关于TLS握手及相关插件我会在后续的文章中再详述。获取到请求的域名后,Nginx就会将其与上一节中listen指令匹配成功的server块进行第2次匹配,其中匹配依据就是server_name指令后的选项。我们暂且不谈server_name指令的匹配语法,先来看server_name匹配完成后的3种可能情况:1.      域名恰好与1个server{ }块中的server_name相匹配,选用该server{ }中的指令处理与请求;2.      有多个server{ }块匹配上了域名,此时按server_name规定的优先级选中一个server{ }块即可;3.      所有server{ }块都没有匹配上域名,此时必须有一个默认server { }块来处理这个请求。 其中在第3种情况里,Nginx是这么定义默认server { }的:1.      当listen指令后明确的跟着default_server选项时,它所属的server{ }就是默认server。2.      如果监听同一个端口的所有server{ }都没有通过listen指令显式设置default_server,那么这些server{ }配置块中,在nginx.conf配置文件里第1个出现的就是默认server。 注意,你不能把监听相同端口、地址对的两个server{ }块同时设为默认server,否则nginx将无法启动,并给出类似下方的错误输出:nginx: [emerg] a duplicate default server for 0.0.0.0:80 in /usr/local/nginx/conf/nginx.conf:40这就是请求匹配server{ }块的总体流程,下面我们来看server_name与域名的匹配,这也是最复杂的环节。 server_name指令对server{ }块的第2次关联 如果你购买过域名肯定清楚,虽然只买到一个域名,但你会有无数个子域名可以使用。比如我买到的是taohui.pub二级域名(pub是一级域名),我就可以配置出blog.taohui.pub这个三级域名,甚至自己搭建一个子域名解析服务,再配置出四级域名nginx.blog.taohui.pub,甚至五级、六级域名都能使用,如下图所示: 由于多级域名的存在,关联域名的server_name指令也相应地复杂起来,下面我们从3个层次看看server_name的选项种类。 首先,server_name支持精确地完全匹配,例如:server_name blog.taohui.pub;其次,server_name可以通过*符号作为通配符来匹配一类域名,比如:server_name *.taohui.pub;既可以匹配blog.taohui.pub,也可以匹配image.taohui.pub。由于*通配符在前方,所以我把它叫做前缀通配符。server_name还支持后缀通配符,例如:server_name www.taohui.*;它既可以匹配www.taohui.pub,也可以匹配www.taohui.tech域名。注意,server_name支持的通配符只能出现在最前方或者最后方,它不能出现在域名的中间,例如:server_name www.*.pub;就是非法的选项。 最后,当遇到通配符无法解决的场景时,可以使用正则表达式来匹配域名。当然,使用正则表达式的前提是将pcre开发库编译进Nginx(在CentOS下安装pcre开发库很简单,执行yum install pcre-devel -y即可。当有多个pcre版本并存时,可以通过configure --with-pcre=指定编译具体的pcre库)。 使用正则表达式时,需要在server_name选项前加入~符号,例如:server_name ~^ww\d.\w+.taohui.tech;$它可以匹配如ww3.blog.taohui.tech这样的域名。 当然,想一次写对正则表达式并不容易。pcre库提供的pcretest工具可以让我们提前测试正则表达式。注意,你用yum等工具安装pcre时,并不会自动安装pcretest工具,这需要你下载源代码(最新的pcre2-10.34下载地址参见这里,帮助文档参见这里)自行编译获得。本文不会讨论正则表达式的语法,也不会讨论pcretest工具的用法,关于Nginx中如何使用这两者,你可以观看下我在极客时间上的视频课程《Nginx核心知识100讲》第46课《Nginx中的正则表达式》。 Nginx中的正则表达式通常会提供提取变量的能力,server_name指令也不例外!我们可以通过小括号将域名中的信息取出来,交给后续的指令使用,例如:    server {        server_name ~^(ww\d).(?\w+).taohui.tech$;        return 200 ‘regular variable: $1 $domain‘;    }此时发起访问域名ww3.blog.taohui.tech的请求,由于第1个小括号我通过$1变量获取值为ww3,而第2个小括号我通过domain名称获得值为blog(通过$2也可以获得相同的内容),因此return指令发来的响应将会是regular variable: ww3 blog。 说完这3种域名选项后,我们再来看它们同时出现且匹配命中时,Nginx是怎样根据优先级来选择server{ }块的。域名的总体匹配优先级,与server{ }块在nginx.conf中的出现顺序无关,也与server_name指令在server{ }块中的出现顺序无关。事实上,对于监听同一地址、端口的server{ }块而言,Nginx会在进程启动时在收集所有server_name后,将精确匹配的字符串域名、前缀通配符、后缀通配符分别构建出3个哈希表,并将正则表达式构建为一个链表。我们看下请求到达时的匹配流程: 1.      匹配域名时,首先在字符串域名构成的哈希表上做精确查询,如果查询到了,就直接返回,因此,完全匹配的字符串域名优先级是最高的;2.      其次,将在前缀通配符哈希表上,按照每级域名的值分别查询哈希表,完成最长通配符的匹配。比如,blog.taohui.tech同时可以匹配以下2个前缀通配符:server_name *.tech;server_name *.taohui.tech;但Nginx会匹配命中*.taohui.tech。3.      其次,会在后缀通配符哈希表上做查询,完成最长通配符的匹配。4.      最后,会按照正则表达式在nginx.conf中出现的顺序,依次进行正则表达式匹配,这一步的性能比起前3步要慢许多。 这就是域名匹配的核心流程。 关于域名匹配你还需要了解的技巧 事实上,还有一些域名匹配的小技巧需要你掌握。首先,就像前面说过的HTTP/1.0协议是没有Host头部的,所以使用relative URL的HTTP/1.0请求并没有域名。按照之前的流程,它只能被默认server{ }块处理,这大大限制了默认server {}块的功能。在Nginx 0.7.11之后的版本,你可以通过server_name “”指定空字符串,来匹配没有域名的请求,这就解放了默认server { }的职责。其次,当Nginx对内网提供HTTP服务时,许多客户端会通过网络可达的主机名发起请求,这样客户端填写的域名就是主机名。如果必须由管理员先用hostname命令获取到主机名,再改写server_name指令,这就太不方便了。因此,server_name后还可以填写$hostname变量,这样Nginx启动时,会自动把$hostname替换为真正的主机名。server_name $hostname;最后一点,上文说过非正则表达式的server_name选项都会存放在哈希表中,这样哈希表中每个bucket桶大小就限制了域名的最大长度。当我们使用长域名或者多级域名时,默认的桶大小很可能就不够了,这时需要提升server_name的桶大小。桶大小由server_names_hash_bucket_size配置控制,由于CPU从内存中读入数据时是按批进行的,其中每批字节数是cpu cache line,因此为了一次可以载入一个哈希桶,server_names_hash_bucket_size的默认值被定为cpu cache line。目前多数CPU的cache line值是64字节,所以若域名较长时需要增加桶的大小。当你增大桶大小时,需要保证server_names_hash_bucket_size是cpu cache line的整数倍,这样读取哈希桶时,会尽量少地读取主存。毕竟操作主存的速度通常在100纳秒左右,这比CPU的速度慢得多!小结最后对本文做个小结。当TCP三次握手完成后,Linux内核就会按照内核的负载均衡算法,唤醒监听相应端口的某个Nginx worker进程。而从读事件及socket句柄上,Nginx可以找到对应的listen指令及所属的server{ }块,这完成了初次匹配。接着,Nginx会接收HTTP请求,从absolute URL、 Host域名或者TLS插件中取出域名,再将域名与server_name进行匹配。其中匹配优先级是这样的:精确的字符串匹配优先级最高,其次是前缀通配符和后缀通配符匹配(这两者匹配时,如果多个通配符命中,会选择最长的server_name),最后才是正则表达式匹配。如果以上情况皆未匹配上,请求会被默认server{ }处理。其中默认server {}是监听同一端口、地址的一系列server{ }块中,第1个在nginx.conf中出现的那个server{ }。当然,通过listen default_server也可以显示地定义默认server{ }。最后留给你一个思考题,为什么有人用server_name _;来处理未匹配上的请求?欢迎你留言一起探讨。  

点赞 13
4 条评论
TA的热门
PDF课件是放在github上的,地址是https://github.com/russelltao/geektime-nginx  因为github对国内网络不太稳定,有同学在微信群里请我发下课件,干脆我把6个PDF课件放到这篇文章的附件里,需要的同学请下载取用。   Nginx核心知识100讲-第一部分课件.pdf  Nginx核心知识100讲-第二部分课件.pdf  Nginx核心知识100讲-第三部分课件.pdf  Nginx核心知识100讲-第四部分课件.pdf  Nginx核心知识100讲-第五部分课件.pdf  Nginx核心知识100讲-第六部分课件.pdf 
Nginx是当下最流行的Web服务器,通过官方以及第三方C模块,以及在Nginx上构建出的Openresty,或者在Openresty上构建出的Kong,你可以使用Nginx生态满足任何复杂Web场景下的需求。Nginx的性能也极其优秀,它可以轻松支持百万、千万级的并发连接,也可以高效的处理磁盘IO,因而通过静态资源或者缓存,能够为Tomcat、Django等性能不佳的Web应用扛住绝大部分外部流量。 但是,很多刚接触Nginx的同学,对它的理解往往失之偏颇,不太清楚Nginx的能力范围。比如:你可能清楚Nginx对上游应用支持Google的gRPC协议,但对下游的客户端是否支持gRPC协议呢?Openresty中的Nginx版本是单号的,而Nginx官网中的stable稳定版本则是双号的,我们到底该选择哪个版本的Nginx呢?安装Nginx时,下载Nginx docker镜像,或者用yum/apt-get安装,都比下载源代码再编译出可执行文件要简单许多,那到底有必要基于源码安装Nginx吗?当你下载完Nginx源码后,你清楚每个目录与文件的意义吗? 本文是《从头搭建1个静态资源服务器》系列文章中的第1篇,也是我在6月4日晚直播内容的文字总结,在这篇文章中我将向你演示:Nginx有什么特点,它的能力上限在哪,该如何获取Nginx,Nginx源代码中各目录的意义又是什么。 Nginx到底是什么?  Nginx是一个集静态资源、负载均衡于一身的Web服务器,这里有3个关键词,我们一一来分析。 l    Web我爱把互联网服务的访问路径,与社会经济中的供应链放在一起做类比,这样很容易理解“上下游”这类比较抽象的词汇。比如,购买小米手机时,实体或者网上店铺是供应链的下游,而高通的CPU则是上游。类似地,浏览器作为终端自然是下游,关系数据库则是上游,而Nginx位于源服务器和终端之间,如下图所示:                                                弄明白了上下游的概念后,我们就清楚了“Web服务器”的外延:Nginx的下游协议是Web中的HTTP协议,而上游则可以是任意协议,比如python的网关协议uwsgi,或者C/C++爱用的CGI协议,或者RPC服务常用的gRPC协议,等等。 在Nginx诞生之初,它的下游协议仅支持HTTP/1协议,但随着版本的不断迭代,现在下游还支持HTTP/2、MAIL邮件、TCP协议、UDP协议等等。 Web场景面向的是公网,所以非常强调信息安全。而Nginx对TLS/SSL协议的支持也非常彻底,它可以轻松的对下游或者上游装载、卸载TLS协议,并通过Openssl支持各种安全套件。 l    静态资源 Web服务器必须能够提供图片、Javascript、CSS、HTML等资源的下载能力,由于它们多数是静态的,所以通常直接存放在磁盘上。Nginx很擅长读取本机磁盘上的文件,并将它们发送至下游客户端!你可能会觉得,读取文件并通过HTTP协议发送出去,这简直不要太简单,Nginx竟然只是擅长这个?这里可大有文章! 比如,你可能了解零拷贝技术,Nginx很早就支持它,这使得发送文件时速度可以至少提升一倍!可是,零拷贝对于特大文件很不友好,占用了许多PageCache内存,但使用率却非常低,因此Nginx用Linux的原生异步IO加上直接IO解决了这类问题。再比如,小报文的发送降低了网络传输效率,而Nginx通过Nagle、Cork等算法,以及应用层的postpone_out指令批量发送小报文,这使得Nginx的性能远远领先于Tomcat、Netty、Apache等竞争对手,因此主流的CDN都是使用Nginx实现的。 l    负载均衡 在分布式系统中,用加机器扩展系统,是提升可用性的最有效方法。但扩展系统时,需要在应用服务前添加1个负载均衡服务,使它能够将请求流量分发给上游的应用。这一场景中,除了对负载均衡服务的性能有极高的要求外,它还必须能够处理应用层协议。在OSI网络体系中,IP网络层是第3层,TCP/UDP传输层是第4层,而HTTP等应用层则是第7层,因此,在Web场景中,需求量最大的自然是7层负载均衡,而Nginx非常擅长应用层的协议处理,这体现在以下4个方面: 1.      通过多路复用、事件驱动等技术,Nginx可以轻松支持C10M级别的并发;2.      由C语言编写,与操作系统紧密结合的Nginx(紧密结合到什么程度呢?Nginx之父Igor曾经说过,他最后悔的就是让Nginx支持windows操作系统,因为它与类Unix系统差异太大,这使得紧密结合的Nginx必须付出很大代价才能实现),能够充分使用CPU、内存等硬件,极高的效率使它可以同时为几十台上游服务器提供负载均衡功能;3.      Nginx的架构很灵活,它允许任何第三方以C模块的形式,与官方模块互相协作,给用户提供各类功能。因此,丰富的生态使得Nginx支持多种多样的应用层协议(你可以在Github上搜索到大量的C模块),你也可以直接开发C模块定制Nginx。4.      Nginx使用了非常开放的2-clause BSD-like license源码许可协议,它意味着你在修改Nginx源码后,还可以作为商业用途发布,TEngine就受益于这一特性。当Lua语言通过C模块注入Nginx后,就诞生了Openresty及一堆Lua语言模块,这比直接开发C语言模块难度下降了很多。而在Lua语言之上,又诞生了Kong这样面向微服务的生态。 从上述3个关键词的解释,我相信你已经明白了Nginx的能力范围。接下来,我们再来看看如何安装Nginx。 怎样获取Nginx? Nginx有很多种获取、安装的方式,我把它们分为以下两类: l    非定制化安装 主要指下载编译好的二进制文件,再直接安装在目标系统中,比如:u    拉取含有Nginx的docker镜像;u    在操作系统的应用市场中直接安装,比如用apt-get/yum命令直接安装Nginx;u    获取到网上编译好的Nginx压缩包后,解压后直接运行; l    定制化安装 在http://nginx.org/en/download.html上或者https://www.nginx-cn.net/product上下载Nginx源代码,调用configure脚本生成定制化的编译选项后,执行make命令编译生成可执行文件,最后用make install命令安装Nginx。 非定制化安装虽然更加简单,但这样的Nginx默认缺失以下功能: u    不支持更有效率的HTTP2协议;u    不支持TCP/UDP协议,不能充当4层负载均衡;u    不支持TLS/SSL协议,无法跨越公网保障网络安全;u    未安装stub_status模块,无法实时监控Nginx连接状态;你可以通过configure –help命令给出的--with-XXX-module说明,找到Nginx默认不安装的官方模块,例如:(dynamic是动态模块,在后续文章中我会演示其用法)--with-http_ssl_module enable ngx_http_ssl_module   --with-http_v2_module enable ngx_http_v2_module   --with-http_realip_module enable ngx_http_realip_module   --with-http_addition_module enable ngx_http_addition_module   --with-http_xslt_module enable ngx_http_xslt_module   --with-http_xslt_module=dynamic enable dynamic ngx_http_xslt_module   --with-http_image_filter_module enable ngx_http_image_filter_module   --with-http_image_filter_module=dynamic enable dynamic ngx_http_image_filter_module   --with-http_geoip_module enable ngx_http_geoip_module   --with-http_geoip_module=dynamic enable dynamic ngx_http_geoip_module   --with-http_sub_module enable ngx_http_sub_module   --with-http_dav_module enable ngx_http_dav_module   --with-http_flv_module enable ngx_http_flv_module   --with-http_mp4_module enable ngx_http_mp4_module   --with-http_gunzip_module enable ngx_http_gunzip_module   --with-http_gzip_static_module enable ngx_http_gzip_static_module   --with-http_auth_request_module enable ngx_http_auth_request_module   --with-http_random_index_module enable ngx_http_random_index_module   --with-http_secure_link_module enable ngx_http_secure_link_module   --with-http_degradation_module enable ngx_http_degradation_module   --with-http_slice_module enable ngx_http_slice_module   --with-http_stub_status_module enable ngx_http_stub_status_module 因此,从功能的全面性上来说,我们需要从源码上安装Nginx。 你可能会想,那为什么不索性将所有模块都编译到默认的Nginx中呢?按需编译模块,至少有以下4个优点: u    执行速度更快。例如,通过配置文件关闭功能,就需要多做一些条件判断。u    减少nginx可执行文件的大小。u    有些模块依赖项过多,在非必要时启用它们,会增加编译、运行环境的复杂性。u    给用户提供强大的自定义功能,比如在configure时设定配置文件、pid文件、可执行文件的路径,根据实际情况重新指定编译时的优化参数等等。 当然,最重要的还是可以通过configure --add-module选项任意添加自定义模块,这赋予Nginx无限的可能。 由于Nginx有许多分支和版本,该如何选择适合自己的版本呢?这有两个技巧,我们先来看mainline和stable版本的区别,在http://nginx.org/en/download.html上你会看到如下页面: 这里,mainline是含有最新功能的主线版本,它的迭代速度最快。另外,你可能注意到mainline是单号版本,而Openresty由于更新Nginx的频率较低,所以为了获得最新的Nginx特性,它通常使用mainline版本。 stable是mainline版本稳定运行一段时间后,将单号大版本转换为双号的稳定版本,比如1.18.0就是由1.17.10转换而来。 Legacy则是曾经的稳定版本。如果从头开始使用Nginx,那么你只需要选择最新的stable或者mainline版本就可以了。但如果你已经在使用某一个Legacy版本的Nginx,现在是否把它升级到最新版本呢?毕竟在生产环境上升级前要做完整的功能、性能测试,成本并不低。此时,我们要从CHANGES变更文件中,寻找每个版本的变化点。点开CHANGES文件,你会看到如下页面: 这里列出了每个版本的发布时间,以及发布时的变更。这些变更共分为以下4类:u    Feature新功能,比如上图HTTP框架新增的auth_delay指令。u    Bugfix问题修复,我们尤其要关注一些重大Bug的修复。u    Change已知特性的变更,比如之前允许HTTP请求头部中出现多个Host头部,但在1.17.9这个Change后,就认定这类HTTP请求非法了。u    Security安全问题的升级,比如1.15.6版本就修复了CVE-2018-16843等3个安全问题。 从Feature、Bugfix、Change、Security这4个方面,我们就可以更有针对性的升级Nginx。 认识Nginx的源码目录 当获取到Nginx源码压缩包并解压后,你可能对这些目录一头雾水,这里我对它们做个简单说明。比如1.18.0版本的源代码目录是这样的: 其中包含5个文件和5个目录,我们先来看单个文件的意义: u    CHANGES:即上面介绍过的版本变更文件。u    CHANGES.ru:由于Igor是俄罗斯人,所以除了上面的英文版变更文件外,还有个俄文版的变更文件。u    configure:如同其他Linux源码类软件一样,这是编译前的必须执行的核心脚本,它包含下面4个子功能:Ø  解析configure脚本执行时传入的各种参数,包括定制的第三方模块;Ø  针对操作系统、体系架构、编译器的特性,生成特定的编译参数;Ø  生成Makefile、ngx_modules.c等文件;Ø  在屏幕上显示汇总后的执行结果。u    LICENSE:这个文件描述了Nginx使用的2-clause BSD-like license许可协议。u    README:它只是告诉你去使用http://nginx.org官网查询各模块的用法。 再来看各个目录的意义: u    auto:configure只是一个简单的入口脚本,真正的功能是由auto目录下各个脚本完成的。u    conf:当安装完Nginx后,conf目录下会有默认的配置文件,这些文件就是从这里的conf目录复制过去的。u    contrib:包含了Nginx相关的周边小工具,比如下一讲将要介绍vim中如何高亮显示Nginx语法,就依赖于其中的vim子目录。u    html:安装完Nginx并运行后,会显示默认的欢迎页面,以及出现错误的500页面,这两个页面就是由html目录拷贝到安装目录的。u    man:目录中仅包含nginx.8一个文件,它其实是为Linux系统准备的man帮助文档,使用man -l nginx.8命令,可以看到Nginx命令行的使用方法: u    src:放置所有Nginx源代码的目录。关于src下的子目录,后续我分析源码时再详细介绍它们。 以上就是官方压缩包解压后的内容,当你执行完configure脚本后,还会多出Makefile文件以及objs目录,在下一篇文章中我会介绍它们。 小结最后,对《从头搭建静态资源服务器》系列第1篇做个总结。 Nginx是集静态资源与负载均衡与一身的Web服务器,它支持C10M级别的并发连接,也通过与操作系统的紧密结合,能够高效的使用系统资源。除性能外,Nginx通过优秀的模块设计,允许第三方的C模块、Lua模块等嵌入到Nginx中运行,这极大丰富了Nginx生态。 下载源码编译安装Nginx,可以获得定制Nginx的能力。这样不仅用助于性能的提升,还通过各类模块扩展了Nginx的功能。 Nginx源代码中有5个文件和5个一级目录,其中configure脚本极为关键,在它执行后,还会生成Makefile文件和objs目录,它们与定制化的模块、系统的高性能参数密切相关,此后才能正式编译Nginx。 下一篇,我将在《如何configure定制出属于你的Nginx》一文中介绍configure脚本的用法,配置文件的语法格式,以及如何配置出静态资源服务。 
Nginx是企业内网的对外入口,它常常同时对接许多应用,因此,Nginx上会同时监听多个端口、为多个域名提供服务。然而,匹配多级域名并不简单,Nginx为此准备了字符串精确匹配、前缀通配符、后缀通配符、正则表达式,当它们同时出现时,弄清楚HTTP请求会被哪个server{ }下的指令处理,就成了一件困难的事。这是因为基于域名规范,请求匹配server{ }配置块时,并不会按照它们在nginx.conf文件中的出现顺序作为选择依据。而且对于不支持Host头部、没有域名的HTTP/1.0请求和无法匹配到合适server{ }的异常请求,我们都要区别对待。另外,为了加快匹配速度,Nginx将字符串域名、前缀通配符、后缀通配符都放在了哈希表中,该设计充分使用了CPU的批量载入主存功能。如果不了解这些流程,既有可能导致请求没有被正确的server{ }块处理,也有可能降低了原本非常高效地哈希表查询性能。本文将沿着Nginx处理HTTP请求的流程,介绍一个请求是如何根据listen、server_name等配置关联到server{ }块的。我们将从TCP连接的建立、Nginx从哪些字段取出域名、域名是怎样与server_name匹配的,讲清楚Nginx如何为请求找到处理它的server{ }块。在实际运维中,大部分问题都是由于请求匹配指令错误造成的,搞清楚这一匹配流程,对我们掌握Nginx非常重要。listen指令对server{ }块的第1次关联为了让一台服务器可以处理访问多个域名的不同请求,我们用“虚拟主机”来定义一种域名的处理方式,在Nginx中这对应着一个server{ }块。因此,HTTP请求到达时,Nginx首先要找到处理它的server{ }配置块。请求关联server{ }块时主要依据listen和server_name这两个指令,其中listen指令发生在TCP连接建立完成时,它对server{ }块进行首次匹配,等到接收HTTP请求头部时,server_name再进行第二次匹配,这样就可以决定请求由哪个server{ }块中的指令处理。我们先来看listen指令是如何匹配请求的。Nginx启动时创建socket并监听listen指令告知的端口(包括绑定IP地址)。当运行在TCP协议之上的HTTP请求到达服务器时,操作系统首先收到了TCP三次握手请求。我们知道,TCP这种传输层协议是由内核实现的,因此,由内核完成TCP的三次握手后,就会通过“读事件”经由Linux的Epoll通知到Nginx的worker进程以及具体监听的socket。 比如,我们在nginx.conf中配置了以下两个server:server {    listen 192.168.1.5:80; }server {    listen 127.0.0.1:80; }如果是本机进程发来的HTTP请求(在Linux中可以用curl或者telnet发起请求),它的IP报文头部目的IP地址就是127.0.0.1,而TCP报文头部的目的端口就是80。这样,Linux内核就找到了相应的socket,进而通过epoll_wait函数唤醒Nginx进程,而Nginx也就找到了对应的listen指令以及其所属的server{ }块。版本号头部长度服务类型总长度标识标志位分片偏移TTL生存时间上层协议首部校验和源IP地址127.0.0.1源端口号80序列号确认序列号首部长度保留位UAPRSF窗口大小校验和紧急指针 你可能注意到,有些server{ }块没有listen指令也可以正常的工作。这是因为Nginx认为每个server{}都应该监听TCP端口,当你没有显式的配置listen指令时,Nginx会默认帮你打开80端口。 Nginx是怎样从HTTP请求中取出域名的?Nginx允许多个server{ }块监听相同的端口,所以当访问相同端口、不同域名的请求到达时,还需要根据请求中的域名做第2次匹配,以决定最终关联的server{ }块。这里我们先要搞清楚域名是怎么从HTTP请求中取出来的。在HTTP/1.0协议中并没有Host头部,这是因为互联网起步时,HTTP的设计者并没有考虑到域名的数量会远多于服务器。对于HTTP/1.0请求而言,只能从absolute URL中携带域名。举个例子,下面这个没有携带Host头部的请求可以取到www.taohui.pub域名:GET http://www.taohui.pub/index.html HTTP/1.0如果你不清楚HTTP协议的格式,建议你先观看下我在极客时间上的视频课程《Web协议详解与抓包实战》第12课《详解HTTP的请求行》。互联网业务的推动导致一台服务器必须要处理大量域名,于是HTTP/1.1协议推出了描述访问域名的Host头部。对于不含有Host头部的HTTP/1.1请求,RFC规范要求服务器必须返回400错误码(Nginx也正是这么做的)。当Host头部与上述absolute URL中的域名同时出现时,将会以后者为准。例如对于下面这个请求,Nginx会取出www.taohui.tech作为匹配域名:GET http://www.taohui.tech/index.html HTTP/1.0Host: www.taohui.pub另外,对于使用了TLS/SSL协议的HTTPS请求来说,还可以从TLS握手中获取到域名。关于TLS握手及相关插件我会在后续的文章中再详述。获取到请求的域名后,Nginx就会将其与上一节中listen指令匹配成功的server块进行第2次匹配,其中匹配依据就是server_name指令后的选项。我们暂且不谈server_name指令的匹配语法,先来看server_name匹配完成后的3种可能情况:1.      域名恰好与1个server{ }块中的server_name相匹配,选用该server{ }中的指令处理与请求;2.      有多个server{ }块匹配上了域名,此时按server_name规定的优先级选中一个server{ }块即可;3.      所有server{ }块都没有匹配上域名,此时必须有一个默认server { }块来处理这个请求。 其中在第3种情况里,Nginx是这么定义默认server { }的:1.      当listen指令后明确的跟着default_server选项时,它所属的server{ }就是默认server。2.      如果监听同一个端口的所有server{ }都没有通过listen指令显式设置default_server,那么这些server{ }配置块中,在nginx.conf配置文件里第1个出现的就是默认server。 注意,你不能把监听相同端口、地址对的两个server{ }块同时设为默认server,否则nginx将无法启动,并给出类似下方的错误输出:nginx: [emerg] a duplicate default server for 0.0.0.0:80 in /usr/local/nginx/conf/nginx.conf:40这就是请求匹配server{ }块的总体流程,下面我们来看server_name与域名的匹配,这也是最复杂的环节。 server_name指令对server{ }块的第2次关联 如果你购买过域名肯定清楚,虽然只买到一个域名,但你会有无数个子域名可以使用。比如我买到的是taohui.pub二级域名(pub是一级域名),我就可以配置出blog.taohui.pub这个三级域名,甚至自己搭建一个子域名解析服务,再配置出四级域名nginx.blog.taohui.pub,甚至五级、六级域名都能使用,如下图所示: 由于多级域名的存在,关联域名的server_name指令也相应地复杂起来,下面我们从3个层次看看server_name的选项种类。 首先,server_name支持精确地完全匹配,例如:server_name blog.taohui.pub;其次,server_name可以通过*符号作为通配符来匹配一类域名,比如:server_name *.taohui.pub;既可以匹配blog.taohui.pub,也可以匹配image.taohui.pub。由于*通配符在前方,所以我把它叫做前缀通配符。server_name还支持后缀通配符,例如:server_name www.taohui.*;它既可以匹配www.taohui.pub,也可以匹配www.taohui.tech域名。注意,server_name支持的通配符只能出现在最前方或者最后方,它不能出现在域名的中间,例如:server_name www.*.pub;就是非法的选项。 最后,当遇到通配符无法解决的场景时,可以使用正则表达式来匹配域名。当然,使用正则表达式的前提是将pcre开发库编译进Nginx(在CentOS下安装pcre开发库很简单,执行yum install pcre-devel -y即可。当有多个pcre版本并存时,可以通过configure --with-pcre=指定编译具体的pcre库)。 使用正则表达式时,需要在server_name选项前加入~符号,例如:server_name ~^ww\d.\w+.taohui.tech;$它可以匹配如ww3.blog.taohui.tech这样的域名。 当然,想一次写对正则表达式并不容易。pcre库提供的pcretest工具可以让我们提前测试正则表达式。注意,你用yum等工具安装pcre时,并不会自动安装pcretest工具,这需要你下载源代码(最新的pcre2-10.34下载地址参见这里,帮助文档参见这里)自行编译获得。本文不会讨论正则表达式的语法,也不会讨论pcretest工具的用法,关于Nginx中如何使用这两者,你可以观看下我在极客时间上的视频课程《Nginx核心知识100讲》第46课《Nginx中的正则表达式》。 Nginx中的正则表达式通常会提供提取变量的能力,server_name指令也不例外!我们可以通过小括号将域名中的信息取出来,交给后续的指令使用,例如:    server {        server_name ~^(ww\d).(?\w+).taohui.tech$;        return 200 ‘regular variable: $1 $domain‘;    }此时发起访问域名ww3.blog.taohui.tech的请求,由于第1个小括号我通过$1变量获取值为ww3,而第2个小括号我通过domain名称获得值为blog(通过$2也可以获得相同的内容),因此return指令发来的响应将会是regular variable: ww3 blog。 说完这3种域名选项后,我们再来看它们同时出现且匹配命中时,Nginx是怎样根据优先级来选择server{ }块的。域名的总体匹配优先级,与server{ }块在nginx.conf中的出现顺序无关,也与server_name指令在server{ }块中的出现顺序无关。事实上,对于监听同一地址、端口的server{ }块而言,Nginx会在进程启动时在收集所有server_name后,将精确匹配的字符串域名、前缀通配符、后缀通配符分别构建出3个哈希表,并将正则表达式构建为一个链表。我们看下请求到达时的匹配流程: 1.      匹配域名时,首先在字符串域名构成的哈希表上做精确查询,如果查询到了,就直接返回,因此,完全匹配的字符串域名优先级是最高的;2.      其次,将在前缀通配符哈希表上,按照每级域名的值分别查询哈希表,完成最长通配符的匹配。比如,blog.taohui.tech同时可以匹配以下2个前缀通配符:server_name *.tech;server_name *.taohui.tech;但Nginx会匹配命中*.taohui.tech。3.      其次,会在后缀通配符哈希表上做查询,完成最长通配符的匹配。4.      最后,会按照正则表达式在nginx.conf中出现的顺序,依次进行正则表达式匹配,这一步的性能比起前3步要慢许多。 这就是域名匹配的核心流程。 关于域名匹配你还需要了解的技巧 事实上,还有一些域名匹配的小技巧需要你掌握。首先,就像前面说过的HTTP/1.0协议是没有Host头部的,所以使用relative URL的HTTP/1.0请求并没有域名。按照之前的流程,它只能被默认server{ }块处理,这大大限制了默认server {}块的功能。在Nginx 0.7.11之后的版本,你可以通过server_name “”指定空字符串,来匹配没有域名的请求,这就解放了默认server { }的职责。其次,当Nginx对内网提供HTTP服务时,许多客户端会通过网络可达的主机名发起请求,这样客户端填写的域名就是主机名。如果必须由管理员先用hostname命令获取到主机名,再改写server_name指令,这就太不方便了。因此,server_name后还可以填写$hostname变量,这样Nginx启动时,会自动把$hostname替换为真正的主机名。server_name $hostname;最后一点,上文说过非正则表达式的server_name选项都会存放在哈希表中,这样哈希表中每个bucket桶大小就限制了域名的最大长度。当我们使用长域名或者多级域名时,默认的桶大小很可能就不够了,这时需要提升server_name的桶大小。桶大小由server_names_hash_bucket_size配置控制,由于CPU从内存中读入数据时是按批进行的,其中每批字节数是cpu cache line,因此为了一次可以载入一个哈希桶,server_names_hash_bucket_size的默认值被定为cpu cache line。目前多数CPU的cache line值是64字节,所以若域名较长时需要增加桶的大小。当你增大桶大小时,需要保证server_names_hash_bucket_size是cpu cache line的整数倍,这样读取哈希桶时,会尽量少地读取主存。毕竟操作主存的速度通常在100纳秒左右,这比CPU的速度慢得多!小结最后对本文做个小结。当TCP三次握手完成后,Linux内核就会按照内核的负载均衡算法,唤醒监听相应端口的某个Nginx worker进程。而从读事件及socket句柄上,Nginx可以找到对应的listen指令及所属的server{ }块,这完成了初次匹配。接着,Nginx会接收HTTP请求,从absolute URL、 Host域名或者TLS插件中取出域名,再将域名与server_name进行匹配。其中匹配优先级是这样的:精确的字符串匹配优先级最高,其次是前缀通配符和后缀通配符匹配(这两者匹配时,如果多个通配符命中,会选择最长的server_name),最后才是正则表达式匹配。如果以上情况皆未匹配上,请求会被默认server{ }处理。其中默认server {}是监听同一端口、地址的一系列server{ }块中,第1个在nginx.conf中出现的那个server{ }。当然,通过listen default_server也可以显示地定义默认server{ }。最后留给你一个思考题,为什么有人用server_name _;来处理未匹配上的请求?欢迎你留言一起探讨。  
分布式系统提升可用性时,最有效的方案就是在空间维度上,将资源复制一份作为缓存,并把缓存放在离用户更近的地方。这样,通过缩短用户的访问路径,不只可以降低请求的时延,多份资源还能提升系统的健壮性。比如WEB服务中的CDN就是这样一个缓存系统。 Nginx由于具有下面3个特性,因此是最合适的缓存系统:l  首先,高并发、低延迟赋予了Nginx优秀的性能;l  其次,多进程架构让Nginx具备了很高的稳定性;l  最后,模块化的开源生态,以及从开放中诞生的Openresty、Kong等其他体系,这都让Nginx的功能丰富而强大。 所以,Nginx往往部署在企业最核心的边缘位置,在最外层的Nginx上部署共享缓存,能够给服务带来更大的收益,更短的访问路径带来了更佳的用户体验。然而,当整个系统的可用性极度依赖Nginx的缓存功能时,我们必须仔细地配置Nginx,还得使用到缓存的许多进阶功能。 比如,在超大流量下如果热点资源的缓存失效,那么在巨大的流量穿透Nginx缓存后,非常有可能把脆弱的上游服务打挂。此时合并回源请求功能,就是你的最佳应对手段!再比如,在源服务器暂时不可用时,使用失效过期的缓存,为用户提供有限的服务,可以通过降级体验来提升系统的健壮性,这要比给用户返回“系统暂时不可用”要好得多。再比如,Nginx重启后,需要为磁盘上大量的缓存文件,在共享内存中建立起索引,这一过程可能很漫长,我们必须防止它对正常的服务产生过大的影响,降低用户体验。 如果你不清楚Nginx缓存的这些用法,就无法在超大流量场景中,用Nginx缓存保障服务的高可用性。当然,你可能觉得加机器扩容简单粗暴又有效,可是,一旦Nginx缓存正常工作后,这些新增的机器利用率又变得极低,这提升了公司的IT成本。因此,Nginx缓存的这些进阶用法,是你必须掌握的知识点! 这篇文章将分为上、下两个部分,先带着你回顾下Nginx缓存的基本用法,再来看如何使用缓存的进阶功能。 缓存的基本工作流程时间局部性原理,决定了缓存的有效性。比如,当客户端首次获取到/url1对应的资源时,它可以用key/value的形式,将请求与响应同时缓存到磁盘上,其中,key就是/url1,而value就是响应中的资源。只要服务器没有更改/url1对应的资源内容,那么在时间维度上,后续访问/url1的请求,就一直可以使用磁盘上的缓存代替网络访问来提升性能,如下图所示:通过缓存,请求的时延得到极大的下降,因为访问磁盘不过几十毫秒,但经过以秒级计量的网络再到达源服务器,并由源服务器再读取磁盘,速度就慢了很多,尤其在网络是不稳定、不可控的时候。 然而,服务器上的资源一旦发生了更新,客户端的缓存就失效了,使用这样的缓存有可能导致业务逻辑出错,怎么解决这一问题呢?通常,由于服务器最清楚资源的变更频率,这样,在服务器发送的响应中,设置一个预估缓存失效时间的HTTP头部,就可以解决这一问题。就像Cache-Control: max-age头部,或者Expires头部,等等。 客户端基于服务器告知的失效时间设立定时器,当定时器归零触发时,再将缓存设置为失效即可。注意,失效的缓存仍然有多方面的价值。 首先,客户端的失效定时器,毕竟来源于服务器的预估时间,时间过期后服务器上的资源也可能并未发生变化。这样,如果我们给每份资源生成唯一的ID(也就是缓存摘要,RFC规范用etag头部表示摘要),而客户端在判定缓存失效后,能够携带着摘要访问服务器,这就允许服务器通过304 Not Modified空包体回复客户端。如果资源是一个数百MB的视频文件,这一下就省去了数百MB字节的网络传输,如下图所示: Etag摘要究竟是怎样生成的呢?Nginx会将文件大小和最近修改时间,拼接为一个字符串作为文件摘要(详见《Nginx核心知识100讲》第97课),虽然区分度稍差,但优点是生成速度非常快。 其次,在某些场景下,给用户提供不那么及时的过期页面,要比返回500 服务器内部错误好得多。Nginx的proxy_cache_use_stale指令就可以完成这一功能,我们下一篇再细说。 缓存可以存放在任何位置!其中,仅存放于终端、只为一个用户服务的缓存,叫做私有缓存(Private Cache),而存放于服务器上,可以被多个用户共享使用的缓存,叫做共享缓存(Public Cache)。比如下图的REST架构中,浏览器User Agent中的$符号($表示现金cash,与cache发音很接近,故常用来表示缓存)表示私有缓存,而Proxy正向代理、Gateway反向代理中的$符号表示共享缓存:  Nginx上的缓存就是共享缓存,接下来我们看看如何配置共享缓存。 如何开启基础版的共享缓存功能?接下来,我将按照缓存的工作流程,介绍配置基础共享缓存的Nginx指令。 首先,我们要确定每个资源对应的key关键字。这里的原则是,既要确保能够区分开不同的资源(正确性),又要尽量被更多的同类用户共享使用(高效率)。 比如,若多个域名下不同的资源,使用了相同的URL,那么你仅用URL作为key,一定会导致缓存被错误地共享使用。再比如,如果你把客户端的IP地址也写进了key中,那么不同的客户端就无法共享缓存,导致缓存的利用率大幅下降。再比如,若你的服务器同时提供HTTP和 HTTPS服务,那么你将$scheme写入key中,就会导致HTTP和HTTPS不能使用对方产生的缓存。 在Nginx的HTTP反向代理中,可以使用proxy_cache_key指令配置缓存的key关键字,其中值可以使用变量,比如:proxy_cache_key $proxy_host$request_uri; 其他反向代理也有类似的配置(比如python语言的ngx_http_uwsgi_module反向代理模块中,使用相似的uwsgi_cache_key指令完成这一功能)。 其次,我们要决定缓存对应的文件放置在磁盘中的哪个位置,这可以通过proxy_cache_path指令完成,例如:proxy_cache_path /data/nginx/cache levels=2:2 keys_zone=one:10m; 这行指令要求Nginx把缓存文件放在/data/nginx/cache目录下。注意,1个目录中不能存放太多的文件(会影响性能),所以levels选项要求Nginx使用2级缓存子目录,其中每级目录用文件的MD5摘要中2个字符命名。 虽然缓存内容是存放在磁盘中的,但为了加快访问速度,Nginx会在共享内存中为缓存建立红黑树索引,这可以为多个worker进程加快处理速度。keys_zone选项定义了共享内存的大小和名称,其中每1MB的共享内存,大约可以存放8千个key关键字。 定义好了缓存及索引的存放方式后,还需要定义究竟缓存哪些资源。比如,404错误码表示找不到资源,302错误码表示临时重定向,这样的响应是否需要缓存呢?这就是具体情况具体分析,如果访问频率非常高,而且这些错误一时无法恢复,那么缓存它们也是个不错的选择。 通过proxy_cache_valid指令可以设置缓存哪些响应码,并可以设置缓存的有效时间,如下所示:proxy_cache_valid 200 302 10m; 你可能在想,刚刚才说过源服务器会定义缓存的过期时间,为何代理服务器Nginx要多此一举,重新定义过期时间呢?这是因为Nginx的这个过期时间,是用于兜底的,它的优先级很低,仅针对不含有这些过期时间头部的响应。当上游源服务器的响应中含有过期时间时,会优先使用响应中的时间。 而且,响应头部中同时出现多个过期时间,Nginx还定义了不同的优先级,比如:Nginx私有的X-Accel-Expires头部优先级最高,其次才是RFC规范中定义的Expires和Cache-Control头部。另外,对于含有Set-Cookie头部的响应,出于安全性考虑,Nginx默认是不会缓存的,包括Vary: *头部也是如此,如果你希望缓存它们,那么可以通过proxy_ignore_headers指令实现。 这里还要注意,proxy_cache_valid指令中的时间是必填项,当只填写了时间时,proxy_cache_valid默认会缓存以下3个错误码的响应:200、301、302。 在定义好了缓存的处理方式后,最后还得通过proxy_cache指令,告诉Nginx针对哪些域名(在server{ }内配置)、URL(在location{ }内配置)才缓存请求。proxy_cache指令非常简单,指定proxy_cache指令中keys_zone选项的共享内存名即可,比如:proxy_cache one; 至此,我们就配置好了可以基本工作的Nginx缓存。虽然此时的缓存已经能够大幅度提升性能,但Nginx在缓存失效后,还无法在回源请求中携带etag缓存摘要,自然也无法享受到源服务器返回304空包体响应带来的好处了。你还需要通过proxy_cache_revalidate指令打开这一功能,如下所示:proxy_cache_revalidate on; 小结本文介绍了Nginx缓存的工作流程,以及如何配置一个基础版的共享缓存。 首先,Nginx作为代理服务器,它提供的是效率更高的共享缓存。因此,我们要通过proxy_cache_key指令,通过Nginx变量选定合适的缓存关键字,既要保证在功能上有正确的区分度,也要在效率上可以为同类用户共享使用。 其次,缓存内容存放在磁盘文件中,而为了加快处理速度,Nginx还在共享内存中建立了红黑树索引。通过proxy_pass指令,可以设定磁盘文件的存储方式,以及共享内存的名称及大小。 最后,服务器会通过响应头部,预估缓存的过期时间。而在Nginx中,proxy_cache_valid既可以设置通用的、优先级较低的过期时间,也可以缓存非200错误码的响应。当然,缓存过期后,打开proxy_cache_revalidate功能还可以减少源服务器的数据传输量。 篇幅所限,我将在下一篇文章中,再来讨论Nginx缓存的进阶功能。比如,如何设置缓存的最大容量,如何在Nginx重启后控制cache loader进程对CPU资源的消耗,如何控制回源请求的数量,如何使用过期缓存提供降级服务等等。 最后,留给你一道思考题,你知道Nginx的性能非常高,那么它在实现层面是怎样淘汰过期缓存的吗?欢迎你在留言中与大家一起分享你的看法。 感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎你把它分享给你的朋友。  
在实时性要求较高的特殊场景下,简单的UDP协议仍然是我们的主要手段。UDP协议没有重传机制,还适用于同时向多台主机广播,因此在诸如多人会议、实时竞技游戏、DNS查询等场景里很适用,视频、音频每一帧可以允许丢失但绝对不能重传,网络不好时用户可以容忍黑一下或者声音嘟一下,如果突然把几秒前的视频帧或者声音重播一次就乱套了。使用UDP协议作为信息承载的传输层协议时,就要面临反向代理如何选择的挑战。通常我们有数台企业内网的服务器向客户端提供服务,此时需要在下游用户前有一台反向代理服务器做UDP包的转发、依据各服务器的实时状态做负载均衡,而关于UDP反向代理服务器的使用介绍网上并不多见。本文将讲述udp协议的会话机制原理,以及基于nginx如何配置udp协议的反向代理,包括如何维持住session、透传客户端ip到上游应用服务的3种方案等。UDP协议简介许多人眼中的udp协议是没有反向代理、负载均衡这个概念的。毕竟,udp只是在IP包上加了个仅仅8个字节的包头,这区区8个字节又如何能把session会话这个特性描述出来呢?图1 UDP报文的协议分层在TCP/IP或者 OSI网络七层模型中,每层的任务都是如此明确:物理层专注于提供物理的、机械的、电子的数据传输,但这是有可能出现差错的;数据链路层在物理层的基础上通过差错的检测、控制来提升传输质量,并可在局域网内使数据报文跨主机可达。这些功能是通过在报文的前后添加Frame头尾部实现的,如上图所示。每个局域网由于技术特性,都会设置报文的最大长度MTU(Maximum Transmission Unit),用netstat -i(linux)命令可以查看MTU的大小:  而IP网络层的目标是确保报文可以跨广域网到达目的主机。由于广域网由许多不同的局域网,而每个局域网的MTU不同,当网络设备的IP层发现待发送的数据字节数超过MTU时,将会把数据拆成多个小于MTU的数据块各自组成新的IP报文发送出去,而接收主机则根据IP报头中的Flags和Fragment Offset这两个字段将接收到的无序的多个IP报文,组合成一段有序的初始发送数据。IP报头的格式如下图所示:图2 IP报文头部IP协议头(本文只谈IPv4)里最关键的是Source IP Address发送方的源地址、Destination IP Address目标方的目的地址。这两个地址保证一个报文可以由一台windows主机到达一台linux主机,但并不能决定一个chrome浏览的GET请求可以到达linux上的nginx。4、传输层主要包括TCP协议和UDP协议。这一层最主要的任务是保证端口可达,因为端口可以归属到某个进程,当chrome的GET请求根据IP层的destination IP到达linux主机时,linux操作系统根据传输层头部的destination port找到了正在listen或者recvfrom的nginx进程。所以传输层无论什么协议其头部都必须有源端口和目的端口。例如下图的UDP头部:图3 UDP的头部TCP的报文头比UDP复杂许多,因为TCP除了实现端口可达外,它还提供了可靠的数据链路,包括流控、有序重组、多路复用等高级功能。由于上文提到的IP层报文拆分与重组是在IP层实现的,而IP层是不可靠的所有数组效率低下,所以TCP层还定义了MSS(Maximum Segment Size)最大报文长度,这个MSS肯定小于链路中所有网络的MTU,因此TCP优先在自己这一层拆成小报文避免的IP层的分包。而UDP协议报文头部太简单了,无法提供这样的功能,所以基于UDP协议开发的程序需要开发人员自行把握不要把过大的数据一次发送。对报文有所了解后,我们再来看看UDP协议的应用场景。相比TCP而言UDP报文头不过8个字节,所以UDP协议的最大好处是传输成本低(包括协议栈的处理),也没有TCP的拥塞、滑动窗口等导致数据延迟发送、接收的机制。但UDP报文不能保证一定送达到目的主机的目的端口,它没有重传机制。所以,应用UDP协议的程序一定是可以容忍报文丢失、不接受报文重传的。如果某个程序在UDP之上包装的应用层协议支持了重传、乱序重组、多路复用等特性,那么他肯定是选错传输层协议了,这些功能TCP都有,而且TCP还有更多的功能以保证网络通讯质量。因此,通常实时声音、视频的传输使用UDP协议是非常合适的,我可以容忍正在看的视频少了几帧图像,但不能容忍突然几分钟前的几帧图像突然插进来:-)UDP协议的会话保持机制有了上面的知识储备,我们可以来搞清楚UDP是如何维持会话连接的。对话就是会话,A可以对B说话,而B可以针对这句话的内容再回一句,这句可以到达A。如果能够维持这种机制自然就有会话了。UDP可以吗?当然可以。例如客户端(请求发起者)首先监听一个端口Lc,就像他的耳朵,而服务提供者也在主机上监听一个端口Ls,用于接收客户端的请求。客户端任选一个源端口向服务器的Ls端口发送UDP报文,而服务提供者则通过任选一个源端口向客户端的端口Lc发送响应端口,这样会话是可以建立起来的。但是这种机制有哪些问题呢?问题一定要结合场景来看。比如:1、如果客户端是windows上的chrome浏览器,怎么能让它监听一个端口呢?端口是会冲突的,如果有其他进程占了这个端口,还能不工作了?2、如果开了多个chrome窗口,那个第1个窗口发的请求对应的响应被第2个窗口收到怎么办?3、如果刚发完一个请求,进程挂了,新启的窗口收到老的响应怎么办?等等。可见这套方案并不适合消费者用户的服务与服务器通讯,所以视频会议等看来是不行。有其他办法么?有!如果客户端使用的源端口,同样用于接收服务器发送的响应,那么以上的问题就不存在了。像TCP协议就是如此,其connect方的随机源端口将一直用于连接上的数据传送,直到连接关闭。这个方案对客户端有以下要求:不要使用sendto这样的方法,几乎任何语言对UDP协议都提供有这样的方法封装。应当先用connect方法获取到socket,再调用send方法把请求发出去。这样做的原因是既可以在内核中保存有5元组(源ip、源port、目的ip、目的端口、UDP协议),以使得该源端口仅接收目的ip和端口发来的UDP报文,又可以反复使用send方法时比sendto每次都上传递目的ip和目的port两个参数。对服务器端有以下要求:不要使用recvfrom这样的方法,因为该方法无法获取到客户端的发送源ip和源port,这样就无法向客户端发送响应了。应当使用recvmsg方法(有些编程语言例如python2就没有该方法,但python3有)去接收请求,把获取到的对端ip和port保存下来,而发送响应时可以仍然使用sendto方法。 接下来我们谈谈nginx如何做udp协议的反向代理。Nginx的stream系列模块核心就是在传输层上做反向代理,虽然TCP协议的应用场景更多,但UDP协议在Nginx的角度看来也与TCP协议大同小异,比如:nginx向upstream转发请求时仍然是通过connect方法得到的fd句柄,接收upstream的响应时也是通过fd调用recv方法获取消息;nginx接收客户端的消息时则是通过上文提到过的recvmsg方法,同时把获取到的客户端源ip和源port保存下来。我们先看下recvmsg方法的定义:ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);相对于recvfrom方法,多了一个msghdr结构体,如下所示:struct msghdr {             void         *msg_name;       /* optional address */             socklen_t     msg_namelen;    /* size of address */             struct iovec *msg_iov;        /* scatter/gather array */             size_t        msg_iovlen;     /* # elements in msg_iov */             void         *msg_control;    /* ancillary data, see below */             size_t        msg_controllen; /* ancillary data buffer len */             int           msg_flags;      /* flags on received message */ };其中msg_name就是对端的源IP和源端口(指向sockaddr结构体)。以上是C库的定义,其他高级语言类似方法会更简单,例如python里的同名方法是这么定义的:(data, ancdata, msg_flags, address) = socket.recvmsg(bufsize[, ancbufsize[, flags]])其中返回元组的第4个元素就是对端的ip和port。配置nginx为UDP反向代理服务以上是nginx在udp反向代理上的工作原理。实际配置则很简单:# Load balance UDP-based DNS traffic across two servers stream {             upstream dns_upstreams {                         server 192.168.136.130:53;                         server 192.168.136.131:53;             }                  server {                         listen 53 udp;                         proxy_pass dns_upstreams;                         proxy_timeout 1s;                         proxy_responses 1;                         error_log logs/dns.log;             } }在listen配置中的udp选项告诉nginx这是udp反向代理。而proxy_timeout和proxy_responses则是维持住udp会话机制的主要参数。UDP协议自身并没有会话保持机制,nginx于是定义了一个非常简单的维持机制:客户端每发出一个UDP报文,通常期待接收回一个报文响应,当然也有可能不响应或者需要多个报文响应一个请求,此时proxy_responses可配为其他值。而proxy_timeout则规定了在最长的等待时间内没有响应则断开会话。如何通过nginx向后端服务传递客户真实IP最后我们来谈一谈经过nginx反向代理后,upstream服务如何才能获取到客户端的地址?如下图所示,nginx不同于IP转发,它事实上建立了新的连接,所以正常情况下upstream无法获取到客户端的地址:图4 nginx反向代理掩盖了客户端的IP上图虽然是以TCP/HTTP举例,但对UDP而言也一样。而且,在HTTP协议中还可以通过X-Forwarded-For头部传递客户端IP,而TCP与UDP则不行。Proxy protocol本是一个好的解决方案,它通过在传输层header之上添加一层描述对端的ip和port来解决问题,例如:但是,它要求upstream上的服务要支持解析proxy protocol,而这个协议还是有些小众。最关键的是,目前nginx对proxy protocol的支持则仅止于tcp协议,并不支持udp协议,我们可以看下其代码:可见nginx目前并不支持udp协议的proxy protocol(笔者下的nginx版本为1.13.6)。虽然proxy protocol是支持udp协议的。怎么办呢?方案1:IP地址透传可以用IP地址透传的解决方案。如下图所示:图5 nginx作为四层反向代理向upstream展示客户端ip时的ip透传方案这里在nginx与upstream服务间做了一些hack的行为:nginx向upstream发送包时,必须开启root权限以修改ip包的源地址为client ip,以让upstream上的进程可以直接看到客户端的IP。server {              listen 53 udp;          proxy_responses 1;          proxy_timeout 1s;          proxy_bind $remote_addr transparent;               proxy_pass dns_upstreams; }upstream上的路由表需要修改,因为upstream是在内网,它的网关是内网网关,并不知道把目的ip是client ip的包向哪里发。而且,它的源地址端口是upstream的,client也不会认的。所以,需要修改默认网关为nginx所在的机器。# route del default gw 原网关ip # route add default gw nginx的ipnginx的机器上必须修改iptable以使得nginx进程处理目的ip是client的报文。# ip rule add fwmark 1 lookup 100 # ip route add local 0.0.0.0/0 dev lo table 100  # iptables -t mangle -A PREROUTING -p tcp -s 172.16.0.0/28 --sport 80 -j MARK --set-xmark 0x1/0xffffffff这套方案其实对TCP也是适用的。方案2:DSR(上游服务无公网)除了上述方案外,还有个Direct Server Return方案,即upstream回包时nginx进程不再介入处理。这种DSR方案又分为两种,第1种假定upstream的机器上没有公网网卡,其解决方案图示如下:图6 nginx做udp反向代理时的DSR方案(upstream无公网)这套方案做了以下hack行为:1、在nginx上同时绑定client的源ip和端口,因为upstream回包后将不再经过nginx进程了。同时,proxy_responses也需要设为0。server {         listen 53 udp;     proxy_responses 0;         proxy_bind $remote_addr:$remote_port transparent;          proxy_pass dns_upstreams; }2、与第一种方案相同,修改upstream的默认网关为nginx所在机器(任何一台拥有公网的机器都行)。3、在nginx的主机上修改iptables,使得nginx可以转发upstream发回的响应,同时把源ip和端口由upstream的改为nginx的。例如:# tc qdisc add dev eth0 root handle 10: htb # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.11 match ip sport 53 action nat egress 172.16.0.11 192.168.99.10 # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.12 match ip sport 53 action nat egress 172.16.0.12 192.168.99.10 # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.13 match ip sport 53 action nat egress 172.16.0.13 192.168.99.10 # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.14 match ip sport 53 action nat egress 172.16.0.14 192.168.99.10方案3:DSR(上游服务有公网)DSR的另一套方案是假定upstream上有公网线路,这样upstream的回包可以直接向client发送,如下图所示:图6 nginx做udp反向代理时的DSR方案(upstream有公网)这套DSR方案与上一套DSR方案的区别在于:由upstream服务所在主机上修改发送报文的源地址与源端口为nginx的ip和监听端口,以使得client可以接收到报文。例如:# tc qdisc add dev eth0 root handle 10: htb # tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.11 match ip sport 53 action nat egress 172.16.0.11 192.168.99.10结语以上三套方案皆可以使用开源版的nginx向后端服务传递客户端真实IP地址,但都需要nginx的worker进程跑在root权限下,这对运维并不友好。从协议层面,可以期待后续版本支持proxy protocol传递客户端ip以解决此问题。在当下的诸多应用场景下,除非业务场景明确无误的拒绝超时重传机制,否则还是应当使用TCP协议,其完善的流量、拥塞控制都是我们必须拥有的能力,如果在UDP层上重新实现这套机制就得不偿失了。