云原生网络扫雷笔记:对一次TCP速率突然变慢无法恢复只能重启的深入追查

本文涉及的产品
容器服务 Serverless 版 ACK Serverless,317元额度 多规格
容器服务 Serverless 版 ACK Serverless,952元额度 多规格
简介: 本文联合作者:@敬易问题的背景一个平静的下午,前线同学接到有用户发出灵魂拷问:你们这个镜像下载服务也太慢了!我们的第一感觉是,有恶意用户在占用宝贵的服务器带宽资源,与是开始查找是否有可疑的用户在进行可疑的操作。在对几个行为异常的客户进行屏蔽操作后,整体流量下降到了有客户曝出问题之前的水平。本以为故障就此消弭在基操之中,然而持续不断的客户反馈让我们意识到,这个问题还没有解决,为了尽快提供稳定的服务,

本文联合作者:@敬易

问题的背景

一个平静的下午,前线同学接到有用户发出灵魂拷问:

你们这个镜像下载服务也太慢了!

我们的第一感觉是,有恶意用户在占用宝贵的服务器带宽资源,与是开始查找是否有可疑的用户在进行可疑的操作。在对几个行为异常的客户进行屏蔽操作后,整体流量下降到了有客户曝出问题之前的水平。本以为故障就此消弭在基操之中,然而持续不断的客户反馈让我们意识到,这个问题还没有解决,为了尽快提供稳定的服务,不得已选择了重启大法。。。

在初次见识到问题之后,我们大概摸清量他的几个特点:

  1. 有一些客户将流量推高后就会出现速率低的现象。
  2. 一旦速率低的现象出现,就会持续很久,及时流量恢复到打高之前的水平,也不会提升。
  3. 重启可以解决。

问题的排查过程

抓包分析初见端倪

为了搞清楚速率为什么位置在几百kb的水平,我们在一次复现的火线时刻进行了抓包,抓包的分析结果如下:

速率低的情况,我们首先想到可能是网络质量问题,不过在所有报文中仅有两个重传报文,只有一个是具备有效载荷的报文,也没有出现ack_rtt过大的情况,显然,网络质量不是造成速率低下的主要原因。

随后我们查看了吞吐量的变化,可以看出来整个网络期间的整体速率都比较稳定,稳定得比较慢,看起来是一个符合预期的情况,应该是有什么地方限制了速率,而这个限制因素和网络质量无,首先让我们想到的就是TCP的发送窗口。

我们选取了其中耗时最久的一条流,查看RWND窗口的变化趋势:

果不其然,可以看到,在客户端的38572端口到服务端443端口的方向,实际发送速率几乎和RWND窗口持平,其中RWND窗口(仔细看有一条绿色的线在顶端)在握手阶段开始就几乎没有什么变化。

随后我们查看了抓包文件中有关窗口变化的报文:

可以看到wireshark识别出了大量的TCP Windows Full信息,也就是说,有大量的发送端报文实际上是一次性就把窗口给耗尽了,这也就能解释为什么会产生较低的速率,对于客户端来说,由于每一次发送数据都会耗尽窗口,那么就必须要等待发送出去的数据被确认,才能继续发送数据,主要的瓶颈在于,由于窗口的限制,不得不控制发送的频率。

看上去异常状态下的流,他的速率较低是一个正常的现象,核心原因就是窗口较低,那么窗口较低是否是正常的呢,我们抓去了正常状态下的流进行对比:

可以发现与出现低速率现象时的窗口变化有着明显的差异:

  1. 在握手阶段结束后,窗口会随着吞吐量的变化而上升。
  2. 窗口的上限值明显比异常状态下的窗口要大很多。

不难发现,正常状态下,随着每次客户端发送数据占据窗口,RWND是会迅速升高的,但是异常情况下,RWND始终保持慢启动状态下的较低的值,引发后续的速率问题。为了找到根因,我们梳理了内核进行RWND窗口确认的逻辑。

TCP窗口增长的内核原理

tcp协议在每一个报文segment的报头中都会携带窗口信息,窗口的本质是一个tcp协议层面的通信带宽限制,主要取决于两个关键的因素:

  1. 接收数据的接收端处理数据和缓存数据的能力,处理快或者缓存空间较大,则窗口会变大,这部分被称为Receive Window, 即RWND。
  2. 网络的质量,如果网络质量较差,则窗口会降低,反之会较大,这部分被称为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的处理逻辑中可以发现,限制窗口大小的因素有以下几个:

  1. rcv_ssthresh,RWND的限制,在慢启动阶段也会通过ssthresh来控制窗口变化。
  2. full_space,节点级别的内存空间。
  3. free_space,协议级别的内存空间。
  4. 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:

  1. 正常的业务报文,即单纯的控制报文如挥手报文,ZeroWindow探测报文是不会触发窗口更新的。
  2. 报文的长度大于128(这里的128的限制其实包含了tcp的报头和options以及未来可能添加的option,实际上这个限制不是精确的)。

