Nginx 平滑升级可以在不停止服务的情况下升级 Nginx 的版本,为了加深理解,先了解如何实操,再从源码层面学习 Nginx 是如何实现平滑升级的,以 nginx-1.18.0 升级到 nginx-1.24.0 为例,机器是 Centos7 操作系统

安装 nginx

  • 安装必要的工具包

    # 更新 yum 源
    yum update
    
    # 安装 wget 工具, 用于获取 nginx 源码
    yum -y install wget
    
    # 安装 vim 用于编辑文本
    yum -y install vim
    
    # 安装 GCC 和 G++ 编译器
    yum -y install gcc gcc-c++
    
    # 安装 PCRE 库
    yum -y install pcre pcre-devel
    
    # 安装 zlib 库
    yum -y install zlib zlib-devel
    
    # 安装 OpenSSL 开发库
    yum -y install openssl openssl-devel
  • 获取 nginx-1.18.0 源码, 并解压编译安装运行这个版本的 nginx

    wget https://nginx.org/download/nginx-1.18.0.tar.gz
    
    tar -zxvf nginx-1.18.0.tar.gz
    
    cd nginx-1.18.0
    
    ./configure && make && make install
  • 查看 nginx 的版本,为了方便可以将 /usr/local/nginx/sbin 加入环境变量中

    /usr/local/nginx/sbin/nginx -V
    # nginx version: nginx/1.18.0
    # built by gcc 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC) 
    # configure arguments:
    
    vim ~/.bashrc
    
    # 将如下语句加入到文件中
    export PATH=/usr/local/nginx/sbin:$PATH
    
    # 保存退出后执行 source 命令使其生效
    source ~/.bashrc
    
    # 然后就可以直接执行 nginx 命令来启动了
    # 注意这里需要通过绝对路径来启动 nginx,否则后续平滑升级时将无法启动新的进程
    # https://blog.csdn.net/Jo_Andy/article/details/98482090
    /usr/local/nginx/sbin/nginx
    
    # 查看 nginx 进程, master 进程的 pid 为 79942, worker 进程的 pid 为 79943
    ps -ef | grep nginx
    # root       79942       1  0 13:57 ?        00:00:00 nginx: master process /usr/local/nginx/sbin/nginx
    # nobody     79943   79942  0 13:57 ?        00:00:00 nginx: worker process

