Nginx 零停机重载与热升级:信号、连接排空与平滑回滚

完整拆解 Nginx 零停机变更的实现机制,覆盖 reload 信号与配置校验、worker 优雅退出与连接排空、二进制热升级、upstream 平滑摘除、回滚验证以及自动化发布流水线。

接入层是所有流量的必经之路,它的一次重启就意味着全站不可用。因此 Nginx 的变更必须做到「用户无感知」:改配置不中断连接、换二进制不断开长连接、下线节点不影响进行中的请求。Nginx 通过 master-worker 架构与信号机制天然支持这些能力,但要用对、用好,需要理解 reload 的内部过程、连接排空的边界条件,以及长连接场景下的额外处理。本文按一次完整变更的生命周期展开:校验 → 重载 → 排空 → 验证 → 回滚。

一句话总结: 零停机的本质是「新老 worker 并存 + 老 worker 排空后退出」,任何绕过这个过程的变更方式都会产生连接中断。

1. 重载的语义与信号机制

一句话总结: reload 不是重启,它让 master 用新配置启动一批新 worker,老 worker 在服务完存量连接后自行退出。

Nginx 的信号语义可以概括为「master 接收信号、worker 响应信号」:

# 常用信号一览
nginx -s reload    # 等同 kill -HUP <master_pid>
nginx -s quit      # 等同 kill -QUIT <master_pid>  优雅退出
nginx -s stop      # 等同 kill -TERM <master_pid>  快速退出
nginx -s reopen    # 等同 kill -USR1 <master_pid>  重开日志文件
kill -USR2 <master_pid>   # 热升级:启动新 master
kill -WINCH <master_pid>  # 热升级:让老 worker 优雅退出
kill -QUIT <master_pid>   # 热升级:退出老 master

执行 nginx -s reload 后,master 进程会做三件事:解析新配置文件、用新配置 fork 出新的 worker 集合、向老 worker 发送 QUIT 信号让它们优雅退出。新老 worker 会短暂并存,同时监听同一个端口(Nginx 在 fork 前创建好监听套接字,所有 worker 共享)。用 watch -n 0.2 'ps -eo pid,etime,cmd | grep "nginx: worker"' 就能看到两代 worker 并存的过程。

关键点在于:reload 期间没有一刻是所有 worker 都不可用的。老 worker 停止接受新连接,但继续服务已建立的连接;新 worker 立即开始接受新连接。只要配置文件语法正确且新配置不会导致启动失败,整个过程对用户完全透明。

但 reload 有两个失败模式需要警惕。第一,配置解析失败:nginx -s reload 遇到语法错误时不会报错到终端,而是写进 error_log,master 继续用老配置运行。因此重载前必须用 nginx -t 校验,并且要检查 nginx -t 的退出码而不是仅看输出。

# 正确的重载流程:先校验,后重载,再验证
if nginx -t 2>&1 | tee /tmp/nginx-t.log; then
    nginx -s reload
    sleep 1
    # 确认 worker 已经换代
    ps -eo pid,etime,cmd | grep "nginx: worker" | grep -v grep
else
    echo "配置校验失败,放弃重载"
    exit 1
fi

第二,新配置导致启动失败:配置语法正确但运行时报错(例如 ssl_certificate 指向的文件不存在、proxy_cache_path 目录不可写)。这种情况下 master 会启动失败并保留老 worker 继续服务,同时 error_log 记录 still could not bind() 之类的错误。因此 reload 后必须检查 error_log 与进程数,确认新 worker 真的起来了。

# reload 后校验:worker 数量是否符合 worker_processes 配置
grep -c "nginx: worker" <(ps -eo cmd)
# 检查 error_log 尾部是否有新错误
tail -20 /var/log/nginx/error.log

2. 配置校验与重载前置检查

一句话总结: nginx -t 只做语法与部分语义检查,完整的重载前置检查还要覆盖文件存在性、目录权限、upstream 可达性与变更影响面。

