Nginx 配置指令的执行顺序(学习笔记二十)

大多数 Nginx 新手都会频繁遇到这样一个困惑,那就是当同一个location配置块使用了多个 Nginx 模块的配置指令时,这些指令的执行顺序很可能会跟它们的书写顺序大相径庭。于是许多人选择了“试错法”,然后他们的配置文件就时常被改得一片狼藉。这个系列的教程就旨在帮助读者逐步地理解这些配置指令背后的执行时间和先后顺序的奥秘。


    现在就来看这样一个令人困惑的例子:

?location/test {

?set$a 32;

?     echo $a;

?

?set$a 56;

?     echo $a;

? }

从这个例子的本意来看,我们期望的输出是一行32和一行56,因为我们第一次用echo配置指令输出了$a变量的值以后,又紧接着使用set配置指令修改了$a. 然而不幸的是,事实并非如此:

$ curl 'http://localhost:8080/test

56

56

我们看到,语句set $a 56似乎在第一条echo $a语句之前就执行过了。这究竟是为什么呢?难道我们遇到了 Nginx 中的一个 bug?


    显然,这里并没有 Nginx 的 bug;要理解这里发生的事情,就首先需要知道 Nginx 处理每一个用户请求时,都是按照若干个不同阶段(phase)依次处理的。


Nginx 的请求处理阶段共有 11 个之多,我们先介绍其中 3 个比较常见的。按照它们执行时的先后顺序,依次是rewrite阶段、access阶段以及content阶段(后面我们还有机会见到其他更多的处理阶段)。


所有 Nginx 模块提供的配置指令一般只会注册并运行在其中的某一个处理阶段。比如上例中的set指令就是在rewrite阶段运行的,而echo指令就只会在content阶段运行。前面我们已经知道,在单个请求的处理过程中,rewrite阶段总是在content阶段之前执行,因此属于rewrite阶段的配置指令也总是会无条件地在content阶段的配置指令之前执行。于是在同一个location配置块中,set指令总是会在echo指令之前执行,即使我们在配置文件中有意把set语句写在echo语句的后面。


    回到刚才那个例子,

set$a 32;

echo $a;

set$a 56;

echo $a;

实际的执行顺序应当是

set$a 32;

set$a 56;

echo $a;

echo $a;

即先在rewrite阶段执行完这里的两条set赋值语句,然后再在后面的content阶段依次执行那两条echo语句。分属两个不同处理阶段的配置指令之间是不能穿插着运行的。


    为了进一步验证这一点,我们不妨借助 Nginx 的“调试日志”来一窥 Nginx 的实际执行过程。


因为这是我们第一次提及 Nginx 的“调试日志”,所以有必要先简单介绍一下它的启用方法。调试日志默认是禁用的,因为它会引入比较大的运行时开销,让 Nginx 服务器显著变慢。一般我们需要重新编译和构造 Nginx 可执行文件,并且在调用 Nginx 源码包提供的./configure脚本时传入--with-debug命令行选项。例如我们下载完 Nginx 源码包后在 Linux 或者 Mac OS X 等系统上构建时,典型的步骤是这样的:

tar xvf nginx-1.0.10.tar.gz

cd nginx-1.0.10/

./configure --with-debug

make

sudu make install

如果你使用的是我维护的ngx_openresty软件包,则同样可以向它的./configure脚本传递--with-debug命令行选项。


当我们启用--with-debug选项重新构建好调试版的 Nginx 之后,还需要同时在配置文件中通过标准的error_log配置指令为错误日志使用debug日志级别(这同时也是最低的日志级别):

error_loglogs/error.log debug;

这里重要的是error_log指令的第二个参数,debug,而前面第一个参数是错误日志文件的路径,logs/error.log. 当然,你也可以指定其他路径,但后面我们会检查这个文件的内容,所以请特别留意一下这里实际配置的文件路径。


    现在我们重新启动 Nginx(注意,如果 Nginx 可执行文件也被更新过,仅仅让 Nginx 重新加载配置是不够的,需要关闭再启动 Nginx 主服务进程),然后再请求一下我们刚才那个示例接口:

$ curl 'http://localhost:8080/test'

56

56

现在可以检查一下前面配置的 Nginx 错误日志文件中的输出。因为文件中的输出比较多(在我的机器上有 700 多行),所以不妨用grep命令在终端上过滤出我们感兴趣的部分:

    grep -E 'http (output filter|script (set|value))' logs/error.log

在我机器上的输出是这个样子的(为了方便呈现,这里对grep命令的实际输出作了一些简单的编辑,略去了每一行的行首时间戳):

[debug] 5363#0: *1 http script value: "32"

[debug] 5363#0: *1 http script set $a

[debug] 5363#0: *1 http script value: "56"

[debug] 5363#0: *1 http script set $a

[debug] 5363#0: *1 http output filter "/test?"

[debug] 5363#0: *1 http output filter "/test?"

[debug] 5363#0: *1 http output filter "/test?"

这里需要稍微解释一下这些调试信息的具体含义。set配置指令在实际运行时会打印出两行以http script起始的调试信息,其中第一行信息是set语句中被赋予的值,而第二行则是set语句中被赋值的 Nginx 变量名。于是上面首先过滤出来的

[debug] 5363#0: *1 http script value: "32"

[debug] 5363#0: *1 http script set $a

这两行就对应我们例子中的配置语句

set$a 32;

而接下来这两行调试信息

[debug] 5363#0: *1 http script value: "56"

[debug] 5363#0: *1 http script set $a

则对应配置语句

set$a 56;

此外,凡在 Nginx 中输出响应体数据时,都会调用 Nginx 的所谓“输出过滤器”(output filter),我们一直在使用的echo指令自然也不例外。而一旦调用 Nginx 的“输出过滤器”,便会产生类似下面这样的调试信息:

    [debug] 5363#0: *1 http output filter "/test?"

当然,这里的"/test?"部分对于其他接口可能会发生变化,因为它显示的是当前请求的 URI. 这样联系起来看,就不难发现,上例中的那两条set语句确实都是在那两条echo语句之前执行的。


细心的读者可能会问,为什么这个例子明明只使用了两条echo语句进行输出,但却有三行http output filter调试信息呢?其实,前两行http output filter信息确实分别对应那两条echo语句,而最后那一行信息则是对应ngx_echo模块输出指示响应体末尾的结束标记。正是为了输出这个特殊的结束标记,才会多出一次对 Nginx “输出过滤器”的调用。包括ngx_proxy在内的许多模块在输出响应体数据流时都具有此种行为。


现在我们就不会再为前面那个例子输出两行一模一样的56而感到惊讶了。我们根本没有机会在第二条set语句之前用echo输出。幸运的是,仍然可以借助一些小技巧来达到最初的目的:

location/test {

set$a 32;

set$saved_a $a;

set$a 56;

echo $saved_a;

echo $a;

}

此时的输出便符合那个问题示例的初衷了:

$ curl 'http://localhost:8080/test'

32

56

这里通过引入新的用户变量$saved_a,在改写$a之前及时保存了$a的初始值。而对于多条set指令而言,它们之间的执行顺序是由ngx_rewrite模块来保证与书写顺序相一致的。同理,ngx_echo模块自身也会保证它的多条echo指令之间的执行顺序。


细心的读者应当发现,我们在Nginx 变量漫谈系列的示例中已经广泛使用了这种技巧,来绕过因处理阶段而引起的指令执行顺序上的限制。


看到这里,有的读者可能会问:“那么我在使用一条陌生的配置指令之前,如何知道它究竟运行在哪一个处理阶段呢?”答案是:查看该指令的文档(当然,高级开发人员也可以直接查看模块的 C 源码)。在许多模块的文档中,都会专门标记其配置指令所运行的具体阶段。例如echo指令的文档中有这么一行:

    phase: content

这一行便是说,当前配置指令运行在content阶段。如果你使用的 Nginx 模块碰巧没有指示运行阶段的文档,可以直接联系该模块的作者请求补充。不过,值得一提的是,并非所有的配置指令都与某个处理阶段相关联,例如我们先前在Nginx 变量漫谈(一)中提到过的geo指令以及在Nginx 变量漫谈(四)中介绍过的map指令。这些不与处理阶段相关联的配置指令基本上都是“声明性的”(declarative),即不直接产生某种动作或者过程。Nginx 的作者 Igor Sysoev 在公开场合曾不止一次地强调,Nginx 配置文件所使用的语言本质上是“声明性的”,而非“过程性的”(procedural)。


我们前面已经知道,当set指令用在location配置块中时,都是在当前请求的rewrite阶段运行的。事实上,在此上下文中,ngx_rewrite模块中的几乎全部指令,都运行在rewrite阶段,包括Nginx 变量漫谈(二)中介绍过的rewrite指令。不过,值得一提的是,当这些指令使用在server配置块中时,则会运行在一个我们尚未提及的更早的处理阶段,server-rewrite阶段。


