0%

HTTP请求是如何关联Nginx server{}块的?

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:

1
2
3
4
5
6
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{ }块。

你可能注意到,有些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域名:

1
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作为匹配域名:

1
2
GET http://www.taohui.tech/index.html HTTP/1.0
Host: 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将无法启动,并给出类似下方的错误输出:

1
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支持精确地完全匹配,例如:

1
server_name blog.taohui.pub;

其次,server_name可以通过*符号作为通配符来匹配一类域名,比如:

1
server_name *.taohui.pub;

既可以匹配blog.taohui.pub,也可以匹配image.taohui.pub。由于*通配符在前方,所以我把它叫做前缀通配符。server_name还支持后缀通配符,例如:

1
server_name www.taohui.*;

它既可以匹配www.taohui.pub,也可以匹配www.taohui.tech域名。注意,server_name支持的通配符只能出现在最前方或者最后方,它不能出现在域名的中间,例如:

1
server_name www.*.pub;

就是非法的选项。

最后,当遇到通配符无法解决的场景时,可以使用正则表达式来匹配域名。当然,使用正则表达式的前提是将pcre开发库编译进Nginx(在CentOS下安装pcre开发库很简单,执行yum install pcre-devel -y即可。当有多个pcre版本并存时,可以通过configure –with-pcre=指定编译具体的pcre库)。

使用正则表达式时,需要在server_name选项前加入~符号,例如:

1
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指令也不例外!我们可以通过小括号将域名中的信息取出来,交给后续的指令使用,例如:

1
2
3
4
server {
server_name ~^(ww\d).(?<domain>\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个前缀通配符:
    1
    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 _;来处理未匹配上的请求?欢迎你留言一起探讨。