接入层是所有流量的必经之路,它的一次重启就意味着全站不可用。因此 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 内置模块不够用时,如何自己写一个模块,这是下一篇文章的主题。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。