我给一个客户的论坛做故障复盘时,看到过这样一条 nginx 错误日志:emerg: unknown directive "proxx_pass"。一个字母打错,Nginx 直接起不来。他们的运维当时正在赶时间,习惯性地敲了 systemctl reload nginx——还好 reload 会先校验配置,失败时旧进程继续服务,站点没受影响。可如果他敲的是 restart,整个站点就会在那一刻变成 502,直到有人发现并改回来。
这个故事说明一件事:Nginx 的配置变更本身是安全的,危险的是变更管理方式。常见的翻车姿势有四种:改完不校验直接 restart;多人同时改同一个文件,谁的版本覆盖了谁不知道;改坏了想回滚,发现上一次的好配置早被覆盖;以及 reload 成功了但新配置有语义错误,站点看似正常、实际上所有请求都走错了 upstream。
这篇文章讲一套适合个人站点的"配置发布闸门":把 Nginx 配置当成代码来管,用目录版本化 + 符号链接原子切换,加上 nginx -t 前置校验和 reload 后的健康探针,再配一个失败自动回滚的脚本。整体不依赖任何额外软件,纯 shell 加 git 就能跑。
第一层:nginx -t 到底校验了什么,没校验什么
先把这个工具的能力边界搞清楚,否则会误以为"校验过了就是安全的"。
# 校验默认配置
nginx -t
# 校验一个还没上线的候选配置(这是发布闸门的关键用法)
nginx -t -c /etc/nginx/candidates/nginx.conf
# 只看解析后的完整配置(排查 include 顺序、变量展开时非常有用)
nginx -T 2>/dev/null | lessnginx -t 做的事情包括:语法解析、模块指令是否认识、include 的文件是否存在、日志目录能否写入、ssl_certificate 指向的文件能否读取且证书与私钥是否匹配、listen 端口是否被占用。这些都是"静态"检查,它 不会 告诉你:
proxy_pass指向的 upstream 是否真的活着(语法对,但后端 502)root路径是否真的存在(目录不存在时 Nginx 不报错,只是所有请求 404)- 重写规则是否会把正常 URL 变成死循环(
rewrite的循环要到运行时才撞上 10 次上限) location的匹配优先级是否符合你的预期(正则 location 的顺序问题,改了之后某些路径悄悄失效)- 证书是否已经过期(能读、能配对,但有效期昨天就过了)
所以 nginx -t 是第一道闸门,不是唯一一道。后面还要加"活体验证"。
第二层:把配置目录版本化
默认的 /etc/nginx/nginx.conf 加 sites-enabled/ 结构,在单人、单站的情况下够用。但一旦你要做"可回滚",就需要一个明确知道"上一个好版本是什么"的结构。我用的方案是这样:
/etc/nginx/
├── nginx.conf # 主配置,include releases/current/*
├── releases/
│ ├── 20261018-0930/ # 每个版本一个目录,按时间戳命名
│ │ ├── nginx.conf
│ │ └── conf.d/
│ ├── 20261019-1412/
│ └── current -> 20261019-1412 # 符号链接指向当前生效版本
└── candidates/ # 待发布版本,测试通过后才进 releases主配置文件 /etc/nginx/nginx.conf 只保留最小骨架,其余全部从 current 链接进来:
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 2048;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;
# 关键:从 current 链接读取站点配置
include /etc/nginx/releases/current/conf.d/*.conf;
}用符号链接的好处是 切换是原子操作。ln -sfn 替换链接在文件系统层面是一个 rename,Nginx 读配置时要么看到完整的旧链接、要么看到完整的新链接,不会读到"一半旧一半新"的状态。如果你直接改 conf.d/ 里的文件,在编辑器写盘的瞬间,文件可能是空的或者被截断的——如果这期间恰好有 reload 或者有 worker 重启,就会读到坏配置。
# 建一个新版本目录(从当前版本复制,保证起点一致)
STAMP=$(date +%Y%m%d-%H%M)
cp -a /etc/nginx/releases/$(readlink /etc/nginx/releases/current) \
/etc/nginx/releases/$STAMP
# 改完之后原子切换
ln -sfn /etc/nginx/releases/$STAMP /etc/nginx/releases/current.tmp
mv -T /etc/nginx/releases/current.tmp /etc/nginx/releases/current注意最后用的是 mv -T 而不是直接 ln -sfn。直接 ln -sfn 在某些情况下会先删除旧链接再创建新链接,中间有一个极短的窗口 current 不存在。用"先创建临时链接、再 rename 覆盖"的写法,才能保证任何时刻 current 都指向一个有效的目录。
同时用 git 保管一份,这样能回答"三个月前那次改动到底改了什么":
cd /etc/nginx
git init
printf 'releases/*\n!releases/current\n*.log\ncandidates/\n' > .gitignore
# 每次发布后提交(只记录 current 里的实际内容)
git add -A && git commit -m "release $STAMP: 给 /api 加限速"
# 出问题时看差异
git log --oneline -10
git diff HEAD~1 HEAD -- '*.conf'如果嫌 releases/ 目录太重,也可以不建目录树,直接依赖 git:所有配置放 sites-available/,改动走 git 提交,回滚走 git checkout。对单人站点来说这条更轻。两种方式的核心是一样的——你随时能拿到"上一个已知良好版本"。
第三层:发布闸门脚本
把校验、切换、重载、验证、回滚串成一个脚本。这个脚本是整套方案的核心,坏配置进不来,进来了自动退回。
#!/bin/bash
# /usr/local/bin/nginx-release —— 发布 current 链接指向的配置
set -euo pipefail
NGX=/etc/nginx
CUR=$(readlink -f $NGX/releases/current)
BASE=$(dirname "$CUR")
MARK=/tmp/nginx_release_mark
log() { echo "[$(date +%H:%M:%S)] $*"; }
# 1) 候选配置必须是 current 指向的目录(调用方已改好)
log "校验配置:$CUR"
if ! nginx -t -c $NGX/nginx.conf 2>/tmp/ngx_test.log; then
log "配置校验失败,已中止发布"
cat /tmp/ngx_test.log
exit 1
fi
# 2) 记录切换前的状态,用于回滚
PREV=$(basename "$CUR")
log "当前版本:$PREV"
# 3) 原子切换(如果调用方是先在 candidates 里改,这里再做链接)
# 本脚本约定:$CUR 就是已经校验过的目标版本,无需再切
# 4) 重载并确认 worker 数量与进程在
touch $MARK
BEFORE_PID=$(cat /run/nginx.pid)
nginx -s reload
sleep 1
if ! kill -0 "$(cat /run/nginx.pid)" 2>/dev/null; then
log "reload 后 master 进程消失,立即回滚"
ln -sfn $BASE/$PREV $NGX/releases/current
nginx -t -c $NGX/nginx.conf && nginx -s reload
exit 1
fi
# 5) 活体探针:连续 5 次请求,允许 1 次失败
FAILS=0
for i in 1 2 3 4 5; do
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
-H 'Host: www.example.com' http://127.0.0.1/)
[ "$CODE" = "200" ] || FAILS=$((FAILS+1))
done
if [ "$FAILS" -gt 1 ]; then
log "探针失败 $FAILS/5,回滚到 $PREV"
ln -sfn $BASE/$PREV $NGX/releases/current
nginx -t -c $NGX/nginx.conf && nginx -s reload
exit 1
fi
log "发布成功:$PREV,探针 200/5"
几个设计要点值得说明。
为什么探针打 127.0.0.1 而不是域名。 打域名会经过 DNS、CDN、可能还有 WAF,任何一环抖动都会让探针误判。打本地回环加 Host 头,测的才是 Nginx 本身。
为什么允许 1 次失败而不是要求 5 次全过。 极端情况下 worker 还没完全接手,第一次请求可能命中一个刚退出的旧 worker 的 keepalive 连接。容错一次可以避免大量误回滚,同时 2/5 的失败率仍然能捕捉到真正的配置错误。
为什么 reload 前 touch 一个标记文件。 用于事后判断"这次重载有没有产生新的 error log"。下一节会用到。
第四层:reload 之后看什么
探针过了不代表没关系了,还要确认 reload 过程本身没留下错误。reload 时 Nginx 会启新 worker、让旧 worker 处理完手上的连接后退出。这个过程如果配置有语义问题(比如 upstream 指向了一个不存在的主机),错误不会在探针里出现——因为探针只打首页,首页可能是静态文件、根本不走那个 upstream。
# reload 后新增的 error log(只看标记文件之后的行)
awk -v mark="$(date -r /tmp/nginx_release_mark '+%Y/%m/%d %H:%M')" \
'$0 >= mark' /var/log/nginx/error.log | head -30
# 确认 worker 是新起的(每个 worker 的启动时间)
ps -o pid,lstart,cmd -C nginx
# 确认新旧 worker 已经交替完成:旧 worker 应该在一个 graceful timeout 内退出
# 如果旧 worker 一直不退出,说明有长连接或上游卡住
ps -o pid,etimes,cmd -C nginx --sort=etimes | tail -5另一个常被忽略的点是 reload 会重置 upstream 的 keepalive 连接池。如果你启用了 keepalive 到后端(比如 PHP-FPM 或另一个 upstream),每次 reload 都会断开重连,后端会看到一波瞬时连接数上升。对个人站点无所谓,但如果后端连接数有上限,频繁 reload 可能导致短暂的 502。这也是为什么"改一行配置就 reload 一次"的习惯其实不太好,应该攒一批改动一次发布。
第五层:灰度 upstream 与快速回退
有些改动不只想快速回滚,还想先小流量验证。Nginx 的 weight 参数可以做简单的流量分配:
upstream backend {
server 10.0.0.11:8080 weight=9; # 老版本 90%
server 10.0.0.12:8080 weight=1; # 新版本 10%
keepalive 32;
}
server {
listen 80;
server_name www.example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
}
}这里 proxy_next_upstream 值得单独说一句:它让 Nginx 在遇到后端错误时自动换一个 upstream 重试。这本身就是一种"微回滚"——新版本那台挂了,用户请求会被转到老版本,用户看到的是页面稍微慢了一点,而不是 502。但要注意它对 非幂等请求 是危险的:一个 POST 请求如果后端已经处理了一半再返回 502,重试会导致重复提交。所以稳妥的写法是按方法区分:
# 只对幂等请求开启重试
map $request_method $can_retry {
default 0;
GET 1;
HEAD 1;
OPTIONS 1;
}
upstream backend {
server 10.0.0.11:8080 weight=9;
server 10.0.0.12:8080 weight=1;
}
server {
location / {
proxy_pass http://backend;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
# 非幂等请求直接返回错误,不重试
if ($can_retry = 0) {
proxy_next_upstream off;
}
}
}如果不想折腾 upstream 权重,更朴素的办法是在 CDN 层面切一部分流量到另一个源站。或者干脆什么都不做——对个人站点来说,把改动写对、回滚做快,比灰度更重要。
第六层:几种"reload 成功但站点挂了"的实战场景
下面这几种都是我实际遇到过的,配置全都能通过 nginx -t,但线上立刻出问题。列出来是为了让你在写探针的时候覆盖到它们。
1)改了 root 路径,目录不存在。 Nginx 启动成功,所有请求返回 404。根因是 root 里的路径拼错了或者是软链接断了。nginx -t 不检查目录存在性。探针里一定要包含一个"已知会返回 200 的具体文章页",而不只是首页。
# 发布前顺手验证静态根目录
test -d /var/www/example.com && echo OK || echo "根目录不存在"2)location 顺序变化导致伪静态失效。 加了一条正则 location 之后,原本由 try_files 处理的路径被新规则截走了,页面标题能出来但样式全丢。这类问题探针抓不到,只能靠对比:发布后抓一次首页和文章页的 HTML,跟发布前 diff。
# 发布前后各存一份,diff 看结构有没有变化
curl -s -H 'Host: www.example.com' http://127.0.0.1/ -o /tmp/before.html
# ... 发布 ...
curl -s -H 'Host: www.example.com' http://127.0.0.1/ -o /tmp/after.html
diff <(wc -c < /tmp/before.html) <(wc -c < /tmp/after.html) \
|| echo "首页体积变化超过预期,人工确认"3)证书链不完整。 换了证书文件之后,Chrome 正常但部分安卓机和 curl 报错。原因是只配了站点证书,没配中间证书。用 openssl 验证:
echo | openssl s_client -connect 127.0.0.1:443 -servername www.example.com 2>>/dev/null \
| grep -E "Verify return code|depth="输出里 Verify return code: 0 (ok) 且 depth 至少到 1,说明链完整。如果只有 depth=0,就是缺中间证书。
4)client_max_body_size 漏了一处。 只在主 server 块里加了大文件上传限制,但某个子 location 里又覆盖回了默认值,结果一部分上传报 413。这类问题只能靠逐个 location 检查,或者发布后实测一次上传接口。
# 实测上传(用 5MB 随机文件打接口)
head -c 5M /dev/urandom > /tmp/t.bin
curl -s -o /dev/null -w '%{http_code}\n' -F "file=@/tmp/t.bin" \
-H 'Host: www.example.com' http://127.0.0.1/upload把这件事变成习惯
整套方案看起来步骤不少,落地之后其实只有三条纪律:
一、改配置永远改一个新的版本目录,不改 current 里的文件。 这样任何一次改动都是"新建再切换",天然可回滚。current 里的文件永远是"已经验证过的线上状态",被误改的概率降为零。
二、发布只走脚本,不手敲 reload。 手敲的命令没有校验、没有探针、没有回滚。一旦养成脚本发布的习惯,那次"半夜改配置改错了"的事故就不会发生。
三、每次发布留一条 git commit。 三个月后你一定会想知道"这个 rewrite 是哪次加的、为什么加"。commit message 写清楚动机,比配置本身更有价值。
回到开头那个 proxx_pass 的例子。用了这套流程之后,改错的配置会在 nginx -t 这一关被拦下,脚本直接退出,线上一个字节都没变。整个处理时间从"发现问题、登录、回滚、重启"的十几分钟,缩短到"看到报错、改个字母、重新发布"的一分钟。这才是配置管理的意义——不是让人不犯错,而是让错误在到达用户之前就被挡住。