NGINX Server匹配原理及源码分析
274 次浏览
发表于 2020-11-23 17:00

一概述

在Bigip中,我们有virtual server的概念,在Openstack Neutron里面,我们有listener的概念。它们共同表示的是主机提供的虚拟服务。在NGINX中,与之对应的是配置文件中定义的server。

NGINX最重要的功能之一把用户的服务请求转发到正确的服务server上去。如果说二层的转发是依据数据包的二层mac地址,三层的转发依据的是数据包三层的IP地址,那么NGINX的服务转发依靠的是数据包的四七层信息。

NGINX对请求的路由转发,可以分成两个阶段。

第一个阶段是匹配定义的server。首先根据请求中的目的地址和端口进行匹配。如果相同的目的地址和端口同时还会对应多个servers,再根据server_name属性进行进一步匹配。需要注意的是,只有当listen指令无法找到最佳匹配时才会考虑评估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]

  1. 第一个参数是指定NGINX运行的主机地址。一条listen指令中可以指定一个主机地址,如果配置的主机地址是*或者忽略,默认绑定主机所有的地址。如果要选择主机众多地址中的某几个,可以通过多个listen指令来实现。不能通过一条listen指令配置服务器众多地址中的某几个不连续地址。主机地址可以是本机的hostname或者域名。在NGINX启动期间会对配置hostname或者域名进行解析。如果配置的地址或者域名和本机ip地址不相配,NGINX会启动失败。地址还可以是Unix socket地址。
  2. 第二个参数指定要监听的端口。可以是单独的一个数字比如8080,也可以规定一个联系的范围,比如8080-9090。如果需要监听多个非连续的端口,可以通过多个listen指令实现。
  3. flags参数大多数是用来控制监听端口socket参数和网络行为的。其中default_server是指定当相同地址和端口对有多个server对应时,如果在匹配过程中通过server_name也不能匹配到相关的server,带有default_server标志的server就成为该匹配的结果。
  4. 相同的IP以及端口可以设置一个默认虚拟服务器。如果相同IP以及端口对应的server都没有标注default_server,那么配置文件中对于这个ip和端口对的第一个定义的server就是default_server。

需要注意以下几点:

  1. 如果指定ip或者domain_name而不指定端口号,比如listen 1.1.1.1; listen my_host_name; 如此配置对应的端口对于root用户是80,对于非root用户是8000。
  2. 指定端口号而不指定ip或者domain_name,比如listen 80;默认对NGINX所在主机的所有的ip地址的80端口都监听。
  3. 通过通配符*指定所有的主机地址。比如listen *:80;默认对所有的主机地址的端口都进行监听。
  4. 如果server中没有配置listen指令,对应root用户默认是执行listen *,对应非root用户执行的是listen *:8000。
  5. 另外listen之类也支持unix socket路径,用于和同主机之间的服务交互。

server_name指令

指令server_name的语法是:server_name name1 [name2] ..[namen];

可以配置同时配置一个或者多个name。如果不配置server_name对应的默认值是server_name““

参数name的配置形式有如下几种:

  1. 全字符串。
  2. 特殊变量$hostname。当使用变量$hostname时,在NGINX启动运行时,会把$hostname变量替换成本机的主机名。所以从本质上讲,在匹配时,这种情况也是一种全字符匹配。
  3. 带有通配符的字符串。对于这种字符串,通配符*的位置只能出现在头部或者末尾,而且不能和别的字符租户。比如*.test.com;  www.test.*是合法的。但是 *test.com; www.*.comwww.*a.comwww.abc.*om;都是不合法的。
  4. 正则表达式,NGINX要求正则表达式必须以~开头进行表示,而且正则表达式的字符全部按照小写字符进行匹配。在使用正则表达式时,通常会以 ^ 开头以 $ 结尾,虽然正则语法上并不要求这样配置,但是会大大提升解析效率。另外,点符号(.)是正则表达式的一个关键字,所以域名中的点需要使用反斜线来转意(\.),比如~^(?.+)\.domain\.com$;就是一个合理的正则表达式定义的server_name

另外,server_name字符是不区分大写小的。无论是全字符,前后缀通配符还是正则表达式,所有配置的server_name都按照小写字母来处理

对于这几种配置类型,匹配时的优先级顺序是:

  1. 首选匹配全字符串。
  2. 如果全字符串没有命中,则进行最长前缀通配符匹配。
  3. 如果前缀通配字符没有命中,则进行最长后缀通配符匹配。
  4. 如果后缀通配字符没有命中,则进行正则表达式类型匹配。按照在配置文件中出现的顺序,第一个匹配到的正则表达式就是匹配结果。
  5. 如果正则表达式也没有命中,则选择这个ip和port对的default_server。