nginx -t 是必备但不充分的。它验证语法、指令上下文、以及部分「可静态判定」的语义(比如 proxy_cache_path 的 keys_zone 是否被引用)。它不会验证:证书文件是否可读(部分版本会检查)、upstream 服务器是否可达、后端服务是否健康、新配置是否会导致业务路由错乱。

一套完整的重载前置检查清单:

#!/usr/bin/env bash
set -euo pipefail
CONF=/etc/nginx/nginx.conf

# 1. 语法与语义校验
nginx -t -c "$CONF"

# 2. 证书文件可读性与有效期
for cert in $(grep -rhoP 'ssl_certificate\s+\K[^;]+' /etc/nginx/conf.d/); do
    openssl x509 -in "$cert" -noout -checkend 604800 \
      || echo "警告:$cert 将在 7 天内过期"
done

# 3. 上游可达性抽查
for upstream in $(grep -rhoP 'server\s+\K[0-9.]+:[0-9]+' /etc/nginx/conf.d/); do
    nc -z -w2 "${upstream%:*}" "${upstream#*:}" \
      || echo "警告:上游不可达 $upstream"
done

# 4. 缓存与日志目录可写
test -w /data/cache && test -w /var/log/nginx || echo "目录不可写"

2.1 用 diff 评估变更影响面

一句话总结: 重载前先看配置 diff,判断变更是否涉及监听端口、证书、upstream 等高风险项,据此决定是否需要额外的验证步骤。

重载的风险与变更内容强相关。修改 log_format 几乎无风险,而修改 listen 端口、ssl_certificate、upstream 成员则可能引发连接中断或流量异常。因此在流水线里应该把配置 diff 分类:

# 生成配置 diff 并标注高风险行
diff -u /etc/nginx/nginx.conf.bak /etc/nginx/nginx.conf \
  | grep -E '^\+' \
  | grep -E 'listen|ssl_certificate|upstream|server_name' \
  && echo "包含高风险变更,需要人工确认"

高风险变更建议采用更保守的策略:先在小流量的灰度节点上重载,观察 5 分钟无异常后再推到全量节点。这比「全量重载后祈祷」可靠得多。

# 灰度重载:先推 1 台,验证后再推全量
for node in $(cat nodes.txt); do
    scp nginx.conf "$node:/etc/nginx/nginx.conf"
    ssh "$node" 'nginx -t && nginx -s reload'
    if [ "$node" = "$(head -1 nodes.txt)" ]; then
        sleep 300   # 灰度节点观察 5 分钟
    fi
done

3. worker 优雅退出与连接排空

一句话总结: 老 worker 收到 QUIT 后停止 accept 新连接,等待存量连接自然结束,超过 worker_shutdown_timeout 才强制关闭。

优雅退出的行为由两个参数控制:worker_shutdown_timeout 决定「最长等待多久」,keepalive_timeout 决定「空闲长连接保留多久」。二者的关系决定了排空时间。

http {
    # 老 worker 最多等待 30 秒完成排空,超时强制关闭
    worker_shutdown_timeout 30s;

    # 客户端长连接空闲 15 秒后关闭,缩短排空等待
    keepalive_timeout 15s;
}

排空时间大致等于「最长请求处理时间」与「keepalive 空闲超时」的较大值。如果业务有耗时 60 秒的接口,而 worker_shutdown_timeout 只有 30 秒,那么这些请求会在重载时被强制中断。因此这两个参数要与业务的 P99 耗时对齐:

# 业务 P99 为 25 秒时,排空超时至少设到 30~40 秒
worker_shutdown_timeout 40s;

长连接场景(WebSocket、gRPC、SSE)是排空的难点。这类连接的生命周期可能是数小时,老 worker 不可能一直等下去。正确的做法是在应用层引导重连:老 worker 在退出前向客户端发送一个「服务端即将关闭」的信号,客户端收到后主动重连(此时会被新 worker 接住)。