Nginx 变量漫谈(二)中介绍过的ngx_set_misc模块的set_unescape_uri指令同样也运行在rewrite阶段。特别地,ngx_set_misc模块的指令还可以和ngx_rewrite的指令混合在一起依次执行。我们来看这样的一个例子:

location/test {

set$a "hello%20world";

set_unescape_uri $b $a;

set$c "$b!";

echo $c;

}

访问这个接口可以得到:

$ curl 'http://localhost:8080/test'

hello world!

我们看到,set_unescape_uri语句前后的set语句都按书写时的顺序一前一后地执行了。


为了进一步确认这一点,我们不妨再检查一下 Nginx 的“调试日志”(如果你还不清楚如何开启“调试日志”的话,可以参考(一)中的步骤):

    grep -E 'http script (value|copy|set)' t/servroot/logs/error.log

过滤出来的调试日志信息如下所示:

[debug] 11167#0: *1 http script value: "hello%20world"

[debug] 11167#0: *1 http script set $a

[debug] 11167#0: *1 http script value (post filter): "hello world"

[debug] 11167#0: *1 http script set $b

[debug] 11167#0: *1 http script copy: "!"

[debug] 11167#0: *1 http script set $c

开头的两行信息

[debug] 11167#0: *1 http script value: "hello%20world"

[debug] 11167#0: *1 http script set $a

就对应我们的配置语句

set$a "hello%20world";

而接下来的两行

[debug] 11167#0: *1 http script value (post filter): "hello world"

[debug] 11167#0: *1 http script set $b

则对应配置语句

    set_unescape_uri $b $a;

我们看到第一行信息与set指令略有区别,多了"(post filter)"这个标记,而且最后显示出 URI 解码操作确实如我们期望的那样工作了,即"hello%20world"在这里被成功解码为"hello world".


    而最后两行调试信息

[debug] 11167#0: *1 http script copy: "!"

[debug] 11167#0: *1 http script set $c

则对应最后一条set语句:

set$c "$b!";

注意,因为这条指令在为$c变量赋值时使用了“变量插值”功能,所以第一行调试信息是以http script copy起始的,后面则是拼接到最终取值的字符串常量"!".


    把这些调试信息联系起来看,我们不难发现,这些配置指令的实际执行顺序是:

set$a "hello%20world";

set_unescape_uri $b $a;

set$c "$b!";

这与它们在配置文件中的书写顺序完全一致。


我们在Nginx 变量漫谈(七)中初识了第三方模块ngx_lua,它提供的set_by_lua配置指令也和ngx_set_misc模块的指令一样,可以和ngx_rewrite模块的指令混合使用。set_by_lua指令支持通过一小段用户 Lua 代码来计算出一个结果,然后赋给指定的 Nginx 变量。和set指令相似,set_by_lua指令也有自动创建不存在的 Nginx 变量的功能。


下面我们就来看一个set_by_lua指令与set指令混合使用的例子:

location/test {

set$a 32;

set$b 56;

set_by_lua $c "return ngx.var.a + ngx.var.b";

set$equation "$a + $b = $c";

echo $equation;

}

这里我们先将$a和$b变量分别初始化为32和56,然后利用set_by_lua指令内联一行我们自己指定的 Lua 代码,计算出 Nginx 变量$a和$b的“代数和”(sum),并赋给变量$c,接着利用“变量插值”功能,把变量$a、$b和$c的值拼接成一个字符串形式的等式,赋予变量$equation,最后再用echo指令输出$equation的值。


这个例子值得注意的地方是:首先,我们在 Lua 代码中是通过ngx.var.VARIABLE接口来读取 Nginx 变量$VARIABLE的;其次,因为 Nginx 变量的值只有字符串这一种类型,所以在 Lua 代码里读取ngx.var.a和ngx.var.b时得到的其实都是 Lua 字符串类型的值"32"和"56";接着,我们对两个字符串作加法运算会触发 Lua 对加数进行自动类型转换(Lua 会把两个加数先转换为数值类型再求和);然后,我们在 Lua 代码中把最终结果通过return语句返回给外面的 Nginx 变量$c;最后,ngx_lua模块在给$c实际赋值之前,也会把return语句返回的数值类型的结果,也就是 Lua 加法计算得出的“和”,自动转换为字符串(这同样是因为 Nginx 变量的值只能是字符串)。


    这个例子的实际运行结果符合我们的期望:

$ curl 'http://localhost:8080/test'

32 + 56 = 88

于是这验证了set_by_lua指令确实也可以和set这样的ngx_rewrite模块提供的指令混合在一起工作。


还有不少第三方模块,例如Nginx 变量漫谈(八)中介绍过的ngx_array_var以及后面即将接触到的用于加解密用户会话(session)的ngx_encrypted_session,也都可以和ngx_rewrite模块的指令无缝混合工作。


标准ngx_rewrite模块的应用是如此广泛,所以能够和它的配置指令混合使用的第三方模块是幸运的。事实上,上面提到的这些第三方模块都采用了特殊的技术,将它们自己的配置指令“注入”到了ngx_rewrite模块的指令序列中(它们都借助了 Marcus Clyne 编写的第三方模块ngx_devel_kit)。换句话说,更多常规的在 Nginx 的rewrite阶段注册和运行指令的第三方模块就没那么幸运了。这些“常规模块”的指令虽然也运行在rewrite阶段,但其配置指令和ngx_rewrite模块(以及同一阶段内的其他模块)都是分开独立执行的。在运行时,不同模块的配置指令集之间的先后顺序一般是不确定的(严格来说,一般是由模块的加载顺序决定的,但也有例外的情况)。比如A和B两个模块都在rewrite阶段运行指令,于是要么是A模块的所有指令全部执行完再执行B模块的那些指令,要么就是反过来,把B的指令全部执行完,再去运行A的指令。除非模块的文档中有明确的交待,否则用户一般不应编写依赖于此种不确定顺序的配置。


如前文所述,除非像ngx_set_misc模块那样使用特殊技术,其他模块的配置指令即使是在rewrite阶段运行,也不能和ngx_rewrite模块的指令混合使用。不妨来看几个这样的例子。


第三方模块ngx_headers_more提供了一系列配置指令,用于操纵当前请求的请求头和响应头。其中有一条名叫more_set_input_headers的指令可以在rewrite阶段改写指定的请求头(或者在请求头不存在时自动创建)。这条指令总是运行在rewrite阶段的末尾,该指令的文档中有这么一行标记:

    phase: rewrite tail

其中的rewrite tail的意思就是rewrite阶段的末尾。


既然运行在rewrite阶段的末尾,那么也就总是会运行在ngx_rewrite模块的指令之后,即使我们在配置文件中把它写在前面,例如:

?location/test {

?set$value dog;

?     more_set_input_headers "X-Species: $value";

?set$value cat;

?

?     echo "X-Species: $http_x_species";

? }

这个例子用到的$http_XXX内建变量在读取时会返回当前请求中名为XXX的请求头,我们在Nginx 变量漫谈(二)中曾经简单提过它。需要注意的是,$http_XXX变量在匹配请求头时会自动对请求头的名字进行归一化,即将名字的大写字母转换为小写字母,同时把间隔符(-)替换为下划线(_),所以变量名$http_x_species才得以成功匹配more_set_input_headers语句中设置的请求头X-Species.


此例书写的指令顺序会误导我们认为/test接口输出的X-Species头的值是dog,然而实际的结果却并非如此:

$ curl 'http://localhost:8080/test'

X-Species: cat

显然,写在more_set_input_headers指令之后的set $value cat语句却先执行了。


上面这个例子证明了即使运行在同一个请求处理阶段,分属不同模块的配置指令也可能会分开独立运行(除非像ngx_set_misc等模块那样针对ngx_rewrite模块提供特殊支持)。换句话说,在单个请求处理阶段内部,一般也会以 Nginx 模块为单位进一步地划分出内部子阶段。


第三方模块ngx_lua提供的rewrite_by_lua配置指令也和more_set_input_headers一样运行在rewrite阶段的末尾。我们来验证一下:

?location/test {

?set$a 1;

?     rewrite_by_lua "ngx.var.a = ngx.var.a + 1";

?set$a 56;

?

?     echo $a;

? }

这里我们在rewrite_by_lua语句内联的 Lua 代码中对 Nginx 变量$a进行了自增计算。从该例的指令书写顺序上看,我们或许会期望输出是56,可是因为rewrite_by_lua会在所有的set语句之后执行,所以结果是57:

$ curl 'http://localhost:8080/test'

57

显然,rewrite_by_lua指令的行为不同于我们前面在(二)中介绍过的set_by_lua指令。


有的读者可能要问,既然more_set_input_headersrewrite_by_lua指令都运行在rewrite阶段的末尾,那么它们之间的先后顺序又是怎样的呢?答案是:不一定。我们应当避免写出依赖它们二者间顺序的配置。


