客户端访问业务,调度到HaProxy,HaProxy通过负载均衡算法选择后端的Server进行响应。HaProxy担当了应用层代理和负载均衡的角色,
前端与HaProxy建立TCP连接,HaProxy与后端Server建立TCP连接,两条连接互相独立,前端是广域网,而后端是局域网。
监控生产环境的传输质量时发现,HaProxy与Server的连接传输过程中,HaProxy经常出现接收缓冲区被占满的情况,其标志是HaProxy发出零窗口的ACK报文,
出现的比例占总体后端连接数量的1/3左右,且下载文件基本都是70K+的量级。此时初步怀疑是局域网和广域网之间的差异导致,但没有直接证据。
先登录生产环境的机房,查看各设备的系统状态,CPU占用率、磁盘IO都处于正常状态,但内存剩余不足百M。(设备上除了Ha还有其他很多业务)
首选需要确认当前的内存是否导致问题的原因,做了如下尝试,排除内存影响 <不是主因>
1. 同环境中,使用一些策略恢复部分设备的内存,观察恢复前后的设备出现的零窗口连接的占比
2. 用虚拟机精确模拟低内存,出现Page alloc failed,甚至OOM kill的场景,观察是否出现零窗口连接
3. 走读Kernel代码,确认内存与窗口调整之间的关联 <存在关联,但不是当前问题的主因>
观察中发现,前端全量连接中出现超时的连接比例约为20%+,后端连接仅为2.4%,后端链路明显优于前端,另外前端的RTT约为后端RTT的N倍,
以上都属于局域网与广域网之间的差异,基本符合我们的预期。
为什么窗口会满
前面整体的观测仅是想缩小问题的调查范围,内存的调整并没有带来明显改善。因此写了一个SystemTap脚本,用于跟踪HaProxy端的接收窗口,当接口将要变
化为0时,记录连接窗口的相关信息,先来梳理一下代码:
Tcp_output.c中的tcp_transmit_skb函数,通过tcp_select_window设置当前的窗口
tcp_select_window函数,确认具体窗口选择方式
T
可以看到选择的新窗口,由__tcp_select_window来设置,同时当new_win小于2^rcv_wscale时清为0。当前环境中窗口扩大因子为8(下图),因此
当new_win小于256时被设置为0,Wireshark的截图不上传了。
__tcp_select_window函数
监控最后返回的window以及free_space与mss之间的关系,将和窗口计算相关的信息进行打印,删除连接IP信息,如下
-
[tcp_select_window: 1745], Wed Jul 22 15:02:46 2015 CST, rcv_buf=87380, rcv_wup=3169662632, rcv_nxt=3169699132, rcv_wnd=60672, adv_mss=1460, cop_seq=3169661164, rcv_wsc=8, rcv_ssthresh=61320, window_clamp=64075, mss=1460, free_space=-1857, full_space=64075, window
从SystemTap脚本统计的结果来看,基本全部命中free_space < mss,看一下free_space的计算方式
-
static inline int tcp_win_from_space(int space)
-
{
-
return sysctl_tcp_adv_win_scale<=0 ?
-
(space>>(-sysctl_tcp_adv_win_scale)) :
-
space - (space>>sysctl_tcp_adv_win_scale);
-
}
-
-
/* Note: caller must be prepared to deal with negative returns */
-
static inline int tcp_space(const struct sock *sk)
-
{
-
return tcp_win_from_space(sk->sk_rcvbuf -
-
atomic_read(&sk->sk_rmem_alloc));
-
}
其中sysctl_tcp_adv_win_scal的值可以通过如下方式查看
-
[root@S-LAB hanwei]# cat /proc/sys/net/ipv4/tcp_adv_win_scale
-
2
从 目前的现象来看,是缓冲区上限sk_rcvbuf减去当前已申请的空间后,剩余空间不足。而当前观测到的sk_rcvbuf缓冲区上限基本都是在130K左右,系统默认配置如下
-
[root@S-LAB hanwei]# cat /proc/sys/net/ipv4/tcp_rmem
-
4096 87380 4194304
即 即sk_rcvbuf默认值为87K,上限为4M,期间系统会根据实际情况动态调整sk_rcvbuf
Tcp_ipv4.c中的tcp_v4_init_sock函数,sk_rcvbuf的初始化如下
-
sk->sk_rcvbuf = sysctl_tcp_rmem[1]
Tcp_input.c中的tcp_rcv_space_adjust函数,用于动态调整sk_rcvbuf
-
void tcp_rcv_space_adjust(struct sock *sk)
-
{
-
struct tcp_sock *tp = tcp_sk(sk);
-
int time;
-
int space;
-
-
if (tp->rcvq_space.time == 0)
-
goto new_measure;
-
-
time = tcp_time_stamp - tp->rcvq_space.time;
-
if (time < (tp->rcv_rtt_est.rtt >> 3) || tp->rcv_rtt_est.rtt == 0)
-
return;
-
-
space = 2 * (tp->copied_seq - tp->rcvq_space.seq);
-
-
space = max(tp->rcvq_space.space, space);
-
-
if (tp->rcvq_space.space != space) {
-
int rcvmem;
-
-
tp->rcvq_space.space = space;
-
-
if (sysctl_tcp_moderate_rcvbuf &&
-
!(sk->sk_userlocks & SOCK_RCVBUF_LOCK)) {
-
int new_clamp = space;
-
-
/* Receive space grows, normalize in order to
-
* take into account packet headers and sk_buff
-
* structure overhead.
-
*/
-
space /= tp->advmss;
-
if (!space)
-
space = 1;
-
rcvmem = SKB_TRUESIZE(tp->advmss + MAX_TCP_HEADER);
-
while (tcp_win_from_space(rcvmem) < tp->advmss)
-
rcvmem += 128;
-
space *= rcvmem;
-
space = min(space, sysctl_tcp_rmem[2]);
-
if (space > sk->sk_rcvbuf) {
-
sk->sk_rcvbuf = space;
-
-
/* Make the window clamp follow along. */
-
tp->window_clamp = new_clamp;
-
}
-
}
-
}
-
-
new_measure:
-
tp->rcvq_space.seq = tp->copied_seq;
-
tp->rcvq_space.time = tcp_time_stamp;
-
}
代码比较清晰,每个RTT调整一次,计算RTT内复制到用户空间的数据量×2,当本次计算的space大于上次时,尝试对sk_rcvbuf进行调整。当可以调整时,会计算可以缓存的包个数,并增加报文的必要开销计算所需内存。当计算结果比当前的sk_rcvbuf要大时,则进行调整,同时调整window_clamp为new_clamp<接收窗口上限>。
当前环境下,窗口占满的初步原因:
rcvbuf与User RTT内的读取数据量正相关,当sk_rcvbuf维持在低量时,说明User的消费能力较弱。当User的消费能力无法匹配Server的生产能力时,sk_rcvbuf的可用缓存空间被占满,导致零窗口出现。
HaProxy读取速度的影响因素
监控一下HaProxy的读取信息,可以发现HaProxy读取数据时Buf经常不足1K,如下
-
[tcp_recvmsg: 1361] execname: haproxy, pid: 1554, len: 8192, nonblock: 64, flags: 0x0000000
-
[tcp_recvmsg: 1361] execname: haproxy, pid: 1554, len: 497, nonblock: 64, flags: 0x00000000
-
[tcp_recvmsg: 1361] execname: haproxy, pid: 1554, len: 381, nonblock: 64, flags: 0x00000000
据此,分析一下HaProxy的代码:
Stream_sock.c文件中的stream_sock_write_read函数
-
cur_read = 0;
-
while (1) {
-
max = buffer_max_len(b) - b->l;
-
-
if (max <= 0) {
-
b->flags |= BF_FULL;
-
si->flags |= SI_FL_WAIT_ROOM;
-
break;
-
}
-
-
/*
-
* 1. compute the maximum block size we can read at once.
-
*/
-
if (b->l == 0) {
-
/* let's realign the buffer to optimize I/O */
-
b->r = b->w = b->lr = b->data;
-
}
-
else if (b->r > b->w) {
-
/* remaining space wraps at the end, with a moving limit */
-
if (max > b->data + b->size - b->r)
-
max = b->data + b->size - b->r;
-
}
-
/* else max is already OK */
-
-
/*
-
* 2. read the largest possible block
-
*/
-
ret = recv(fd, b->r, max, 0);
-
-
if (ret > 0) {
-
b->r += ret;
-
b->l += ret;
-
cur_read += ret;
-
-
/* if we're allowed to directly forward data, we must update send_max */
-
if (b->to_forward && !(b->flags & (BF_SHUTW|BF_SHUTW_NOW))) {
-
unsigned long fwd = ret;
-
if (b->to_forward != BUF_INFINITE_FORWARD) {
-
if (fwd > b->to_forward)
-
fwd = b->to_forward;
-
b->to_forward -= fwd;
-
}
-
b->send_max += fwd;
-
b->flags &= ~BF_OUT_EMPTY;
-
}
-
-
if (fdtab[fd].state == FD_STCONN) {
b->data为一块环形缓冲区,b->r和b->w分为代表当前读写游标,读报文时指定值为缓冲区大小减去其中的数据长度。当上文中发现读数据时的缓冲区不足1K,
说明HaProxy中可用缓冲区长度已不足1K,而HaProxy的缓冲区的默认大小为16384字节,其设置方式如下:
-
struct buffer {
-
unsigned int flags; /* BF_* */
-
int rex; /* expiration date for a read, in ticks */
-
int wex; /* expiration date for a write or connect, in ticks */
-
int rto; /* read timeout, in ticks */
-
int wto; /* write timeout, in ticks */
-
int cto; /* connect timeout, in ticks */
-
unsigned int l; /* data length */
-
char *r, *w, *lr; /* read ptr, write ptr, last read */
-
unsigned int size; /* buffer size in bytes */
-
unsigned int send_max; /* number of bytes the sender can consume om this buffer, <= l */
-
unsigned int to_forward; /* number of bytes to forward after send_max without a wake-up */
-
unsigned int analysers; /* bit field indicating what to do on the buffer */
-
int analyse_exp; /* expiration date for current analysers (if set) */
-
void (*hijacker)(struct session *, struct buffer *); /* alternative content producer */
-
unsigned char xfer_large; /* number of consecutive large xfers */
-
unsigned char xfer_small; /* number of consecutive small xfers */
-
unsigned long long total; /* total data read */
-
struct stream_interface *prod; /* producer attached to this buffer */
-
struct stream_interface *cons; /* consumer attached to this buffer */
-
struct pipe *pipe; /* non-NULL only when data present */
-
char data[0]; /* <size> bytes */
-
}
其中data[0],可变数组data即为HaProxy用于读写的缓冲区,看一下其的赋值方式:
-
/* perform minimal intializations, report 0 in case of error, 1 if OK. */
-
int init_buffer()
-
{
-
pool2_buffer = create_pool("buffer", sizeof(struct buffer) + global.tune.bufsize, MEM_F_SHARED);
-
return pool2_buffer != NULL;
-
}
创建pool2_buffer的地址池,其中每个块发小为sizeof(buffer) + global.tune.bufsize,global.tune.bufsize即为HaProxy读写缓冲区的大小
-
/* global options */
-
struct global global = {
-
logfac1 : -1,
-
logfac2 : -1,
-
loglev1 : 7, /* max syslog level : debug */
-
loglev2 : 7,
-
.stats_sock = {
-
.maxconn = 10, /* 10 concurrent stats connections */
-
.perm = {
-
.ux = {
-
.uid = -1,
-
.gid = -1,
-
.mode = 0,
-
}
-
}
-
},
-
.tune = {
-
.bufsize = BUFSIZE,
-
.maxrewrite = MAXREWRITE,
-
.chksize = BUFSIZE,
-
},
-
/* others NULL OK */
-
}
-
#ifndef BUFSIZE
-
#define BUFSIZE 16384
-
#endif
可以看到data缓冲区的大小,默认被设置为了16384,这个值在配置文件可以设置,当前我们未配置。
这个缓冲区的确不是很大,当文件较大并且HaProxy向广域网方向写的速度较慢时,HaProxy的缓冲区很容易占满,而且读取速度较慢,从而导致HaProxy连接Server端时,其内核接收缓冲区容易被占满。
由于广域网和局域网差异,HaProxy与Server之间连接的传输质量及RTT要明显优于HaProxy与Client之间的连接,比如由于广域网方向出现丢包,可能导致长达数秒没有新数据发送出去,而HaProxy 这段时间内又收到了大量的Server传输的数据,协议栈接收缓冲区/HaProxy缓冲区/协议栈发送缓冲区很快占满,零窗口再现。同样的,RTT的差异,导致单位时间内HaProxy接收的数据可能数倍于发送的数据,当文件足够大时,各缓冲区可以预期肯定会出现占满情况,导致零窗口问题
容易想到,当扩大HaProxy缓冲区上限时,可以有效改善该问题,降低零窗口比例。比如将该缓冲区扩大至M级别,即使存在广域网和局域网的差异M级别内的文件都不再会出现零窗口问题,简单方便。不过会对当前设备的内存分配产生影响。
通过后端的零窗口连接,找到HaProxy与客户端的连接
至此,HaProxy与Server传输时出现的零窗口问题,后端的情况已完成初步分析。对于当前的分析结论,需要找到零窗口连接对应的HA-客户端的连接,并对其进行分析来验证之前的结论。由于HaProxy本身代理的特性,已将前后连接完全剥离,因此无法直接进行关联分析。
基于此,目前有两种方式:
1. 前后端连接同时抓包,根据后端零窗口连接产生的时间/URI,对前端连接进行过滤
2. 修改HaProxy,在请求头部增加客户端的源IP地址和端口,通过IP/端口进行过滤
由于前端报文数量巨大,并发在4万+的量级,pps在百万级别,方法1耗时耗力,尝试后放弃。
而方法2的缺点是需要替换并重启生产环境HaProxy,对HaProxy进行了修改,修改比较简单src/proto_http.c中3730行增加http_header_add_tail即可。
具体代码不贴了,经过上述步骤,关联前后端的连接,并基于大量连接的自动分析验证,基本验证了结论。
广域网与局域网的传输质量与传输时延的差异,是导致HaProxy出现零窗口的主要原因。由于问题的瓶颈在于广域网方向的传输,
因此HaProxy于后端出现的零窗口问题,对用户访问体验并没有影响。
阅读(1832) | 评论(0) | 转发(0) |