# 在 location 中限制长连接的最大存活时间,主动引导周期性重连
location /ws/ {
    proxy_pass http://ws_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    # 每 30 分钟主动断开,客户端重连时会落到新 worker
    proxy_read_timeout 1800s;
    proxy_send_timeout 1800s;
}

proxy_read_timeout 在这里不只是超时保护,它还是「主动断连周期」。把长连接的最大存活时间控制在 30 分钟以内,重载时的排空等待就不会超过这个量级。客户端侧要配合实现自动重连与状态恢复(比如带断点续传或事件序号)。

排空过程可以在 error_log 里观察:老 worker 会输出 shutting down 相关的提示,用 tail -f /var/log/nginx/error.log | grep -i "shutting down" 就能确认排空是否按预期推进。

4. 二进制热升级

一句话总结: 热升级用 USR2 启动新 master 并继承监听套接字,用 WINCH 让老 worker 退出、QUIT 让老 master 退出,过程中新老二进制并存。

热升级用于「不中断服务地更换 Nginx 二进制」:升级版本、加载新编译的模块、或者应用安全补丁。整个过程分四步:

# 第 1 步:用新二进制替换旧二进制(保持原路径不变)
cp /usr/sbin/nginx /usr/sbin/nginx.old
cp ./objs/nginx /usr/sbin/nginx
/usr/sbin/nginx -t          # 用新二进制校验配置

# 第 2 步:向老 master 发送 USR2,启动新 master
kill -USR2 $(cat /run/nginx.pid)

# 此时新 master 会以 nginx.pid.oldbin 记录老 master,并生成新的 nginx.pid
sleep 2
ls -l /run/nginx.pid*

# 第 3 步:确认新 master 正常后,让老 worker 优雅退出
kill -WINCH $(cat /run/nginx.pid.oldbin)

# 第 4 步:观察一段时间无异常,退出老 master
kill -QUIT $(cat /run/nginx.pid.oldbin)

关键细节是监听套接字的继承。新 master 通过环境变量从老 master 继承监听套接字,因此不需要重新 bind,也就不会出现「端口被占用」的窗口期。这也是为什么热升级必须保持二进制路径不变——老 master 需要能再次 exec 该路径。

# 热升级后的进程树:新老 master 与各自 worker 并存
ps -eo pid,ppid,cmd | grep nginx | grep -v grep
# 例:1000 nginx: master (老)  1001/1002 worker
#     2000 nginx: master (新)  2001/2002 worker

热升级的回滚比 reload 更简单:如果新 master 有问题,直接向老 master 发送 HUP(让它重新拉起 worker),再向新 master 发送 QUIT:

# 热升级回滚:老 master 重新接管
kill -HUP  $(cat /run/nginx.pid.oldbin)   # 老 master 重新启动 worker
kill -QUIT $(cat /run/nginx.pid)          # 新 master 优雅退出
kill -WINCH $(cat /run/nginx.pid.oldbin)  # 老 master 停止自己的 worker(可选)

这里有个容易混淆的点:HUP 发给老 master 会让它用磁盘上的二进制重启 worker,而磁盘上的二进制已经是新版本了。所以如果新二进制本身有 bug,这种回滚方式无效,必须先把二进制换回旧版本再 HUP。因此热升级前务必保留 nginx.old,并确认回滚流程可用。

# 完整回滚:先恢复旧二进制,再让老 master 接管
cp /usr/sbin/nginx.old /usr/sbin/nginx
kill -HUP  $(cat /run/nginx.pid.oldbin)
kill -QUIT $(cat /run/nginx.pid)

5. upstream 平滑摘除

一句话总结: 下线后端节点前要先在 Nginx 侧摘除并等待连接排空,直接停进程会导致进行中的请求返回 502。