Nginx 的rewrite阶段是一个比较早的请求处理阶段,这个阶段的配置指令一般用来对当前请求进行各种修改(比如对 URI 和 URL 参数进行改写),或者创建并初始化一系列后续处理阶段可能需要的 Nginx 变量。当然,也不能阻止一些用户在rewrite阶段做一系列更复杂的事情,比如读取请求体,或者访问数据库等远方服务,毕竟有rewrite_by_lua这样的指令可以嵌入任意复杂的 Lua 代码。


在rewrite阶段之后,有一个名叫access的请求处理阶段。Nginx 变量漫谈(五)中介绍过的第三方模块ngx_auth_request的指令就运行在access阶段。在access阶段运行的配置指令多是执行访问控制性质的任务,比如检查用户的访问权限,检查用户的来源 IP 地址是否合法,诸如此类。


例如,标准模块ngx_access提供的allowdeny配置指令可用于控制哪些 IP 地址可以访问,哪些不可以:

location/hello {

allow127.0.0.1;

denyall;

echo "hello world";

}

这个/test接口被配置为只允许从本机(IP 地址为保留的127.0.0.1)访问,而从其他 IP 地址访问都会被拒(返回403错误页)。ngx_access模块自己的多条配置指令之间是按顺序执行的,直到遇到第一条满足条件的指令就不再执行后续的allowdeny指令。如果首先匹配的指令是allow,则会继续执行后续其他模块的指令或者跳到后续的处理阶段;而如果首先满足的是deny则会立即中止当前整个请求的处理,并立即返回403错误页。所以看上面这个例子,如果是从本地访问的,则首先匹配allow 127.0.0.1这一条语句,于是 Nginx 就继续往下执行其他模块的指令以及后续的处理阶段;而如果是从其他机器访问,则首先匹配的则是deny all这一条语句,即拒绝所有地址,它会导致403错误页立即返回给客户端。


    我们来实测一下。从本机访问这个接口可以得到

$ curl 'http://localhost:8080/hello'

hello world

而从另一台机器访问这台机器(假设运行 Nginx 的机器地址是192.168.1.101)提供的接口时则得到

$ curl 'http://192.168.1.101:8080/hello'

403 Forbidden

403 Forbidden


nginx

值得一提的是,ngx_access模块还支持所谓的“CIDR 记法”来表示一个网段,例如169.200.179.4/24则表示路由前缀是169.200.179.0(或者说子网掩码是255.255.255.0)的网段。


因为ngx_access模块的指令运行在access阶段,而access阶段又处于rewrite阶段之后,所以前面我们见到的所有那些在rewrite阶段运行的配置指令,都总是在allowdeny之前执行,而无论它们在配置文件中的书写顺序是怎样的。所以,为了避免阅读配置时的混乱,我们应该总是让指令的书写顺序和它们的实际执行顺序保持一致。


ngx_lua模块提供了配置指令access_by_lua,用于在access请求处理阶段插入用户 Lua 代码。这条指令运行于access阶段的末尾,因此总是在allowdeny这样的指令之后运行,虽然它们同属access阶段。一般我们通过access_by_luangx_access这样的模块检查过客户端 IP 地址之后,再通过 Lua 代码执行一系列更为复杂的请求验证操作,比如实时查询数据库或者其他后端服务,以验证当前用户的身份或权限。


我们来看一个简单的例子,利用access_by_lua来实现ngx_access模块的 IP 地址过滤功能:

location/hello {

access_by_lua '

if ngx.var.remote_addr == "127.0.0.1" then

return

end

ngx.exit(403)

';

echo "hello world";

}

这里在 Lua 代码中通过引用 Nginx 标准的内建变量$remote_addr来获取字符串形式的客户端 IP 地址,然后用 Lua 的if语句判断是否为本机地址,即是否等于127.0.0.1. 如果是本机地址,则直接利用 Lua 的return语句返回,让 Nginx 继续执行后续的请求处理阶段(包括echo指令所处的content阶段);而如果不是本机地址,则通过ngx_lua模块提供的 Lua 函数ngx.exit中断当前的整个请求处理流程,直接返回403错误页给客户端。


这个例子在功能上完全等价于先前在(三)中介绍过的那个使用ngx_access模块的例子:

location/hello {

allow127.0.0.1;

denyall;

echo "hello world";

}

虽然这两个例子在功能上完全相同,但在性能上还是有区别的,毕竟ngx_access是用纯 C 实现的专门化的 Nginx 模块。


下面我们不妨来实际测量一下这两个例子的性能差别。因为我们使用 Nginx 就是为了追求性能,而量化的性能比较,在工程上具有很大的现实意义,所以我们顺便介绍一下重要的测量技术。由于无论是ngx_access还是ngx_lua在进行 IP 地址验证方面的性能都非常之高,所以为了减少测量误差,我们希望能对access阶段的用时进行直接测量。为了做到这一点,传统的做法一般会涉及到修改 Nginx 源码,自己插入专门的计时代码和统计输出代码,抑或是重新编译 Nginx 以启用像GNU gprof这样专门的性能监测工具。


幸运的是,在新一点的 Solaris, Mac OS X, 以及 FreeBSD 等系统上存在一个叫做dtrace的工具,可以对任意的用户程序进行微观性能分析(以及行为分析),而无须对用户程序的源码进行修改或者对用户程序进行重新编译。因为 Mac OS X 10.5 以后就自带了dtrace,所以为方便起见,下面在我的 MacBook Air 笔记本上演示一下这里的测量过程。


首先,在 Mac OS X 系统中打开一个命令行终端,在某一个文件目录下面创建一个名为nginx-access-time.d的文件,并编辑内容如下:

#!/usr/bin/env dtrace -s

pid$1::ngx_http_handler:entry

{

elapsed = 0;

}

pid$1::ngx_http_core_access_phase:entry

{

begin = timestamp;

}

pid$1::ngx_http_core_access_phase:return

/begin > 0/

{

elapsed += timestamp - begin;

begin = 0;

}

pid$1::ngx_http_finalize_request:return

/elapsed > 0/

{

@elapsed = avg(elapsed);

elapsed = 0;

}

保存好此文件后,再赋予它可执行权限:

    $ chmod +x ./nginx-access-time.d

这个.d文件中的代码是用dtrace工具自己提供的D语言来编写的(注意,这里的D语言并不同于 Walter Bright 作为另一种“更好的 C++”而设计的D语言)。由于本系列教程并不打算介绍如何编写dtrace的D脚本,同时理解这个脚本需要不少有关 Nginx 内部源码实现的细节,所以这里我们不展开介绍。大家只需要知道这个脚本的功能是:统计指定的 Nginx worker 进程在处理每个请求时,平均花费在access阶段上的时间。


现在来演示一下这个D脚本的运行方法。这个脚本接受一个命令行参数用于指定监视的 Nginx worker 进程的进程号(pid)。由于 Nginx 支持多 worker 进程,所以我们测试时发起的 HTTP 请求可能由其中任意一个 worker 进程服务。为了确保所有测试请求都为固定的 worker 进程处理,不妨在nginx.conf配置文件中指定只启用一个 worker 进程:

worker_processes1;

重启 Nginx 服务器之后,可以利用ps命令得到当前 worker 进程的进程号:

    $ ps ax|grep nginx|grep worker|grep -v grep

在我机器上的一次典型输出是

    10975   ??  S      0:34.28 nginx: worker process

其中第一列的数值便是我的 nginx worker 进程的进程号,10975。如果你得到的输出不止一行,则通常意味着你的系统中同时运行着多个 Nginx 服务器实例,或者当前 Nginx 实例启用了多个 worker 进程。


接下来使用刚刚得到的 worker 进程号以及 root 身份来运行nginx-access-time.d脚本:

    $ sudo ./nginx-access-time.d 10975

如果一切正常,则会看到这样一行输出:

    dtrace: script './nginx-access-time.d' matched 4 probes

这行输出是说,我们的D脚本已成功向目标进程动态植入了 4 个dtrace“探针”(probe)。紧接着这个脚本就挂起了,表明dtrace工具正在对进程10975进行持续监视。


然后我们再打开一个新终端,在那里使用curl这样的工具多次请求我们正在监视的接口

$ curl 'http://localhost:8080/hello'

hello world

$ curl 'http://localhost:8080/hello'

hello world

最后我们回到原先那个一直在运行D脚本的终端,按下Ctrl-C组合键中止dtrace的运行。而该脚本在退出时会向终端打印出最终统计结果。例如我的终端此时是这个样子的:

$ sudo ./nginx-access-time.d 10975

dtrace: script './nginx-access-time.d' matched 4 probes

^C

19219

最后一行输出19219便是那几次curl请求在access阶段的平均用时(以纳秒,即 10 的负 9 次方秒为单位)。


