本文联合作者:@敬易
问题的背景
一个平静的下午,前线同学接到有用户发出灵魂拷问:
你们这个镜像下载服务也太慢了!
我们的第一感觉是,有恶意用户在占用宝贵的服务器带宽资源,与是开始查找是否有可疑的用户在进行可疑的操作。在对几个行为异常的客户进行屏蔽操作后,整体流量下降到了有客户曝出问题之前的水平。本以为故障就此消弭在基操之中,然而持续不断的客户反馈让我们意识到,这个问题还没有解决,为了尽快提供稳定的服务,不得已选择了重启大法。。。
在初次见识到问题之后,我们大概摸清量他的几个特点:
-
有一些客户将流量推高后就会出现速率低的现象。
-
一旦速率低的现象出现,就会持续很久,及时流量恢复到打高之前的水平,也不会提升。
-
重启可以解决。
问题的排查过程
抓包分析初见端倪
为了搞清楚速率为什么位置在几百kb的水平,我们在一次复现的火线时刻进行了抓包,抓包的分析结果如下:
速率低的情况,我们首先想到可能是网络质量问题,不过在所有报文中仅有两个重传报文,只有一个是具备有效载荷的报文,也没有出现ack_rtt过大的情况,显然,网络质量不是造成速率低下的主要原因。
随后我们查看了吞吐量的变化,可以看出来整个网络期间的整体速率都比较稳定,稳定得比较慢,看起来是一个符合预期的情况,应该是有什么地方限制了速率,而这个限制因素和网络质量无,首先让我们想到的就是TCP的发送窗口。
我们选取了其中耗时最久的一条流,查看RWND窗口的变化趋势:
果不其然,可以看到,在客户端的38572端口到服务端443端口的方向,实际发送速率几乎和RWND窗口持平,其中RWND窗口(仔细看有一条绿色的线在顶端)在握手阶段开始就几乎没有什么变化。
随后我们查看了抓包文件中有关窗口变化的报文:
可以看到wireshark识别出了大量的TCP Windows Full信息,也就是说,有大量的发送端报文实际上是一次性就把窗口给耗尽了,这也就能解释为什么会产生较低的速率,对于客户端来说,由于每一次发送数据都会耗尽窗口,那么就必须要等待发送出去的数据被确认,才能继续发送数据,主要的瓶颈在于,由于窗口的限制,不得不控制发送的频率。
看上去异常状态下的流,他的速率较低是一个正常的现象,核心原因就是窗口较低,那么窗口较低是否是正常的呢,我们抓去了正常状态下的流进行对比:
可以发现与出现低速率现象时的窗口变化有着明显的差异:
-
在握手阶段结束后,窗口会随着吞吐量的变化而上升。
-
窗口的上限值明显比异常状态下的窗口要大很多。
不难发现,正常状态下,随着每次客户端发送数据占据窗口,RWND是会迅速升高的,但是异常情况下,RWND始终保持慢启动状态下的较低的值,引发后续的速率问题。为了找到根因,我们梳理了内核进行RWND窗口确认的逻辑。
TCP窗口增长的内核原理
tcp协议在每一个报文segment的报头中都会携带窗口信息,窗口的本质是一个tcp协议层面的通信带宽限制,主要取决于两个关键的因素:
-
接收数据的接收端处理数据和缓存数据的能力,处理快或者缓存空间较大,则窗口会变大,这部分被称为Receive Window, 即RWND。
-
网络的质量,如果网络质量较差,则窗口会降低,反之会较大,这部分被称为Congestion Window,即CWND。
.-------------------------------+-------------------------------.
| Source Port | Destination Port |
|-------------------------------+-------------------------------|
| Sequence Number |
|---------------------------------------------------------------|
| Acknowledgment Number |
|-------------------+-+-+-+-+-+-+-------------------------------|
| Data | |U|A|P|R|S|F| |
| Offset| Reserved |R|C|S|S|Y|I| Window |
| | |G|K|H|T|N|N| |
|-------+-----------+-+-+-+-+-+-+-------------------------------|
| Checksum | Urgent Pointer |
`---------------------------------------------------------------'
TCP在发送一个segment时,会通过tcp_select_window对窗口大小进行设置:
static int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb, int clone_it,
gfp_t gfp_mask)
{
if (unlikely(tcb->tcp_flags & TCPHDR_SYN)) {
// syn握手报文的窗口是协议相关的固定值
th->window = htons(min(tp->rcv_wnd, 65535U));
} else {
// 非syn报文通过select window决定窗口的大小
th->window = htons(tcp_select_window(sk));
}
return net_xmit_eval(err);
}
u32 __tcp_select_window(struct sock *sk)
{
int mss = icsk->icsk_ack.rcv_mss;
int free_space = tcp_space(sk);
int allowed_space = tcp_full_space(sk);
int full_space = min_t(int, tp->window_clamp, allowed_space);
int window;
// 这里很关键,free_space最终会被rcv_ssthresh限制
if (free_space > tp->rcv_ssthresh)
free_space = tp->rcv_ssthresh;
window = tp->rcv_wnd;
if (tp->rx_opt.rcv_wscale) {
window = free_space;
if (((window >> tp->rx_opt.rcv_wscale) << tp->rx_opt.rcv_wscale) != window)
window = (((window >> tp->rx_opt.rcv_wscale) + 1)
<< tp->rx_opt.rcv_wscale);
} else {
if (window <= free_space - mss || window > free_space)
window = (free_space / mss) * mss;
else if (mss == full_space &&
free_space > window + (full_space >> 1))
window = free_space;
}
return window;
}
在__tcp_select_window的处理逻辑中可以发现,限制窗口大小的因素有以下几个:
-
rcv_ssthresh,RWND的限制,在慢启动阶段也会通过ssthresh来控制窗口变化。
-
full_space,节点级别的内存空间。
-
free_space,协议级别的内存空间。
-
rcv_wscale,TCP Window Scale Option的缩放系数。
TCP Window Scale Option是为了能够让窗口扩张到足够大,在上文的抓包中可以看到,正常情况下的窗口是远大于65535,option配置不存在问题;窗口在慢启动到会话结束一直没有增长,但是传输了大量的数据,显然不是因为free_space不足,这里窗口无法增长的原因较大概率就是rcv_ssthresh的限制。重点来看rcv_ssthresh的变化:
static void tcp_event_data_recv(struct sock *sk, struct sk_buff *skb)
{
inet_csk_schedule_ack(sk);
// 正常顺序的报文先进行mss扩展的检验
tcp_measure_rcv_mss(sk, skb);
tcp_rcv_rtt_measure(tp);
// 如果报文的长度大于128字节,则会触发窗口的增加
if (skb->len >= 128)
tcp_grow_window(sk, skb);
}
static void tcp_grow_window(struct sock *sk, const struct sk_buff *skb)
{
/* Check #1 */
if (tp->rcv_ssthresh < tp->window_clamp &&
(int)tp->rcv_ssthresh < tcp_space(sk) &&
!tcp_under_memory_pressure(sk)) {
int incr;
// 根据当前的内存情况来确认缩放的大小
if (tcp_win_from_space(skb->truesize) <= skb->len)
incr = 2 * tp->advmss;
else
incr = __tcp_grow_window(sk, skb);
if (incr) {
incr = max_t(int, incr, 2 * skb->len);
// 将rcv_ssthresh设置为缩放后大小于窗口上限的较小值
tp->rcv_ssthresh = min(tp->rcv_ssthresh + incr,
tp->window_clamp);
inet_csk(sk)->icsk_ack.quick |= 1;
}
}
}
在每次正常的收取数据过程中,当满足以下条件时就会调用tcp_grow_window来确认是否需要调整rcv_ssthresh:
-
正常的业务报文,即单纯的控制报文如挥手报文,ZeroWindow探测报文是不会触发窗口更新的。
-
报文的长度大于128(这里的128的限制其实包含了tcp的报头和options以及未来可能添加的option,实际上这个限制不是精确的)。
在上文的抓包中有很多正常的业务报文,在正常但是能否正常调整成功,有几个限制条件:
-
window_clamp,这个是窗口的极限值,在注释中可以发现,慢启动阶段,rcv_ssthresh比window_clamp要strict很多。
-
tcp_space(sk),socket当前剩余的buffer大小,这里的检查和前方__tcp_select_window类似,确保rcv_ssthresh不要超过socket的接收能力。
-
tcp_under_memory_pressure,这是TCP协议层面的压力控制开关。
以上三个因素综合分析,tcp_under_memory_pressure的可能性最大,与是我们继续看tcp_under_memory_pressure是如何判断的:
static inline bool tcp_under_memory_pressure(const struct sock *sk)
{
if (mem_cgroup_sockets_enabled && sk->sk_cgrp)
return !!sk->sk_cgrp->memory_pressure;
// 在判断cgroup限制后,直接返回tcp_memory_pressure这个全局变量
return tcp_memory_pressure;
}
struct proto tcp_prot = {
.name = "TCP",
.recvmsg = tcp_recvmsg,
.sendmsg = tcp_sendmsg,
.enter_memory_pressure = tcp_enter_memory_pressure,
.leave_memory_pressure = tcp_leave_memory_pressure,
.stream_memory_free = tcp_stream_memory_free,
.memory_allocated = &tcp_memory_allocated,
.memory_pressure = &tcp_memory_pressure,
.sysctl_mem = sysctl_tcp_mem,
.max_header = MAX_TCP_HEADER,
.obj_size = sizeof(struct tcp_sock),
};
tcp_under_memory_pressure的本质是引用了一个内核态的全局变量tcp_memory_pressure,这个变量其实是针对整个TCP协议共同生效的,查看TCP协议注册到socket子系统的初始结构体,tcp_memory_pressure以及tcp_enter_memory_pressure和tcp_leave_memory_pressure等与内存压力相关的方法都是针对整个协议的范围生效。
深入内核定位根因
经过上文的分析,我们打算针对tcp_memory_pressure这个内核态的全局变量进行观测,为此我们按照了surftrace工具,参照以下方式持续捕获tcp_memory_pressure的值:
surftrace 'p inet_csk_accept point=@tcp_memory_pressure'
上面这一行命令的含义是在每次内核调用inet_csk_accept这个方法时,输出tcp_memory_pressure全局变量的值。
在持续运行了一段时间之后,我们终于抓到了现场:
可以看到,在问题出现时,tcp_memory_pressure的值为1,这也就验证了上文的推论,由于tcp_memory_pressure的限制,导致窗口一直处于慢启动的初始状态,无法增长,进而引发速率持续很低。
然而问题到这里并没有结束,既然tcp_memory_pressure是内核用于表示存在内存压力的全局变量,那为什么当TCP总体内存恢复到之前的水准的时候,速率还是上不去呢?为此,我们继续深入内核一探究竟。
void tcp_enter_memory_pressure(struct sock *sk)
{
if (!tcp_memory_pressure) {
NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPMEMORYPRESSURES);
tcp_memory_pressure = 1;
}
}
static inline void sk_leave_memory_pressure(struct sock *sk)
{
int *memory_pressure = sk->sk_prot->memory_pressure;
if (!memory_pressure)
return;
if (*memory_pressure)
*memory_pressure = 0;
}
// __sk_mem_schedule是核心的方法,在4.19版本中被重构了,实际上每次socket分配内存都会触发
int __sk_mem_schedule(struct sock *sk, int size, int kind)
{
sk->sk_forward_alloc += amt * SK_MEM_QUANTUM;
allocated = sk_memory_allocated_add(sk, amt, &parent_status);
// 只有当前分配的内存小于tcp_mem[0]的时候才会退出压力状态
if (parent_status == UNDER_LIMIT &&
allocated <= sk_prot_mem_limits(sk, 0)) {
sk_leave_memory_pressure(sk);
return 1;
}
// 当前分配内存大于tcp_mem[1]的时候会进入压力状态
if ((parent_status > SOFT_LIMIT) ||
allocated > sk_prot_mem_limits(sk, 1))
sk_enter_memory_pressure(sk);
// 如果当前分配内存大于tcp_mem[2],就不会再分配出内存
if ((parent_status == OVER_LIMIT) ||
(allocated > sk_prot_mem_limits(sk, 2)))
goto suppress_allocation;
return 0;
}
首先我们查看了tcp_memory_pressure变量的管理,可以看到他的核心部分在于__sk_mem_schedule方法,这个方法在不同版本内核上有着较大的差异,从函数注释上不难发现,这个方法就是为socket分配内存的,在对tcp_memory_pressure的管理中,有一个很熟悉的sysctl,也就是sk_prot_mem_limits所引用的tcp_mem,在内核文档中,他的描述如下:
tcp_mem - vector of 3 INTEGERs: min, pressure, max
min: below this number of pages TCP is not bothered about its
memory appetite.
pressure: when amount of memory allocated by TCP exceeds this number
of pages, TCP moderates its memory consumption and enters memory
pressure mode, which is exited when memory consumption falls
under "min".
max: number of pages allowed for queueing by all TCP sockets.
Defaults are calculated at boot time from amount of available
memory.
在tcp_mem的设置中,有min,pressure和max三个值,内核通过sk_prot_mem_limits(sk, index)的方式引用这三个值组成的数组,他们的含义分别是:
-
第一个min值,是从pressure状态恢复的门限,当tcp的内存占用小于这个值时,如果此时处于压力状态下,会从压力状态恢复。
-
第二个pressure值,是进入压力状态的阈值,当内存分配处于这个状态时,如果不在压力状态,会触发内核进入压力状态。
-
第三个max值,这个比较好理解,当内存分配大于max的时候,无法分配socket的内存。
回到代码中tcp_memory_pressure变量的变化,可以看到他的变化有三个核心的要点:
-
当有socket内存申请的时候进行检查并处理置位和恢复。。
-
当前socket占用内存大与tcp_mem的pressure值时,进入压力状态,tcp_memory_pressure设置为1.
-
当前socket占用内粗小于tcp_mem的min值时,会从压力状态恢复,tcp_memory_pressure设置为0.
随即我们查看了常规状态下的镜像服务节点的TCP内存状态:
结合节点上的配置:372120 496162 744240
可以发现,在流量回落之后,TCP分配的内存总量493089正好位于min与pressure之间,这也就解释了最初的一个疑问:
-
常规状态下,服务的TCP内存分配位于min和pressure之间,但是由于没有超过pressure,所以窗口依然能够增长,速率较高。
-
出现捣蛋鬼客户一波大流量打高之后,TCP内存分配超过了pressure,进入承压状态,窗口无法增长。
-
捣蛋鬼客户被赶走之后,内存分配恢复之前的水平,然而依然比min要高,因此只能继续在承压状态,重启之后才恢复。
问题的背后
尽管tcp_mem这个内核参数经常被提到,但是他真正生效的内在逻辑,以及如何引发,如何解决,搜了很多网络上的文章,也没有发现描述的非常清楚的,在这个case的基础上,为了优化排查的效率,我们针对tcp_memory_pressure增加了一些监控,包括:
-
tcp_memory_pressure,实时映射内核中的tcp_memory_pressure值。
-
tcp_memory_pressure_level,表征tcp_memory_pressure在tcp_mem三层阶梯中所处的位置。
云原生环境下,节点级别的限制很容易产生诡异的问题,依托于kubeskoop项目,我们构建了基于大量案例的高阶指标和趁手工具,欢迎大家体验和交流!