在上文的抓包中有很多正常的业务报文,在正常但是能否正常调整成功,有几个限制条件:

  1. window_clamp,这个是窗口的极限值,在注释中可以发现,慢启动阶段,rcv_ssthresh比window_clamp要strict很多。
  2. tcp_space(sk),socket当前剩余的buffer大小,这里的检查和前方__tcp_select_window类似,确保rcv_ssthresh不要超过socket的接收能力。
  3. 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)的方式引用这三个值组成的数组,他们的含义分别是:

  1. 第一个min值,是从pressure状态恢复的门限,当tcp的内存占用小于这个值时,如果此时处于压力状态下,会从压力状态恢复。
  2. 第二个pressure值,是进入压力状态的阈值,当内存分配处于这个状态时,如果不在压力状态,会触发内核进入压力状态。
  3. 第三个max值,这个比较好理解,当内存分配大于max的时候,无法分配socket的内存。

回到代码中tcp_memory_pressure变量的变化,可以看到他的变化有三个核心的要点:

  1. 当有socket内存申请的时候进行检查并处理置位和恢复。。
  2. 当前socket占用内存大与tcp_mem的pressure值时,进入压力状态,tcp_memory_pressure设置为1.
  3. 当前socket占用内粗小于tcp_mem的min值时,会从压力状态恢复,tcp_memory_pressure设置为0.

随即我们查看了常规状态下的镜像服务节点的TCP内存状态:

结合节点上的配置:372120  496162  744240

可以发现,在流量回落之后,TCP分配的内存总量493089正好位于min与pressure之间,这也就解释了最初的一个疑问:

  1. 常规状态下,服务的TCP内存分配位于min和pressure之间,但是由于没有超过pressure,所以窗口依然能够增长,速率较高。
  2. 出现捣蛋鬼客户一波大流量打高之后,TCP内存分配超过了pressure,进入承压状态,窗口无法增长。
  3. 捣蛋鬼客户被赶走之后,内存分配恢复之前的水平,然而依然比min要高,因此只能继续在承压状态,重启之后才恢复。

问题的背后

尽管tcp_mem这个内核参数经常被提到,但是他真正生效的内在逻辑,以及如何引发,如何解决,搜了很多网络上的文章,也没有发现描述的非常清楚的,在这个case的基础上,为了优化排查的效率,我们针对tcp_memory_pressure增加了一些监控,包括:

  1. tcp_memory_pressure,实时映射内核中的tcp_memory_pressure值。
  2. tcp_memory_pressure_level,表征tcp_memory_pressure在tcp_mem三层阶梯中所处的位置。

云原生环境下,节点级别的限制很容易产生诡异的问题,依托于kubeskoop项目,我们构建了基于大量案例的高阶指标和趁手工具,欢迎大家体验和交流!

