做运维这些年,Nginx 升级算是最常碰、也最容易被轻视的操作之一。很多人觉得不就是替换个二进制文件嘛,可真到了线上,一个 configure 参数没对齐、一个信号用错,轻则模块全丢,重则服务直接起不来。今天我把直接升级和在线升级这两条路完整走一遍,把每一步背后的原理和坑都交代清楚,照着操作基本不会翻车。
先给个结论:如果你的 Nginx 扛着线上流量,且不能接受中断,那就用在线升级(业内也叫平滑升级、热升级);如果你能接受短暂停服,或者干脆是内网环境、业务低峰期,那直接升级更省事。两种方式我都讲透,最后附上踩坑实录。
1. 两种升级方式的本质区别
1.1 直接升级为什么让人又爱又恨
直接升级就是“先停掉旧 Nginx,再装新版本,然后启动”。操作路径非常短:卸载旧包或者替换二进制文件,改改配置,nginx -t验证,然后 start。好处是干净利落,新版本一把梭,不会出现新旧进程并存带来的各种奇葩问题。
坏处也特别直白:停止服务到新服务启动之间有一段空窗期,这个窗口里所有请求都会直接失败。如果 Nginx 前面挂了负载均衡器或者云厂商的 LB,健康检查会立刻把后端标记为不可用,触发告警。要是恰好赶在业务高峰期操作,那基本属于事故预定了。
直接升级的另一个隐患在于“回滚”。旧版本二进制如果没保留,或者启动方式、配置文件路径有差异,回滚时要重新折腾一遍,压力巨大。我见过不止一次,升级完发现新版本某个第三方模块有内存泄漏,但旧二进制已经被覆盖,最后只能从备份里恢复,白白耽误半小时。
1.2 在线升级背后的进程模型与信号机制
在线升级之所以能做到“不中断服务”,靠的是 Nginx 经典的 master-worker 进程架构。master 进程负责读取配置、管理 worker、监听端口;worker 进程负责真正处理请求。关键点在于,监听端口的 socket 是由 master 创建的,worker 只是继承了这个 socket 的文件描述符。
理解了这一点,在线升级的思路就清楚了:启动一个新的 master,让它接管监听 socket,然后让旧 worker 处理完手头的请求后优雅退出。整个过程中,新连接由新版本接管,旧连接由旧版本处理完,两边互不干扰。
这套流程在实现上不靠任何魔法,全靠一组信号:
USR2:让旧 master 启动新的 master 和新的 worker 进程。新老两代此时共存,但新 master 会尝试bind同一个监听端口,由于 SO_REUSEADDR 的机制,不会报地址占用。WINCH:通知旧 master 优雅关闭它的 worker 进程,也就是等 worker 处理完当前请求再退出。这样一来,存量请求一个都不会丢。QUIT:优雅关闭旧 master 进程本身。走到这一步,旧进程就彻底退场了。
还有一个非常容易被忽略的细节:Nginx 在升级时会通过环境变量把监听的 socket 传递给新进程。具体来说,旧 master 在收到 USR2 后,会 fork 出新 master,新 master 继承旧的 socket 文件描述符,所以新老进程能共用同一个端口,而不是真的重新 bind。这也是为什么在线升级过程中,你去看ss -lntp会发现端口同时被两个 nginx 进程监听,别慌,这是正常现象。
1.3 为什么 reload 不能替代在线升级
很多人有个误区:nginx -s reload不就是平滑升级吗?真不是。reload相当于“原地换配置”:旧 master 重新加载配置文件,然后用新配置拉起一批新 worker,再优雅关闭旧 worker。它确实能做到不中断服务,但你的二进制文件没换,跑的还是旧版本代码。
换句话说,reload解决的是“配置文件变了想生效”的问题,在线升级解决的是“Nginx 版本变了想替换”的问题。如果你改了版本号或者重新编译了内核,光reload是没用的,进程依然是旧二进制在跑,新代码压根没加载进去。验证方法很简单:升级后执行nginx -V,看版本号有没有变化。
2. 升级前的准备工作(这一步决定成败)
2.1 摸清现状:版本、configure 参数、运行状态
动手之前先把家底摸清楚,这是整个升级过程中最重要的一步,没有之一。
# 查看当前版本和编译参数 /path/to/nginx -V # 查看运行状态 /path/to/nginx -t ps -ef | grep nginx ss -lntp | grep 80或你的监听端口nginx -V输出里的 configure arguments 是后面的命根子。这部分决定了新版本编译时需要开哪些模块、指定哪个安装路径。我强烈建议把这段输出原样复制存到一个文本文件里,后面编译新版本时直接对着抄。
还要检查一下 Nginx 的启动方式。是用 systemd 管理的,还是直接二进制启动?这关系到升级后要不要刷新 systemd 的 unit 文件。如果用的是 systemd,systemctl status nginx看一眼 ExecStart 指向的是哪个路径,升级完二进制之后记得确认这个路径没变。如果用的是第三方进程管理工具,比如 supervisor,也要注意路径一致。
最后看一眼 worker 数量和当前连接数。ps -ef | grep nginx里能看到 worker 进程数量,配合ss -s看下当前连接是否很多。如果当前连接量非常大,在线升级时 WINCH 之后旧 worker 可能要跑很久才能把存量请求处理完,需要有点耐心。
2.2 备份:给自己留好后悔药
我不止一次强调,升级 Nginx 前不备份等于裸奔。备份内容分三块:
# 1. 备份二进制 cp /path/to/nginx /path/to/nginx.old.$(date +%Y%m%d) # 2. 备份配置目录 cp -r /path/to/conf /path/to/conf.bak.$(date +%Y%m%d) # 假设配置都在 conf 目录下,也可能是 /etc/nginx # 3. 备份软链/启动脚本相关 ls -l /usr/local/bin/nginx # 如果存在软链,记录指向需要注意的是,备份配置文件不只是为了升级失败回滚,还有一个用处:新版本升级完,有些第三方模块的配置语法可能变了,你需要对比旧配置文件来确认改动点。别嫌麻烦,这个对比在排错时能省大量时间。
除此之外,还要记住旧版本的 PID 文件位置,一般是logs/nginx.pid或/run/nginx.pid。在线升级过程中操作信号依赖这个 PID 文件。升级前看下里面的数字和ps -ef是否对应,避免 PID 文件是旧的误导你发信号。
2.3 确认新版本的兼容性与升级路线
选新版本之前,去官网看一下该版本的 upgrade notes 或 changelog,重点关注三类内容:
- 配置文件语法变更:某些指令可能在新版本里被废弃或者改了语义,比如
worker_connections、proxy_pass相关行为。 - 模块兼容性:如果你用了第三方模块,比如 HTTP 层的一些过滤模块,要确认是否支持新版 Nginx。不兼容的话,要么等模块适配,要么换替代方案。
- 依赖版本要求:新版 Nginx 编译时可能要求更高的 gcc、PCRE、OpenSSL 版本,尤其是 OpenSSL,新版 Nginx 对 TLS 1.3 的支持依赖系统里的 OpenSSL 版本,不满足就得先升级依赖。
此外明确一下升级路线:是主版本跳跃(比如 1.20 升 1.26),还是小版本升级(1.24.0 升 1.24.1)。主版本跳跃出现配置兼容性问题的概率更大,建议先在测试环境完整跑一遍业务验证流程,再上生产。生产环境最好也先弄一台试点机器,确认没问题再批量搞。
3. 在线升级实操全流程
3.1 编译新版本:configure 参数是命根子
在线升级的核心前提就是新版本二进制要和旧版本保持相同的 configure 参数,尤其是--prefix和其他路径相关参数。否则可能出现新旧进程指向不通的配置目录,引发混乱。
编译前,先把旧版本参数拿到手:
$ /usr/local/nginx/sbin/nginx -V nginx version: nginx/1.24.0 built by gcc 8.5.0 20210514 (Red Hat 8.5.0) (GCC) configure arguments: --prefix=/usr/local/nginx --with-http_ssl_module --with-http_stub_status_module --with-stream然后下载新版本源码,在源码目录里执行 configure,参数直接复制旧的,按需增补。这里提醒一句:不建议随意加新的编译参数,除非你很确定要用的模块;加模块意味着二进制体积变大、可能会引入新依赖,升级的“最小改动原则”往往比多一个花哨模块重要得多。
wget https://nginx.org/download/nginx-1.26.3.tar.gz tar -zxvf nginx-1.26.3.tar.gz cd nginx-1.26.3 ./configure --prefix=/usr/local/nginx --with-http_ssl_module --with-http_stub_status_module --with-stream make这里注意,make之后不要急着make install。我们只想要编译出来的objs/nginx这个二进制文件,而不是让它直接覆盖你的安装目录。直接make install会把你现有的 html、conf 等目录重新生成为默认状态,虽然配置目录不一定被覆盖,但风险太大,在线升级完全不需要这一步。
正确的做法是编译完成后,把新二进制文件复制到 Nginx 安装目录的 sbin 下,替换旧二进制,或者放到一个独立位置并通过软链切换。我个人更推荐“先备份旧二进制,再原地替换新二进制”的方式,因为你已有的 systemd 脚本和启动路径都不用动。
# 备份旧二进制 cp /usr/local/nginx/sbin/nginx /usr/local/nginx/sbin/nginx.old-1.24.0 # 用新二进制替换 cp ./objs/nginx /usr/local/nginx/sbin/nginx替换完成之后,先别急着发信号,先跑一遍配置验证:
/usr/local/nginx/sbin/nginx -t如果这里报错,说明新版本对配置文件的兼容性有问题,或者 configure 参数不对,马上用旧二进制恢复,还能当作什么都没发生。
3.2 发信号完成新旧交替
确认nginx -t通过后,开始在线升级的标准动作。假设旧 master 的 PID 是OLD_PID,拿cat /usr/local/nginx/logs/nginx.pid获取,然后:
# 第一步:向旧 master 发送 USR2 信号 kill -USR2 $(cat /usr/local/nginx/logs/nginx.pid) # 观察进程状态 ps -ef | grep nginxUSR2 发完,你会看到进程列表里出现一组新的 master-worker。此时旧 worker 还在正常处理请求,新 master 已经开始接管监听端口。注意 PID 文件也会发生变化,Nginx 会生成一个新文件nginx.pid.oldbin,里面存的是旧 master 的 PID,这其实是在为可能回滚预留信息。
要不要用新配置验证一下?这一步很有必要。新进程起来之后,再做一次请求验证,确认新版本确实能正常响应:
curl -I http://127.0.0.1/如果响应正常,继续下一步,否则马上回滚(回滚方法见 5.3)。
# 第二步:让旧 worker 优雅退出 kill -WINCH $(cat /usr/local/nginx/logs/nginx.pid.oldbin)WINCH 发完之后,再观察进程。会看到旧 worker 数量逐步减少,直到全部退出,只剩下旧 master 孤零零挂着。旧 worker 退出是一个渐进过程,因为要等存量请求处理完,连接数大时这个过程可能持续几十秒甚至几分钟,属于正常现象。
旧 worker 全部退出后,服务已经完全由新版本接管。此时再验证一次:
curl -I http://127.0.0.1/ /usr/local/nginx/sbin/nginx -V # 确认版本号已变化结果没问题,就发 QUIT 让旧 master 退场:
# 第三步:优雅关闭旧 master kill -QUIT $(cat /usr/local/nginx/logs/nginx.pid.oldbin) # 确认只剩新进程 ps -ef | grep nginx到这里在线升级就算完成了,整个过程线上服务零中断。
3.3 收尾确认与旧进程清理
升级完成后,有几个收尾细节值得注意。第一,确认 PID 文件已经是新 master 的 PID,cat nginx.pid里的数字和ps -ef的新 master 对应上。第二,看下日志有没有异常报错,错误日志路径一般在配置文件的error_log指令里,重点看启动阶段有没有模块加载失败或者 listen 方面的问题。
另外一个很容易忽略的东西:编译新版本时如果依赖了动态模块,比如--add-dynamic-module这种,那么你的模块.so文件路径也要检查,确保模块文件仍然是可加载的。部分动态模块跟 Nginx 版本有严格对应关系,加载失败会直接导致 worker 启动失败。
最后,清理临时文件,把nginx.pid.oldbin这个残留文件删掉或者备份一下都行。不删也没关系,下次启动时会自动更新,但删掉更干净。
3.4 直接升级(停机替换)的完整步骤
既然标题里提到了直接升级,就顺带把这条路径也走一遍。直接升级最适合的场景是:业务允许短暂停服,或者本来就没什么流量。操作步骤:
# 1. 优雅停止现在的 Nginx nginx -s stop # 或者 systemctl stop nginx # 2. 备份旧二进制和配置 cp /usr/local/nginx/sbin/nginx /usr/local/nginx/sbin/nginx.old cp -r /usr/local/nginx/conf /usr/local/nginx/conf.bak # 3. 编译新版本,步骤与 3.1 相同,编译完直接 make install(因为服务已经停了) make install # 4. 检查配置文件 nginx -t # 5. 启动新版本 nginx # 或者 systemctl start nginx # 6. 验证 curl -I http://127.0.0.1/ nginx -V直接升级时,make install会覆盖安装目录里的二进制、html 和 conf 目录。conf 目录有备份就没问题,但 html 目录里的默认页面可能被重置。如果你的静态资源文件直接放在 html 目录下且没有备份,做完make install之后会傻眼,文件全没了。所以直接升级前,html 目录必须备份,或者更稳妥的做法是跟在线升级一样,手动替换二进制而不是make install。
还有一个差别:直接升级时旧进程已经完全停止,不存在共享 socket 的问题,所以即使新旧版本 configure 参数完全不一样,也不会有太大的在线冲突。但前提是配置文件本身仍然兼容新版本,所以nginx -t依然必须放在前面。
4. 常见问题与排查实录
4.1 升级后配置检查报错,但旧版本明明能跑
这种情况十有八九是配置文件里的某个指令在新版本中语法变了或者被废弃了。排查方法很朴素:把报错那段指令用注释符#注释掉,让配置暂时能加载,然后去新版文档里查这条指令的最新格式。再不行就对比旧版本版本号对应的配置文档,看有没有捕获到“指令已废弃”之类的说明。
遇到过最典型的例子是某些第三方模块在 Nginx 1.25 之后调整了指令作用域。比如原来写在http{}里的指令,新版本要求必须写在server{}或location{}里,升级时直接nginx -t报“ directive is not allowed here”。解决办法很简单,调整指令位置就行,但这需要你对配置结构足够熟悉。
4.2 USR2 之后新 master 没起来
USR2 命令发下去之后,如果你发现进程列表没有任何变化,新 master 压根没出现,排查思路有三个方向:
- 第一,旧二进制替换成新二进制之后,是否把新二进制放对了路径。如果替换的文件路径和当前运行中的二进制路径不一致,USR2 会继续沿用旧的二进制启动,等于什么都没发生。
- 第二,新二进制是否有可执行权限。复制文件之后权限可能丢失,
chmod +x补上。 - 第三,目录空间是否足够。编译时若
/usr/local分区满了,新 master 起不来,错误日志里会留下“No space left on device”。
拿到nginx -t验证通过之后才发 USR2 的基本不会遇到启动报错,但如果那种错误日志里有明显报错,先读日志再改配置,不要盲目重启。
4.3 在线升级后想回滚,该怎么做
在线升级的好处就是回滚同样不中断服务。旧 master 在 QUIT 之前一直保留着,哪怕你已经把新 worker 跑起来了,旧二进制还是在磁盘上。回滚路径如下:
# 1. 把旧二进制复制回原路径 cp /usr/local/nginx/sbin/nginx.old-1.24.0 /usr/local/nginx/sbin/nginx # 2. 向当前新 master 发送 USR2,让旧版本 master 重新拉起来 kill -USR2 $(cat /usr/local/nginx/logs/nginx.pid) # 3. 向新 master(此时是新版本)发送 WINCH 让它关闭自己的 worker kill -WINCH $(cat /usr/local/nginx/logs/nginx.pid.oldbin) # 4. 验证恢复后,发 QUIT 关闭新 master kill -QUIT $(cat /usr/local/nginx/logs/nginx.pid)回滚的原理跟升级一模一样,只不过角色互换。需要注意的是,回滚时旧版本的配置文件和 pid 文件路径必须跟升级前一致,否则可能找不到 pid 文件导致信号发不出去。这也是我前面反复让你备份的原因。
4.4 在线升级后部分连接异常
有一个我踩过比较隐蔽的坑:在线升级如果差了配置参数里的--with-stream之类模块,新版本没有编译 stream 模块,结果升级时配置文件里的stream{}块直接报错,nginx -t都过不了。但如果你用了多配置文件 include 的方式,有些子配置没被加载到,nginx -t又恰好没抓到,跑起来后才出现部分请求 502 或连接被拒。
解决办法是升级前认真核对nginx -V的完整输出,任何一个模块参数都不要落下。如果是多配置文件 include 的方式,升级后多花一分钟看看nginx -T(大写的 T),它会输出最终合并后的完整配置,配合错误日志能快速锁定哪个模块缺失。
5. 写到最后的一点经验
在线升级这套流程看起来很成熟,但每一次部署我都坚持把它当成一次小型发布来做:先看-V,再备份,编译,-t,发信号,验证,收尾。没有一次因为“熟了”就跳过某一步。因为线上环境的奇怪程度远超你想象,可能是某个第三方模块的编译依赖跟新版本源码冲突,也可能是某个配置文件里隐藏的字符集问题。把每一步都按流程走,遇到问题才有据可查。
最后再分享一个小技巧:如果业务环境允许,升级之后让旧版本进程多挂一会儿,别急着 QUIT。我以前习惯 WINCH 之后确认新版本没问题就马上 QUIT,后来有一次新版本跑了半小时后出现 worker 内存缓慢增长,还好旧 master 还留着,回滚非常从容。从那以后,我都是让旧 master 多待一段时间,确认新版本稳定了再清理。这个习惯救过我,希望也能帮上你。