☰
Nginx平滑升级:信号机制、流程与回滚全解析
2026/9/30 5:06:33 网站建设 项目流程

你有没有遇到过这种尴尬:线上Nginx还在跑着老版本,安全扫描报告列了一排漏洞,领导催着升级,可业务24小时不能断,直接重启又怕引发一堆报警。这种时候,Linux下Nginx的平滑升级就是标准的解决办法。所谓平滑升级,就是不停止服务,把正在运行的master进程换成新的可执行文件,新旧进程短暂共存、逐批切换worker,整个过程中用户几乎无感知。这篇文章我把整个流程、原理和坑都梳理了一遍,从信号机制讲到编译参数,再到回滚方案,照着做基本能一次成功。

适用人群很明确:用源码方式部署Nginx的运维和开发同学,尤其是生产环境业务无法接受重启的用户。如果你是通过apt或yum装的Nginx,升级方式不同,但信号机制和排查思路同样有参考价值。

1. 平滑升级的核心思路:为什么不能直接重启

1.1 一次线上事故带来的教训

先讲个我自己的经历。早年间我处理过一次升级事故,当时图省事,升级Nginx用的方式很粗暴:下载新包,make install覆盖,然后nginx -s stop再nginx启动。结果就是那一瞬间线上所有连接全部中断,正在上传的文件断了一半,数据库连接池全部重建,监控大屏上的错误率直接拉满,后面被业务方追着问了一个礼拜。

从那以后我就明白了,生产环境升级Nginx,最重要的不是“版本有多新”,而是“切换这一步能不能做到用户无感知”。而Nginx的设计恰好给了我们一条非常优雅的路:它的master-worker进程模型,加上Unix信号机制,可以让新旧版本同时存活一小段时间,然后逐批把流量切到新版本上。这就是平滑升级能成立的根基。

1.2 平滑升级能解决什么问题

平滑升级解决的核心痛点有三个:第一,不丢请求。正在处理的请求由旧worker继续处理完,新请求交给新worker处理,中间不存在服务空档。第二,不中断连接。因为新旧master是同一进程fork出来的,监听socket是继承的,所以端口不会冲突,也不会出现“端口被占用”或“连接拒绝”的情况。第三,可回滚。切换后如果发现新版本有异常,旧master还活着,可以一键把流量切回去,这对于生产环境来说是最后的保险。

当然平滑升级也有它的局限性。比如配置结构和数据结构有破坏性变更的大版本,旧worker可能处理不了新格式的请求,这种情况虽然少见,但升级前一定要看官方changelog。另一个限制是,如果旧worker里有永不关闭的长连接(比如没设超时的WebSocket),旧worker可能一直退不干净,这种情况需要配合worker_shutdown_timeout来处理,后面我会详细说。

1.3 原理拆解:master进程、worker进程和信号的关系

要理解平滑升级,先得把Nginx的进程模型和信号机制摸清楚。Nginx启动后有一个master进程,它负责读取配置文件、fork出worker进程、管理worker生命周期;worker进程才是真正处理请求的,每个worker是一个单线程的循环,同一时刻只能处理一个连接上的事件。

升级的核心在于master进程如何“更换自己的可执行文件”。Nginx支持一个特殊动作:向master进程发送USR2信号,master收到后会先把自己的pid文件改名为nginx.pid.oldbin,然后fork一个子进程,这个子进程会重新exec磁盘上的nginx二进制文件。这里有个关键点:此时磁盘上的sbin/nginx必须已经是新版本,否则exec出来的还是旧版本。所以操作顺序一定是:先覆盖二进制,再发信号,而不是反过来。

新master起来后,会重新解析配置文件并fork出自己的worker。这期间新旧两套master和worker是共存的,它们共享由旧master继承来的监听socket,所以内核会在新旧worker之间负载均衡分发新连接。接下来向旧master发送WINCH信号,旧master收到后会让自己的worker优雅退出,即处理完当前所有请求后逐个关闭,这个过程中新连接全部由新worker处理。最后发送QUIT给旧master,旧master退出,升级完成。

这几个信号各司其职,顺序不能乱,也不能跳步。我整理了信号对照表,方便查阅。

信号作用在升级流程中的位置
USR2旧master fork并exec新master第一步,新旧进程开始共存
WINCH让旧master的worker优雅退出第二步,流量切到新版本
HUP重新加载配置文件或触发旧master拉起worker用于回滚或日常配置重载
QUIT优雅关闭进程最后一步,关闭旧master
TERM/INT快速关闭进程,不保证优雅回滚时关闭新master可用

2. 升级前的准备工作:别嫌麻烦,这一小时能省你一天

2.1 摸清家底:查看当前版本和编译参数

