云原生网络扫雷笔记:对一次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搭建和管理企业级网站应用
目录
相关文章
|
24天前
|
Cloud Native 安全 网络安全
云计算与网络安全:技术融合与挑战云原生技术在现代软件开发中的应用
【8月更文挑战第28天】在数字时代的浪潮中,云计算和网络安全成为信息技术领域的两大支柱。本文将探讨云计算服务的分类、特点及其面临的安全威胁,分析网络安全的基本概念、重要性以及信息安全的关键要素。同时,文章将深入讨论云计算环境下的网络安全问题,包括数据保护、访问控制和合规性挑战,并提出相应的解决策略和技术措施。最后,通过一个代码示例,展示如何在云计算环境中实现基本的数据加密,以增强信息的安全性。 【8月更文挑战第28天】 随着云计算技术的飞速发展,云原生技术已成为推动软件行业创新的关键力量。本文将深入探讨云原生的核心概念、优势以及如何在现代软件开发中有效利用云原生技术。我们将通过具体案例,展示
|
8天前
|
机器学习/深度学习
小土堆-pytorch-神经网络-损失函数与反向传播_笔记
在使用损失函数时,关键在于匹配输入和输出形状。例如,在L1Loss中,输入形状中的N代表批量大小。以下是具体示例:对于相同形状的输入和目标张量,L1Loss默认计算差值并求平均;此外,均方误差(MSE)也是常用损失函数。实战中,损失函数用于计算模型输出与真实标签间的差距,并通过反向传播更新模型参数。
|
17天前
|
网络协议 C语言
C语言 网络编程(十三)并发的TCP服务端-以进程完成功能
这段代码实现了一个基于TCP协议的多进程并发服务端和客户端程序。服务端通过创建子进程来处理多个客户端连接,解决了粘包问题,并支持不定长数据传输。客户端则循环发送数据并接收服务端回传的信息,同样处理了粘包问题。程序通过自定义的数据长度前缀确保了数据的完整性和准确性。
|
17天前
|
网络协议 C语言
C语言 网络编程(十一)TCP通信创建流程---服务端
在服务器流程中,新增了绑定IP地址与端口号、建立监听队列及接受连接并创建新文件描述符等步骤。`bind`函数用于绑定IP地址与端口,`listen`函数建立监听队列并设置监听状态,`accept`函数则接受连接请求并创建新的文件描述符用于数据传输。套接字状态包括关闭(CLOSED)、同步发送(SYN-SENT)、同步接收(SYN-RECEIVE)和已建立连接(ESTABLISHED)。示例代码展示了TCP服务端程序如何初始化socket、绑定地址、监听连接请求以及接收和发送数据。
|
17天前
|
网络协议 C语言
C语言 网络编程(十四)并发的TCP服务端-以线程完成功能
这段代码实现了一个基于TCP协议的多线程服务器和客户端程序,服务器端通过为每个客户端创建独立的线程来处理并发请求,解决了粘包问题并支持不定长数据传输。服务器监听在IP地址`172.17.140.183`的`8080`端口上,接收客户端发来的数据,并将接收到的消息添加“-回传”后返回给客户端。客户端则可以循环输入并发送数据,同时接收服务器回传的信息。当输入“exit”时,客户端会结束与服务器的通信并关闭连接。
|
17天前
|
网络协议 C语言
C语言 网络编程(十二)TCP通信创建-粘包
TCP通信中的“粘包”现象指的是由于协议特性,发送方的数据包被拆分并在接收方按序组装,导致多个数据包粘连或单个数据包分割。为避免粘包,可采用定长数据包或先传送数据长度再传送数据的方式。示例代码展示了通过在发送前添加数据长度信息,并在接收时先读取长度后读取数据的具体实现方法。此方案适用于长度不固定的数据传输场景。
|
17天前
|
缓存 网络协议 网络性能优化
C语言 网络编程(二)TCP 协议
TCP(传输控制协议)是一种面向连接、可靠的传输层协议,通过校验和、序列号、确认应答等机制确保数据完整性和可靠性。通信双方需先建立连接,再进行通信,采用三次握手建立连接,四次挥手断开连接。TCP支持任意字节长度的数据传输,具备超时重传、流量控制及拥塞控制机制。三次握手用于同步序列号和确认双方通信能力,四次挥手则确保双方均能完成连接关闭操作,保证数据传输的可靠性。
|
17天前
|
网络协议 C语言
C语言 网络编程(十)TCP通信创建流程---客户端
在TCP通信中,客户端需通过一系列步骤与服务器建立连接并进行数据传输。首先使用 `socket()` 函数创建一个流式套接字,然后通过 `connect()` 函数连接服务器。连接成功后,可以使用 `send()` 和 `recv()` 函数进行数据发送和接收。最后展示了一个完整的客户端示例代码,实现了与服务器的通信过程。
|
24天前
|
安全 网络安全 数据安全/隐私保护
云原生技术探索:容器化与微服务架构的实践之路网络安全与信息安全:保护数据的关键策略
【8月更文挑战第28天】本文将深入探讨云原生技术的核心概念,包括容器化和微服务架构。我们将通过实际案例和代码示例,展示如何在云平台上实现高效的应用部署和管理。文章不仅提供理论知识,还包含实操指南,帮助开发者理解并应用这些前沿技术。 【8月更文挑战第28天】在数字化时代,网络安全和信息安全是保护个人和企业数据的前线防御。本文将探讨网络安全漏洞的成因、加密技术的应用以及提升安全意识的重要性。文章旨在通过分析网络安全的薄弱环节,介绍如何利用加密技术和提高用户警觉性来构建更为坚固的数据保护屏障。
|
14天前
|
网络协议
网络协议概览:HTTP、UDP、TCP与IP
理解这些基本的网络协议对于任何网络专业人员都是至关重要的,它们不仅是网络通信的基础,也是构建更复杂网络服务和应用的基石。网络技术的不断发展可能会带来新的协议和标准,但这些基本协议的核心概念和原理将继续是理解和创新网络技术的关键。
31 0