之前工作时候,一台引流测试机器的一个ngx_lua服务突然出现了一些HTTP/500响应,从错误日志打印的堆栈来看,是不久前新发布的版本里添加的一个Lua表不存在,而有代码向其进行索引导致的。这令人百思不得其解,如果是版本回退导致的,那么为什么使用这个Lua表的代码没有被回退,偏偏定义这个表的代码被回退了呢?
经过排查发现,当时nginx刚刚完成热更新操作,旧的主进程还存在,因为要准备机器重启,先切掉了引流流量(但有些请求还在),同时系统触发了nginx - s停下来,这才导致了这个问题。
场景复现
下面我将使用一个原生的nginx,在我的安装了fedora26的虚拟机上复现这个过程,我使用的nginx版本是目前最新的1.13.4
首先启动nginx
可以看到主人和工人都已经在运行。
接着我们向主人发送一个SIGUSR2信号,当nginx核心收到这个信号后,就会触发热更新。
可以看到新的大师和该主叉出来的工人已经在运行了,此时我们接着向旧主发送一个SIGWINCH信号,旧主收到这个信号后,会向它的工人发送SIGQUIT,于是旧主的工人进程就会退出:
此时只剩下旧的主人,新的大师和新主人的工人在运行,这和当时线上运行的情况类似。
接着我们使用停止命令:
我们会发现,新的主人和它的工人都已经退出,而旧的主人还在运行,并产生了工人出来。这就是当时线上的情况了。
事实上,这个现象和nginx自身的设计有关:当旧的主准备产生叉新的大师之前,它会把nginx。pid这个文件重命名为nginx.pid。oldbin,然后再由叉出来的新的主人去创建新的nginx。pid,这个文件将会记录新主人的pid.nginx认为热更新完成之后,旧主的使命几乎已经结束,之后它随时会退出,因此之后的操作都应该由新主人接管。当然,在旧主人没有退出的情况下通过向新主人发送SIGUSR2企图再次热更新是无效的,新主人只会忽略掉这个信号然后继续它自己的工作。
问题分析
更不巧的是,我们上面提到的这个Lua表,定义它的Lua文件早在运行init_by_lua这个钩的时候,就已经被LuaJIT加载到内存并编译成字节码了,那么显然旧的主必然没有这个Lua表,因为它加载那部分Lua代码是旧版本的。
而索引该表格的Lua代码并没有在init_by_lua的时候使用的到,这些代码都是在工人进程里被加载起来的,这时候项目目录里的代码都是最新的,所以工人进程加载的都是最新的代码,如果这些工人进程处理到相关的请求,就会出现Lua运行时错误,外部表现则是对应的HTTP 500。
吸收了这个教训之后,我们需要更加合理地关闭我们的nginx服务,所以一个更加合理的nginx服务启动关闭脚本是必需的,网上流传的一些脚本并没有对这个现象做处理,我们更应该参考nginx官方提供的脚本。
这段代码引自NGINX官方的/etc/init.d/nginx。
nginx信号集
接下来我们来全面梳理下nginx信号集,这里不会涉及到源码细节,感兴趣的同学可以自行阅读相关源码。
我们有两种方式来向主人进程发送信号,一种是通过nginx - s信号来操作,另一种是通过杀命令手动发送。
第一种方式的原理是,产生一个新进程,该进程通过nginx。pid文件得到主进程的pid,然后把对应的信号发送到主人,之后退出,这种进程被称为通讯兵。
第二种方式要求我们了解nginx - s信号到真实信号的映射。下表是它们的映射关系:
操作信号
重载SIGHUP
重开SIGUSR1
停止SIGTERM
退出SIGQUIT
热更新SIGUSR2,SIGWINCH,SIGQUIT
停止和退出
停止发送SIGTERM信号,表示要求强制退出,退出发送SIGQUIT,表示优雅地退出。具体区别在于,工人进程在收到SIGQUIT消息(注意不是直接发送信号,所以这里用消息替代)后,会关闭监听的套接字,关闭当前空闲的连接(可以被抢占的连接),然后提前处理所有的定时器事件,最后退出。没有特殊情况,都应该使用辞职而不是停止。