通过上面介绍的步骤,可以通过nginx-access-time.d脚本分别统计出各种不同的 Nginx 配置下access阶段的平均用时。针对我们感兴趣的三种情况可以进行三组平行试验,即使用ngx_access过滤 IP 地址的情况,使用access_by_lua过滤 IP 地址的情况,以及不在access阶段使用任何配置指令的情况。最后一种情况属于“空白对照组”,用于校正测试过程中因dtrace探针等其他因素而引入的“系统误差”。另外,为了最小化各种不可控的“随机误差”,可以用ab这样的批量测试工具来取代curl发起连续十万次以上的请求,例如

    $ ab -k -c1 -n100000 'http://127.0.0.1:8080/hello'

这样我们的D脚本统计出来的平均值将更加接近“真实值”。


    在我的苹果系统上,一次典型的测试结果如下:

ngx_access 组               18146

access_by_lua 组            35011

空白对照组                   15887

把前两组的结果分别减去“空白对照组”的结果可以得到

ngx_access 组               2259

access_by_lua 组           19124

可以看到,ngx_access组比access_by_lua组快了大约一个数量级,这正是我们所预期的。不过其绝对时间差是极小的,对于我的Intel Core2Duo 1.86 GHz的 CPU 而言,也只有区区十几微秒,或者说是在十万分之一秒的量级。


当然,上面使用access_by_lua的例子还可以通过换用$binary_remote_addr内建变量进行优化,因为$binary_remote_addr读出的是二进制形式的 IP 地址,而$remote_addr则返回更长一些的字符串形式的地址。更短的地址意味着用 Lua 进行字符串比较时通常可以更快。


值得注意的是,如果按(一)中介绍的方法为 Nginx 开启了“调试日志”的话,上面统计出来的时间会显著增加,因为“调试日志”自身的开销是很大的。


Nginx 的content阶段是所有请求处理阶段中最为重要的一个,因为运行在这个阶段的配置指令一般都肩负着生成“内容”(content)并输出 HTTP 响应的使命。正因为其重要性,这个阶段的配置指令也异常丰富,例如前面我们一直在示例中广泛使用的echo指令,在Nginx 变量漫谈(二)中接触到的echo_exec指令,Nginx 变量漫谈(三)中接触到的proxy_pass指令,Nginx 变量漫谈(五)中介绍过的echo_location指令,以及Nginx 变量漫谈(七)中介绍过的content_by_lua指令,都运行在这个阶段。


content阶段属于一个比较靠后的处理阶段,运行在先前介绍过的rewrite和access这两个阶段之后。当和rewrite、access阶段的指令一起使用时,这个阶段的指令总是最后运行,例如:

location/test {

# rewrite phase

set$age 1;

rewrite_by_lua "ngx.var.age = ngx.var.age + 1";

# access phase

deny10.32.168.49;

access_by_lua "ngx.var.age = ngx.var.age * 3";

# content phase

echo "age = $age";

}

这个例子中各个配置指令的执行顺序便是它们的书写顺序。测试结果完全符合预期:

$ curl 'http://localhost:8080/test'

age = 6

即使改变它们的书写顺序,也不会影响到执行顺序。其中,set指令来自ngx_rewrite模块,运行于rewrite阶段;而rewrite_by_lua指令来自ngx_lua模块,运行于rewrite阶段的末尾;接下来,deny指令来自ngx_access模块,运行于access阶段;再下来,access_by_lua指令同样来自ngx_lua模块,运行于access阶段的末尾;最后,我们的老朋友echo指令则来自ngx_echo模块,运行在content阶段。


    这个例子展示了通过同时使用多个处理阶段的配置指令来实现多个模块协同工作的效果。在这个过程中,Nginx 变量则经常扮演着在指令间乃至模块间传递(小份)数据的角色。这些配置指令的执行顺序,也强烈地受到请求处理阶段的影响。


进一步地,在rewrite和access这两个阶段,多个模块的配置指令可以同时使用,譬如上例中的set指令和rewrite_by_lua指令同处rewrite阶段,而deny指令和access_by_lua指令则同处access阶段。但不幸的是,这通常不适用于content阶段。


绝大多数 Nginx 模块在向content阶段注册配置指令时,本质上是在当前的location配置块中注册所谓的“内容处理程序”(content handler)。每一个location只能有一个“内容处理程序”,因此,当在location中同时使用多个模块的content阶段指令时,只有其中一个模块能成功注册“内容处理程序”。考虑下面这个有问题的例子:

?location/test {

?     echo hello;

?     content_by_lua 'ngx.say("world")';

? }

这里,ngx_echo模块的echo指令和ngx_lua模块的content_by_lua指令同处content阶段,于是只有其中一个模块能注册和运行这个location的“内容处理程序”:

$ curl 'http://localhost:8080/test'

world

实际运行结果表明,写在后面的content_by_lua指令反而胜出了,而echo指令则完全没有运行。具体哪一个模块的指令会胜出是不确定的,例如把上例中的echo语句和content_by_lua语句交换顺序,则输出就会变成hello,即ngx_echo模块胜出。所以我们应当避免在同一个location中使用多个模块的content阶段指令。


将上例中的content_by_lua指令替换为echo指令就可以如愿了:

location/test {

echo hello;

echo world;

}

测试结果证明了这一点:

$ curl 'http://localhost:8080/test'

hello

world

这里使用多条echo指令是没问题的,因为它们同属ngx_echo模块,而且ngx_echo模块规定和实现了它们之间的执行顺序。值得一提的是,并非所有模块的指令都支持在同一个location中被使用多次,例如content_by_lua就只能使用一次,所以下面这个例子是错误的:

?location/test {

?     content_by_lua 'ngx.say("hello")';

?     content_by_lua 'ngx.say("world")';

? }

这个配置在 Nginx 启动时就会报错:

    [emerg] "content_by_lua" directive is duplicate ...

正确的写法应当是:

location/test {

content_by_lua 'ngx.say("hello") ngx.say("world")';

}

即在content_by_lua内联的 Lua 代码中调用两次ngx.say函数,而不是在当前location中使用两次content_by_lua指令。


类似地,ngx_proxy模块的proxy_pass指令和echo指令也不能同时用在一个location中,因为它们也同属content阶段。不少 Nginx 新手都会犯类似下面这样的错误:

?location/test {

?     echo "before...";

?proxy_passhttp://127.0.0.1:8080/foo;

?     echo "after...";

? }

?

?location/foo {

?     echo "contents to be proxied";

? }

这个例子表面上是想在ngx_proxy模块返回的内容前后,通过ngx_echo模块的echo指令分别输出字符串"before..."和"after...",但其实只有其中一个模块能在content阶段运行。测试结果表明,在这个例子中是ngx_proxy模块胜出,而ngx_echo模块的echo指令根本没有运行:

$ curl 'http://localhost:8080/test'

contents to be proxied

要实现这个例子希望达到的效果,需要改用ngx_echo模块提供的echo_before_bodyecho_after_body这两条配置指令:

location/test {

echo_before_body "before...";

proxy_passhttp://127.0.0.1:8080/foo;

echo_after_body "after...";

}

location/foo {

echo "contents to be proxied";

}

测试结果表明这一次我们成功了:

$ curl 'http://localhost:8080/test'

before...

contents to be proxied

after...

配置指令echo_before_bodyecho_after_body之所以可以和其他模块运行在content阶段的指令一起工作,是因为它们运行在 Nginx 的“输出过滤器”中。前面我们在(一)中分析echo指令产生的“调试日志”时已经知道,Nginx 在输出响应体数据时都会调用“输出过滤器”,所以ngx_echo模块才有机会在“输出过滤器”中对ngx_proxy模块产生的响应体输出进行修改(即在首尾添加新的内容)。值得一提的是,“输出过滤器”并不属于(一)中提到的那 11 个请求处理阶段(毕竟许多阶段都可以通过输出响应体数据来调用“输出过滤器”),但这并不妨碍echo_before_bodyecho_after_body指令在文档中标记下面这一行:

    phase: output filter

这一行的意思是,当前配置指令运行在“输出过滤器”这个特殊的阶段。


前面我们在(五)中提到,在一个location中使用content阶段指令时,通常情况下就是对应的 Nginx 模块注册该location中的“内容处理程序”。那么当一个location中未使用任何content阶段的指令,即没有模块注册“内容处理程序”时,content阶段会发生什么事情呢?谁又来担负起生成内容和输出响应的重担呢?答案就是那些把当前请求的 URI 映射到文件系统的静态资源服务模块。当存在“内容处理程序”时,这些静态资源服务模块并不会起作用;反之,请求的处理权就会自动落到这些模块上。


Nginx 一般会在content阶段安排三个这样的静态资源服务模块(除非你的 Nginx 在构造时显式禁用了这三个模块中的一个或者多个,又或者启用了这种类型的其他模块)。按照它们在content阶段的运行顺序,依次是ngx_index模块,ngx_autoindex模块,以及ngx_static模块。下面就来逐一介绍一下这三个模块。


ngx_indexngx_autoindex模块都只会作用于那些 URI 以/结尾的请求,例如请求GET /cats/,而对于不以/结尾的请求则会直接忽略,同时把处理权移交给content阶段的下一个模块。而ngx_static模块则刚好相反,直接忽略那些 URI 以/结尾的请求。