很多人升级Nginx时最容易犯的错,就是新版本configure参数和原来不一致。Nginx的很多功能是在编译期决定的,比如--with-http_ssl_module决定了你有没有SSL模块,--with-stream决定了你能不能做TCP/UDP代理。要是编译参数丢了,升级后某些功能直接消失,这在生产环境是非常严重的故障。

所以升级前第一件事,就是把当前版本的完整编译参数扒下来。命令很简单:

/usr/local/nginx/sbin/nginx -V

注意是小写的-V,输出会包含configure arguments:这一行,这就是你在新版本configure时必须原样保留的参数。如果有缺失,宁可先解决编译参数问题再升级,也不要心存侥幸。

同时把当前版本号记下来,比如nginx version: nginx/1.18.0,待会切换完要对比确认。我习惯把这段输出保存到一个临时文件里,比如/tmp/nginx_upgrade/old_version.txt,后面比对能用上。

2.2 备份策略:二进制、配置、html一个都不能少

备份是升级的保命符。我见过不少同学只备份了配置文件,结果升级完依赖的模块缺失、二进制回滚不了,最后只能手忙脚乱地重新编译。

我的备份习惯是建立一个专门的升级目录,把关键内容全部放进去:

mkdir -p /tmp/nginx_upgrade/backup cp /usr/local/nginx/sbin/nginx /tmp/nginx_upgrade/backup/nginx.bak cp /usr/local/nginx/conf/nginx.conf /tmp/nginx_upgrade/backup/nginx.conf.bak cp -r /usr/local/nginx/conf/ /tmp/nginx_upgrade/backup/conf_bak/ cp -r /usr/local/nginx/html/ /tmp/nginx_upgrade/backup/html_bak/

二进制备份尤其重要,因为它是回滚的物理基础。配置目录整体备份是为了防止新版本对某个配置文件有兼容性改动,万一升级后配置加载失败,可以快速对比差异。html目录很多人忽略,但如果你的nginx里放的是前端静态资源,改动过而没备份,升级后如果误操作覆盖了,那又是一个事故。

2.3 确认源码包和系统依赖

版本选择上,我一般首选nginx官网的mainline版本(最新主线版)或stable版本(稳定版)。主线版功能新但更新频率高,稳定版更保守,适合生产环境。具体下载地址用https://nginx.org/download/nginx-x.x.x.tar.gz,拿1.26.2举例,下载解压的命令是:

wget https://nginx.org/download/nginx-1.26.2.tar.gz tar -zxvf nginx-1.26.2.tar.gz cd nginx-1.26.2

源码编译需要一些依赖库,CentOS/RHEL系和Debian/Ubuntu系包名略有不同。如果在configure时报找不到pcre、zlib或openssl,就需要先装对应的开发包。Debian系的典型命令是:

apt-get install -y libpcre3-dev zlib1g-dev libssl-dev

CentOS系则是:

yum install -y pcre-devel zlib-devel openssl-devel

如果你原来编译时用了--with-pcre=... --with-openssl=...这种指定源码目录的静态编译方式,那还需要提前下载对应版本的源码包,放到一个固定目录并解压好,保证configure能找到。这些依赖缺了任何一个,编译都会在中途失败,提前准备好能避免浪费大量时间。

3. 核心实操:平滑升级详细流程与步骤解析

3.1 编写configure参数:复制旧参数,再决定要不要加新模块

进入新版本源码目录后,首先重新执行configure,参数必须要包含原版本的全部configure arguments,一个都不能少。在此基础上,你才可以追加想新增的模块参数。举个例子,假设旧版本的nginx -V输出是:

configure arguments: --prefix=/usr/local/nginx --with-http_ssl_module --with-http_gzip_static_module --with-stream

那么新版本的configure就应该写成:

./configure \ --prefix=/usr/local/nginx \ --with-http_ssl_module \ --with-http_gzip_static_module \ --with-stream \ --with-http_v2_module \ --with-http_v3_module

最后的--with-http_v2_module和--with-http_v3_module是我在这个版本里想新增的模块。需要注意的是,新增模块前一定要确认当前Nginx版本支持这个模块,比如HTTP/3模块在1.25.0之后才逐渐成熟,旧版本configure会直接报错。

configure这一步还会检查你系统里缺哪些依赖,如果报错,优先解决依赖再重跑。configure顺利通过后,接着执行编译:

make

这里不需要执行make install,原因有两点。第一,make install会把新版本的文件直接覆盖到prefix目录,包括conf和html,这是有风险的;第二,升级切换需要精确控制二进制文件的替换时机,手动copy更好掌控。编译完成后,新版本的可执行文件在objs/nginx目录下。

