如何在高并发环境中灰度升级Nginx?
1.9k 次浏览
发表于 2020-05-19 14:17

2019年Nginx发布了6个stable版本以及12个mainline版本,这些发布要么修改了重要的漏洞,要么新增了很有用的特性。如果你不能及时升级Nginx,那么既无法享受到技术进步带来的降本增效,还会让服务暴露在安全风险之下。

十多年前,我们大可以升级前在官网上发个公告,声明某个凌晨不提供服务,那时可以从容地停止进程、更换程序、重启服务。然而,当下的用户却很难容忍停机升级这种体验,尤其对于接入层充当负载均衡的Nginx来说,它的并发连接数以百万计,哪怕只终止Nginx进程1秒钟,也会导致大量用户出现业务中断。

怎样保证升级高负载的Nginx时,不影响到海量的在线用户呢?而且,虽然官方Nginx是稳定的,但毕竟Nginx在编译期可以定制加入各种C模块,如果某些模块在升级后出现异常,就需要将Nginx回滚到旧版本,此时又怎样保证降级时也不会影响到正常服务的在线用户?

实际上,Nginx的热升级功能可以解决上述问题,它允许新老版本灰度地平滑过渡,这受益于Nginx的多进程架构。本文将介绍该如何升级、回滚Nginx,以及Nginx的进程架构是怎样保障不对用户产生影响的。理解热升级后,你也能更透彻的掌握热加载功能(reload使新配置文件生效),因为热加载相当于简化版的热升级。

 

怎样才能平滑升级程序?

最简单的升级方式,是关闭现有的旧进程后,再基于新程序启动进程。许多可用性要求不高的场景,就是这么做的。然而,在多数服务SLA(Service-Level Agreement)高达4个9以上的今天(99.99%意味着服务一年内的总宕机时间不得超过0.876小时),这种简单粗暴的方式不可取,它对于服务质量影响太大。当旧进程关闭时,操作系统会对进程打开的所有TCP连接发送RST复位报文,强行关闭TCP连接,接着,所有浏览器都会收到ERR_CONNECTION_RESET错误。

 

为了不影响现有TCP连接,能不能在命令行中先启动新程序,由升级后的新程序服务后建立的TCP连接,而原TCP连接在全部自然终止后,再关闭老进程呢?这其实做不到。

这是因为服务器程序不同于客户端,通常它需要监听80等指定端口,这样客户端才能针对明确的80端口建立TCP连接,而OSI传输层(由Linux内核实现)保证报文可以到达Nginx进程。因此,两个完全不同的进程是不能打开同一个端口的,如果我们在旧进程关闭前,启动新程序,往往会遇到bind failed( Address already in use)错误,导致进程无法启动。

事实上,上述通过新老进程并存的升级方案,就是平滑升级的最佳解决方案。但是怎样绕过同一端口不能被两个进程同时打开的限制呢?其实通过父子进程(参见wiki)就可以做到,而Nginx的平滑升级也正是这么做到的。

操作系统规定,每一个进程都必须由另一个进程启动,这两个进程就称为父子进程,其中,子进程自动继承父进程已经申请到的资源,比如监听的80端口。在Linux中,子进程是由fork函数创建的,最初它只是父进程的副本。比如在生产环境中启动Nginx时(即master_process on;),nginx会在绑定80端口后再用fork函数生成worker子进程(注意,nginx会自动将父进程名字改为nginx: master process),这样,worker进程也可以通过80端口与客户端建立TCP连接。当然,多个worker进程同时监听80端口时,系统内核会有一套算法决定某个TCP连接由哪个worker进程处理(可以参考Linux 3.9内核版本后提供的SO_REUSEPORT选项),均衡多个worker子进程间的负载,如下图所示:

那么,既然master与worker可以绑定同一端口,那么升级新版本nginx时,也由现在的老master进程启动(子进程默认是父进程的副本,但通过exec函数可以载入新版本的nginx程序,下文会详细介绍),这样,新master进程就是老master进程的子进程,可以共享老版本nginx已经打开的、包括端口在内的各类资源。至此,两个版本的nginx皆在运行中,只要老版本的nginx停止建立新连接,内核自然只会将新的TCP连接交给新版本的nginx处理,等到老版本nginx处理完现存的客户请求后可令其退出,这就完成了平滑升级。

