TCP?HTTP? 不同类型探测的引发的坑

2022-06-14 14:24:14 浏览数 (1)

背景:

nginx-gateway部署在公有云 A, 业务测试服务器部署在办公区机房B, 公有云region A 和 办公区机房 B通过soft V**互连。B机房中有不同类型的应用服务器【nodejs,java(tomcat)】做nginx-gateway的后端upstream节点。nginx-gateway编译安装了ngx_http_upstream_check_module插件,ngx_http_upstream_check_module用于做后端upstream节点的健康监测, healthcheck为每个upstream的后端节点配置有一个raise_counts/fall_couts状态的计数器。业务方同事反馈:从外部访问内部某些应用有概率出现超时, 经观察, nodejs,java(tomcat)的raise_counts计数器概率性地重置为0,

并且概率不一样(前者概率低,后者概率高)。

nodejs的healthcheck配置:

代码语言:javascript复制
server xx.xxx.xx.xx:yy;
server yyy.yyy.yyy.yyy:xxx;
check interval=3000 fall=5 rise=2 timeout=3000 default_down=true type=tcp;
keepalive 300;

Java的healtchcheck配置:

代码语言:javascript复制
server x.xx.x.x:y;
server y.y.y.y:xx;
check interval=3000 rise=2 fall=5 timeout=5000 type=http;
check_http_send "GET / HTTP/1.1 Host:xx.xx.com rnrn";
check_http_expect_alive http_2xx http_3xx;
keepalive 300;

现象:

观察了一段时间后(抽象出2个现象):

1. 办公区机房 B中的nodejs, java服务器过一段时间就会出现raise_count重置为0, nodejs出现的概率比Java应用低。

2. zabbix监控显示网络存在少量icmp丢包的迹象,丢包的时间和nodejs healthcheck raise_counts重置为0的时间并不完全吻合(zabbix icmp ping探测), 但跟java(tomcat) healthcheck raise_counts重置为0的时间较为吻合。

由于先前有过类似的故障:(原因是: 操作系统windows/linux的TCP协议栈实现有所不同:默认TCP RTO不同,导致TCP重传失败无法建连)。 但这次出现的情况不一样, 所以先前的经验并不能迅速定位到问题。

问题的分析和定位:

整个过程,有2个关键点需要确认:

关键点1. healthcheck的tcp/http类型的raise_counts重置为0判断条件是什么?

关键点2. 故障时刻点TCP/http发生了什么?(为啥同机房不同应用有这种现象?为啥nodejs/java和丢包时刻吻合度存在差异?)

关键点1:

在没有梳理代码逻辑前,脑海一直认为healthcheck插件是这样的: 如果是TCP类型的探测,则每个work进程都发起TCP短连接探测upstream后端节点的存活,每个nginx work进程独立工作; 如果是HTTP类型的探测,则是通过upstream keepalive长连接发起的healtcheck。通过看代码,debug, 抓包,发现实际上的情况跟想象中的存在较大差异。healtchcheck代码文件为ngx_http_upstream_check_module.c , 该插件使用了共享内存作为nginx work进程通信ipc手段,共享内存同时用来维护各个后端节点的healtchheck status状态/每个upstream节点和work进程关系/任务更新时间等。 每个upstream 后端节点只能由一个work进程heathcheck探测(第一次随机nginx work来执行healthcheck,如果某个upstream 后端节点较长的没有healchcheck,则由另外的work进程绑定该节点healtchcheck任务)。

nginx work进程通过定时事件触发执行healthcheck任务,各个nginx work进程通过共享内存 锁的方式来保证单个upstream后端节点只有一个nginx work对其探测;每个后端节点都有添加了一个定时器回调,nginx work 通过时间事件触发执行回调函数。

healthcheck的类型有TCP/HTTP/mysql/ssl/ajp等,我们案例中使用的是TCP和HTTP, 先以TCP为例分析,

模块初始化后进入主逻辑:

ngx_http_upstream_check_begin_handler --> ngx_http_upstream_check_connect_handler

主逻辑在ngx_http_upstream_check_connect_handler:

代码语言:javascript复制
ngx_http_upstream_check_connect_handler(ngx_event_t *event)
{
    ngx_int_t                            rc;
    ngx_connection_t                    *c;
    ngx_http_upstream_check_peer_t      *peer;
    ngx_http_upstream_check_srv_conf_t  *ucscf;
    ##是否收到退出信号,是否需要gracefully exit
    if (ngx_http_upstream_check_need_exit()) {
        return;
    }
    peer = event->data;
    ucscf = peer->conf;
    ###如果有可用的连接
    if (peer->pc.connection != NULL) {
        c = peer->pc.connection;
        if ((rc = ngx_http_upstream_check_peek_one_byte(c)) == NGX_OK) {
            goto upstream_check_connect_done;
        } else {
       ##异常连接,需要关闭
            ngx_close_connection(c);                                                                                                                      
            peer->pc.connection = NULL;                                                                                                                   
        }                                                                                                                                                 
    }                                                                                                                                                     
    ngx_memzero(&peer->pc, sizeof(ngx_peer_connection_t));                                                                                                                                                                                                                                                          
    peer->pc.sockaddr = peer->check_peer_addr->sockaddr;                                                                                                  
    peer->pc.socklen = peer->check_peer_addr->socklen;                                                                                                    
    peer->pc.name = &peer->check_peer_addr->name;                                                                                                                                                                                                                                                                
    peer->pc.get = ngx_event_get_peer;                                                                                                                    
    peer->pc.log = event->log;                                                                                                                            
    peer->pc.log_error = NGX_ERROR_ERR;                                                                                                                                                                                                                                                                          
    peer->pc.cached = 0;                                                                                                                                  
    peer->pc.connection = NULL;                                                                                                                                                                                                                                                                                    
    //没有可用的长连接,则创建新的连接
    rc = ngx_event_connect_peer(&peer->pc);
    //如果创建连接失败,计数器清0
    if (rc == NGX_ERROR || rc == NGX_DECLINED) {                                                                                                          
        ngx_http_upstream_check_status_update(peer, 0);                                                                                                   
        ngx_http_upstream_check_clean_event(peer);                                                                                                        
        return;                                                                                                                                           
    }  
    c = peer->pc.connection;                                                                                                                              
    c->data = peer;                                                                                                                                       
    c->log = peer->pc.log;                                                                                                                                
    c->sendfile = 0;                                                                                                                                      
    c->read->log = c->log;                                                                                                                                
    c->write->log = c->log;                                                                                                                               
    c->pool = peer->pool;                                                                                                                                 
                                                                                                                                                          
upstream_check_connect_done:                                                                                                                              
    peer->state = NGX_HTTP_CHECK_CONNECT_DONE;                                                                                                            
     //配置TCP类型连接的回调函数配置 ---> TCP类型对应的handle:ngx_http_upstream_check_peek_handler
     //读写事件都触发:ngx_http_upstream_check_peek_handler回调函数                                                                                                                                                    
    c->write->handler = peer->send_handler;                                                                                                               
    c->read->handler = peer->recv_handler;                                                                                                                                                                                                                                                                      
    ngx_add_timer(&peer->check_timeout_ev, ucscf->check_timeout);                                                                                         
                                                                                                                                                          
    /* The kqueue's loop interface needs it. */                                                                                                           
    if (rc == NGX_OK) {                                                                                                                                   
        c->write->handler(c->write);                                                                                                                      
    }                                                                                                                                                     
}

}

TCP类型探测的回调函数ngx_http_upstream_check_peek_handler:

代码语言:javascript复制
ngx_http_upstream_check_peek_handler(ngx_event_t *event)
{
    ngx_connection_t               *c;
    ngx_http_upstream_check_peer_t *peer;
    ####当前work进程的pid和共享内存中的pid是否一致,不一致则退出(由其他nginx work来完成探测)
    if (ngx_http_upstream_check_need_exit()) {
        return;
    }
    c = event->data;
    peer = c->data;
    ####获取已经存在的tcp长连接读取1个字节
    if (ngx_http_upstream_check_peek_one_byte(c) == NGX_OK) {
        ngx_http_upstream_check_status_update(peer, 1);
    } else {
        c->error = 1;
        ##upstream tcp长连接recv读取错误,则重置计数器
        ngx_http_upstream_check_status_update(peer, 0);
    }
    ngx_http_upstream_check_clean_event(peer);
    ngx_http_upstream_check_finish_handler(event);                                                                                                        
}

//读去长连接的1个字节
ngx_http_upstream_check_peek_one_byte(ngx_connection_t *c)
{
    char                            buf[1];
    ngx_int_t                       n;
    ngx_err_t                       err;
    n = recv(c->fd, buf, 1, MSG_PEEK);
    err = ngx_socket_errno;
    ngx_log_debug2(NGX_LOG_DEBUG_HTTP, c->log, err,
                   "http check upstream recv(): %i, fd: %d",
                   n, c->fd);
    if (n == 1 || (n == -1 && err == NGX_EAGAIN)) {
        return NGX_OK;
    } else {
        return NGX_ERROR;
    }
}

如果后端upstream节点无可用长连接, 则调用ngx_event_connect_peer创建TCP长连接,判断是否正常。

如果存在可用长连接, 取出连接,读取1个字节,判断是否正常。

综合上面代码逻辑:

TCP类型探测 2种情况计数器清0:

1. 建立新TCP连接失败,计数器清0

2. 可用TCP长连接读取异常(只读取1字节),计数器清0

HTTP类型探测,分析过后, 也是2种情况:

1. 建立新HTTP连接失败,计数器清0

2. check_module keepalive可用长连接中, http send 请求返回的http code不是预期配置中的状态码,计数器清0。

ngx_http_upstream_check_module自己维持/创建长连接, 跟ngx_http_upstream_module的keepalive 长连接没有关系(跟keepalive 300这个配置参数无关)。如果是http探测类型,http长连接还受到

check_keepalive_requests这个参数控制,如果在upstream healtcheck中没有该参数,则使用默认值1,

