Nginx server匹配原理及源码分析
一概述
Nginx最重要的功能之一就是把不同的请求分发到不同的服务处理中。其实质就是把服务请求转发到正确的服务上去。
如果说二层的转发是依据数据包的二层mac地址,三层的转发依据的是数据包三层的IP地址,那么Nginx的服务转发依靠的是数据包的四七层信息。
Nginx的请求路由转发,可以分成两个阶段。第一个阶段是匹配定义的server。首先根据请求中的目的地址和端口进行匹配。如果相同的目的地址和端口同时还会对应多个servers,再根据server_name属性进行进一步匹配。
第二阶段,在匹配到server后,Nginx根据请求URL中的path信息再匹配server中定义的某一个location。
本文试着从配置,原理和源代码的角度对Nginx如果进行server匹配进行分析。
二配置指令
相关指令
在Nginx中,我们可以通过server指令定义一系列不同的virtual server。在server中通过listen指令定义这个virtual server服务的地址和端口。在相同的地址和端口可以定义多个server是。他们通过server_name指令来进一步进行区分。
server {
listen 80;
server_name example.com;
}
匹配server需要数据包的四七层信息。与四层相关的是数据包的目的地址和port,与七层有关的信息是hostname和url中的path。其中对应四层的配置指令是listen指令。对应七层的配置指令是server_name和location两个指令。
其中与server匹配相关的四七层指令是listen和server_name两个。
listen指令
Listen指令语法:listen [address|*][:port] [default_server] [flags]
- 第一个参数是指定Nginx运行的主机地址。一条listen指令中可以指定一个主机地址,如果配置的主机地址是*或者忽略,默认绑定主机所有的地址。如果要选择主机众多地址中的某几个,可以通过多个listen指令来实现,不能通过一条listen指令配置服务器众多地址中的某几个地址。主机地址可以是本机的hostname或者域名。在Nginx启动期间会对配置hostname或者域名进行解析。如果配置的地址或者域名和本机ip地址不相配,nginx会启动失败。地址还可以是Unix socket地址。
- 第二个参数指定要监听的端口。可以是单独的一个数字比如8080,也可以规定一个联系的范围,比如8080-9090。如果需要监听多个非连续的端口,可以通过多个listen指令实现。
- 最后的flags参数大多数是用来控制监听端口socket参数和网络行为的。其中default_server是指定当相同地址和端口对有多个server对应时,如果在匹配过程中通过server_name也不能匹配到相关的server,带有default_server标志的server就成为该匹配的结果。
- 相同的IP以及端口只可以设置一个默认虚拟服务器。如果相同IP以及端口对应的server都没有标注default_server,那么配置文件中对于这个ip和端口对的第一个定义的server就是default_server。
需要注意一下几点:
- 如果指定ip或者domain_name而不指定端口号,比如listen 1.1.1.1; listen my_host_name; 如此配置对应的端口对于root用户是80,对于非root用户是8000。
- 指定端口号而不指定ip或者domain_name,比如listen 80;默认对nginx所在主机的所有的ip地址的80端口都监听。
- 通过通配符*指定所有的主机地址。比如listen *:80;默认对所有的主机地址的端口都进行监听。
- 如果server中没有配置listen指令,对应root用户默认是执行listen *,对应非root用户执行的是listen *:8000。
- 另外listen之类也支持unix socket路径,用于和同主机之间的服务交互。
server_name指令
指令server_name的语法是:server_name name1 [name2] ..[namen];
可以配置同时配置一个或者多个name。如果不配置server_name对应的默认值是server_name““。
参数name的配置形式有如下几种:
- 全字符串。
- 特殊变量$hostname
当使用变量$hostname时,在nginx启动运行时,会把$hostname变量替换成本机的主机名。所以从本质上讲,在匹配时,这种情况也是一种全字符匹配。
- 带有通配符的字符串。对于这种字符串,通配符*的位置只能出现在头部或者末尾,而且不能和别的字符租户。比如*.test.com; www.test.*; 是合法的。但是 *test.com; www.*.com; www.*a.com; www.abc.*om;都是不合法的。
-
正则表达式,nginx要求正则表达式必须以~开头进行表示,而且正则表达式的字符全部按照小写字符进行匹配。在使用正则表达式时,通常会以 ^ 开头以 $ 结尾,虽然正则语法上并不要求这样配置,但是会大大提升解析效率。另外,点符号(.)是正则表达式的一个关键字,所以域名中的点需要使用反斜线来转意(\.),比如
~^(?.+)\.domain\.com$;
就是一个合理的正则表达式定义的
server_name
。
另外,server_name字符是不区分大写小的。无论是全字符,前后缀通配符还是正则表达式,所有配置的server_name都按照小写字母来处理
对于这几种配置类型,匹配时的优先级顺序是:
- 首选匹配全字符串。
- 如果全字符串没有命中,则进行最长前缀通配符匹配。如果多个匹配的前缀字符串,最长匹配就是匹配结果。
- 如果前缀通配字符没有命中,则进行最长后缀通配符匹配。如果多个匹配的后缀字符串,最长匹配就是匹配结果。
- 如果后缀通配字符没有命中,则进行正则表达式类型匹配。按照在配置文件中出现的顺序,第一个匹配到的正则表达式就是匹配结果。
- 如果正则表达式也没有命中,则选择这个ip和port对的default_server。
对于用来进行匹配参数的server_name, 有如下的几种获取途径:
- 对于http1.1,协议规定必须携带host头部,此头部就可以用来进行server_name的匹配。
- 对于HTTP/1.0请求来说就没有这个要求,所以对于HTTP/1.0,只能把absolute URL中携带域名用来匹配。
- 如果host头部和absolute URL都可以提取server_name,按照absolute URL为准。如果两者都没有,使用server_name “”;来匹配。
- 对于HTTPS请求来说,可以从TLS握手过程中获取到域名。
三Server匹配效果举例
对应ip地址和port的匹配,过程相对清晰简单,我们就不在举例说明。下面通过几个例子解释server_name匹配的原则。
1.如果有server_name正好完全匹配http中的Host头部,则定义这个完整字符串的server block就被选择处理请求。
如下配置,如果server_name值是host1.jikui.com,则第二个server block被选中用来处理请求。
server {
listen 80;
server_name *.jikui.com;
…
}
server {
listen 80;
server_name host1.jikui.com;
}
2. 如果完全字符串没有匹配,则在前缀通配符中进行最长匹配。
如下配置,如果请求中的server_name数值是 “www.jikui.org”, 下面第二个服务器就会被选中。
server {
listen 80;
server_name www.jikui.*;
. . .
}
server {
listen 80;
server_name *.jikui.org;
. . .
}
server {
listen 80;
server_name *.org;
. . .
}
3.如果前缀匹配没有成功,则进行最长后缀匹配。
如下列配置, 如果server_name值是“www.jikui.com”, 第三个服务器将会被选中。
server {
listen 80;
server_name host1.jikui.com;
. . .
}
server {
listen 80;
server_name jikui.com;
. . .
}
server {
listen 80;
server_name www.jikui.*;
. . .
}
4. 如果后缀匹配没有成功,则进行正则表达式匹配。第一个匹配成功的正则表达式所定义的server将会被用来处理请求。
比如,如果server_name值是“www.jikui.com”, 第二个定义的服务器被选中用来处理服务。
server {
listen 80;
server_name jikui.com;
. . .
}
server {
listen 80;
server_name ~^(www|host1).*\.jikui\.com$;
. . .
}
server {
listen 80;
server_name ~^(subdomain|set|www|host1).*\.jikui\.com$;
. . .
}
5. 如果正则表达式也没有匹配成功,则会使用ip和port对的default_server来处理请求。
四源代码分析
数据结构
与 server匹配相关的数据结构有:ngx_listening_t, ngx_http_port_t, ngx_http_in_addr_t, ngx_http_addr_conf_t, ngx_http_server_name_t, ngx_http_conf_port_t, ngx_http_conf_addr_t, ngx_http_request_t, ngx_http_connection_t等。
他们相互的关系如下图所示。
配置层面源码分析
与指令listen对应的解析函数是ngx_http_core_listen。而与指令server_name对应的是ngx_http_core_server_name。
指令listen解析流程
- ngx_http_core_listen 首先调用ngx_parse_url来解析listen指令后的ip地址或者domain_name和端口。如果有多个连续端口,在u->last_port会记录最后一个port。然后解析剩下的配置项并且存放在ngx_http_listen_opt_t结构中。在使用ngx_parse_url解析时,可能得到多个地址。函数会把每一个地址连同生成的属性调用ngx_http_add_listen存放到ngx_http_core_main_conf_t结构中的port数组中。从上面数据结构图中,我们可以看到,一个port结构对应着一个addr数组。这个一对多的关系就是对一个主机有多个IP地址然后每一个IP地址都可以在某一个port上启服务这种模型进行建模。
- 函数ngx_http_add_listen会遍历ngx_http_core_main_conf_t中的ports数组。对于每一个port调用ngx_http_add_addresses为每一个port添加对应的ngx_http_core_srv_conf_t结构。
- 在函数ngx_http_add_addresses中,如果当前port已经存在一个相同的地址,则调用ngx_http_add_server把解析对应对的server结构ngx_http_core_srv_conf_t添加到port对应的server数组中。如果没有找到相同的地址,则在ports里新加一个port再调用ngx_http_add_address添加一个addr到此port中。
至此,port和addr之间以及addr和server之间的1对多关系都已经建立起来了。 一个具体的端口比如80可能对应主机的多个ip地址。然后即使ip和端口相同,也可以定义不同的servers。
指令server_name解析流程
与server_name指令对应的解析函数是 ngx_http_core_server_name。函数会把配置的server_name添加到ngx_http_core_srv_conf_t结果的server_name数组中。 如果server_name 指令后面有host_name变量,则直接解析成真正的hostname添加到server_name数组中。
监听端口创建
所有listen和server_name相关配置解析完毕后,在http解析函数ngx_http_block的最后会调用函数ngx_http_optimize_servers来生成监听端口来启动http服务。具体流程是:
- 函数ngx_http_optimize_servers会遍历ngx_http_core_main_conf_t结构中的port数组。 对于每一个port的所有的地址进行排序。
- 然后遍历此port的所有地址,如果某一个对应的地址的server数量大于1,则调用函数ngx_http_server_names进行处理。
- 函数ngx_http_server_names会遍历对应地址所有的server_name,然后把所有的全字符,前缀通配符字符串,后缀通配字符串对应server_name加入到对应addr结构的hash, wc_head, wc_tail 三种hash表中。然后把所有的正则表达式的字符串加入到对应的addr结构的regex数组中。
- 然后函数ngx_http_optimize_servers会对每一个port调用ngx_http_init_listening来创建监听socket接收服务连接。函数ngx_http_optimize_servers会对于每个port进行判断这个port对应的addr中有没有是通过通配符进行监听的。如果有,则所有的此端口对应的地址只会生成一个ngx_listening_t结构socket来监听服务连接。如果没有,则对于port对应的所有的addr都会生成一个ngx_listening_t结构端口进行监听。
例一:
server {
listen *:2121;
proxy_timeout 65534;
proxy_pass v*nftp1;
alg ftp;
}
server {
listen 10.250.64.103:2121;
proxy_timeout 65534;
proxy_pass v*nftp;
alg ftp;
}
server {
listen 60.60.60.77:2121;
proxy_timeout 65534;
proxy_pass v*nftp;
alg ftp;
}
上述例子中,因为有通配符的存在只会1个ngx_listen_t。
例二:
server {
listen 10.250.64.103:2121;
proxy_timeout 65534;
proxy_pass v*nftp;
alg ftp;
}
server {
listen 60.60.60.77:2121;
proxy_timeout 65534;
proxy_pass v*nftp;
alg ftp;
}
上述例子中,因为没有通配符所以生成2个ngx_listen_t。
- 使用函数ngx_http_add_listening创建监听端口。在此函数中,会通过ngx_create_listening函数create一个socket,然后设置监听端口的属性。其中最重要的把监听端口的handler设置为nginx_http_init_connection。当有新的连接建立时,此函数就会得到调用。
- 然后,创建ngx_http_port_t结构关联到监听端口的servers成员中。然后再调用ngx_http_add_addrs把端口所有的addr复制到servers数组中的每一个成员中。
至此,port和address以及port,address对和server_name之间的1对多关系就建立起来。对于所有配置的listen端口也都创建了相关的监听端口用来服务连接。
数据层面源码分析
配置层面的数据结构构建好以后,我们再来分析用户连接到达以后,对应的server是如何被匹配的。
- 对于每一个http连接请求都会对应一个ngx_http_requst结构,在这个结构中有一个ngx_http_connection_t的成员。这个成员中的addr_conf成员在函数ngx_http_init_connection中被赋值,这样数据结构图中A标志的关系就建立起来。
- 因为server_name是从数据报文的应用层中获取的,所以对server进行选择是发生在HTTP连接已经完成,开始读取client的请求头部信息时。对应的具体代码就是从函数ngx_http_process_request_line或者ngx_http_process_request_header调用ngx_http_set_virtual_server开始的。
- 函数ngx_http_set_virtual_server会通过ngx_http_request结构找到对应的ngx_http_connection_t成员,然后通过ngx_http_connection_t成员找到对应的ngx_http_addr_conf_t结构,这个结构中存放这为这一地址端口对存储的所有server_name数组。
- 然后调用函数ngx_http_find_virtual_server来找到某一个具体的server定义。函数ngx_http_find_virtual_server通过ngx_hash_find_combined来查找具体的server定义。
- 函数ngx_hash_find_combined按顺序依次找查找全字符hash,前缀匹配hash,后缀匹配hash。如果查找不到,函数ngx_http_find_virtual_server再一次查找正则表达式数组。
- 最后,如果函数ngx_hash_find_combined也没有匹配成功,就选取default_server。
至此,请求所对应的server被正确匹配。
五结语
作为反向代理,Nginx如何对请求进行服务匹配是一个核心过程。其本质上就是对数据包进行四七层的请求转发。本文分析了服务匹配的第一阶段的原理,下一篇,我们将分析服务转发的第二阶段,location的匹配。