那么,到底怎样通知nginx升级呢?下面我们来看详细的操作步骤。

 

Nginx的平滑升级步骤是什么?

为了通知运行中的Nginx进程执行升级,我们必须使用一种进程间通讯的方案。在Linux中,通知进程的最简便方法是信号,Nginx便选择了这一方案。由于热升级涉及到复杂的回滚操作,必须对新老master进程独立的发送信号,因此Nginx决定由管理员通过命令行中的kill命令发送信号,完成热升级或者回滚。

我们先来看热升级的步骤。升级前,建议你先将老的binary二进制文件后(即/usr/local/nginx/sbin/nginx文件)备份到另一个位置,为后续可能的回滚做准备。接着,你需要把新版本的nginx二进制文件覆盖老文件,这样,运行中的master进程生成子进程后才能载入新版本的nginx。注意,虽然你覆盖了老nginx,但并不会影响运行中的老nginx进程。

接着,你可以用ps命令找到master进程的pid,并通过kill命令向它发送USR2信号,这样master进程就会生成新的子进程,同时用exec函数载入新版本的nginx二进制文件,并将进程改名为nginx: master process。当然,新的master也会依据nginx.conf中的内容,再次启动新worker子进程提供服务,这些父子进程的关系如下图所示:

此时,老版本的nginx已经停止监听80端口,你可以通过netstat命令看到,现在只有新版本的nginx进程会监听80端口了,今后新建立的TCP连接都会由新版本进程处理:

那么,如何让老版本的nginx进程在处理完现存TCP连接后退出呢?很简单,使用nginx的优雅退出功能即可,具体通过kill向老master进程发送WINCH或者QUIT信号即可:

当老版本的master、worker进程都退出后,根据Linux内核的规则,pid为1的系统守护进程将成为新master的父进程(目前的守护进程为systemd,其演进流程参见酷壳上的这篇文章)。

 

因此,平滑升级Nginx通常会经历3个阶段:

1、 仅老nginx进程在运行,此时先备份nginx binary文件,再把新版本的nginx覆盖原位置,最后通过kill发送USR2信号。

2、 新老nginx进程同时并存,此时需要通过信号命令老master进程优雅退出。

3、 当处理完所有请求后,老的nginx进程退出,此时平滑升级完毕。

在新老nginx并存时,如果向老master进程发送了QUIT信号,那么在它的worker子进程退出后,老master进程也会自行退出。这时如果需要从新版本回滚到老版本,就得重新执行一次“升级”。还有一种更简单的回滚方法,向老master进程发送WINCH信号,这样老worker进程全部退出后,老master进程仍然存在

图片.png

 

由于老master进程是由老版本的nginx二进制文件启动,这样回滚很容易,只要将它的worker进程重新拉起,即可向用户提供旧版本服务,同时要求新版本的Nginx进行优雅退出即可。

图片.png 

这就是Nginx平滑升级和回滚的全过程,这是我们在大流量生产环境中必须掌握的步骤。


Nginx是怎样实现 “平滑”升级的?

最后,我们结合Nginx的进程架构,从实现层面分析Nginx到底是如何执行平滑升级的,这样就可以快速定位热升级时可能遇到的问题。

平滑升级涉及两个关键的子功能,一是在收到USR2信号后,启动新版本Nginx;二是将不再监听端口的nginx进程优雅退出。先来看USR2信号的处理。

在Linux中,使用fork函数就可以生成子进程副本,再用execve函数载入新版本的nginx二进制文件运行,就进入新老版本nginx并存的阶段。此时,写入master进程pid的nginx.pid文件内容会发生变化(了解了这一点就清楚找不到nginx.pid文件后,nginx的命令行为何不再生效)。

由于nginx支持通过命令行发送信号,比如上文介绍过的热加载,其实与向master进程发送HUP信号是完全一致的。但日常我们更习惯通过更方便的nginx -s reload命令行来完成,reload命令在读取nginx.pid文件中的进程id后,就会向master进程发送HUP信号。

图片.png

 

在升级过程中新版本的nginx启动后,nginx.pid中只会存放新master进程的id,而老master进程的id则会改放在nginx.pid.oldbin文件中。

图片.png

 

