nginx 的 rewrite ... break 会吞掉后面的规则:一次缓存直出引发的顺序陷阱-第三维度MokIII

一个会"吞掉"后续配置的指令

nginx 里有两类很常见的写法:

# 缓存直出:命中就返回静态文件
if (-f /path/to/cache/index.html) {
    rewrite ^(.*)$ /path/to/cache/index.html break;
}

# 某个 UA 直接拒绝
if ($http_user_agent ~* "SomeBot") { return 403; }

如果第一段排在第二段前面,那么:只要命中了缓存,第二段的拦截就完全不会执行。

这不是"顺序影响优先级",而是第二段根本没被求值。

原因:server 级 rewrite 指令会被编译成同一个脚本

nginx 会把 vhost 主文件以及它 include 的所有 server 级指令编译成一个 rewrite 脚本,然后从上到下执行。

break 的语义是:终止这个脚本的剩余部分。

所以它不只是"跳出当前 if"——它是把后面所有 server 级的 if / rewrite / return 一起掐掉。

我踩到的两个具体后果

① 爬虫拦截失效

反爬规则(某些 UA 返回 403)写在缓存直出规则后面。实测:对已经生成缓存的首页,AhrefsBot / SemrushBot / SERankingBacklinksBot 全部返回 200——拦截规则被整体跳过了。把缓存直出规则挪到最后,立刻恢复 403。

② http→https 跳转失效

面板生成的 HTTP→HTTPS 段和非 www→www 跳转,都排在扩展配置之后。于是源站 80 端口上:

curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: www.example.com' http://127.0.0.1/
# 修复前:200  ← 应该 301
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: www.example.com' 'http://127.0.0.1/?x=1'
# 301  ← 未命中缓存的请求走面板原规则,本来正常

同一个地址,带不带查询参数结果都不一样——因为带参数就不命中缓存,不带就命中。这种"时好时坏"最难查。

修法一:调整顺序(治本但不够)

最直接的办法是把缓存直出规则排到最后——在宝塔里就是让它成为 extension 目录里文件名字典序最大的那个。

这样其它规则先执行,缓存规则最后执行,break 也就没有"后面"可吞了。

但它只解决了"排在缓存之后"的规则,而 http→https 这种由面板生成、位置固定的规则,你没法把它挪到 extension 里面去。

修法二:用 return 抢在 break 之前(补充手段)

return 和 rewrite 都在 rewrite 阶段执行,按脚本顺序。所以只要把跳转规则放进 extension 目录、用 00- 前缀排在缓存规则之前,它就能先执行:

# 1) http(80) → https
set $force_https 1;
if ($server_port != 443) { set $force_https 2; }
if ($uri ~ /\.well-known/) { set $force_https 1; }
if ($force_https != 1) { return 301 https://$host$request_uri; }

# 2) 非 www → www
if ($host ~ '^example.com') { return 301 https://www.example.com$request_uri; }

两个细节值得说:

  • 用 set + 单个 if,而不是叠好几个 if:nginx 的 if 是嵌套陷阱的重灾区,多个 if 里做 return 容易互相干扰。用一个变量先算好条件,最后只判断一次,可读性和可靠性都好得多。
  • 保留 /.well-known/ 例外:SSL 证书的 HTTP-01 验证走 80 端口,如果这里也 301 走 https,证书续期会失败。这类"必须绕过的路径"要单独放行。

影响面:到底谁受影响

这一整套只影响绕过 CDN、直连源站 80 端口的请求。公网访客经 CDN 访问时,http→https 是边缘做的。

但"绕过 CDN 直连"恰恰是扫描器、爬虫、证书验证、以及你自己排障时会做的事——所以不能不修。

回归自检(改完必做)

# 期望 301 https://www.example.com/
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -H 'Host: www.example.com' http://127.0.0.1/

# 期望 404(不能 301,否则证书续期会挂)
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: www.example.com' http://127.0.0.1/.well-known/acme-challenge/x

# 期望 200
curl -sk -o /dev/null -w '%{http_code}\n' --resolve www.example.com:443:127.0.0.1 https://www.example.com/

# 期望 403(拦截仍然生效,这是最容易被吞掉的一条)
curl -sk -o /dev/null -w '%{http_code}\n' -A 'AhrefsBot/7.0' --resolve www.example.com:443:127.0.0.1 https://www.example.com/

每次都跑一遍,尤其是最后两条——它们分别对应 break 吞掉的两类规则。

回滚

删掉那段跳转配置 → nginx -t && nginx -s reload。回滚后缓存仍然有效,只是源站 80 端口的跳转又回到"命中缓存时不生效"的状态。


一句话总结:rewrite ... break 终止的是整个 server 级 rewrite 脚本,不是"跳出当前 if"。所以凡是排在它后面的 server 级规则(爬虫拦截、强制跳转),在缓存命中时都会静默失效——把缓存规则放最后,或用 return 抢在它前面执行。