ngx_index模块主要用于在文件系统目录中自动查找指定的首页文件,类似index.html和index.htm这样的,例如:

location/ {

root/var/www/;

indexindex.htmindex.html;

}

这样,当用户请求/地址时,Nginx 就会自动在root配置指令指定的文件系统目录下依次寻找index.htm和index.html这两个文件。如果index.htm文件存在,则直接发起“内部跳转”到/index.htm这个新的地址;而如果index.htm文件不存在,则继续检查index.html是否存在。如果存在,同样发起“内部跳转”到/index.html;如果index.html文件仍然不存在,则放弃处理权给content阶段的下一个模块。


我们前面已经在Nginx 变量漫谈(二)中提到,echo_exec指令和rewrite指令可以发起“内部跳转”。这种跳转会自动修改当前请求的 URI,并且重新匹配与之对应的location配置块,再重新执行rewrite、access、content等处理阶段。因为是“内部跳转”,所以有别于 HTTP 协议中定义的基于 302 和 301 响应的“外部跳转”,最终用户的浏览器的地址栏也不会发生变化,依然是原来的 URI 位置。而ngx_index模块一旦找到了index指令中列举的文件之后,就会发起这样的“内部跳转”,仿佛用户是直接请求的这个文件所对应的 URI 一样。


为了进一步确认ngx_index模块在找到文件时的“内部跳转”行为,我们不妨设计下面这个小例子:

location/ {

root/var/www/;

indexindex.html;

}

location/index.html {

set$a 32;

echo "a = $a";

}

此时我们在本机的/var/www/目录下创建一个空白的index.html文件,并确保该文件的权限设置对于运行 Nginx worker 进程的帐户可读。然后我们来请求一下根位置(/):

$ curl 'http://localhost:8080/'

a = 32

这里发生了什么?为什么输出不是index.html文件的内容(即空白)?首先对于用户的原始请求GET /,Nginx 匹配出location /来处理它,然后content阶段的ngx_index模块在/var/www/下找到了index.html,于是立即发起一个到/index.html位置的“内部跳转”。


到这里,相信大家都不会有问题。接下来有趣的事情发生了!在重新为/index.html这个新位置匹配location配置块时,location /index.html的优先级要高于location /,因为location块按照 URI 前缀来匹配时遵循所谓的“最长子串匹配语义”。这样,在进入location /index.html配置块之后,又重新开始执行rewrite、access、以及content等阶段。最终输出a = 32自然也就在情理之中了。


我们接着研究上面这个例子。如果此时把/var/www/index.html文件删除,再访问/又会发生什么事情呢?答案是返回403 Forbidden出错页。为什么呢?因为ngx_index模块找不到index指令指定的文件(在这里就是index.html),接着把处理权转给content阶段的后续模块,而后续的模块也都无法处理这个请求,于是 Nginx 只好放弃,输出了错误页,并且在 Nginx 错误日志中留下了类似这一行信息:

    [error] 28789#0: *1 directory index of "/var/www/" is forbidden

所谓directory index便是生成“目录索引”的意思,典型的方式就是生成一个网页,上面列举出/var/www/目录下的所有文件和子目录。而运行在ngx_index模块之后的ngx_autoindex模块就可以用于自动生成这样的“目录索引”网页。我们来把上例修改一下:

location/ {

root/var/www/;

indexindex.html;

autoindexon;

}

此时仍然保持文件系统中的/var/www/index.html文件不存在。我们再访问/位置时,就会得到一张漂亮的网页:

$ curl 'http://localhost:8080/'

Index of /

Index of /


../

cgi-bin/  08-Mar-2010 19:36   -

error/      08-Mar-2010 19:36   -

htdocs/    05-Apr-2010 03:55   -

icons/      08-Mar-2010 19:36   -


生成的 HTML 源码显示,我本机的/var/www/目录下还有cgi-bin/,error/,htdocs/, 以及icons/这几个子目录。在你的系统中尝试上面的例子,输出很可能会不太一样。


值得一提的是,当你的文件系统中存在/var/www/index.html时,优先运行的ngx_index模块就会发起“内部跳转”,根本轮不到ngx_autoindex执行。感兴趣的读者可以自己测试一下。


在content阶段默认“垫底”的最后一个模块便是极为常用的ngx_static模块。这个模块主要实现服务静态文件的功能。比方说,一个网站的静态资源,包括静态.html文件、静态.css文件、静态.js文件、以及静态图片文件等等,全部可以通过这个模块对外服务。前面介绍的ngx_index模块虽然可以在指定的首页文件存在时发起“内部跳转”,但真正把相应的首页文件服务出去(即把该文件的内容作为响应体数据输出,并设置相应的响应头),还是得靠这个ngx_static模块来完成。


来看一个ngx_static模块服务磁盘文件的例子。我们使用下面这个配置片段:

location/ {

root/var/www/;

}

同时在本机的/var/www/目录下创建两个文件,一个文件叫做index.html,内容是一行文本this is my home;另一个文件叫做hello.html,内容是一行文本hello world. 同时注意这两个文件的权限设置,确保它们都对运行 Nginx worker 进程的系统帐户可读。


    现在来通过 HTTP 协议请求一下这两个文件所对应的 URI:

$ curl 'http://localhost:8080/index.html'

this is my home

$ curl 'http://localhost:8080/hello.html'

hello world

我们看到,先前创建的那两个磁盘文件的内容被分别输出了。


不妨来分析一下这里发生的事情:location /中没有使用运行在content阶段的模块指令,于是也就没有模块注册这个location的“内容处理程序”,处理权便自动落到了在content阶段“垫底”的那 3 个静态资源服务模块。首先运行的ngx_indexngx_autoindex模块先后看到当前请求的 URI,/index.html和/hello.html,并不以/结尾,于是直接弃权,将处理权转给了最后运行的ngx_static模块。ngx_static模块根据root指令指定的“文档根目录”(document root),分别将请求 URI/index.html和/hello.html映射为文件系统路径/var/www/index.html和/var/www/hello.html,在确认这两个文件存在后,将它们的内容分别作为响应体输出,并自动设置Content-Type、Content-Length以及Last-Modified等响应头。


为了确认ngx_static模块确实运行了,可以启用(一)中介绍过的 Nginx “调试日志”,然后再次请求/index.html这个接口。此时,在 Nginx 错误日志文件中可以看到类似下面这一行的调试信息:

    [debug] 3033#0: *1 http static fd: 8

这一行信息便是ngx_static模块生成的,其含义是“正在输出的静态文件的描述符是数字8”。当然,具体的文件描述符编号会经常发生变化,这里只是我机器的一次典型输出。值得一提的是,能生成这一行调试信息的还有标准模块ngx_gzip_static,但它默认是不启用的,后面会专门介绍到这个模块。


注意上面这个例子中使用的root配置指令只起到了声明“文档根目录”的作用,并不是它开启了ngx_static模块。ngx_static模块总是处于开启状态,但是否轮得到它运行就要看content阶段先于它运行的那些模块是否“弃权”了。为了进一步确认这一点,来看下面这个空白location的定义:

location/ {

}

因为没有配置root指令,所以在访问这个接口时,Nginx 会自动计算出一个缺省的“文档根目录”。该缺省值是取所谓的“配置前缀”(configure prefix)路径下的html/子目录。举一个例子,假设“配置前缀”是/foo/bah/,则缺省的“文档根目录”便是/foo/bar/html/.


那么“配置前缀”是由什么来决定的呢?默认情况下,就是 Nginx 安装时的根目录(或者说 Nginx 构造时传递给./configure脚本的--prefix选项的路径值)。如果 Nginx 安装到了/usr/local/nginx/下,则“配置前缀”便是/usr/local/nginx/,同时默认的“文档根目录”便是/usr/local/nginx/html/. 不过,我们也可以在启动 Nginx 的时候,通过--prefix命令行选项临时指定自己的“配置前缀”路径。假设我们启动 Nginx 时使用的命令是

    nginx -p /home/agentzh/test/

则对于该服务器实例,其“配置前缀”便是/home/agentzh/test/,而默认的“文档根目录”便是/home/agentzh/test/html/. “配置前缀”不仅会决定默认的“文档根目录”,还决定着 Nginx 配置文件中许多相对路径值如何解释为绝对路径,后面我们还会看到许多需要引用到“配置前缀”的例子。


    获取当前“文档根目录”的路径有一个非常简便的方法,那就是请求一个肯定不存在的文件所对应的资源名,例如:

$ curl 'http://localhost:8080/blah-blah.txt'

404 Not Found

404 Not Found


nginx

我们会很自然地得到404错误页。此时再看 Nginx 错误日志文件,应该会看到类似下面这一行错误消息:

    [error] 9364#0: *1 open() "/home/agentzh/test/html/blah-blah.txt" failed (2: No such file or directory)

