
你有没有遇到过这种尴尬线上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优雅关闭进程最后一步关闭旧masterTERM/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-devCentOS系则是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管理的nginxpid文件可能在/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 processPID 1234是旧master5678是新master。过一段时间后1235和1236这两个旧worker会逐渐消失最终只剩下新master和它的worker。3.3 切换完成后的验证与收尾旧worker全部退出后不代表升级就结束了还需要做几项验证。第一再次确认版本/usr/local/nginx/sbin/nginx -V输出应该能明显看到版本号已经变成新版本且configure arguments包含你的全部参数。第二检查进程是否只保留了一个master和一组workerps -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然后再关闭新masterkill -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/nginx4.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-reload4.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两块逻辑万一操作到一半被打断你直接跑脚本回滚比临时敲命令稳得多。