平滑升级

  • 准备要升级的 nginx-1.24.0 源代码并编译,但是不要执行 make install 安装(因为安装会把原来的二进制文件直接覆盖,导致没有备份),为了更好地区别,升级后的 nginx 启用 ssl 模块,并修改配置文件的 worker 进程数为 2

    wget https://nginx.org/download/nginx-1.24.0.tar.gz
    
    tar -zxvf nginx-1.24.0.tar.gz
    
    cd nginx-1.24.0
    
    # 新版本的 nginx 开启 ssl 模块
    # 编译之后生成的可执行文件在 objs 目录下
    ./configure --with-http_ssl_module && make
  • 备份原来的二进制文件和配置文件

    # 备份原来的二进制文件
    cp /usr/local/nginx/sbin/nginx /usr/local/nginx/sbin/nginx.bak
    # 用新的二进制文件覆盖旧的二进制文件
    cp objs/nginx /usr/local/nginx/sbin/ -rf
    
    # 配置文件放在 conf 目录下,根据实际情况备份即可, 这里修改一下 nginx.conf 的进程数
    cp /usr/local/nginx/conf/nginx.conf /usr/local/nginx/conf/nginx.conf.bak
    # 也可以用你写好的配置文件直接替换
    vim /usr/local/nginx/conf/nginx.conf
  • 测试一下新版本的 nginx 是否配置正常

    /usr/local/nginx/sbin/nginx -t
    # nginx: the configuration file /usr/local/nginx/conf/nginx.conf syntax is ok
    # nginx: configuration file /usr/local/nginx/conf/nginx.conf test is successful
  • 给 nginx master 进程发送平滑迁移信号 USR2 启动新的主进程,Nginx 会启动一个新版本的 master 进程和相对应的 worker 进程,和旧的 master 一起处理进程,但是旧的不再接受新的请求,新的请求交给新的 master 进程处理,旧 master 进程的 pid 保存在 /usr/local/nginx/logs/nginx.pid.oldbin

    kill -USR2 `cat /usr/local/nginx/logs/nginx.pid`
    # 执行完后 ps -ef | grep nginx 可以看到新的 master 进程和 worder 进程与旧的并存
    
    # 查看此时进程的情况, 可以看到新的 master 进程 pid 为 84530, 两个 worker 进程 pid
    # 为 84531 和 84532, 还可以看出, 新的 master 进程的父进程是旧的 master 进程
    root       79942       1  0 13:57 ?        00:00:00 nginx: master process /usr/local/nginx/sbin/nginx
    nobody     84109   79942  0 14:27 ?        00:00:00 nginx: worker process
    root       84530   79942  0 14:36 ?        00:00:00 nginx: master process /usr/local/nginx/sbin/nginx
    nobody     84531   84530  0 14:36 ?        00:00:00 nginx: worker process
    nobody     84532   84530  0 14:36 ?        00:00:00 nginx: worker process
  • 发送 WINCH 信号给旧版 master 进程,它会逐步关闭自己的工作进程(主进程不退出),这时所有的用户请求都会由新版的 Nginx 进程处理

    kill -WINCH `cat /usr/local/nginx/logs/nginx.pid.oldbin`
    
    # 可以看到旧 worker 进程退出了
    root       79942       1  0 13:57 ?        00:00:00 nginx: master process /usr/local/nginx/sbin/nginx
    root       84530   79942  0 14:36 ?        00:00:00 nginx: master process /usr/local/nginx/sbin/nginx
    nobody     84531   84530  0 14:36 ?        00:00:00 nginx: worker process
    nobody     84532   84530  0 14:36 ?        00:00:00 nginx: worker process
  • 结束旧的 master 进程

    kill -QUIT `cat /usr/local/nginx/logs/nginx.pid.oldbin`
    # 可以看到旧的 master 进程正常退出了
    root       84530       1  0 14:36 ?        00:00:00 nginx: master process /usr/local/nginx/sbin/nginx
    nobody     84531   84530  0 14:36 ?        00:00:00 nginx: worker process
    nobody     84532   84530  0 14:36 ?        00:00:00 nginx: worker process
  • 检查升级是否成功,可以看到版本升级成功,且这个过程是用户无感知的

    nginx -V
    # nginx version: nginx/1.24.0
    # built by gcc 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC) 
    # built with OpenSSL 1.0.2k-fips  26 Jan 2017
    # TLS SNI support enabled
    # configure arguments: --with-http_ssl_module

版本回退

  • 在给旧 master 进程发送 WINCH 信号后,如果想回退使用旧版本,就可以向旧 master 进程发送 HUP 信号,它会重新启动工作进程,而且仍使用旧版配置文件

    kill -HUP `cat /usr/local/nginx/logs/nginx.pid.oldbin`
    
    # 可以看到旧的 master 进程根据旧的配置文件 fork 了一个 worker 进程, pid 为 84109
    root       79942       1  0 13:57 ?        00:00:00 nginx: master process /usr/local/nginx/sbin/nginx
    root       83736   79942  0 14:20 ?        00:00:00 nginx: master process /usr/local/nginx/sbin/nginx
    nobody     83737   83736  0 14:20 ?        00:00:00 nginx: worker process
    nobody     83738   83736  0 14:20 ?        00:00:00 nginx: worker process
    nobody     84109   79942  0 14:27 ?        00:00:00 nginx: worker process
  • 使用信号(QUIT、TERM 或 KILL)将新版本的Nginx进程杀死

    kill -QUIT `cat /usr/local/nginx/logs/nginx.pid`
    
    # 此时只运行旧版本的 nginx 了
    root       79942       1  0 13:57 ?        00:00:00 nginx: master process /usr/local/nginx/sbin/nginx
    nobody     84109   79942  0 14:27 ?        00:00:00 nginx: worker process
  • 将二进制文件和配置文件回滚

    mv /usr/local/nginx/sbin/nginx.bak /usr/local/nginx/sbin/nginx
    mv /usr/local/nginx/conf/nginx.conf.bak /usr/local/nginx/conf/nginx.conf
  • 测试一下配置文件并查看版本

    /usr/local/nginx/sbin/nginx -t
    # nginx: the configuration file /usr/local/nginx/conf/nginx.conf syntax is ok
    # nginx: configuration file /usr/local/nginx/conf/nginx.conf test is successful
    
    /usr/local/nginx/sbin/nginx -V
    # nginx version: nginx/1.18.0
    # built by gcc 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC) 
    # configure arguments:

源码分析

nginx 除了我们熟悉的 master-worker 多进程运行模式,还支持单进程运行,而平滑升级是针对多进程模式,不支持单进程模式,以下就分析多进程时是如何进行平滑升级的。

信号处理

首先需要知道 nginx 是如何启动,它会先 fork 出指定数量的 worker 进程,每个 worker 进程就负责进行事件的监听处理,master 进程则负责管理 worker 进程,父子进程之间的通信通过管道实现,master 进程最后会阻塞在一个信号等待函数 sigsuspend ,当 master 进程收到信号之后就会解除阻塞并调用信号处理函数 ngx_signal_handler 设置一些变量,对于 USR2 信号来说,处理如下:

case ngx_signal_value(NGX_CHANGEBIN_SIGNAL):
    if (ngx_getppid() == ngx_parent || ngx_new_binary > 0) {

        /*
         * Ignore the signal in the new binary if its parent is
         * not changed, i.e. the old binary's process is still
         * running.  Or ignore the signal in the old binary's
         * process if the new binary's process is already running.
         */

        action = ", ignoring";
        ignore = 1;
        break;
    }

    ngx_change_binary = 1;
    action = ", changing binary";
    break;

首先会判断 ngx_parent 的值是否等于当前进程的父进程 pid,当 nginx 是做为守护进程运行时,ngx_parent 的值为运行 nginx 命令的 bash 进程的 pid,由于是作为守护进程,因此启动后 nginx master 进程的 pid 由 init 进程接管,它的 pid 为 1,这时两者不满足条件,这也说明了如果 nginx 同步运行(即配置了 daemon off 参数)时不支持平滑升级,因为 USR2 信号会被这里忽略掉。还有一种情况是给已经创建了新 master 进程的旧 master 进程发送此信号的时候会忽略(ngx_new_binary > 0);还有一种情况就是旧的 master 进程还没退出,此时给新的 master 进程发送 USR2 信号也会被忽略(满足 ngx_getppid() == ngx_parent)。
如果没有被忽略,就设置 ngx_change_binary 全局变量为 1。

创建新进程

在上面的信号处理完之后,会执行 sigsuspend 之后的语句,其中对 ngx_change_binary 不为 0 时的处理如下:

if (ngx_change_binary) {
    ngx_change_binary = 0;
    ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "changing binary");
    ngx_new_binary = ngx_exec_new_binary(cycle, ngx_argv);
}

ngx_exec_new_binary 就是创建一个 master 子进程,并且让这个新的 master 进程创建的 worker 进程负责后续请求的监听。旧的请求就还是由老进程处理,函数返回新的 master 子进程的 pid 给 ngx_new_binary 全局变量,从这可以明白对已经创建过新 master 进程的旧 master 进程是没用的(注意在新 master 进程中,ngx_new_binary 为 0)。最后还会将旧 master 进程的 pid 保存在 /usr/local/nginx/logs/nginx.pid.oldbin 文件中,原来的 nginx.pid 则保存新 master 进程的 pid。
还有一个细节问题就是,子进程继承父进程的全局变量时,ngx_new_binary 的值还是 0,因此对于旧 master 进程来说,ngx_new_binary 的值为新 master 进程的 pid;而新 master 进程的 ngx_new_binary 是 0。