这条错误消息是ngx_static模块打印出来的,因为它并不能在文件系统的对应路径上找到名为blah-blah.txt的文件。因为这条错误信息中包含有ngx_static试图打开的文件的绝对路径,所以从这个路径不难看出,当前的“文档根目录”是/home/agentzh/test/html/.


很多初学者会想当然地把404错误理解为某个location不存在,其实上面这个例子表明,即使location存在并成功匹配,也是可能返回404错误页的。因为决定着404错误页的是抽象的“资源”是否存在,而非某个具体的location是否存在。


初学者常犯的一个错误是忘记配置content阶段的模块指令,而他们自己其实并不期望使用content阶段缺省运行的静态资源服务,例如:

location/auth {

access_by_lua '

-- a lot of Lua code omitted here...

';

}

显然,这个/auth接口只定义了access阶段的配置指令,即access_by_lua,并未定义任何content阶段的配置指令。于是当我们请求/auth接口时,在access阶段的 Lua 代码会如期执行,然后content阶段的那些静态文件服务会紧接着自动发生作用,直至ngx_static模块去文件系统上找名为auth的文件。而经常地,404错误页会抛出,除非运气太好,在对应路径上确实存在一个叫做auth的文件。所以,一条经验是,当遇到意外的404错误并且又不涉及静态文件服务时,应当首先检查是否在对应的location配置块中恰当地配置了content阶段的模块指令,例如content_by_luaecho以及proxy_pass之类。当然,Nginx 的error.log文件一般总是会提供各种意外问题的答案,例如对于上面这个例子,我的error.log中有下面这条错误信息:

    [error] 9364#0: *1 open() "/home/agentzh/test/html/auth" failed (2: No such file or directory)


前面我们详细讨论了rewrite、access和content这三个最为常见的 Nginx 请求处理阶段,在此过程中,也顺便介绍了运行在这三个阶段的众多 Nginx 模块及其配置指令。同时可以看到,请求处理阶段的划分直接影响到了配置指令的执行顺序,熟悉这些阶段对于正确配置不同的 Nginx 模块并实现它们彼此之间的协同工作是非常必要的。所以接下来我们接着讨论余下的那些阶段。


前面在(一)中提到,Nginx 处理请求的过程一共划分为 11 个阶段,按照执行顺序依次是post-read、server-rewrite、find-config、rewrite、post-rewrite、preaccess、access、post-access、try-files、content以及log.


最先执行的post-read阶段在 Nginx 读取并解析完请求头(request headers)之后就立即开始运行。这个阶段像前面介绍过的rewrite阶段那样支持 Nginx 模块注册处理程序。比如标准模块ngx_realip就在post-read阶段注册了处理程序,它的功能是迫使 Nginx 认为当前请求的来源地址是指定的某一个请求头的值。下面这个例子就使用了ngx_realip模块提供的set_real_ip_fromreal_ip_header这两条配置指令:

server{

listen8080;

set_real_ip_from127.0.0.1;

real_ip_headerX-My-IP;

location/test {

set$addr $remote_addr;

echo "from: $addr";

}

}

这里的配置是让 Nginx 把那些来自127.0.0.1的所有请求的来源地址,都改写为请求头X-My-IP所指定的值。同时该例使用了标准内建变量$remote_addr来输出当前请求的来源地址,以确认是否被成功改写。


首先在本地请求一下这个/test接口:

$ curl -H 'X-My-IP: 1.2.3.4' localhost:8080/test

from: 1.2.3.4

这里使用了 curl 工具的-H选项指定了额外的 HTTP 请求头X-My-IP: 1.2.3.4. 从输出可以看到,$remote_addr变量的值确实在rewrite阶段就已经成为了X-My-IP请求头中指定的值,即1.2.3.4. 那么 Nginx 究竟是在什么时候改写了当前请求的来源地址呢?答案是:在post-read阶段。由于rewrite阶段的运行远在post-read阶段之后,所以当在location配置块中通过set配置指令读取$remote_addr内建变量时,读出的来源地址已经是经过post-read阶段篡改过的。


如果在请求上例中的/test接口时没有指定X-My-IP请求头,或者提供的X-My-IP请求头的值不是合法的 IP 地址,那么 Nginx 就不会对来源地址进行改写,例如:

$ curl localhost:8080/test

from: 127.0.0.1

$ curl -H 'X-My-IP: abc' localhost:8080/test

from: 127.0.0.1

如果从另一台机器访问这个/test接口,那么即使指定了合法的X-My-IP请求头,也不会触发 Nginx 对来源地址进行改写。这是因为上例已经使用set_real_ip_from指令规定了来源地址的改写操作只对那些来自127.0.0.1的请求生效。这种过滤机制可以避免来自其他不受信任的地址的恶意欺骗。当然,也可以通过set_real_ip_from指令指定一个 IP 网段(利用(三)中介绍过的“CIDR 记法”)。此外,同时配置多个set_real_ip_from语句也是允许的,这样可以指定多个受信任的来源地址或地址段。下面是一个例子:

set_real_ip_from10.32.10.5;

set_real_ip_from127.0.0.0/24;

有的读者可能会问,ngx_realip模块究竟有什么实际用途呢?为什么我们需要去改写请求的来源地址呢?答案是:当 Nginx 处理的请求经过了某个 HTTP 代理服务器的转发时,这个模块就变得特别有用。当原始的用户请求经过转发之后,Nginx 接收到的请求的来源地址无一例外地变成了该代理服务器的 IP 地址,于是 Nginx 以及 Nginx 背后的应用就无法知道原始请求的真实来源。所以,一般我们会在 Nginx 之前的代理服务器中把请求的原始来源地址编码进某个特殊的 HTTP 请求头中(例如上例中的X-My-IP请求头),然后再在 Nginx 一侧把这个请求头中编码的地址恢复出来。这样 Nginx 中的后续处理阶段(包括 Nginx 背后的各种后端应用)就会认为这些请求直接来自那些原始的地址,代理服务器就仿佛不存在一样。正是因为这个需求,所以ngx_realip模块才需要在第一个处理阶段,即post-read阶段,注册处理程序,以便尽可能早地改写请求的来源。


post-read阶段之后便是server-rewrite阶段。我们曾在(二)中简单提到,当ngx_rewrite模块的配置指令直接书写在server配置块中时,基本上都是运行在server-rewrite阶段。下面就来看这样的一个例子:

server{

listen8080;

location/test {

set$b "$a, world";

echo $b;

}

set$a hello;

}

这里,配置语句set $a hello直接写在了server配置块中,因此它就运行在server-rewrite阶段。而server-rewrite阶段要早于rewrite阶段运行,因此写在location配置块中的语句set $b "$a, world"便晚于外面的set $a hello语句运行。该例的测试结果证明了这一点:

$ curl localhost:8080/test

hello, world

由于server-rewrite阶段位于post-read阶段之后,所以server配置块中的set指令也就总是运行在ngx_realip模块改写请求的来源地址之后。来看下面这个例子:

server{

listen8080;

set$addr $remote_addr;

set_real_ip_from127.0.0.1;

real_ip_headerX-Real-IP;

location/test {

echo "from: $addr";

}

}

请求/test接口的结果如下:

$ curl -H 'X-Real-IP: 1.2.3.4' localhost:8080/test

from: 1.2.3.4

在这个例子中,虽然set指令写在了ngx_realip的配置指令之前,但仍然晚于ngx_realip模块执行。所以$addr变量在server-rewrite阶段被set指令赋值时,从$remote_addr变量读出的来源地址已经是经过改写过的了。


紧接在server-rewrite阶段后边的是find-config阶段。这个阶段并不支持 Nginx 模块注册处理程序,而是由 Nginx 核心来完成当前请求与location配置块之间的配对工作。换句话说,在此阶段之前,请求并没有与任何location配置块相关联。因此,对于运行在find-config阶段之前的post-read和server-rewrite阶段来说,只有server配置块以及更外层作用域中的配置指令才会起作用。这就是为什么只有写在server配置块中的ngx_rewrite模块的指令才会运行在server-rewrite阶段,这也是为什么前面所有例子中的ngx_realip模块的指令也都特意写在了server配置块中,以确保其注册在post-read阶段的处理程序能够生效。


当 Nginx 在find-config阶段成功匹配了一个location配置块后,会立即打印一条调试信息到错误日志文件中。我们来看这样的一个例子:

location/hello {

echo "hello world";

}

如果启用了 Nginx 的“调试日志”,那么当请求/hello接口时,便可以在error.log文件中过滤出下面这一行信息:

$ grep 'using config' logs/error.log

[debug] 84579#0: *1 using configuration "/hello"

我们有意省略了信息行首的时间戳,以便放在这里。


运行在find-config阶段之后的便是我们的老朋友rewrite阶段。由于 Nginx 已经在find-config阶段完成了当前请求与location的配对,所以从rewrite阶段开始,location配置块中的指令便可以产生作用。前面已经介绍过,当ngx_rewrite模块的指令用于location块中时,便是运行在这个rewrite阶段。另外,ngx_set_misc模块的指令也是如此,还有ngx_lua模块的set_by_lua指令和rewrite_by_lua指令也不例外。


