Open jinhailang opened 6 years ago
用户反馈说线上程序貌似没有更新,承诺的新特性都没有。去线上确认,发现程序内存版本号确实没有更新。由于项目是使用 shell 脚本命令(docker kill --signal "HUP" xxx)重启的,当时就怀疑是不是重启命令没有被正确执行?但是查询日志,发现更新时,有 init_by_lua 阶段的日志输出,就是说 重启指令执行了,且 init_by_lua 阶段正常被执行了,但进程内的代码数据还都是旧的,而且使用 nginx stop 重启更新是正常的,这就很诡异了!
docker kill --signal "HUP" xxx
init_by_lua
nginx stop
init_by_lua 阶段输出的日志:
因为之前版本没有发现过,只能猜测是新版代码有问题,但是,新版代码都是些业务代码之类的更新,怎么会出现这种问题呢?很让人抓狂,只得老老实实在测试环境,模拟一下新旧版本的切换场景,没有想到,出现了下面的错误:
会不会线上也是因为端口占用,导致的 reload 失败呢?去线上再次查日志,发现更新的时间点,果然也有端口占用的错误日志(/var/log/syslog):
/var/log/syslog
时间上刚好与 init_by_lua 阶段输出的日志时间一致。 这里脚本执行也有个问题,由于 reload 后没有正确判断执行结果,导致只能去 /var/log/syslog 下查日志才发现。
基本可以确认是因为执行 Nginx reload 时,出现了端口被占用的错误,导致更新没有完成。而 init_by_lua 被执行的是因为该阶段在端口监听之前被执行(从上面日志可以直接看到)。
Nginx reload
所以,到底是什么操作触发了这个错误呢?既然是端口监听的问题,自然跟 nginx 的 listen 配置有关,联想到之前,为了安全起见,将配置 listen 8081 改成了 listen 127.0.0.1:80。于是,在本地尝试复现,发现每次必现:
listen 8081
listen 127.0.0.1:80
至此,问题原因基本查清楚了,就是地址端口修改导致的“血案”,而且只有在 listen 8081 和 listen x.x.x.x:8081 格式之间切换时,才会出现这种问题。
listen x.x.x.x:8081
既然是 listen 配置问题,那么直接使用固定 listen IP:PORT 就可以避免了,而且很重要的是,listen PORT 这种用法也是很不安全(可能被外网访问),且不专业的。
listen IP:PORT
listen PORT
为了找到百分百的实锤,也是为了彻底搞清楚 reload 的详细过程,查看了下这块 Nginx 代码。与热(Nginx 二进制)更新(kill -USR2) 相比,reload 实现代码相对简单些。 Nginx master 进程收到 HUP 信号后将 ngx_reconfigure = 1,会执行函数 ngx_master_process_cycle 内的以下代码块:
reload
kill -USR2
HUP
ngx_reconfigure = 1
ngx_master_process_cycle
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_init_cycle,这个函数负责解析初始化配置,并创建新的 ngx_cycle_t 结构体以及清理旧的 ngx_cycle_t 。
进入 函数 ngx_init_cycle(ngx_cycle_t *old_cycle) ,与监听相关的片段有两处(按前后顺序):
ngx_init_cycle(ngx_cycle_t *old_cycle)
以上代码说明,reload 后首先会遍历旧结构体内原来的监听的句柄,如果监听的地址和端口都跟新的配置结构体相同,则直接将句柄添加到新的结构体。然后才会关闭旧结构体内没有被新结构体引用的监听句柄。
ngx_cmp_sockaddr 函数负责判断新旧配置监听的地址端口是否相同,主要实现如下:
ngx_cmp_sockaddr
if (cmp_port && sin1->sin_port != sin2->sin_port) { return NGX_DECLINED; } if (sin1->sin_addr.s_addr != sin2->sin_addr.s_addr) { return NGX_DECLINED; }
经过源码分析,就很清晰自然了,由于 listen 8081 监听的实际上是 0.0.0.0:8081,改成 127.0.0.1:8081 后,reload 时会先重新创建监听 127.0.0.1:8081 的句柄,自然会与原来的 0.0.0.0:8081 监听冲突了。Nginx 这么设计是很合理的,是配置使用的问题,线上不应该使用 listen 8081 这种指令,显然很不安全。
0.0.0.0:8081
127.0.0.1:8081
最后,梳理下 reload 基本过程:
ngx_init_cycle
ngx_cycle_t
此时,有两种情况:
如果 ngx_init_cycle 执行失败,则还是会使用原有的配置(回滚)。
如果 ngx_init_cycle 执行成功,则使用新的配置(ngx_cycle_t 结构体),新建所有 worker 进程。新建成功后发送退出信号给旧的 worker 进程。
旧 worker 进程接收到到退出信号后会继续服务,当所有请求的客户端被服务后,worker 进程退出。
end:)
Nginx reload 端口占用问题分析
起因
用户反馈说线上程序貌似没有更新,承诺的新特性都没有。去线上确认,发现程序内存版本号确实没有更新。由于项目是使用 shell 脚本命令(
docker kill --signal "HUP" xxx
)重启的,当时就怀疑是不是重启命令没有被正确执行?但是查询日志,发现更新时,有init_by_lua
阶段的日志输出,就是说 重启指令执行了,且init_by_lua
阶段正常被执行了,但进程内的代码数据还都是旧的,而且使用nginx stop
重启更新是正常的,这就很诡异了!init_by_lua
阶段输出的日志:问题定位
因为之前版本没有发现过,只能猜测是新版代码有问题,但是,新版代码都是些业务代码之类的更新,怎么会出现这种问题呢?很让人抓狂,只得老老实实在测试环境,模拟一下新旧版本的切换场景,没有想到,出现了下面的错误:
会不会线上也是因为端口占用,导致的 reload 失败呢?去线上再次查日志,发现更新的时间点,果然也有端口占用的错误日志(
/var/log/syslog
):时间上刚好与
init_by_lua
阶段输出的日志时间一致。 这里脚本执行也有个问题,由于 reload 后没有正确判断执行结果,导致只能去/var/log/syslog
下查日志才发现。原因分析
基本可以确认是因为执行
Nginx reload
时,出现了端口被占用的错误,导致更新没有完成。而init_by_lua
被执行的是因为该阶段在端口监听之前被执行(从上面日志可以直接看到)。所以,到底是什么操作触发了这个错误呢?既然是端口监听的问题,自然跟 nginx 的 listen 配置有关,联想到之前,为了安全起见,将配置
listen 8081
改成了listen 127.0.0.1:80
。于是,在本地尝试复现,发现每次必现:至此,问题原因基本查清楚了,就是地址端口修改导致的“血案”,而且只有在
listen 8081
和listen x.x.x.x:8081
格式之间切换时,才会出现这种问题。解决方案
既然是 listen 配置问题,那么直接使用固定
listen IP:PORT
就可以避免了,而且很重要的是,listen PORT
这种用法也是很不安全(可能被外网访问),且不专业的。源码分析
为了找到百分百的实锤,也是为了彻底搞清楚
reload
的详细过程,查看了下这块 Nginx 代码。与热(Nginx 二进制)更新(kill -USR2
) 相比,reload 实现代码相对简单些。 Nginx master 进程收到HUP
信号后将ngx_reconfigure = 1
,会执行函数ngx_master_process_cycle
内的以下代码块:这里关键函数是 ngx_init_cycle,这个函数负责解析初始化配置,并创建新的 ngx_cycle_t 结构体以及清理旧的 ngx_cycle_t 。
进入 函数
ngx_init_cycle(ngx_cycle_t *old_cycle)
,与监听相关的片段有两处(按前后顺序):以上代码说明,reload 后首先会遍历旧结构体内原来的监听的句柄,如果监听的地址和端口都跟新的配置结构体相同,则直接将句柄添加到新的结构体。然后才会关闭旧结构体内没有被新结构体引用的监听句柄。
ngx_cmp_sockaddr
函数负责判断新旧配置监听的地址端口是否相同,主要实现如下:小结
经过源码分析,就很清晰自然了,由于
listen 8081
监听的实际上是0.0.0.0:8081
,改成127.0.0.1:8081
后,reload 时会先重新创建监听127.0.0.1:8081
的句柄,自然会与原来的0.0.0.0:8081
监听冲突了。Nginx 这么设计是很合理的,是配置使用的问题,线上不应该使用listen 8081
这种指令,显然很不安全。最后,梳理下 reload 基本过程:
HUP
信号ngx_init_cycle
函数,创建新的结构体ngx_cycle_t
,解析加载配置。监听配置的地址端口(直接引用或新创建)此时,有两种情况:
如果
ngx_init_cycle
执行失败,则还是会使用原有的配置(回滚)。如果
ngx_init_cycle
执行成功,则使用新的配置(ngx_cycle_t
结构体),新建所有 worker 进程。新建成功后发送退出信号给旧的 worker 进程。旧 worker 进程接收到到退出信号后会继续服务,当所有请求的客户端被服务后,worker 进程退出。
end:)