编译完成后先别急着替换,最好用新二进制测试一下能否正常加载当前配置:

/usr/local/nginx/objs/nginx -t -c /usr/local/nginx/conf/nginx.conf

不过这里有个细节:如果新旧版本配置文件指令差异较大,可能测试会报一些旧版本没有的指令错误,这时候需要谨慎判断,是配置文件里的指令在新版本里废弃了,还是版本不兼容。一般小版本升级不会出现这种问题,大版本升级前必须看官方changelog。

3.2 替换二进制与信号切换:整个流程最关键的一步

配置和编译都没问题后,开始进入切换环节。先对磁盘上的二进制做一次备份,然后覆盖为新版本:

cp /usr/local/nginx/sbin/nginx /usr/local/nginx/sbin/nginx.old cp objs/nginx /usr/local/nginx/sbin/nginx chmod +x /usr/local/nginx/sbin/nginx

这里nginx.old和升级前备份到/tmp/nginx_upgrade/backup/nginx.bak是同一份文件的两种保留位置,放两份是为了保险。接着用nginx -t再验证一遍:

/usr/local/nginx/sbin/nginx -t

一切正常后,找到当前master进程的PID。标准做法是读取pid文件:

cat /usr/local/nginx/logs/nginx.pid

如果是systemd管理的nginx,pid文件可能在/run/nginx.pid,以实际路径为准。接下来发送第一个信号:

kill -USR2 $(cat /usr/local/nginx/logs/nginx.pid)

这条命令执行后,旧master会fork出一个新master,新master加载的是磁盘上新的nginx二进制。此时你会看到进程列表里有两个master和两批worker。紧接着发送第二个信号:

kill -WINCH $(cat /usr/local/nginx/logs/nginx.pid.oldbin)

nginx.pid.oldbin是旧master的pid文件在USR2之后自动改名生成的。WINCH信号让旧master的worker优雅退出,也就是说,正在处理的请求继续处理完,处理完一个退一个。这时候新连接全部由新worker接受处理,整个过程是平滑的。

为了直观,我放一个升级过程中的进程状态示例,你们能更清楚看到新旧共存的样子:

# ps -ef | grep nginx root 1234 1 0 10:00 ? 00:00:00 nginx: master process /usr/local/nginx/sbin/nginx nobody 1235 1234 0 10:00 ? 00:00:00 nginx: worker process nobody 1236 1234 0 10:00 ? 00:00:00 nginx: worker process root 5678 1 0 10:05 ? 00:00:00 nginx: master process /usr/local/nginx/sbin/nginx nobody 5679 5678 0 10:05 ? 00:00:00 nginx: worker process nobody 5680 5678 0 10:05 ? 00:00:00 nginx: worker process

PID 1234是旧master,5678是新master。过一段时间后,1235和1236这两个旧worker会逐渐消失,最终只剩下新master和它的worker。

3.3 切换完成后的验证与收尾

旧worker全部退出后,不代表升级就结束了,还需要做几项验证。第一,再次确认版本:

/usr/local/nginx/sbin/nginx -V

输出应该能明显看到版本号已经变成新版本,且configure arguments包含你的全部参数。第二,检查进程是否只保留了一个master和一组worker:

ps -ef | grep nginx

如果旧worker还在,说明还有长连接没处理完,可以继续等,或者查看worker是否卡死。第三,用curl带Host头访问几个本地业务接口,确认返回正常:

curl -I http://127.0.0.1/

确认无误后,最后一步是把旧master彻底关闭:

kill -QUIT $(cat /usr/local/nginx/logs/nginx.pid.oldbin)

如果你确定不需要回滚了,可以顺手把备份的旧二进制保留一段时间再删,我一般会保留一个版本周期(至少一周),防止新版本有潜伏问题。nginx.pid.oldbin这个文件在旧master退出后会自动删除,如果没删干净,手动清理掉也不影响。

升级完成后,记得检查一下systemd单元服务是否正常。如果你的nginx是通过systemd启动的,unit文件里的PIDFile指向/run/nginx.pid,新master的pid文件路径没有改变,systemd能继续正常管理。但如果你手动改过pid路径,就需要systemctl daemon-reload让systemd重新读取。

4. 平滑升级中的常见问题与排查技巧

4.1 信号切换后如何判断新旧进程状态

升级过程中最让人慌的问题就是“怎么确认到底切没切成功,旧worker什么时候退完”。我的建议是分两步观察。第一步看worker数量:旧master的worker会逐个消失,新master的worker数量保持在配置的worker_processes数量。第二步看pid文件:新master的pid会覆盖写入nginx.pid,旧pid在nginx.pid.oldbin里。如果nginx.pid.oldbin不存在,说明旧master已经退出或还没被USR2触发。