当老版本的master进程优雅退出后,nginx.pid.oldbin文件会被自动删除。这些细节可以协助分析热升级时遇到的问题。

再来看nginx是如何优雅退出的,即worker进程怎样判定所有TCP连接都处理完了。当master进程收到QUIT或者WINCH信号后,会向所有worker子进程发送QUIT信号。而worker进程收到QUIT信号后,会做以下4件事:

1、 设置worker_shutdown_timeout定时器,因为有些应用协议nginx并不解析,也就无从判断何时会结束。比如,使用stream模块做四层负载均衡,或者用作七层的websocket反向代理时,nginx都无法判断何时该关闭连接。因此,旧版本的nginx进程会长时间存在。设置定时器后,worker进程会在worker_shutdown_timeout秒后强行退出。当然,通常情况下不需要配置worker_shutdown_timeout,因为老worker进程长时间存在并不会影响新nginx的业务

2、 关闭监听着的所有端口;

3、 关闭所有空闲的TCP连接;

4、 设置ngx_exiting标志位为1(协助业务模块关闭连接),等待业务模块关闭所有的TCP连接后,自行退出进程。比如对于HTTP短连接请求而言(即HTTP头部中存在Connection: closed),当nginx发送完响应后就可以主动关闭TCP连接。如果是HTTP长连接(即存在Connection: keep-alive头部),正常情况下应当由客户端关闭连接,或者连接上处理过的请求个数超过了keepalive_request_count才能由nginx关闭连接,但在优雅退出这个场景中,nginx可以在处理完当前http请求后立刻关闭连接,如下代码所示:

    if (!ngx_terminate
         && !ngx_exiting //在优雅退出时,ngx_exiting会置为1
         && r->keepalive
         && clcf->keepalive_timeout > 0)
    {
        ngx_http_set_keepalive(r); //作为HTTP长连接继续复用
        return;
    }


worker进程正是按照这样的优雅退出流程自行关闭的。热重载新的nginx.conf配置文件时也使用了优雅退出这一功能,如下图所示:

图片.png

小结

 

本文介绍了Nginx热升级的原理、运维操作步骤及架构实现。

平滑升级的前提是同时启动新老2个版本的Nginx进程,其中老进程服务于正在传输数据的TCP连接,而新进程处理之后建立的TCP连接。由于新老进程需要同时打开80等监听端口,这就需要利用父子进程可以共享资源这一特性,因此,新版本的Nginx必须由老的master进程启动。

Nginx提供的热升级功能,需要使用Linux命令行的kill命令发送信号。其中,USR2信号用于命令老master进程启动新版本的nginx;WINCH信号用于令老master进程优雅的终止worker子进程;HUP信号用于回滚时启动老worker进程;QUIT信号用于令老master及worker进程优雅地退出。

Nginx为了提供-s reload等命令行,需要将master进程的pid保存到nginx.pid文件中。需要注意的是,在热升级中nginx.pid文件的内容会发生变化。

优雅退出是平滑升级的关键,它需要业务模块的支持。比如http模块通常可以完美的实现优雅退出,而其他一些不解析协议内容的模块就很难做到,此时,nginx提供了优雅退出定时器,限制worker进程在worker_shutdown_timeout秒内必须关闭。这些措施都进一步增强了热升级的适用性。

最后能不能请你谈谈,你还使用过哪些其他支持热升级的软件?它们的实现方式与本文介绍的Nginx热升级方案相似吗?具体是怎样实现的?欢迎你在帖子下方留言,与我一起探讨更好的热部署实现方案。

 


如果您觉得不错,就打赏支持一下吧〜
已有 1 人进行打赏
  • 阿尔巴
点击标签,发现更多精彩
发表评论
  • jiangyidigital

    感谢陶大师分享,学习中

    2020-05-19 18:19
    0
    回复
  • 陶辉 回复 jiangyidigital

    ^_^

    2020-05-29 09:58
    0
    回复
  • Radio

    谢谢

    2020-06-15 12:11
    0
    回复
  • WillTang

    感谢陶辉老师的讲解!

    2021-01-14 16:40
    0
    回复
发表者

陶辉

暂无个人介绍

  • 15

    文章

  • 1

    关注

  • 112

    粉丝

活动推荐
版权所有©F5 Networks,Inc.保留所有权利。京ICP备16013763号-5