背景:
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相关参数(缓解途径)