如果旧worker一直没有退出,常见原因是长连接卡住了,比如WebSocket、大文件下载或者keepalive连接。这种情况下可以先检查连接状态:

ss -tnp | grep <old_worker_pid>

看看是不是有大量连接处于ESTABLISHED状态长期不释放。生产环境建议在nginx.conf里设置一个全局的优雅退出超时,比如在events块之后、http块之前配置:

worker_shutdown_timeout 30s;

这个指令的意思是worker在收到退出信号后,最多等30秒,超过就直接退出。这能避免升级时旧worker永远退不干净,但也意味着极端情况下可能有请求被强制断开,所以超时时间要结合业务情况设置,不能太激进。

4.2 升级失败如何快速回滚

回滚是平滑升级的“后悔药”。这里分两种情况。

第一种情况,如果你只执行了USR2,还没执行WINCH,旧master的worker还在正常工作。这种情况下想回滚非常简单,直接让新master优雅退出,旧服务完全不受影响:

kill -QUIT $(cat /usr/local/nginx/logs/nginx.pid)

新master退出后,旧master继续提供服务,你只需要把磁盘上的二进制恢复成旧版本即可。

第二种情况,你已经执行了WINCH,旧worker已经退出。此时回滚需要两步,先用HUP信号让旧master重新拉起worker,然后再关闭新master:

kill -HUP $(cat /usr/local/nginx/logs/nginx.pid.oldbin) kill -QUIT $(cat /usr/local/nginx/logs/nginx.pid)

为什么发HUP给旧master能恢复?因为Nginx的worker是由master fork出来的,fork出来的子进程直接使用master进程内存中的代码,不会重新读取磁盘上的二进制。所以即使sbin/nginx已经被替换成新版本,旧master fork出来的worker依然是旧版本代码。这也是Nginx平滑升级设计里非常巧妙的一点。

回滚完成后,记得把/usr/local/nginx/sbin/nginx恢复成旧版本备份,不然下次重启用的是新版本,可能还会出问题:

cp /usr/local/nginx/sbin/nginx.old /usr/local/nginx/sbin/nginx

4.3 其他高频问题速查表

现象可能原因处理办法
configure时报“the HTTP rewrite module requires the PCRE library”缺少PCRE开发库安装libpcre3-dev或pcre-devel
编译时提示SSL模块缺失缺少OpenSSL开发库安装libssl-dev或openssl-devel
切换后nginx -V版本没变覆盖二进制前就发了USR2,或覆盖路径不对确认sbin/nginx是新的,重发一次USR2
旧worker长时间不退长连接未关闭配置worker_shutdown_timeout后重试
新版本无法加载某个指令配置指令在新版本已废弃对照官方文档调整配置文件
端口被占用新master不是通过fork继承socket检查是否用了负载均衡器或代理导致端口变化
systemd无法停掉服务旧pid.oldbin残留或unit文件PIDFile路径不对检查systemctl status输出,daemon-reload

4.4 两个容易忽略的细节

第一个细节是日志位置的变化。如果你升级前后prefix路径保持一致,日志路径一般不会变。但如果你在configure时改了--prefix,新旧版本日志可能写到不同目录,这时候旧worker退出前的日志会写到旧路径,新日志写到新路径,排查问题的时候容易混。我的建议是升级前后prefix保持一致,不要顺手改到别的路径。

第二个细节是动态模块的兼容性问题。Nginx从1.9.11开始支持动态模块,第三方模块以.so形式加载。如果你用了load_module指令加载动态模块,升级时必须确保模块版本和编译参数与新Nginx完全匹配。最简单的办法是查看旧版本编译参数里有没有--with-compat,如果有,动态模块的兼容性会好很多;如果没有,升级后动态模块很可能加载失败。一旦出现module is not binary compatible错误,除了重编模块没有更好的办法。

还有一个容易被忽略的点是,执行nginx -t验证配置时要带上完整前缀,比如/usr/local/nginx/sbin/nginx -t,如果直接用nginx -t,系统可能用的是PATH里另一个Nginx二进制,验证结果没有意义。我吃过这个亏,排查了半天才发现PATH里有一个老版本。

做一线运维这些年,我的体会是:平滑升级最考验人的不是命令熟不熟,而是对信号和进程模型的理解深不深。只要把USR2、WINCH、HUP这几个信号的行为彻底搞明白,Nginx升级就是一套固定流程,不会再出幺蛾子。最后再分享一个小技巧:升级前先写一个简单的回滚脚本,内容就是前面提到的恢复二进制和发HUP、QUIT两块逻辑,万一操作到一半被打断,你直接跑脚本回滚,比临时敲命令稳得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询