Nginx配置发布闸门:校验、原子切换与自动回滚

我给一个客户的论坛做故障复盘时,看到过这样一条 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 | less

nginx -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 这一关被拦下,脚本直接退出,线上一个字节都没变。整个处理时间从"发现问题、登录、回滚、重启"的十几分钟,缩短到"看到报错、改个字母、重新发布"的一分钟。这才是配置管理的意义——不是让人不犯错,而是让错误在到达用户之前就被挡住。

Last modification:October 20th, 2026 at 08:24 pm

Leave a Comment