关闭旧工作进程

当新的 worker 进程和 master 进程起来后,就可以关闭旧的 worker 进程了,也就是给旧的 master 进程发送信号 WINCH。同样在信号处理函数中进程处理,如下:

case ngx_signal_value(NGX_NOACCEPT_SIGNAL):
    if (ngx_daemonized) {
        ngx_noaccept = 1;
        action = ", stop accepting connections";
    }
    break;

在信号处理函数里面会判断是否是守护进程运行,如果是的话就将 ngx_noaccept 全局变量设置为 1。信号处理函数结束后,继续往下执行,当 ngx_noaccept 不为 0 时,就会给旧 worker 进程发送 QUIT 信号使其退出。

if (ngx_noaccept) {
    ngx_noaccept = 0;
    ngx_noaccepting = 1;
    ngx_signal_worker_processes(cycle,
                                ngx_signal_value(NGX_SHUTDOWN_SIGNAL));
}

所有的旧 worker 进程都退出后,最后给旧的 master 进程发送 QUIT 信号使其退出即可,旧 master 进程退出后,新 master 进程变成孤儿进程由 init 进程接管,其父进程 pid 变成 1。到此 nginx 的正常平滑升级流程就结束了,接下来分析版本回退时的逻辑。

版本回退

如果你向旧的 master 进程发送了 WINCH 信号使得旧 worker 进程都退出了,但是你的旧 master 进程还没有退出,那你还可以选择进行回退,也就是向旧的 master 进程发送 HUP 信号。在信号处理函数中的具体行为如下:

case ngx_signal_value(NGX_RECONFIGURE_SIGNAL):
    ngx_reconfigure = 1;
    action = ", reconfiguring";
    break;

可以看到信号处理函数只是将 ngx_reconfigure 全局变量设置为了 1 而已。信号处理函数退出后,继续执行之后的代码,当 ngx_reconfigure 不为 0 时的处理如下:

if (ngx_reconfigure) {
    ngx_reconfigure = 0;

    if (ngx_new_binary) {
        ngx_start_worker_processes(cycle, ccf->worker_processes,
                                   NGX_PROCESS_RESPAWN);
        ngx_start_cache_manager_processes(cycle, 0);
        ngx_noaccepting = 0;

        continue;
    }

    ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "reconfiguring");

    cycle = ngx_init_cycle(cycle);
    if (cycle == NULL) {
        cycle = (ngx_cycle_t *) ngx_cycle;
        continue;
    }

    ngx_cycle = cycle;
    ccf = (ngx_core_conf_t *) ngx_get_conf(cycle->conf_ctx,
                                           ngx_core_module);
    ngx_start_worker_processes(cycle, ccf->worker_processes,
                               NGX_PROCESS_JUST_RESPAWN);
    ngx_start_cache_manager_processes(cycle, 1);

    /* allow new processes to start */
    ngx_msleep(100);

    live = 1;
    ngx_signal_worker_processes(cycle,
                                ngx_signal_value(NGX_SHUTDOWN_SIGNAL));
}

如果 ngx_new_binary 不为 0,即发送信号 HUP 给已经创建了新 master 进程的旧 master 进程时,会进入 ngx_new_binary 不为 0 的逻辑,这里的处理是重新根据配置文件创建 worker 进程,注意它并不会退出原来的 worker 进程。而如果这个信号是发送给旧的 master 进程时,它会重新初始化 cycle (即重新读取配置),根据这个配置创建新的 worker 进程,最后向原来的 worker 进程发送 QUIT 信号使其退出。