对于用来进行匹配参数的server_name, 有如下的几种获取途径:

  1. 对于http1.1,协议规定必须携带host头部,此头部就可以用来进行server_name的匹配。
  2. 对于HTTP/1.0请求来说就没有这个要求,所以对于HTTP/1.0,只能把absolute URL中携带域名用来匹配。
  3. 如果host头部和absolute URL都可以提取server_name,按照absolute URL为准。如果两者都没有,使用server_name  “”;来匹配。
  4. 对于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解析流程

  1. 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上启服务这种模型进行建模。
  2. 函数ngx_http_add_listen会遍历ngx_http_core_main_conf_t中的ports数组对于每一个port调用ngx_http_add_addresses为每一个port添加对应的ngx_http_core_srv_conf_t结构。
  3. 在函数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中。

至此,portaddr之间以及addrserver之间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服务。具体流程是:

  1. 函数ngx_http_optimize_servers会遍历ngx_http_core_main_conf_t结构中的port数组。 对于每一个port的所有的地址进行排序。
  2. 然后遍历此port的所有地址,如果某一个对应的地址的server数量大于1,则调用函数ngx_http_server_names进行处理。
  3. 函数ngx_http_server_names会遍历对应地址所有的server_name,然后把所有的全字符,前缀通配符字符串,后缀通配字符串对应server_name加入到对应addr结构的hash, wc_head, wc_tail 三种hash表中。然后把所有的正则表达式的字符串加入到对应的addr结构的regex数组中。
  4. 然后函数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 vpnftp1;

            }

            server {

                         listen 10.250.64.103:2121;

                         proxy_timeout 65534;

                         proxy_pass vpnftp;

            }

            server {

                         listen 60.60.60.77:2121;

                         proxy_timeout 65534;

                         proxy_pass vpnftp;

           }

    上述例子中,因为有通配符的存在只会1个ngx_listen_t。

    例二:

            server {

                         listen 10.250.64.103:2121;

                         proxy_timeout 65534;

                         proxy_pass vpnftp;

            }

            server {

                         listen 60.60.60.77:2121;

                         proxy_timeout 65534;

                         proxy_pass vpnftp;

            }

上述例子中,因为没有通配符所以生成2个ngx_listen_t。

5. 使用函数ngx_http_add_listening创建监听端口。在此函数中,会通过ngx_create_listening函数create一 个socket,然后设置监听端口的属性。其中最重要的把监听端口的handler设置为 nginx_http_init_connection。当有新的连接建立时,此函数就会得到调用。 

6. 然后创建ngx_http_port_t结构关联到监听端口的servers成员中。然后再调用ngx_http_add_addrs把端口 所有的addr复制到servers数组中的每一个成员中。

至此,port和address以及port,address对和server_name之间的1对多关系就建立起来。对于所有配置的listen端口也都创建了相关的监听端口用来服务连接。

数据层面源码分析

配置层面的数据结构构建好以后,我们再来分析用户连接到达以后,对应的server是如何被匹配的。

  1. 对于每一个http连接请求都会对应一个ngx_http_requst结构,在这个结构中有一个ngx_http_connection_t的成员。这个成员中的addr_conf成员在函数ngx_http_init_connection中被赋值,这样数据结构图中A标志的关系就建立起来。
  2. 因为server_name是从数据报文的应用层中获取的,所以对server进行选择是发生在HTTP连接已经完成,开始读取client的请求头部信息时。对应的具体代码就是从函数ngx_http_process_request_line或者ngx_http_process_request_header调用ngx_http_set_virtual_server开始的。
  3. 函数ngx_http_set_virtual_server会通过ngx_http_request结构找到对应的ngx_http_connection_t成员,然后通过ngx_http_connection_t成员找到对应的ngx_http_addr_conf_t结构,这个结构中存放这为这一地址端口对存储的所有server_name数组。
  4. 然后调用函数ngx_http_find_virtual_server来找到某一个具体的server定义。函数ngx_http_find_virtual_server通过ngx_hash_find_combined来查找具体的server定义。
  5. 函数ngx_hash_find_combined按顺序依次找查找全字符hash(通过函数ngx_hash_find),前缀匹配hash(通过函数ngx_hash_find_wc_head),后缀匹配hash(通过函数ngx_hash_find_wc_tail)。如果查找不到,函数ngx_http_find_virtual_server再一次查找正则表达式数组。
  6. 最后,如果函数ngx_hash_find_combined也没有匹配成功,就选取default_server。

至此,请求所对应的server被正确匹配。

五结语

作为反向代理,NGINX如何对请求进行服务匹配是一个核心过程。其本质上就是对数据包进行四七层的请求转发。本文分析了服务匹配的第一阶段的原理,下一篇,我们将分析服务转发的第二阶段,也就是NGINX如何进行location匹配的。


发表评论
  • jingkaimori

    感谢博主的一系列讲解。如何在server_name模块使用变量?

    server{
    server_name ~$(variable).jingkaimori.net
    }



    2020-12-04 14:50
    0
    回复
  • 皮皮鲁 回复 jingkaimori

    server_name只支持使用$host_name这个变量。或者前后缀通配符,全字符串这几种情况。不运行使用其他变量或者其他变量作为局部的情况。

    2020-12-04 15:05
    0
    回复
发表者

皮皮鲁

暂无个人介绍

  • 27

    文章

  • 19

    关注

  • 22

    粉丝

活动推荐
版权所有©F5 Networks,Inc.保留所有权利。京ICP备16013763号-5