相关实践学习
通过Ingress进行灰度发布
本场景您将运行一个简单的应用,部署一个新的应用用于新的发布,并通过Ingress能力实现灰度发布。
容器应用与集群管理
欢迎来到《容器应用与集群管理》课程,本课程是“云原生容器Clouder认证“系列中的第二阶段。课程将向您介绍与容器集群相关的概念和技术,这些概念和技术可以帮助您了解阿里云容器服务ACK/ACK Serverless的使用。同时,本课程也会向您介绍可以采取的工具、方法和可操作步骤,以帮助您了解如何基于容器服务ACK Serverless构建和管理企业级应用。 学习完本课程后,您将能够: 掌握容器集群、容器编排的基本概念 掌握Kubernetes的基础概念及核心思想 掌握阿里云容器服务ACK/ACK Serverless概念及使用方法 基于容器服务ACK Serverless搭建和管理企业级网站应用
目录
相关文章
|
9天前
|
机器学习/深度学习 数据可视化 计算机视觉
目标检测笔记(五):详细介绍并实现可视化深度学习中每层特征层的网络训练情况
这篇文章详细介绍了如何通过可视化深度学习中每层特征层来理解网络的内部运作,并使用ResNet系列网络作为例子,展示了如何在训练过程中加入代码来绘制和保存特征图。
28 1
目标检测笔记(五):详细介绍并实现可视化深度学习中每层特征层的网络训练情况
|
11天前
|
机器学习/深度学习 数据可视化 Windows
深度学习笔记(七):如何用Mxnet来将神经网络可视化
这篇文章介绍了如何使用Mxnet框架来实现神经网络的可视化,包括环境依赖的安装、具体的代码实现以及运行结果的展示。
29 0
|
9天前
|
机器学习/深度学习 编解码 算法
轻量级网络论文精度笔记(三):《Searching for MobileNetV3》
MobileNetV3是谷歌为移动设备优化的神经网络模型,通过神经架构搜索和新设计计算块提升效率和精度。它引入了h-swish激活函数和高效的分割解码器LR-ASPP,实现了移动端分类、检测和分割的最新SOTA成果。大模型在ImageNet分类上比MobileNetV2更准确,延迟降低20%;小模型准确度提升,延迟相当。
29 1
轻量级网络论文精度笔记(三):《Searching for MobileNetV3》
|
9天前
|
机器学习/深度学习 网络架构 计算机视觉
目标检测笔记(一):不同模型的网络架构介绍和代码
这篇文章介绍了ShuffleNetV2网络架构及其代码实现,包括模型结构、代码细节和不同版本的模型。ShuffleNetV2是一个高效的卷积神经网络,适用于深度学习中的目标检测任务。
37 1
目标检测笔记(一):不同模型的网络架构介绍和代码
|
9天前
|
机器学习/深度学习 数据采集 算法
目标分类笔记(一): 利用包含多个网络多种训练策略的框架来完成多目标分类任务(从数据准备到训练测试部署的完整流程)
这篇博客文章介绍了如何使用包含多个网络和多种训练策略的框架来完成多目标分类任务,涵盖了从数据准备到训练、测试和部署的完整流程,并提供了相关代码和配置文件。
20 0
目标分类笔记(一): 利用包含多个网络多种训练策略的框架来完成多目标分类任务(从数据准备到训练测试部署的完整流程)
|
9天前
|
编解码 人工智能 文件存储
轻量级网络论文精度笔记(二):《YOLOv7: Trainable bag-of-freebies sets new state-of-the-art for real-time object ..》
YOLOv7是一种新的实时目标检测器,通过引入可训练的免费技术包和优化的网络架构,显著提高了检测精度,同时减少了参数和计算量。该研究还提出了新的模型重参数化和标签分配策略,有效提升了模型性能。实验结果显示,YOLOv7在速度和准确性上超越了其他目标检测器。
25 0
轻量级网络论文精度笔记(二):《YOLOv7: Trainable bag-of-freebies sets new state-of-the-art for real-time object ..》
|
11天前
|
机器学习/深度学习 Python
深度学习笔记(九):神经网络剪枝(Neural Network Pruning)详细介绍
神经网络剪枝是一种通过移除不重要的权重来减小模型大小并提高效率的技术,同时尽量保持模型性能。
31 0
深度学习笔记(九):神经网络剪枝(Neural Network Pruning)详细介绍
|
11天前
|
机器学习/深度学习 算法 TensorFlow
深度学习笔记(五):学习率过大过小对于网络训练有何影响以及如何解决
学习率是深度学习中的关键超参数,它影响模型的训练进度和收敛性,过大或过小的学习率都会对网络训练产生负面影响,需要通过适当的设置和调整策略来优化。
87 0
深度学习笔记(五):学习率过大过小对于网络训练有何影响以及如何解决
|
11天前
|
机器学习/深度学习 算法
深度学习笔记(四):神经网络之链式法则详解
这篇文章详细解释了链式法则在神经网络优化中的作用,说明了如何通过引入中间变量简化复杂函数的微分计算,并通过实例展示了链式法则在反向传播算法中的应用。
23 0
深度学习笔记(四):神经网络之链式法则详解
|
11天前
|
机器学习/深度学习 编解码
深度学习笔记(三):神经网络之九种激活函数Sigmoid、tanh、ReLU、ReLU6、Leaky Relu、ELU、Swish、Mish、Softmax详解
本文介绍了九种常用的神经网络激活函数:Sigmoid、tanh、ReLU、ReLU6、Leaky ReLU、ELU、Swish、Mish和Softmax,包括它们的定义、图像、优缺点以及在深度学习中的应用和代码实现。
59 0
深度学习笔记(三):神经网络之九种激活函数Sigmoid、tanh、ReLU、ReLU6、Leaky Relu、ELU、Swish、Mish、Softmax详解

热门文章

最新文章