一、nginx -t 通过,不代表这份配置是对的
改完 nginx 配置,跑一句 nginx -t,屏幕上跳出 syntax is ok 和 test is successful,然后 systemctl reload nginx,页面正常——整个过程看起来干净利落。问题在于,nginx -t 只做三件事:读文件、过语法、检查部分运行时资源(比如 pid 文件路径能否写、证书文件能否读)。它不判断你的意图。
它能告诉你的:括号配对、指令名拼写、参数个数、引号是否闭合、include 的文件是否存在、listen 端口是否被占用(部分情况)、日志目录是否可写。它不能告诉你的:这个 location 会不会匹配不到、这条 rewrite 会不会把带参数的 URL 打回首页、这个 upstream 是不是总在走备用节点、这条 if 会不会在 POST 请求上返回 405、这个 proxy_pass 的路径会不会把前缀吃掉两遍。
所以「配置上线后出问题」的场景里,绝大多数不是语法错,而是语义错——语法完全合法,行为完全不是你想要的。这篇文章按我个人踩过的顺序,列出 nginx -t 放行但线上出事的六类典型情况,每一类都给出可执行的验证命令。
二、location 优先级:正则的优先级比你想的高
Nginx 的 location 匹配顺序是一条固定规则,不是「谁写在前面谁生效」:先匹配前缀 location 并记住最长的一个,然后按配置文件里出现的顺序检查正则 location,第一个匹配成功的正则直接胜出,最后如果正则全不匹配,才用记住的那个最长前缀。只有带 ^~ 的前缀能阻止正则继续匹配。
最常见的翻车:站点配了 location / { try_files ... },另配了 location ~* \.(jpg|png|css|js)$ { expires 30d; },后来又加了伪静态规则 location ~ ^/([0-9]+)\.html$。某天你发现「所有图片都 404 了」,因为某个更早出现的正则先匹配上了。或者反过来,你给某个目录加了 location /static/ {...},但站点里有一条正则 ~* \.(js)$ 在前面,静态目录里的 JS 走进了正则分支,拿不到你设的缓存头。
验证方法不是猜,而是直接看 Nginx 认为该请求走哪个 location。方法是在目标 location 里加一个响应头,然后 curl:
location /static/ {
add_header X-Loc static always;
...
}
location ~* \.(js|css)$ {
add_header X-Loc regex-assets always;
...
}alias ap='curl -sI -o /dev/null -w "%{http_code} %{redirect_url}\n"'
ap https://www.example.com/static/app.js
curl -sI https://www.example.com/static/app.js | grep -i x-loc把所有可疑路径列一遍,看 X-Loc 落在哪个分支。这一步花两分钟,能省掉后面两小时的排查。要避免这类问题,最简单的约定是:正则 location 数量不要超过三条,且一条都不要覆盖静态目录;静态目录一律用 ^~ 前缀把它从正则里摘出来。
三、try_files 的最后一个参数:一个斜杠的差别
Typecho、WordPress 这类 PHP 站的伪静态,标准写法是:
location / {
try_files $uri $uri/ /index.php?$query_string;
}这一行里三个部分各有用处,缺一个就出毛病。$uri 命中真实文件;$uri/ 命中真实目录(会触发目录索引或进入目录下的 index);最后的 /index.php?$query_string 把请求整体交给 PHP 重写,且手动拼上原始查询串。
只写 try_files $uri $uri/ /index.php(不带 ?$query_string),语法完全合法,nginx -t 通过。后果是:所有带参数的页面全部丢掉参数。站内搜索变成空结果,分页跳到第一页,评论提交后的 ?_=xxx 缓存参数丢失——而页面看起来「正常」,只是数据不对。这类 bug 最难查,因为它不报错。
另一类相反的错误是写成 /index.php?(问号后为空)。这在 Nginx 里是「清空查询串」的写法,语义上是有意义的(用于丢弃所有参数),但很多人是手误多打了一个问号,结果和上面的情况一样。
验证方式是拿一个带参数的 URL 直接请求,看后端收到什么。最快的办法是临时在 PHP 入口打一行日志,或者用 Nginx 的 add_header 把 $query_string 回显出来:
location / {
add_header X-QS $query_string always;
try_files $uri $uri/ /index.php?$query_string;
}curl -sI 'https://www.example.com/search?q=nginx&page=2' | grep -i x-qs响应头里应该出现 q=nginx&page=2。如果为空,就是重写把参数吃了。
四、rewrite 与 return:301 链是怎么一节一节长出来的
站点的重定向规则往往是一年一年叠加起来的。2019 年换域名的 301、2021 年统一 HTTPS 的 301、2022 年去掉 www 的 301、2023 年改固定链接结构的 301,每一条单独看都没问题,串起来就是一条 4 跳的重定向链。
nginx -t 完全看不出这个问题,因为每条 rewrite 单独都合法。用户感知是「偶尔要等一下才跳过去」,爬虫感知是「抓取预算被重定向链吃掉」。查法很简单,用 curl 跟随并打印每一跳:
curl -sIL -o /dev/null -w '%{url_effective} %{num_redirects}\n' \
'http://example.com/old-post?id=12'
curl -sv 'http://example.com/old-post?id=12' 2>&1 | grep -E '^< (HTTP|Location)'把老 URL 样本抽一批(从访问日志里筛 301/302 的请求路径,取前 20 条),一条条跑,记录跳数。目标是把所有链压到 1 跳:源地址直接跳到最终地址,中间不留中转。
awk '$9 ~ /^30[12]$/ {print $7}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20压平的做法不是加更多 rewrite,而是把规则合并成一张映射表。少量规则用 map 最清晰:
map $uri $legacy_target {
default "";
/old-post /archives/123.html;
/category/tech /category/server;
~^/p/(?<id>\d+)$ /archives/$id.html;
}
server {
if ($legacy_target != "") {
return 301 $legacy_target;
}
}map 的好处是全部规则集中在一处,一眼能看出有没有目标地址本身又落在另一条规则上。特别注意 if 在 location 外的 server 块里配合 return 使用是安全的(这是官方明确允许的少数 if 用法之一),但 if 嵌套在其他指令里就很容易出事,见下一节。
五、if 是陷阱:哪些能用,哪些一定会出事
Nginx 官方 wiki 上有一篇著名的「If Is Evil」,核心结论是:if 在 location 块内使用时,会把所在 location 的配置变成一种嵌套的、行为不完整的作用域,部分指令(尤其是 try_files、proxy_pass、add_header 的继承)会出现不可预期的结果。
典型事故:用 if ($request_method != POST) { ... } 做方法判断,结果是 POST 请求返回 405;再或者写 if ($http_user_agent ~* bot) { return 403; },本来只想拦爬虫,结果 return 在 if 里的优先级导致某些静态资源也被 403。nginx -t 全过,线上却是一片 405。
能安全使用 if 的场景很少,基本只有两类:server 块里配合 return(如上节的跳转映射)、location 块里单独配一个 return(且该 location 里没有其他复杂指令)。其余判断一律改用 map:
map $http_user_agent $bad_bot {
default 0;
"~*SemrushBot" 1;
"~*AhrefsBot" 1;
}
server {
if ($bad_bot) { return 403; }
}条件判断的前置计算交给 map,if 只负责一个 return,这样就不会破坏 location 的作用域。判断请求方法则应该用 limit_except 而不是 if:
location /api/ {
limit_except GET POST {
deny all;
}
}验证方法是把每种方法都发一遍,看返回码是否符合预期:
for m in GET POST PUT DELETE OPTIONS; do
printf '%-8s ' $m
curl -s -o /dev/null -w '%{http_code}\n' -X $m https://www.example.com/api/ping
done如果 PUT 和 DELETE 返回 200,说明你的方法限制根本没生效——语法通过不等于语义生效。
六、proxy_pass 的斜杠:一条规则里的两种语义
反代到后端应用时,proxy_pass 后面带不带路径,是两套完全不同的行为。这是新手最容易踩、老手也偶尔手滑的一处。
写法一:proxy_pass http://backend;(不带路径,只有 upstream)。此时原始 URI 会被原样转发,location 匹配到的前缀也一起带过去。location /api/ { proxy_pass http://127.0.0.1:8080; } 请求 /api/users,后端收到的是 /api/users。
写法二:proxy_pass http://127.0.0.1:8080/;(带一个斜杠)。此时location 匹配到的前缀会被替换成这个路径。location /api/ { proxy_pass http://127.0.0.1:8080/; } 请求 /api/users,后端收到的是 /users。
这两种写法都合法,nginx -t 都通过,但后端行为天差地别。症状通常有两类:后端报 404(前缀被吃掉,后端路由不认 /users),或者后端报 404 的另一类(前缀被重复,后端收到 /api/api/users,常见于 location 用了正则捕获但 proxy_pass 里没对应引用)。
验证方法是在后端打一行请求日志,或者直接用一个回显服务:
location /echo/ {
proxy_pass http://127.0.0.1:9000/;
}
# 后端用 nc 起一个回显,打印收到的请求行
nc -l -p 9000 -q1 <<< $'GET /x HTTP/1.1\r\nHost: t\r\n\r\n'更实用的办法是让后端把 REQUEST_URI 记到日志里,改一次配置发一次请求对比。不要靠读文档推断,因为这处语义在不同版本、正则与非正则 location 下的行为还有细微差别,实测最准。
顺带一个高频坑:proxy_pass 后面用了变量(如 proxy_pass http://$backend$request_uri;),Nginx 就不会预解析 upstream,也就不会应用 upstream 块的负载均衡与健康检查,还会强制用 resolver 做运行时解析。这在语法上完全合法,但会让你的多节点配置退化成单节点。
七、reload 成功了,不代表新配置生效了
最后一类事故最隐蔽:配置改对了,nginx -t 通过,reload 也返回 0,但线上行为还是旧的。
原因通常有三种。第一种,改错了文件——站点配置在 /etc/nginx/conf.d/,你改的是 /etc/nginx/sites-available/ 下那份没被 include 的副本。第二种,有 include 顺序导致后加载的文件覆盖了前者的 server 块(同名 server_name 时先加载的生效,后面的会被忽略并打一条 warning,但 warning 不fail)。第三种,reload 时旧 worker 还在处理长连接,新配置只在新建连接上生效,你测的正好是复用连接的旧 worker。
第三种情况在 keepalive 场景下特别常见:浏览器或 curl 复用了连接,看起来「配置没生效」。验证时一定要新开连接:
curl -s -o /dev/null -w '%{http_code}\n' --no-keepalive https://www.example.com/
curl -s --no-keepalive https://www.example.com/ | md5sum前两种则要靠确认「到底加载了哪个文件」。Nginx 有一条很实用的调试指令,把完整配置 dump 出来:
nginx -T 2>/dev/null | grep -n 'server_name www.example.com' -A2
nginx -T 2>/dev/null | grep -c 'location'nginx -T 会输出所有被 include 的文件(带 # configuration file /path 注释),这是唯一能确认「实际加载了什么」的手段。养成习惯:改完配置先 nginx -T | grep 你的目标,确认改动在 dump 里,再 reload。
另外,reload 不是万能药。改 listen 的端口、改 user、改 pid 路径,这类需要重启而不是 reload;而 reload 过程中如果有旧的 upstream keepalive 连接,可能出现短暂的 502。稳妥的做法是 reload 后立刻跑一遍核心路径的 curl 检查(首页、静态资源、伪静态页、API),三个 200 才算上线成功。