后端节点发布时,如果直接停掉进程,Nginx 上仍在处理的请求会立刻失败,用户看到 502。正确的顺序是「先从 upstream 摘除 → 等待排空 → 再停进程」。

Nginx 开源版没有动态修改 upstream 的 API,摘除需要改配置并 reload。proxy_next_upstream 可以让请求在失败时自动重试到其他节点,但它只覆盖「请求还未发出」或「响应头还未收到」的阶段,对已经开始返回响应体的请求无效:

upstream app_cluster {
    server 10.0.0.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;

    # 连接失败、超时、以及上游返回 502/503/504 时重试下一台
    # 注意:只在请求可安全重试时开启(幂等请求)
}

location / {
    proxy_pass http://app_cluster;
    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_next_upstream_tries 2;
    proxy_next_upstream_timeout 10s;

    # 摘除节点时,把在途请求切换到其他节点
    proxy_connect_timeout 2s;
}

如果使用 OpenResty 或 Nginx Plus,可以用动态 upstream(lua-resty-upstream 或 zone + API)实现秒级摘除,无需 reload:给 upstream 加上 zone app_cluster 64k; 让它落在共享内存中,再通过 API(如 PATCH /api/6/http/upstreams/app_cluster/servers/0 传 {"down":true})在运行时标记节点下线,配置立即生效。

标准摘除流程(以开源版为例):

#!/usr/bin/env bash
# 优雅下线一台后端节点
TARGET="10.0.0.11:8080"

# 1. 注释掉 upstream 中的目标节点,reload 生效
sed -i "s|^\(\s*server $TARGET;\)|#\1|" /etc/nginx/conf.d/upstream.conf
nginx -t && nginx -s reload

# 2. 等待在途连接排空(观察 upstream 连接数降到 0)
for i in $(seq 1 30); do
    n=$(ss -tn state established "( dport = :8080 )" | grep -c "$TARGET" || true)
    [ "$n" -eq 0 ] && break
    sleep 2
done

# 3. 排空后再停后端进程
ssh "$TARGET" 'systemctl stop app'

第 2 步的「等待排空」常被省略,导致摘除后立刻停进程,在途请求被切断。即使 max_fails 会把失败节点标记为不可用,已经建立连接的请求也无法转移。

6. 回滚与验证

一句话总结: 每次变更都要准备好「一键回滚的配置快照」与「可量化的验证指标」,验证通过才算变更完成。

零停机的另一半是「快速回滚」。回滚的前提是变更前的配置被完整保存,且回滚操作本身也是零停机的。

# 变更前打快照,回滚时直接切换
CONF_DIR=/etc/nginx
SNAP=/var/backups/nginx/$(date +%Y%m%d%H%M%S)
mkdir -p "$SNAP"
cp -r "$CONF_DIR/conf.d" "$CONF_DIR/nginx.conf" "$SNAP/"

# 回滚
cp -r "$SNAP/conf.d" "$CONF_DIR/"
nginx -t && nginx -s reload

回滚要解决一个关键问题:如何判断需要回滚。这依赖可量化的验证指标。变更后 1~5 分钟内必须检查:

# 变更后验证清单(示例脚本)
# 1. 错误率:5xx 占比是否异常
awk '$9 ~ /^5/ {e++} END {print "5xx_ratio", e/NR}' \
    <(tail -10000 /var/log/nginx/access.log)

# 2. 上游失败:upstream 连接失败次数
grep -c "upstream timed out\|connect() failed" /var/log/nginx/error.log

# 3. 进程与连接数:worker 是否正常
ps -eo cmd | grep -c "nginx: worker"
ss -s | grep TCP

# 4. 业务探针:关键接口返回 200 且响应时间正常
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" \
    https://example.com/healthz

把这些检查做成脚本,每次变更后自动执行,任何一项不达标就触发回滚。指标要选灵敏且稳定的:5xx 比例对故障最敏感,$upstream_response_time 的 P99 对性能退化敏感,而 QPS 波动可能只是流量自然变化,不适合作为回滚依据。