tcp类型的healthcheck不受check_keepalive_requests影响,能够影响到TCP healthcheck行为的是后端WEB服务器新连接的空闲超时时间(类似nginx client_header_timeout, tomcat connectionTimeout参数), 达到超时时间后端WEB服务器将主动关闭TCP连接,下一次healthcheck探测, 模块会重新创建新的TCP连接。

至此关键点1中的疑问: healthcheck的底层机制和判断条件已经梳理清楚了。

关键点2:

nodejs, java(tomcat) 服务器都在同一区域,同样的系统版本,同样的内核参数, 按照道理,应该不会出现先前案例中由于TCP内核参数差异导致的问题。

当前案例中nodejs和java(tomcat)唯一差异在于TCP和http探测的协议不同。

通过nc/telnet探测出nodejs创建连接后的空闲等待时间为120s

[类似nginx client_header_timeout:60s), java(tomcat)的connectionTimeout时间为20s], 所以nginx-gateway和办公区机房的nodejs每隔120s会重新创建一次TCP长连接。java(tomcat)使用的是http类型探测,由于在upstream中没有显示配置 check_keepalive_requests,则使用该参数的默认值1, 也就是每次建立的连接都需释放,因此,无论在http探测请求头中是否带keepalive/或者指定HTTP 1.1, http探测都会退化为http短连接方式。综上所述: http的探测类型和TCP的探测类型最大的差异在于: tcp探测类型重新新建TCP连接的概率远低于HTTP类型探测

在定位过程中,已经在nginx-gateway, nodejs, java(tomcat)抓了一段时间包,经过仔细对比TCP上下文, 发现了问题所在。先查看centos7当前TCP 重传相关的内核参数:

代码语言:javascript复制
[root@nginx-gateway0 ~]#sysctl -a 2>/dev/null|grep retri
net.ipv4.tcp_syn_retries = 1
net.ipv4.tcp_synack_retries = 1
net.ipv4.tcp_retries1 = 3
net.ipv4.tcp_retries2 = 2
net.ipv4.tcp_orphan_retries = 0

以下分析都是参考当前服务器的配置而言的(不是centos7的默认值)。

其中net.ipv4.tcp_retries1,net.ipv4.tcp_retries2 是在认定出错并向网络层提交错误报告之前,重试的最大次数(该参数影响的是长连接)。

在RFC1122中有两个门限R1和R2,当重传次数超过R1的时候,TCP向IP层发送negative advice,指示IP层进行MTU探测、刷新路由等过程,以防止由于网络链路发生变化而导致TCP传输失败。当重传次数超过R2的时候,TCP放弃重传并关闭TCP连接。其中R1和R2也可以表述为时间,

即总重传时间超过R1或者R2的时候触发响应的操作。在linux中对于普通数据报文状态下的TCP,R1对应/proc/sys/net/ipv4/tcp_retries1,R2对应/proc/sys/net/ipv4/tcp_retries2参数。

总重传包遵循指数回退。所以对于已经存在的TCP连接的超时时间至少> 2^0 2^1 2^2 2^3 =15s【不是很精准,参看RFC1122】。(^为指数运算)

所以对于已经存在的TCP长连接可以承受15s时间内多次丢包(15s内完成重传即可)。

对于SYN报文,则是由tcp_syn_retries和tcp_synack_retries这两个参数控制(该参数影响新建tcp连接),tcp_syn_retries为syn的重试次数, tcp_synack_retries为synack的重试次数,遵循指数回退, syn的最大超时时间: 2^0 2^1=3s, syn_ack的最大超时时间:2^0 2^1=3s。

所以对于新建的TCP连接承受3秒内的丢包(3秒内完成1次重传即可)

从上面描述,结合抓包的数据分析:

nodejs 针对客户端设置连接超时时间为120s, 故upstream healthcheck创建nodejs的tcp长连接是最大可用时间为120s, java(tomcat)的http healthcheck没有配置check_keepalive_requests, 故healthcheck使用http短连接(每次需要重新建立TCP连接), 由于TCP长连接丢包容忍度远高于新建TCP连接,所以nodejs的raise_counts计数器重置为0的概率远低于JAVA 应用。

过程回溯:

nginx heathcheck维护的TCP长连接(已经存在的),在网络短时丢包的情况下,TCP通过指数回退方式进行重发,由于tcp_retries1/tcp_retries2的默认值较大(次数较多),所以对网络丢包容忍度较高(15s), 所以这个场景下nodejs TCP healthcheck较少概率出现rise_count置0的情况(和zabbix ping丢包时间并不完全吻合的原因)。由于java(tomcat)类型http的healthcheck已经退化为短连接,每次需要建立新连接,在网络状况不好的情况下,失败的概率远高于前者,从而导致java(tomcat) rise_count计数器的值重置为0的概率远大于nodejs应用。

解决办法:

1. 改用带宽相对较为充裕的联通线路作为soft v**线路, 采用网络层openswan ipsec替换当前soft v**软件。(根除途径)

2. 调整java(tomcat)的connecttimeout参数,将探测类型http调整为tcp(缓解途径)

3. 根据自身的业务场景(用户端/服务器端),优化TCP相关参数(缓解途径)

0 人点赞