jinhailang / blog

技术博客:知其然,知其所以然
https://github.com/jinhailang/blog/issues
60 stars 6 forks source link

Nginx reload 端口占用问题分析 #42

Open jinhailang opened 6 years ago

jinhailang commented 6 years ago

Nginx reload 端口占用问题分析

起因

用户反馈说线上程序貌似没有更新,承诺的新特性都没有。去线上确认,发现程序内存版本号确实没有更新。由于项目是使用 shell 脚本命令(docker kill --signal "HUP" xxx)重启的,当时就怀疑是不是重启命令没有被正确执行?但是查询日志,发现更新时,有 init_by_lua 阶段的日志输出,就是说 重启指令执行了,且 init_by_lua 阶段正常被执行了,但进程内的代码数据还都是旧的,而且使用 nginx stop 重启更新是正常的,这就很诡异了!

init_by_lua 阶段输出的日志: image

问题定位

因为之前版本没有发现过,只能猜测是新版代码有问题,但是,新版代码都是些业务代码之类的更新,怎么会出现这种问题呢?很让人抓狂,只得老老实实在测试环境,模拟一下新旧版本的切换场景,没有想到,出现了下面的错误:

reload_error

会不会线上也是因为端口占用,导致的 reload 失败呢?去线上再次查日志,发现更新的时间点,果然也有端口占用的错误日志(/var/log/syslog):

address

时间上刚好与 init_by_lua 阶段输出的日志时间一致。 这里脚本执行也有个问题,由于 reload 后没有正确判断执行结果,导致只能去 /var/log/syslog 下查日志才发现。

原因分析

基本可以确认是因为执行 Nginx reload 时,出现了端口被占用的错误,导致更新没有完成。而 init_by_lua 被执行的是因为该阶段在端口监听之前被执行(从上面日志可以直接看到)。

所以,到底是什么操作触发了这个错误呢?既然是端口监听的问题,自然跟 nginx 的 listen 配置有关,联想到之前,为了安全起见,将配置 listen 8081 改成了 listen 127.0.0.1:80于是,在本地尝试复现,发现每次必现:

load_cfg

至此,问题原因基本查清楚了,就是地址端口修改导致的“血案”,而且只有在 listen 8081listen 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 内的以下代码块:

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) ,与监听相关的片段有两处(按前后顺序):

image

image

以上代码说明,reload 后首先会遍历旧结构体内原来的监听的句柄,如果监听的地址和端口都跟新的配置结构体相同,则直接将句柄添加到新的结构体。然后才会关闭旧结构体内没有被新结构体引用的监听句柄。

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 这种指令,显然很不安全。

最后,梳理下 reload 基本过程:

此时,有两种情况:

end:)