# 为验证提供更细的观测数据
log_format verify '$remote_addr $status $request_time $upstream_response_time '
                  '$upstream_addr $upstream_status';

7. 自动化发布流水线

一句话总结: 把校验、灰度、重载、验证、回滚串成流水线,并保证每一步都有幂等的重试与明确的失败处理。

把前面所有步骤串起来,就是一条 Nginx 配置发布流水线。核心原则是「每一步都可独立失败、可重试、可回滚」:

#!/usr/bin/env bash
set -euo pipefail

# 阶段 1:渲染配置(模板 + 环境变量)
render_config() {
    envsubst < templates/nginx.conf.tmpl > /etc/nginx/nginx.conf
}

# 阶段 2:校验
validate() {
    nginx -t
    check_certificates
    check_upstreams
}

# 阶段 3:快照
snapshot() {
    cp -r /etc/nginx /var/backups/nginx/$(date +%s)
}

# 阶段 4:灰度重载
rolling_reload() {
    for node in $(cat nodes.txt); do
        ssh "$node" 'nginx -t && nginx -s reload'
        verify "$node" || { rollback_all; exit 1; }
    done
}

# 阶段 5:验证
verify() {
    local node=$1
    local code
    code=$(curl -s -o /dev/null -w '%{http_code}' "http://$node/healthz")
    [ "$code" = "200" ] || return 1
    # 检查错误日志是否出现新的错误
    ssh "$node" 'tail -50 /var/log/nginx/error.log' | grep -qi "emerg\|alert" && return 1
    return 0
}

render_config && validate && snapshot && rolling_reload

流水线里有几个工程细节值得强调。幂等性:nginx -s reload 本身是幂等的,重复执行不会有害,这让重试变得安全。失败可见:每个节点都要检查 error_log,而不是只看 SSH 的退出码——SSH 成功不代表 Nginx 重载成功。超时控制:SSH 与 curl 都要加超时,否则流水线可能卡在某个节点上。

# SSH 与 curl 都要显式超时
ssh -o ConnectTimeout=5 -o BatchMode=yes "$node" 'nginx -t'
curl -s --max-time 5 -o /dev/null -w '%{http_code}' "http://$node/healthz"

最后,流水线要处理部分成功的场景:10 台节点里 8 台成功、2 台失败时,是全部回滚还是继续?推荐的做法是「全部回滚」——配置不一致的集群比旧配置更危险,因为它会让故障难以复现。回滚本身也走同一套灰度流程,保证过程同样是零停机的。

8. 总结

环节要点
信号语义HUP 重载、QUIT 优雅退出、USR2/WINCH 热升级
配置校验nginx -t 不够,还需校验证书、目录、上游可达性
变更分级diff 识别高风险变更,走灰度重载
优雅排空worker_shutdown_timeout 与业务 P99 对齐
长连接主动限制连接寿命,引导客户端重连
热升级保留旧二进制,明确四步操作与回滚路径
节点摘除先摘除、再排空、后停进程,顺序不可颠倒
发布流水线校验、快照、灰度、验证、回滚串成可重试流程

零停机不是某个单一配置的效果,而是「正确使用信号 + 合理设置排空超时 + 有序摘除节点 + 可量化验证 + 可一键回滚」的组合结果。任何一环缺失,都会在某个特定场景下暴露为连接中断。掌握了变更的平滑性之后,接入层的可控性就只剩最后一块拼图——当 Nginx 内置模块不够用时,如何自己写一个模块,这是下一篇文章的主题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx 在服务网格中的角色:边车代理、mTLS 与 Envoy 取舍
  2. Nginx 大文件上传与请求体处理:缓冲、临时文件与断点续传
  3. Nginx 证书自动化与 ACME:certbot、DNS-01 通配符与自动续期