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

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

本文联合作者:@敬易

问题的背景

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

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

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

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

  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        |
  `---------------------------------------------------------------'
AI 代码解读

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;
}
AI 代码解读

在__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;
		}
	}
}
AI 代码解读

在每次正常的收取数据过程中,当满足以下条件时就会调用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),
};
AI 代码解读

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'
AI 代码解读

上面这一行命令的含义是在每次内核调用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;
}
AI 代码解读

首先我们查看了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.
AI 代码解读

在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搭建和管理企业级网站应用
目录
打赏
0
0
0
0
0
分享
相关文章
阿里云携手神州灵云打造云内网络性能监测标杆 斩获中国信通院高质量数字化转型十大案例——金保信“云内网络可观测”方案树立云原生运维新范式
2025年,金保信社保卡有限公司联合阿里云与神州灵云申报的《云内网络性能可观测解决方案》入选高质量数字化转型典型案例。该方案基于阿里云飞天企业版,融合云原生引流技术和流量“染色”专利,解决云内运维难题,实现主动预警和精准观测,将故障排查时间从数小时缩短至15分钟,助力企业降本增效,形成可跨行业复制的数字化转型方法论。
132 6
Arista cEOS 4.30.10M - 针对云原生环境设计的容器化网络操作系统
Arista cEOS 4.30.10M - 针对云原生环境设计的容器化网络操作系统
56 0
FFmpeg开发笔记(六十)使用国产的ijkplayer播放器观看网络视频
ijkplayer是由Bilibili基于FFmpeg3.4研发并开源的播放器,适用于Android和iOS,支持本地视频及网络流媒体播放。本文详细介绍如何在新版Android Studio中导入并使用ijkplayer库,包括Gradle版本及配置更新、导入编译好的so文件以及添加直播链接播放代码等步骤,帮助开发者顺利进行App调试与开发。更多FFmpeg开发知识可参考《FFmpeg开发实战:从零基础到短视频上线》。
886 2
FFmpeg开发笔记(六十)使用国产的ijkplayer播放器观看网络视频
VB6网络通信软件上位机开发,TCP网络通信,读写数据并处理,完整源码下载
本文介绍使用VB6开发网络通信上位机客户端程序,涵盖Winsock控件的引入与使用,包括连接服务端、发送数据(如通过`Winsock1.SendData`方法)及接收数据(利用`Winsock1_DataArrival`事件)。代码实现TCP网络通信,可读写并处理16进制数据,适用于自动化和工业控制领域。提供完整源码下载,适合学习VB6网络程序开发。 下载链接:[完整源码](http://xzios.cn:86/WJGL/DownLoadDetial?Id=20)
140 12
云原生应用网关进阶:阿里云网络ALB Ingress 全面增强
云原生应用网关进阶:阿里云网络ALB Ingress 全面增强
102 6
云原生应用网关进阶:阿里云网络ALB Ingress 全能增强
在过去半年,ALB Ingress Controller推出了多项高级特性,包括支持AScript自定义脚本、慢启动、连接优雅中断等功能,增强了产品的灵活性和用户体验。此外,还推出了ingress2Albconfig工具,方便用户从Nginx Ingress迁移到ALB Ingress,以及通过Webhook服务实现更智能的配置校验,减少错误配置带来的影响。在容灾部署方面,支持了多集群网关,提高了系统的高可用性和容灾能力。这些改进旨在为用户提供更强大、更安全的云原生网关解决方案。
1018 25
Golang 实现轻量、快速的基于 Reactor 模式的非阻塞 TCP 网络库
gev 是一个基于 epoll 和 kqueue 实现的高性能事件循环库,适用于 Linux 和 macOS(Windows 暂不支持)。它支持多核多线程、动态扩容的 Ring Buffer 读写缓冲区、异步读写和 SO_REUSEPORT 端口重用。gev 使用少量 goroutine,监听连接并处理读写事件。性能测试显示其在不同配置下表现优异。安装命令:`go get -u github.com/Allenxuxu/gev`。
不为人知的网络编程(十九):能Ping通,TCP就一定能连接和通信吗?
这网络层就像搭积木一样,上层协议都是基于下层协议搭出来的。不管是ping(用了ICMP协议)还是tcp本质上都是基于网络层IP协议的数据包,而到了物理层,都是二进制01串,都走网卡发出去了。 如果网络环境没发生变化,目的地又一样,那按道理说他们走的网络路径应该是一样的,什么情况下会不同呢? 我们就从路由这个话题聊起吧。
172 4
不为人知的网络编程(十九):能Ping通,TCP就一定能连接和通信吗?
TCP报文格式全解析:网络小白变高手的必读指南
本文深入解析TCP报文格式,涵盖源端口、目的端口、序号、确认序号、首部长度、标志字段、窗口大小、检验和、紧急指针及选项字段。每个字段的作用和意义详尽说明,帮助理解TCP协议如何确保可靠的数据传输,是互联网通信的基石。通过学习这些内容,读者可以更好地掌握TCP的工作原理及其在网络中的应用。
网络通信的核心选择:TCP与UDP协议深度解析
在网络通信领域,TCP(传输控制协议)和UDP(用户数据报协议)是两种基础且截然不同的传输层协议。它们各自的特点和适用场景对于网络工程师和开发者来说至关重要。本文将深入探讨TCP和UDP的核心区别,并分析它们在实际应用中的选择依据。
215 3

热门文章

最新文章

AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等