在使用 NGINX 进行配置时,如果不理解 if 指令的逻辑,很多时候都会尝试在 location 块中使用 if 指令来实现某些逻辑控制。我最近被 NGINX 的 if 坑了一下。写个博客记录一下吧。

NGINX 的配置是一种声明式的静态结构,其核心设计理念是基于模块组合和层级定义。而 if 指令最初是由 rewrite 模块引入的,它的本质是命令式执行逻辑,和整体配置风格相悖。为了满足用户日益增长的需求,NGINX 曾一度尝试允许某些非 rewrite 模块的指令也能在 if 中运行。但这样做带来了不少隐患:这些指令看起来似乎可以正常工作,实际上却容易出现各种意料之外的问题,例如指令未被正确执行、配置行为偏离预期,甚至在某些情况下会直接导致 NGINX 崩溃(SIGSEGV)。

目前已知以下指令在 if 中是安全的:

1
2
return ...;
rewrite ... last;

错误示例

举几个意料外的例子。希望大家可以记住这些案例,避免在实际生产环境中使用。

1、只有一个 add_header 生效

1
2
3
4
5
6
7
8
9
10
location /only-one-if {
set $true 1;
if ($true) {
add_header X-First 1;
}
if ($true) {
add_header X-Second 2;
}
return 204;
}

按照我们的逻辑,当 $true 为真时,应该两个 add_header 都生效。但是,实际上,只有第二个 add_header 生效。

2、try_files 失效

1
2
3
4
5
6
7
location /if-try-files {
try_files /file @fallback;
set $true 1;
if ($true) {
# 什么都不做
}
}

一旦加上 if,即使 if 中什么都不做,try_files 也将不再正常工作。

3、if 可能导致 proxy_cache 不生效

1
2
3
4
5
6
7
location /example {
proxy_cache my_cache;
if ($something) {
return 403;
}
proxy_pass http://backend;
}

即使配置了缓存,也不会生效。不会被 NGINX 缓存。

原理

省流:在 NGINX 中,if 并不像一般编程语言中的条件判断语句。它本质上会创建一个新的嵌套 location 区块。一旦条件匹配,Nginx 会“陷入”这个新的 location 执行上下文,并只执行这个 if 区块中的内容处理逻辑(content handler,例如 proxy_pass、echo、return 等)。也就是说,if 块一旦匹配,就相当于“跳转”到了另一个内部 location,执行流程在此终止,外部配置不再生效。

Case 1

1
2
3
4
5
6
7
8
9
10
11
location /proxy {
set $a 32;
if ($a = 32) {
set $a 56;
}
set $a 76;
proxy_pass http://127.0.0.1:$server_port/$a;
}
location ~ /(\d+) {
echo $1;
}

这个时候访问 /proxy 会返回 76。

分析流程:
1、set $a 32;
2、条件 $a = 32 成立,进入 if,设置 $a = 56。
3、紧接着继续执行 set $a 76; 最终 $a = 76。
由于 if 块中没有定义内容处理器(content handler),所以继承外部的 proxy_pass。
请求结束,内容由 proxy 模块处理,路径中的 $a 是最终值 76。

结论:即使进入了 if,只要没有在其中设置内容处理器,仍会继续执行 proxy_pass,但变量已经是最终值了。

Case 2

1
2
3
4
5
6
7
8
9
location /proxy {
set $a 32;
if ($a = 32) {
set $a 56;
echo "a = $a";
}
set $a 76;
proxy_pass http://127.0.0.1:$server_port/$a;
}

这个时候访问 /proxy 会返回 76。

流程分析:
1、条件 $a = 32 成立,进入 if。
2、执行了 set $a 56; 但 echo 是内容处理器,会立刻终止请求流程。
3、由于 set 的值在 rewrite 阶段延后生效,echo 实际输出的是 $a = 76(这是后续 rewrite 设置的值)。

set 指令是在 rewrite 阶段执行的,而内容处理器(如 echo)是在 content 阶段执行的。但在 NGINX 内部,变量的值会随着后续 set 指令不断更新。即使 echo 在 if 块中作为 handler 执行,其实际使用的变量值是 rewrite 阶段结束后最终确定的值。

Case 3

1
2
3
4
5
6
7
8
9
10
location /proxy {
set $a 32;
if ($a = 32) {
set $a 56;
break;
echo "a = $a";
}
set $a 76;
proxy_pass http://127.0.0.1:$server_port/$a;
}

这个时候访问 /proxy 会返回 56。
这一次的不同之处在于我们添加了 break:
break 会终止 rewrite 阶段的后续处理,set $a 76 不会被执行。因此 $a 最终是 56。

Case 4

1
2
3
4
5
6
7
8
9
location /proxy {
set $a 32;
if ($a = 32) {
return 404;
}
set $a 76;
proxy_pass http://127.0.0.1:$server_port/$a;
more_set_headers "X-Foo: $a";
}

访问 /proxy 得到:

1
2
HTTP/1.1 404 Not Found
X-Foo: 32

是不是很吃惊,尽管返回了 404,X-Foo 头依然被设置了。这是因为 more_set_headers 的行为是直接在 header_filter 阶段处理,它在 if 区块中也被继承了。尽管 return 404 终止了流程,头部设置仍然在 header_filter 阶段生效。

如果使用的是 add_header 指令而不是 more_set_headers,则不会看到 X-Foo 头。因为 add_header 默认只在 200/204/301/302 等状态码下生效,404 会被跳过。

如何解决

1、使用 map 来替代 if 的逻辑,但要注意,map 是全局的,小心变量污染。
2、使用 njs 或者 lua 实现动态逻辑。
3、如果非要用 if,确定你没喝酒或者头脑清醒,以及做好了必要的测试。

参考

If is Evil… when used in location context