rewrite阶段再往后便是所谓的post-rewrite阶段。这个阶段也像find-config阶段那样不接受 Nginx 模块注册处理程序,而是由 Nginx 核心完成rewrite阶段所要求的“内部跳转”操作(如果rewrite阶段有此要求的话)。先前在(二)中已经介绍过了“内部跳转”的概念,同时演示了如何通过echo_exec指令或者rewrite指令来发起“内部跳转”。由于echo_exec指令运行在content阶段,与这里讨论的post-rewrite阶段无关,于是我们感兴趣的便只剩下运行在rewrite阶段的rewrite指令。回顾一下(二)中演示过的这个例子:

server{

listen8080;

location/foo {

set$a hello;

rewrite^ /bar;

}

location/bar {

echo "a = [$a]";

}

}

这里在location /foo中通过rewrite指令把当前请求的 URI 无条件地改写为/bar,同时发起一个“内部跳转”,最终跳进了location /bar中。这里比较有趣的地方是“内部跳转”的工作原理。“内部跳转”本质上其实就是把当前的请求处理阶段强行倒退到find-config阶段,以便重新进行请求 URI 与location配置块的配对。比如上例中,运行在rewrite阶段的rewrite指令就让当前请求的处理阶段倒退回了find-config阶段。由于此时当前请求的 URI 已经被rewrite指令修改为了/bar,所以这一次换成了location /bar与当前请求相关联,然后再接着从rewrite阶段往下执行。


不过这里更有趣的地方是,倒退回find-config阶段的动作并不是发生在rewrite阶段,而是发生在后面的post-rewrite阶段。上例中的rewrite指令只是简单地指示 Nginx 有必要在post-rewrite阶段发起“内部跳转”。这个设计对于 Nginx 初学者来说,或许显得有些古怪:“为什么不直接在rewrite指令执行时立即进行跳转呢?”答案其实很简单,那就是为了在最初匹配的location块中支持多次反复地改写 URI,例如:

location/foo {

rewrite^ /bar;

rewrite^ /baz;

echo foo;

}

location/bar {

echo bar;

}

location/baz {

echo baz;

}

这里在location /foo中连续把当前请求的 URI 改写了两遍:第一遍先无条件地改写为/bar,第二遍再无条件地改写为/baz. 而这两条rewrite语句只会最终导致post-rewrite阶段发生一次“内部跳转”操作,从而不至于在第一次改写 URI 时就直接跳离了当前的location而导致后面的rewrite语句没有机会执行。请求/foo接口的结果证实了这一点:

$ curl localhost:8080/foo

baz

从输出结果可以看到,上例确实成功地从/foo一步跳到了/baz中。如果启用 Nginx “调试日志”的话,还可以从find-config阶段生成的locatin块的匹配信息中进一步证实这一点:

$ grep 'using config' logs/error.log

[debug] 89449#0: *1 using configuration "/foo"

[debug] 89449#0: *1 using configuration "/baz"

我们看到,对于该次请求,Nginx 一共只匹配过/foo和/baz这两个location,从而只发生过一次“内部跳转”。


当然,如果在server配置块中直接使用rewrite配置指令对请求 URI 进行改写,则不会涉及“内部跳转”,因为此时 URI 改写发生在server-rewrite阶段,早于执行location配对的find-config阶段。比如下面这个例子:

server{

listen8080;

rewrite^/foo /bar;

location/foo {

echo foo;

}

location/bar {

echo bar;

}

}

这里,我们在server-rewrite阶段就把那些以/foo起始的 URI 改写为/bar,而此时请求并没有和任何location相关联,所以 Nginx 正常往下运行find-config阶段,完成最终的location匹配。如果我们请求上例中的/foo接口,那么location /foo根本就没有机会匹配,因为在第一次(也是唯一的一次)运行find-config阶段时,当前请求的 URI 已经被改写为/bar,从而只会匹配location /bar. 实际请求的输出正是如此:

$ curl localhost:8080/foo

bar

Nginx “调试日志”可以再一次佐证我们的结论:

$ grep 'using config' logs/error.log

[debug] 92693#0: *1 using configuration "/bar"

可以看到,Nginx 总共只进行过一次location匹配,并无“内部跳转”发生。


运行在post-rewrite阶段之后的是所谓的preaccess阶段。该阶段在access阶段之前执行,故名preaccess.


标准模块ngx_limit_reqngx_limit_zone就运行在此阶段,前者可以控制请求的访问频度,而后者可以限制访问的并发度。这里我们仅仅和它们打个照面,后面还会有机会专门接触到这两个模块。


前面反复提到的标准模块ngx_realip其实也在这个阶段注册了处理程序。有些读者可能会问:“这是为什么呢?它不是已经在post-read阶段注册处理程序了吗?”我们不妨通过下面这个例子来揭晓答案:

server{

listen8080;

location/test {

set_real_ip_from127.0.0.1;

real_ip_headerX-Real-IP;

echo "from: $remote_addr";

}

}

与先看前到的例子相比,此例最重要的区别在于把ngx_realip的配置指令放在了location配置块中。前面我们介绍过,Nginx 匹配location的动作发生在find-config阶段,而find-config阶段远远晚于post-read阶段执行,所以在post-read阶段,当前请求还没有和任何location相关联。在这个例子中,因为ngx_realip的配置指令都写在了location配置块中,所以在post-read阶段,ngx_realip模块的处理程序没有看到任何可用的配置信息,便不会执行来源地址的改写工作了。


为了解决这个难题,ngx_realip模块便又特意在preaccess阶段注册了处理程序,这样它才有机会运行location块中的配置指令。正是因为这个缘故,上面这个例子的运行结果才符合直觉预期:

$ curl -H 'X-Real-IP: 1.2.3.4' localhost:8080/test

from: 1.2.3.4

不幸的是,ngx_realip模块的这个解决方案还是存在漏洞的,比如下面这个例子:

server{

listen8080;

location/test {

set_real_ip_from127.0.0.1;

real_ip_headerX-Real-IP;

set$addr $remote_addr;

echo "from: $addr";

}

}

这里,我们在rewrite阶段将$remote_addr的值保存到了用户变量$addr中,然后再输出。因为rewrite阶段先于preaccess阶段执行,所以当ngx_realip模块尚未在preaccess阶段改写来源地址时,最初的来源地址就已经在rewrite阶段被读取了。上例的实际请求结果证明了我们的结论:

$ curl -H 'X-Real-IP: 1.2.3.4' localhost:8080/test

from: 127.0.0.1

输出的地址确实是未经改写过的。Nginx 的“调试日志”可以进一步确认这一点:

$ grep -E 'http script (var|set)|realip' logs/error.log

[debug] 32488#0: *1 http script var: "127.0.0.1"

[debug] 32488#0: *1 http script set $addr

[debug] 32488#0: *1 realip: "1.2.3.4"

[debug] 32488#0: *1 realip: 0100007F FFFFFFFF 0100007F

[debug] 32488#0: *1 http script var: "127.0.0.1"

其中第一行调试信息

    [debug] 32488#0: *1 http script var: "127.0.0.1"

set语句读取$remote_addr变量时产生的。信息中的字符串"127.0.0.1"便是$remote_addr当时读出来的值。


    而第二行调试信息

    [debug] 32488#0: *1 http script set $addr

则显示我们对变量$addr进行了赋值操作。


    后面两行信息

[debug] 32488#0: *1 realip: "1.2.3.4"

[debug] 32488#0: *1 realip: 0100007F FFFFFFFF 0100007F

ngx_realip模块在preaccess阶段改写当前请求的来源地址。我们看到,改写后的新地址确实是期望的1.2.3.4. 但很明显这个操作发生在$addr变量赋值之后,所以已经太迟了。


    而最后一行信息

    [debug] 32488#0: *1 http script var: "127.0.0.1"

则是echo配置指令在输出时读取变量$addr时产生的,我们看到它的值是改写前的来源地址。


看到这里,有的读者可能会问:“如果ngx_realip模块不在preaccess阶段注册处理程序,而在rewrite阶段注册,那么上例不就可以工作了?”答案是:不一定。因为ngx_rewrite模块的处理程序也同样注册在rewrite阶段,而前面我们在(二)中特别提到,在这种情况下,不同模块之间的执行顺序一般是不确定的,所以ngx_realip的处理程序可能仍然在set语句之后执行。


一个建议是:尽量在server配置块中配置ngx_realip这样的模块,以避免上面介绍的这种棘手的例外情况。


运行在preaccess阶段之后的则是我们的另一个老朋友,access阶段。前面我们已经知道了,标准模块ngx_access、第三方模块ngx_auth_request以及第三方模块ngx_luaaccess_by_lua指令就运行在这个阶段。


access阶段之后便是post-access阶段。从这个阶段的名字,我们也能一眼看出它是紧跟在access阶段后面执行的。这个阶段也和post-rewrite阶段类似,并不支持 Nginx 模块注册处理程序,而是由 Nginx 核心自己完成一些处理工作。post-access阶段主要用于配合access阶段实现标准ngx_http_core模块提供的配置指令satisfy的功能。


对于多个 Nginx 模块注册在access阶段的处理程序,satisfy配置指令可以用于控制它们彼此之间的协作方式。比如模块 A 和 B 都在access阶段注册了与访问控制相关的处理程序,那就有两种协作方式,一是模块 A 和模块 B 都得通过验证才算通过,二是模块 A 和模块 B 只要其中任一个通过验证就算通过。第一种协作方式称为all方式(或者说“与关系”),第二种方式则被称为any方式(或者说“或关系”)。默认情况下,Nginx 使用的是all方式。下面是一个例子:

location/test {

satisfy all;

denyall;

access_by_lua 'ngx.exit(ngx.OK)';

echo something important;

}

这里,我们在/test接口中同时配置了ngx_access模块和ngx_lua模块,这样access阶段就由这两个模块一起来做检验工作。其中,语句deny all会让ngx_access模块的处理程序总是拒绝当前请求,而语句access_by_lua 'ngx.exit(ngx.OK)'则总是允许访问。当我们通过satisfy指令配置了all方式时,就需要access阶段的所有模块都通过验证,但不幸的是,这里ngx_access模块总是会拒绝访问,所以整个请求就会被拒:

$ curl localhost:8080/test

403 Forbidden

403 Forbidden


nginx

细心的读者会在 Nginx 错误日志文件中看到类似下面这一行的出错信息:

    [error] 6549\#0: *1 access forbidden by rule

然而,如果我们把上例中的satisfy all语句更改为satisfy any,

location/test {

satisfy any;

denyall;

access_by_lua 'ngx.exit(ngx.OK)';

echo something important;

}

结果则会完全不同:

$ curl localhost:8080/test

something important

即请求反而最终通过了验证。这是因为在any方式下,access阶段只要有一个模块通过了验证,就会认为请求整体通过了验证,而在上例中,ngx_lua模块的access_by_lua语句总是会通过验证的。


在配置了satisfy any的情况下,只有当access阶段的所有模块的处理程序都拒绝访问时,整个请求才会被拒,例如:

location/test {

satisfy any;

denyall;

access_by_lua 'ngx.exit(ngx.HTTP_FORBIDDEN)';

echo something important;

}

此时访问/test接口才会得到403 Forbidden错误页。这里,post-access阶段参与了access阶段各模块处理程序的“或关系”的实现。


值得一提的是,上面这几个的例子需要ngx_lua0.5.0rc19 或以上版本;之前的版本是不能和satisfy any配置语句一起工作的。


紧跟在post-access阶段之后的是try-files阶段。这个阶段专门用于实现标准配置指令try_files的功能,并不支持 Nginx 模块注册处理程序。由于try_files指令在许多 FastCGI 应用的配置中都有用到,所以我们不妨在这里简单介绍一下。


try_files指令接受两个以上任意数量的参数,每个参数都指定了一个 URI. 这里假设配置了N个参数,则 Nginx 会在try-files阶段,依次把前N-1个参数映射为文件系统上的对象(文件或者目录),然后检查这些对象是否存在。一旦 Nginx 发现某个文件系统对象存在,就会在try-files阶段把当前请求的 URI 改写为该对象所对应的参数 URI(但不会包含末尾的斜杠字符,也不会发生 “内部跳转”)。如果前N-1个参数所对应的文件系统对象都不存在,try-files阶段就会立即发起“内部跳转”到最后一个参数(即第N个参数)所指定的 URI.


前面在(六)(七)中已经看到静态资源服务模块会把当前请求的 URI 映射到文件系统,通过root配置指令所指定的“文档根目录”进行映射。例如,当“文档根目录”是/var/www/的时候,请求 URI/foo/bar会被映射为文件/var/www/foo/bar,而请求 URI/foo/baz/则会被映射为目录/var/www/foo/baz/. 注意这里是如何通过 URI 末尾的斜杠字符是否存在来区分“目录”和“文件”的。我们正在讨论的try_files配置指令使用同样的规则来完成其各个参数 URI 到文件系统对象的映射。


    不妨来看下面这个例子:

root/var/www/;

location/test {

try_files/foo /bar/ /baz;

echo "uri: $uri";

}

location/foo {

echo foo;

}

location/bar/ {

echo bar;

}

location/baz {

echo baz;

}

这里通过root指令把“文档根目录”配置为/var/www/,如果你系统中的/var/www/路径下存放有重要数据,则可以把它替换为其他任意路径,但此路径对运行 Nginx worker 进程的系统帐号至少有可读权限。我们在location /test中使用了try_files配置指令,并提供了三个参数,/foo、/bar/和/baz. 根据前面对try_files指令的介绍,我们可以知道,它会在try-files阶段依次检查前两个参数/foo和/bar/所对应的文件系统对象是否存在。


不妨先来做一组实验。假设现在/var/www/路径下是空的,则第一个参数/foo映射成的文件/var/www/foo是不存在的;同样,对于第二个参数/bar/所映射成的目录/var/www/bar/也是不存在的。于是此时 Nginx 会在try-files阶段发起到最后一个参数所指定的 URI(即/baz)的“内部跳转”。实际的请求结果证实了这一点:

$ curl localhost:8080/test

baz

显然,该请求最终和location /baz绑定在一起,执行了输出baz字符串的工作。上例中定义的location /foo和location /bar/完全不会参与这里的运行过程,因为对于try_files的前N-1个参数,Nginx 只会检查文件系统,而不会去执行 URI 与location之间的匹配。


    对于上面这个请求,Nginx 会产生类似下面这样的“调试日志”:

$ grep trying logs/error.log

[debug] 3869#0: *1 trying to use file: "/foo" "/var/www/foo"

[debug] 3869#0: *1 trying to use dir: "/bar" "/var/www/bar"

[debug] 3869#0: *1 trying to use file: "/baz" "/var/www/baz"

通过这些信息可以清楚地看到try-files阶段发生的事情:Nginx 依次检查了文件/var/www/foo和目录/var/www/bar,末了又处理了最后一个参数/baz. 这里最后一条“调试信息”容易产生误解,会让人误以为 Nginx 也把最后一个参数/baz给映射成了文件系统对象进行检查,事实并非如此。当try_files指令处理到它的最后一个参数时,总是直接执行“内部跳转”,而不论其对应的文件系统对象是否存在。


接下来再做一组实验:在/var/www/下创建一个名为foo的文件,其内容为hello world(注意你需要有/var/www/目录下的写权限):

    $ echo 'hello world' > /var/www/foo

然后再请求/test接口:

$ curl localhost:8080/test

uri: /foo

这里发生了什么?我们来看,try_files指令的第一个参数/foo可以映射为文件/var/www/foo,而 Nginx 在try-files阶段发现此文件确实存在,于是立即把当前请求的 URI 改写为这个参数的值,即/foo,并且不再继续检查后面的参数,而直接运行后面的请求处理阶段。


上面这个请求在try-files阶段所产生的“调试日志”如下:

$ grep trying logs/error.log

[debug] 4132#0: *1 trying to use file: "/foo" "/var/www/foo"

显然,在try-files阶段,Nginx 确实只检查和处理了/foo这一个参数,而后面的参数都被“短路”掉了。


类似地,假设我们删除刚才创建的/var/www/foo文件,而在/var/www/下创建一个名为bar的子目录:

    $ mkdir /var/www/bar

则请求/test的结果也是类似的:

$ curl localhost:8080/test

uri: /bar

在这种情况下,Nginx 在try-files阶段发现第一个参数/foo对应的文件不存在,就会转向检查第二个参数对应的文件系统对象(在这里便是目录/var/www/bar/)。由于此目录存在,Nginx 就会把当前请求的 URI 改写为第二个参数的值,即/bar(注意,原始参数值是/bar/,但try_files会自动去除末尾的斜杠字符)。


    这一组实验所产生的“调试日志”如下:

$ grep trying logs/error.log

[debug] 4223#0: *1 trying to use file: "/foo" "/var/www/foo"

[debug] 4223#0: *1 trying to use dir: "/bar" "/var/www/bar"

我们看到,try_files指令在这里只检查和处理了它的前两个参数。


通过前面这几组实验不难看到,try_files指令本质上只是有条件地改写当前请求的 URI,而这里说的“条件”其实就是文件系统上的对象是否存在。当“条件”都不满足时,它就会无条件地发起一个指定的“内部跳转”。当然,除了无条件地发起“内部跳转”之外,try_files指令还支持直接返回指定状态码的 HTTP 错误页,例如:

try_files/foo /bar/ =404;

这行配置是说,当/foo和/bar/参数所对应的文件系统对象都不存在时,就直接返回404 Not Found错误页。注意这里它是如何使用等号字符前缀来标识 HTTP 状态码的。

推荐阅读更多精彩内容