Kubernetes生产化实践之路
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.6 节点调优

前面章节重点讲述了Kubenetes 管理节点CPU、内存、磁盘资源的方式。如果想让节点资源工作在最优状态,则需要对相关资源的参数进行调优。

节点的调优是一个系统工程,可以基于应用类型、硬件类型、资源占用情况、管理需要等综合因素做出调优选择,其覆盖点广、覆盖参数多。因此,在谈论调优的时候不可能面面俱到地覆盖所有情况和所有参数。在此针对常见的调优情况进行描述,希望能启发读者的调优思路,在节点遇到性能问题的时候,能够有针对性地进行调优。

2.6.1 NUMA 架构

NUMA(Non-Uniform Memory Access)是一种内存访问方式,是为多处理器计算机设计的内存架构。在NUMA 架构下,一个计算节点会划分为多个NUMA 节点,每个NUMA节点包括CPU 和其所属的本地内存。

在不同的体系架构下,NUMA 节点的设计思路相同。图2-29 展示了x86 架构下的NUMA 架构。处理器通过Front Side Bus(FSB)访问本地内存,如果需要访问其他NUMA节点的内存,则还需通过Intel 的 QuickPath Interconnect(QPI)进行访问,因此访问本地内存比访问非本地内存有更高的效率。在支持NUMA 的计算节点中,让处理器总是访问本地内存,是一个性能优化的关键手段。

img

图2-29 NUMA 架构

2.6.2 CPU 性能

2.6.2.1 CPU 工作模式

CPU 的硬件可以工作在不同的频率下。根据不同的负载情况,CPU 的工作频率可以动态调整,按需提高性能或者降低功耗。我们可以通过CPU 的scaling_governor 设置CPU工作频率的调整方式,如表2-18 所示。

表2-18 CPU 的工作模式

img

在动态调整频率的ondemand 和conservative 模式下,尽管可以兼顾性能和功耗,但是频率切换带来的开销也不容忽视,因此可以对governor 进行动态配置,比如在流量高峰时间段开启performance 模式,而在其他时间段采用ondemand 或conservative 模式。

在负载比较低的时候,CPU 可以降低频率运行。在CPU 完全空闲时,系统会通过修改CPU 的C-State 值来改变CPU 的电源状态,即让空闲的CPU 完全关闭,进入相应的节能工作模式。C-State 用来表示CPU 的电源状态,包括C0、C1、C2、C6 等多种状态。C0是正常的工作状态,而其他的工作状态则对应不同的节能模式。不同的节能模式的省电和唤醒方式是不同的,因此当CPU 被唤醒时,回到工作状态的时间也不同,该转换过程会带来一定的延时。很多时候为了让CPU 不进入节能模式,减少工作模式切换带来的延时,我们会将CPU 的节能模式功能关闭。但是这样会影响CPU 的TurboBust,使CPU 无法工作在最高的频率,提高功耗却并不一定会带来收益,因此并不建议将节能模式关闭。

2.6.2.2 CPU 绑定

在多核处理器中,每个核都有专属的缓存,而数据一次只能保留在一个CPU 的缓存中,这样可以有效地保证不同核数据的一致性。当数据被导入一个CPU 的缓存时,其他CPU 中该数据的缓存都会失效。如果进程/线程在多个核间频繁切换,会导致缓存经常失效,进而对性能产生影响。

另外,在NUMA 架构中,进程/线程可能会被调度到不同NUMA 节点的核上,通过远程跨NUMA 节点来访问数据。因此,应用程序在访问数据时会消耗更多的时间。

为了减少进程/线程在核间切换带来的开销,Linux 调度器尽可能将进程/线程保持在一个核上。但是在系统CPU 比较繁忙的时候,无法继续给进程/线程分配执行时间,该进程/线程会被调度到其他核上。而通过CPU PIN 的方式,可以将进程/线程限定在某个或者某组特定的核上运行。

CPU PIN 并不能保证其他的进程/线程不被调度到绑定的核上,因此可以通过cpuset CGroup 将核设置为只能被绑定的进程/线程使用,从而有效地提高性能。

Kubernetes 在1.12 版本中引入了CPU Manager 的特性,可以为容器进行CPU 绑定并且进行CPU 资源独占。不过只适用于申请的CPU 资源是整数个并且是Guaranteed 类型的Pod,并不适合BestEffort 和Burstable 类型的Pod。

2.6.2.3 中断处理

在Linux 系统中,硬中断和软中断都可以打断当前程序的执行,如果中断数量比较多,系统的性能会大幅下降。因此,可以通过减少中断的处理时间或者合并中断、减少中断数量的方式来提高性能。

如果进程/线程绑定的核可以和中断处理的核相同,那么中断可以和进程/线程共享Cache Lines。在容器频繁创建的应用节点上,动态地将进程/线程和中断处理的核进行绑定设置的管理复杂度很高,因此很少采用。

系统通过平衡每个核的中断来提高对中断的处理效率,具体体现在以下两方面:

● Irqbalance 服务通过收集系统数据(例如CPU 的负载等信息)和修改中断对CPU的亲和性来平和中断,将中断合理地分配到各个CPU,以充分利用CPU 而避免导致某些核的中断过高。

● 除Irqbalance 服务外,还可以根据硬件情况,通过手工配置CPU 核中断的亲和性来平衡中断,比如用不同的核处理特定网卡队列的中断。

减少中断可以减少操作系统花在中断现场保护和现场恢复的时间。网络中断一般是系统上最多的硬件中断来源,为提高网络处理效率,网卡驱动引入了NAPI 技术。它不采用中断的方式读取数据,而是首先利用中断唤醒数据接收的服务程序,然后通过POLL 的方法轮询来接收数据。目前常见的网卡驱动基本都集成了NAPI 功能。

2.6.3 内存

在Linux 系统中以页为单位来分配和管理内存,每个页的大小默认是4KB。为了提高对物理内存的访问速度,也可以将页分为2MB 或者1GB 的大页(Huge Page)来进行管理。

对于不同的硬件,其使用内存页的处理方式也不同,所以内核将属性相同的页划分到同一个zone 中。zone 的划分与硬件相关,所以不同的处理器的架构可能是不一样的。

通常在64 位x86 处理器上,典型的zone 分配有DMA Zone、DMA32 Zone 和Normal Zone,每个zone 可以属于不同的NUMA 节点。具体的zone 信息可以从/proc/zoneinfo 下查看。

每个zone 都有相应的内存页,zone 与zone 之间没有任何的物理分割,它只是Linux为了便于管理进行的一种逻辑意义上的划分。在设计上,位于低地址端的zone,会给高地址端的zone 预留一定比例的页。当地址的zone 内存充足的时候,高地址端的zone 可以向低地址端的zone 申请预留的内存页。系统通过配置/proc/sys/vm/lowmem_reserve_ratio 的参数来指定低地址端的zone 对高地址端的zone 的预留比例。

内核的内存管理非常复杂,其中内存回收是一个重点内容。内存的回收相对于内存的分配而言,更容易引起用户程序的异常,却不容易在两者之间找到直接的关联证据,因此内存的回收是引起应用程序出问题的重要因素之一。而对于内存的调优,大部分时候也会聚焦于这个方面。

内存的回收包括匿名内存(Anonymous Pages)的回收和页缓存(Page caches)的回收。匿名内存没有对应的后备文件,回收时需要借助swap 机制将数据缓存到磁盘中,产生swap out,然后在需要时从磁盘导入内存页中。如果没有开启swap,可能会产生匿名内存无法回收的情况。对于页缓存的回收,如果不是被修改过的脏页,则可以直接从内存中回收;如果是脏页,则需要通过write back 机制将数据写入磁盘后再进行回收。

内存的使用状态直接决定了内存应该以何种方式被回收。在内核中,使用high、low、min 三条水线(Watermark)来衡量内存zone 的使用状态。

如图2-30 所示,内存的每个zone 都具有high、low、min 三条水线。zone 的可用空闲内存页为zone 的空余内存页减去给高端地址预留的部分。当可用的空闲内存页低于“low” 水线但高于 “min” 水线时,说明内存的使用率比较高,需要进行内存回收。在内存页分配完成后,系统的kswapd 进程将被唤醒,以执行内存回收操作。当可用空余内存回到high的水线后,内存回收就完成了。低地址端的zone 的可用空余内存页只有在高水线之上,才可以向高地址端的zone 提供内存页。

img

图2-30 内存水线

当可用的空闲内存低于min 水线时,说明当前的内存使用量已经过载。如果应用程序申请分配内存,则会触发内存的直接回收操作,内存分配需要等待直接回收完成后才继续进行。很多系统进程,特别是与内存回收直接相关的进程(如kswapd),在该情况下还要保证能够正常工作,因此需要带上PF_MALLOC 的标记位,这样可以不受水线的限制而正常分配内存。

默认情况下,系统总的min 值通过/proc/sys/vm/min_free_kbytes 来设置。该值与系统内总的内存值相关。根据系统总的min 值,通过zone 空间占有的比例来分配相应的min水线、low 水线(默认是min 水线的1.25 倍)和high 水线(默认是min 水线的1.5 倍),用户可以通过/proc/zoneinfo 来查看详细的水线信息和可用页信息。

从内存回收的行为来看,内存在配置时要尽量避免空闲可用内存页到达直接回收的水线。在大规模申请内存(比如接收大量的网络数据)的场景中,内存的回收速度跟不上内存的分配速度,那么内存水线很可能会低于min 水线。另外,内核在给网络数据包分配的时候,会带上PF_MALLOC 的标记位,TCP 协议栈在处理该类型的数据时,会根据socket的类型判断并进行是否丢包及释放skb 的操作。当可使用的空闲内存处在min 水线下时,可能会因为流量的突发而瞬间消耗掉系统的可用内存,导致内存回收或者其他系统服务无法正常工作。

随着网卡速率的不断提高,该问题愈发明显。解决方法是通过/proc/ sys/vm/watermark_ scale_factor 来配置不同水线直接的差值,让kswapd 每次可以回收更多的内存,同时通过配置/proc/sys/vm/min_free_kbytes 来抬高min 水线,防止在极端情况下,系统没有内存可以使用而造成异常。

在内存配置上,参数vm.dirty_ratio 和vm.dirty_background_ratio 同样值得关注。用户可以在节点上通过读取/proc/sys/vm/dirty_ratio 和/proc/sys/vm/dirty_background_ratio 来查看当前的系统配置。

vm.dirty_ratio 参数指定了当文件缓存被修改、页(脏页)数量达到系统内存一定比例的时候, 系统开始将修改的页通过 pdflush 回写到磁盘, 默认比例是 20% ;vm.dirty_background_ratio 参数指定了当文件缓存被修改、页(脏页)数量达到系统内存的一定比例的时候,将一定缓存的脏页异步回写到磁盘,默认比例是10%。

前者是同步操作,因此会阻塞进程。另外,瞬间大量的回写操作页可能会导致磁盘的I/O 过高。为了防止出现这样的情况,可以将vm.dirty_background_ratio 的值调低,使脏页尽快回写到磁盘,避免磁盘I/O 突发或脏页累积过多。

2.6.4 磁盘

对于磁盘的I/O 操作,都需要经过系统的I/O 调度层进行调度。而对于磁盘的调优,主要是对磁盘I/O 调度算法的选择。应用的类型不同,其磁盘对I/O 调度的要求也不同。I/O 调度算法是先将I/O 进行相应的合并和排序,再将数据写入磁盘,减少磁盘的寻道时间,以高效利用磁盘带宽,提高读写速率。常见的磁盘I/O 调度算法有noop、deadline 和CFQ。

1.noop

noop 算法实现了最简单的FIFO 队列,所有的I/O 请求都按照先进先出的顺序进行处理。对新来的请求也会尝试进行I/O 合并。该算法适用于不希望调度器根据扇区号来重新组织I/O 请求顺序的场景,例如:

● 可以自行调度I/O 的底层设备,例如很多块设备——NAS、智能RAID 等,因为在主机上做I/O 排序会浪费CPU 的时间。

● 在具有RAID 的场合,扇区的准确信息对主机进行隐藏,使得主机不能有效地对I/O 请求地址进行排序,以优化磁盘的寻道时间。

● 读写的扇区移动造成的性能影响很小,例如SSD 或者NVME 的磁盘。

2.deadline

deadline 算法对到达I/O 调度器的I/O 请求提供时延保证。在实现上,deadline 算法引入了四个队列,分别是按照扇区序号排序的读、写队列,以及按照请求时间排序的读、写队列。

多队列的设置可以让读写优先级分离,让读操作具有比写操作更高的优先级,因为应用主要会被读操作阻塞,而写操作一般写入缓存即可。当I/O 请求经过合并操作,并排序进入相应的队列后,需要根据读写的优先级和请求是否达到deadline 时间,来调度执行相应的请求。在处理顺序读写的基础上,优先处理即将达到deadline 的请求。

3.CFQ

CFQ(Completely Fair Queuing,即完全公平队列算法)为竞争磁盘设备的每个进程单独创建一个队列来管理该进程所产生的请求,各队列之间使用时间片进行调度。CFQ 将所有进程都归类于不同的三种优先级:RT(real time)、BE(best try)、IDLE(idle)。RT 的优先级比BE 高,BE 的优先级比IDLE 高。因此,高优先级的请求如果比较多,就会阻塞低优先级进程的I/O 调度。默认情况下,进程都属于BE 优先级。

每个进程的时间片和队列长度都取决于进程的I/O 优先级。在调度器上,所有的同步请求(read 或syn write)都会放入进程的请求队列中,而所有的异步请求都会放入一个公共的队列中,因为调度器无法获知当前异步请求的进程。

磁盘调度算法的选择取决于硬件和应用类型。CFQ 从进程的角度出发,尽量保证不同优先级进程I/O 的公平性,但是可能会导致低优先级的进程一直分配不到I/O 执行时间。deadline 可以避免这种情况,却无法兼顾公平性,所以比较适合业务单一并且I/O 多的应用场景,比如数据库业务。相对来说,noop 算法可以减少I/O 调度上的时间消耗,但是应用的场景有限。

2.6.5 网络性能

2.6.5.1 数据包处理流程

理解Linux 操作系统处理数据包的机制是网络优化的第一步。Linux 操作系统分为内核态和用户态:内核态网络协议栈处理数据,用户态应用消费数据。再加上网卡驱动的介入,Linux 系统处理数据包的大致流程如图2-31 所示。

img

图2-31 Linux 系统处理数据包的完整流程

下面详细描述一下Linux 系统处理数据包的流程。

(1)网卡接收数据包后,首先需要做数据包校验,比如判断该数据包的目标地址是否与网卡地址匹配,数据包是否完整等。

(2)数据包校验完成后,通过直接内存访问(DMA)和网卡驱动将数据直接写入系统内存。DMA 内存地址由网卡驱动初始化时的分配,DMA 的内存写入由网卡独立完成,无须CPU 介入。

(3)网卡发起硬中断,通知CPU 有数据被接收。

(4)系统查询中断表,调用中断处理函数。Linux 中断处理函数分为两个部分,上半段(Top Half)和下半段(Bottom Half)。中断处理函数执行时,CPU 无法响应其他中断,如果中断处理时间较长,那么在此期间其他中断请求无法被响应。因此上半段时间应该尽快结束,以便释放CPU 处理更多中断事件。下半段可以通过softirq、tasklet 等多种方式实现,可以被异步调度。针对数据处理场景,中断处理程序的上半段调用网卡驱动处理数据,系统会在内核进程ksoftirqd 或者硬中断退出之前调用do_softirq()等方式处理中断的下半段。

(5)网卡驱动禁用网卡中断,以避免网卡反复发起中断,浪费CPU 时间。

(6)网卡驱动发起软中断,至此,硬中断处理函数结束,CPU 可以重新响应硬中断。

(7)系统进行软中断的处理,调用相应的中断处理函数net_rx_action()。

(8)net_rx_action 从DMA 内存中读取数据包,并构建数据结构skb_buf,然后调用napi_gro_receive 函数。

(9)napi_gro_receive 将所有可以合并的数据包进行合并,以减少协议栈的处理开销。

(10)napi_gro_receive 会直接调用__netif_receive_skb_core。

(11)调用协议栈相应的函数,将数据包交给协议栈处理,协议栈处理数据时,只需修改skb_buff 中的数据包头。

(12)待内存中的所有数据包处理完成,或者执行poll 的配额完成后,启用网卡的硬中断。Linux 会分配特定的CPU 处理网卡中断,在互联网兴起的初期,对网络的依赖还较小,网卡多是单队列网卡。网卡收到数据后,所有中断请求都发送至一个CPU 核。这种配置在节点接收的数据包较少时没有问题,但随着接收的数据量增长,CPU 处理中断的开销会越来越大。这带来的后果是CPU 过载而导致数据无法被及时处理,甚至被丢弃。丢包意味着数据包需要重传,传输速度变慢,甚至传输失败。通过mpstat 命令能查看系统用于处理irq 的CPU 开销,如果大部分CPU 用来响应中断,只剩非常少的空闲CPU,例如5%,那么会有非常大的概率出现因不能及时处理而导致的丢包现象。

随着网络技术的不断发展,依赖网络传输的应用越来越多,硬件性能不断提升,单个节点能处理的数据量越来越大,单队列网卡无法继续满足当前的网络传输需求。业界不断探索和提升数据传输效率的方案,将单队列变成多队列是一个最直接的方案,该方案得到了众多硬件厂商和现代化操作系统的支持。

2.6.5.2 网卡offload

为提高对网络数据的处理,网卡上集成了很多硬件功能来处理特定的网络数据包。将原来需要消耗软件操作的步骤分配给网卡执行,从而减少CPU 的处理时间,增加网络吞吐率,该行为称为offload。网卡offload 有以下三个常见的功能:

1.TCP/IP 头部校验和的计算和校验

在网络协议栈接收或者发送的时候,需要计算TCP 和IP 头部的校验和。IP 头部的校验和需要计算IP 头部,而TCP 的校验和计算需要包含TCP 头部和TCP 数据段,因此需要进行多次计算。如果这些计算通过CPU 来完成,则需要消耗很多CPU。目前市面上常见的网卡,都具有计算TCP 和IP 头部校验和的功能,用户可以通过将网卡的该功能打开来减少CPU 计算校验和的负担,提高协议栈的性能。

2.TSO/GSO

在TCP 协议的握手阶段,需要协商双方数据发送的MSS,也就是最大的分段大小。在数据从TCP 层发送到IP 层之前,需要基于MSS 进行数据分割。TSO(TCP Segment Offload)开启后,TCP 的分段通过网卡来完成。

当TSO 开启后,GSO(Generic Segmentation Offload)会自动开启。数据包在出节点之前可能会经过多个端口的处理。GSO 把对数据的分片操作尽可能地推迟到在数据发送给物理网卡驱动之前,检查网卡是否支持TSO 机制,再决定是否将数据直接发给网卡,或者分片后再发给网卡,以此来保证协议栈处理的次数最少,从而提高数据传输和处理的效率。

3.GRO

GRO(Generic Receive Offload)工作在接收端,将接收的TCP 数据进行合并后再发送给协议栈,以减少协议栈处理数据的压力。GRO 目前只在开启了NAPI 的驱动上实现,因为开启了NAPI 后,驱动可以一次处理多个数据包。

2.6.5.3 网卡多队列RSS

Receive Side Scaling(RSS)是指网卡接收数据包后,利用多CPU 处理数据包的技术将不同的数据包发送至不同的接收队列。网卡根据数据包头将它们归并为不同的数据流,同一数据流的所有数据包会被分发至特定的接收队列,不同接收队列的数据包被不同CPU处理。在数据包较多时,多CPU 同时处理数据包的机制保证了数据传输的性能不会因为单个CPU 过忙而显著降低。

支持多队列的网卡驱动通常提供一个内核模块参数来配置硬件队列数量。通常在RSS设备驱动初始化时, 会创建一个用于处理数据包的、 基于哈希算法的间接转发表(Indirection Table)。哈希算法默认将接收队列平均分配到转发表的不同的队列中,转发表可以使用ethtool 命令动态修改队列的不同权重。

RSS 需要网卡和网卡驱动的支持,如图2-32 所示。

img

图2-32 RSS 接收队列

多队列网卡通过如下步骤将数据转发至多队列,以交由多CPU 处理。

(1)网卡解析接收数据包包头,包括IP 地址、端口等信息。

(2)网卡通过直接内存访问将数据放入内存中。

(3)网卡基于数据包头N 元组信息(IP 地址、端口、协议等)计算哈希值,并基于哈希值计算其对应的CPU 序号,数据包会被发送至对应CPU 的接收队列。

(4)每个接收队列对应一个独立的硬件中断信号,不同队列的中断请求发送至不同的CPU。

现在大多数网卡都支持RSS,不同厂商、型号的网卡支持不同数量的接收队列。以英特尔82599 网卡为例,网卡驱动利用数据包N 元组通过如下方式计算出RSS 序号:

● 解析接收数据包头的N 元组信息来计算哈希值,32 位的哈希计算结果会被写入数据包描述符中。哈希计算默认使用Toeplitz 算法,该算法通过矩阵相乘计算哈希值,并且当哈希元素位置前后互换时,哈希结果一致,通过此哈希算法可以确保入站和出站流量的哈希值一致。

● 该哈希结果的七个LSB 位(也就是每个字节的最低位的二进制值)会用来索引128个Redirection Table(重定向表)的成员,该表中的每个成员提供了一个4 位的RSS 输出索引。

RSS 哈希计算过程如图2-33 所示。

img

图2-33 RSS 哈希计算

当追求数据转发效率,特别是当接收中断处理成为系统瓶颈时,就需要考虑启用RSS了。最高效的配置是启用多个接收队列,以避免单队列场景下数据包较多时,CPU 因忙于处理中断而无法及时处理数据,导致接收队列溢出、数据包被丢弃的情况发生。

2.6.5.4 网卡流管理Flow Director

RSS 将处理数据的压力分散到多个CPU,多个接收队列使得在接收数据较多时系统的整体处理效果不会受到影响。但RSS 存在一个不可忽视的问题:网卡依据数据包头的N元组进行哈希,哈希计算的结果决定了其被分配至哪个CPU。这使得CPU 分配几乎是随机的,一个数据包很可能被放入队列1,在内核空间被第一个CPU 处理数据,然而在用户空间中消费此数据的应用进程则运行在第二个CPU 上。

数据包被放入队列并触发中断,CPU1 被唤醒并处理数据。当网络协议栈处理完skb后,CPU1 触发IPI(Inter-Processor Interrupts)操作,并通知CPU2 将数据库拷贝至用户空间,这将引入一次唤醒CPU 的开销。并且,当CPU1 在内核空间处理该数据包时,CPU1已经有了该数据的缓存信息。而应用不在CPU1 上,因此CPU1 的缓存数据就完全浪费了。

以上情况中,最理想的情况是应用程序运行在哪个CPU,就在该CPU 上对数据进行网络协议处理,以节省开销。为此,目前的网卡支持按数据流(Traffic Flow)传输的功能。数据流是指N 元组相同的一组相关数据包,比如一个TCP 连接的所有数据包。

以英特尔网卡为例,英特尔网卡支持Flow Director。该功能由英特尔网卡驱动支持,在网卡中开辟了一块内存空间来存放接收哈希表,该表用来保存数据流与CPU 序号的对应关系。该功能可以将属于一个数据流的所有数据包转至与处理该数据流最相关的CPU 对应的接收队列中。

如图2-34 所示,在Flow Director 工作模式下,网卡的接收队列与系统的CPU 核数相同,网卡默认工作在应用目标接收模式(Application Targeted Receive)下。当网卡第一次接收N 元组数据包时,它以常规的RSS 哈希算法将数据包转至不同的RSS 接收队列中,再交由对应的CPU 处理。当应用程序处理完请求发送的响应包时,用户空间处理数据流的CPU 和数据流之间的对应关系会被更新至接收哈希表。当网卡处理相同N 元组的后续请求时,会先查询接收哈希表,如果该表中存在数据流和CPU 核的对应关系,则该数据包会被直接放入CPU 对应的接收队列中。通过此机制,该数据流的所有请求都会被发送至消耗该数据流的应用进程所对应的CPU 接收队列中。

img

图2-34 Flow Director 工作模式

Flow Director 虽然能高效地处理数据传输,但其存在潜在的包乱序问题。Linux 系统中,当进程被分配到某个CPU 以后,只要该CPU 不过载,进程就会一直被同一CPU 处理,在此场景下Flow Director 总能按预期工作。然而,当某个CPU 压力过大时,操作系统会按既定算法将该CPU 中的部分进程迁移至相对空闲的CPU,这可能会导致Flow Driector处理数据时出现问题。

如图2-35 所示,在T0 时间接收哈希表中的Flow1 对应CPU0,此时内核协议栈中已接收的数据由CPU0 进行处理。如果此时CPU0 的负载突然增加,操作系统将进程从CPU0迁移至CPU1,那么该进程响应包被发送回客户端的同时,接收哈希表中的Flow1 对应的CPU 会被更新为CPU1。进而,当网卡在时间T1 接收该流的更多数据包时,通过查询接收哈希表,数据包会被转发至CPU1 对应的接收队列中。而此时CPU0 对应的队列中还可能有该流未处理完的数据,这就导致两个接收队列中可能包含相同流的数据。由于不同队列处理的CPU 不同,系统无法保证并发处理数据包的先后顺序,所以很可能出现数据包乱序。此外,TCP 是按顺序处理数据包的,当出现比较严重的数据包乱序时,就可能出现重排序失败进而引发大量重传,严重影响数据传输的性能。

img

图2-35 进程迁移对Flow Director 的影响

开启Flow Director 要求进程不能频繁迁移,在传统的非云平台的应用部署中,可以通过系统配置将应用绑定在某个CPU 核上,于是即使CPU 负载高,进程也不会迁移。绑定CPU 和Flow Director 配合使用可以在极大程度上提升系统处理数据的效率,有效利用CPU缓存。但在Kubernetes 框架下的容器世界,Pod 是动态调度的,资源是支持超售的,这给绑定CPU 带来了诸多限制。因此,在无法将消费数据的应用进程绑定到固定CPU 时,启用网卡Flow Director 功能需要限制CPU 开销,防止因CPU 过载而导致应用频繁迁移。

2.6.5.5 软件多队列RPS

Receive Packet Steering (RPS) 是RSS 的软件实现。如图2-36 所示,RSS 通过接收数据包的N 元组哈希选择接收队列,并且通过硬中断唤醒CPU 处理数据包。RPS 与之类似,通过计算接收数据包的N 元组哈希,将数据包发送至不同CPU 的积压队列(Backlog Queue)中,并通过软中断唤醒CPU 处理数据。与RSS 相比,RPS 有如下优势:

(1)由操作系统支持,无须网卡支持。

(2)利用软件处理数据包,因此处理数据的逻辑很容易定制化。

(3)不增加硬中断频率,只增加IPI。

img

图2-36 开启RPS 后的数据包处理

当网卡接收数据包时,首先向处理中断CPU 发起硬中断,唤醒CPU 处理数据包。RPS是中断处理 Handler 的下半段执行的, 当网卡驱动将一个数据包通过 netif_rx()或netif_receive_skb()函数发送至网络协议栈时,会调用get_rps_cpu()函数为数据包选择接收队列。

选择接收队列的方法是基于数据包的N 元组信息计算数据流哈希,某些网卡会复用接收数据包描述符中的由网卡计算出来的RSS 哈希值。此值被保存在skb 中,并且在协议栈中当作数据流哈希值使用。

每个硬件接收队列维护一个对应的CPU 列表,列表中的每个CPU 对应一个数据处理队列。针对每个接收数据,哈希模块会依据数据流的哈希值计算出CPU 列表中的索引值,该索引对应的CPU 就是处理此数据流中数据包的CPU,数据包会被加入该CPU 的backlog队列中。在中断的下半段中处理硬中断的CPU,需要向所有与有数据的backlog 队列相对应的CPU 发起IPI,唤醒对应的CPU 处理数据。

下面的文件保存着网络设备接收队列对应的CPU 序号:

img

RPS 通常用于单队列设备。如果硬件设备支持多队列,但支持的队列数量小于CPU数,那么当每个队列的rps_cpus 与该队列处理中断的CPU 属于同一个内存域的时候,RPS的开启是有收益的。

但开启RPS 同时带来了额外开销,因为RPS 引入了额外的IPI。启用RPS 在大部分情况下有助于提升处理数据包的效率,但在某些极端场景下并无显著作用。比如一个应用接收的所有数据都来自一个流,那么很可能导致该节点只有一个CPU 处于高负载状态,而其他CPU 空闲,因此是否开启RPS 并无显著区别。

对于单队列网卡,其典型配置是将rps_cpus 设置为与中断CPU 在同一个NUMA 节点的CPU。如果计算节点不是NUMA 节点,则可以将rps_cpus 设置为当前节点的所有CPU。当数据传输量较大时,建议将中断CPU 排除在rps_cpus 之外,以防止其因同时承担处理硬中断和处理协议栈数据而过载。

对于多队列网卡,如果RSS 接收队列与CPU 数量一致,则RPS 的配置无须开启。如果网卡支持的队列数小于 CPU 数,那么可以为每个接收队列配置 rps_cpus,让同一个NUMA 节点的更多CPU 处理和接收数据。

在Kubernetes 集群中,容器的网卡通常是虚拟网卡(Veth Pair)。这是一个单队列虚拟设备,因此在处理数据时,默认只有一个CPU 介入。如果容器进程提供高并发网络服务,那么虚拟网卡很容易成为系统瓶颈。于是,针对虚拟网卡开启RPS,将网络流量分散到不同的核来处理,有助于提高容器进程数据的传输效率。

2.6.5.6 RFS

就如同RPS 是RSS 的软件实现,RFS(Receive Flow Steering)是网卡流管理技术的软件实现。

开启RFS 后,数据包并非按照N 元组哈希值直接转发,而是被当作流哈希表的索引。流哈希表记录数据流与处理该数据流的CPU 的对应关系,该表中的CPU 信息是最后一个处理该数据流的CPU 号。如果记录的CPU 序号无效,则数据包依据RPS 规则处理。

rps_sock_flow_table 是内核维护的一个全局流表,它包含数据流期望的处理CPU,该CPU 是用户空间正在处理数据流的CPU 序号,该表中的值会在调用recvmsg 和sendmsg时被更新。

当系统进程调度器将进程从一个CPU 迁移至另一个CPU 时,很可能在旧的CPU 处理队列中还有很多尚未处理的数据,这可能会导致数据包乱序。为了避免此情况发生,RFS引入了第二个流表,记录每个流的待处理数据,即rps_dev_flow_table。其存储了CPU 序号和一个计数器,序号记录的是内核当前处理某个数据流的CPU,计数器记录了当前CPU的backlog 队列的长度。

在理想情况下,内核态处理数据网络协议栈的CPU 和用户态接收数据的CPU 是同一个。如果进程调度器在用户态将进程从一个CPU 迁移至另一个CPU 时,旧的CPU 还有未处理的数据,那么两个表中同一数据流的CPU 序号就会不同。如果系统不做任何处理,任由同一数据流的数据包交由两个CPU 队列处理,那么就会出现与Flow Director 一样的包乱序问题。

为避免包乱序, 当选择处理数据的 CPU 时, 系统会比较 rps_sock_flow 表和rps_dev_flow 表,如果期望CPU 与当前CPU 相同,则数据包会被放入该CPU 对应的backlog队列;如果两者不相同,则只有满足如下条件时,当前CPU 才会被更新为期望CPU:

(1)当前CPU 队列头计数器值 >= rps_dev_flow[i]的尾计数器值,也就是说,只要当前处理队列中的数据尚未完全处理,就不更新期望CPU,依然用迁移发生以前的老CPU处理数据包。

(2)当前CPU 未设置值。

(3)当前CPU 处于离线状态。

RFS 默认配置下不开启,如果需要开启 RFS,需要编辑两个文件。第一个文件是/proc/sys/net/core/rps_sock_flow_entries,该文件配置期望最大的并发数据流。对于一般的商用服务器,建议将该值设置为32768。该值应为2 的N 次方。第二个文件是/sys/class/net/ [dev]/queues/[rx-queue]/rps_flow_cnt,该文件配置每个设备的每个接收队列支持的最大并发数据流。该值应设置为 rps_sock_flow_entries 除以该节点配置的数据队列数。例如,rps_sock_flow_entries 为32768,当前节点总共配置了16 个接收队列,那么rps_flow_cnt的值应为32768/16=2048。单队列系统中rps_flow_cnt 应与rps_sock_flow_entries 设置相同的值。

RFS 的优势是不会产生乱序,对Kubernetes 集群随机调度和无CPU 绑定的应用场景也适用。

2.6.5.7 滑动窗口

传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF 的RFC 793 定义。其可靠性来源于控制二字,TCP协议通过通信双方的协商和一系列控制规则来实现数据的高效传输。

当客户端和服务器端应用进行网络通信时,应用程序只负责组装请求包或者响应包并发送,应用层的数据包的大小不受限制,它也无须关心数据如何传输,传输控制是交由下层协议栈处理的。然而下层协议栈传输数据时,是不可能将应用层数据包直接传输的。这是因为网络传输的带宽限制和网络的不可靠性,若将大数据包作为一个整体传输,则出错重试的开销过大,因此将大数据包拆分成小的碎片,分批传输是明智之举。这样即使某个碎片传输失败,也只需重新传输该碎片即可。

事实上,任何基于网络传输的数据包有最大传输单元的限制(Maximum Transmission Unit,即MTU),MTU 是指在网络层传输的最大数据报单元,MTU 的大小通常由链路层设备决定。比如,最常见的以太网设备帧的大小是1518 字节,去掉链路层包头,IP 层最多只能使用1500 字节,这就是MTU 的默认限制。

应用层组装的大数据包会被切分成多个数据包,传输层需要控制如何将这些数据包发送出去。理想状态下,一次性将所有数据包发送出去是最高效的,但事实上网络传输有延时和带宽限制,也有出错的可能。数据送达对端以后,还会有内核处理数据及应用消费数据的过程,而数据若无法被及时处理和消费,都会导致数据被丢弃。

数据传输的两个重要目标是可靠和高效,为实现这两个目标,TCP 引入了数据包序号、应答(Acknowledge,即ACK)机制和窗口机制。

TCP 会将缓冲区中的待发送数据包按顺序编号,并按序号发送数据,然后暂停传输,等待接收方确认。

为控制传输速率,操作系统网络协议栈在数据发送方维护发送缓冲区,在接收方维护接收缓冲区。TCP 引入滑动窗口(Sliding Window)机制实现数据传输的流量控制。滑动窗口的大小在通信双方建立连接时协商确定,并且在通信过程中不断更新,故取名为滑动窗口。它本质上是描述接收方数据缓冲区大小的数据,发送方根据接收方窗口的大小计算能够同时发送的数据包数量。

如果接收方接收一个数据分段,就会将该分段的序列号加上数据字节长的值,作为分段确认的确认号发送回去,表示期望发送方发送下一个序列号的分段。为降低网络开销,操作系统支持减少ACK 包传输数量的方法,比如接收方可以延迟200~500ms,再发送已确认的最大序号的ACK 包,这样可以显著降低ACK 包的数量,但在某些场景下会影响应用效率。

发送方在收到ACK 消息后,根据确认消息中的序号决定下一个发送的数据包,根据接收窗口的大小决定接下来传输的数据包数量。窗口大小对传输效率至关重要:如果窗口过小,会导致发送方暂停传输等待确认,传输效率无法保证;如果窗口过大,可能导致接收方无法及时处理数据,而造成数据被丢弃,被丢弃的数据需要重传,带宽被白白浪费。

如图2-37 所示,每个数字代表一个发送方缓冲区内的数据。缓冲区内的数据可以归纳为以下四种状态,其中已发送但尚未收到ACK 和尚未发送但允许发送的两部分数据为发送窗口:

img

图2-37 发送方缓冲区

(1)已发送且收到接收方的ACK 包。

(2)已发送但尚未收到接收方的ACK 包。

(3)尚未发送但允许发送。

(4)尚未发送且不可发送。

假设接收窗口为8,发送方发送完1~8 的数据后,需要等待接收方的ACK。此时,其收到了第三个数据包的ACK,说明接收方已经确认1、2、3 接收完毕。然后,发送方等待第四个数据包的ACK,发送窗口被设为4-11,若一定时间内未收到4 的ACK,则发送窗口内的所有数据,包括再次发送数据包4-8。

经过一段时间,假设接收方收到发送窗口号为6 的数据包的ACK,则发送窗口后移,如图2-38 所示。随着更多数据包被确认,发送窗口不断后移。

img

图2-38 发送窗口

滑动窗口机制是一个通过通信双方的协商,基于缓存实时信息调整发送数据包数量的机制。然而网络传输不仅仅牵涉通信双方,还涉及网络链路、网络设备等。因此,链路带宽和设备缓存都会影响网络传输效率。当发送方发送数据时,除了考虑接收窗口的大小,还需要考虑链路拥塞情况,拥塞控制(Congestion Control)就是为了解决此问题而引入TCP 的。

2.6.5.8 拥塞控制

如图2-39 所示,数据在通信双方进行传输的时候,不仅涉及发送方和接收方,还涉及两者之间的网络链路和设备,数据传输有其速度限制,网络设备有其缓存。当传输路径中的路由器交换机的输入流大于其输出流时,便会发生拥塞,已接收数据会被保存在网络设备的缓存中,并在拥塞缓解时发送。与现实世界的交通治理一样,如不对网络拥塞进行控制,会导致数据传输链路过载,数据传输质量也无法保证。直至链路完全堵死,网络调用失败,如果每个发送方都不自律,则各方数据都无法正常传输。

img

图2-39 数据传输

因此,TCP 协议作为数据传输的控制协议,需要在数据发送时考虑链路拥塞因素,并以此控制数据发送速率。

TCP 的拥塞控制主要通过拥塞窗口来实现。拥塞窗口与滑动窗口类似,它影响每次发送的数据包数量。数据传输速率以接收窗口和拥塞窗口的较小值为准。如图2-40 所示,拥塞控制分为四个部分:慢启动、拥塞避免、快速重传、快速恢复。

img

图2-40 TCP 拥塞控制

(1)慢启动的意义是,在不知道连接的带宽瓶颈时,以初始较小的拥塞窗口发送数据,比如拥塞窗口大小为1,这意味着第一次先发送1 个数据包,此时慢启动阈值为超大值,其最终值由慢启动阶段探索而来。传输的数据包数量随RTT 呈指数级增长,即每次收到一个ACK 包,就将拥塞窗口翻倍。但是拥塞窗口不可能一直以指数级别增长,TCP 通过一个慢启动阈值(ssthresh)的变量来决定何时停止慢启动阶段,进入拥塞避免阶段。慢启动阶段拥塞指数级增长的目的是:快速探索链路传输速率上线。

(2)拥塞避免阶段是指当拥塞窗口超过慢启动阈值后,慢启动过程结束。拥塞窗口不再呈指数级增长,而是开始根据拥塞控制算法增加拥塞窗口的数量,调整到运行的最佳值,避免拥塞窗口增长过快导致网络拥塞。

(3)快速重传和快速恢复一般一起使用。TCP 发送方每发送一个分段都会启动一个重传计时器(RTO),如果没能在特定时间内接收相应分段的确认,发送方就假设这个分段在网络上丢失了,需要重发。此时TCP 会重新进入慢启动阶段,慢启动阈值设置为拥塞窗口的一半,并且将拥塞窗口设置为1,这会影响数据的传输效率。因此TCP 引入了快速重传的机制,当发送端收到三个相同的ACK 时,无需等待重传超时时间,立即重传该数据包。在快速重传阶段,TCP 会根据拥塞控制算法调整慢启动阈值和拥塞窗口。由于可以接收3 个ACK,说明网络正常,没有必要重新进入慢启动阶段开始传输。因此,TCP 会启动快速恢复机制,进入拥塞避免阶段,而不是像RTO 超时那样重新开始慢启动,从而提高TCP 的传输效率。

对于拥塞控制,出现过多种算法,比如最经典的CUBIC 算法,即作为Linux 2.6.19到3.2 内核版本的默认算法。

CUBIC 算法的本质是充分利用网络设备的缓存,发送方不断增加发送数据量,直至数据无法正常传输。当传输不正常时,发送方迅速降低发送数据量,使得网络设备中的缓存数据得以处理。但这事实上很难实现,因此,在很多场景下无法提供可靠的传输保证。

首先,慢启动假设分段的未确认是由网络拥塞造成的,虽然大部分网络的确如此,但也有其他原因,例如一些链路质量差的网络,会导致分段包丢失。在一些网络环境(例如无线网络)中慢启动效率并不高。

其次,慢启动对一些短暂连接来说性能并不好,一些较旧的网页浏览器会建立大量连续的短暂连接,通过快速开启和关闭连接来请求获得文件,这使得大多数连接处于慢启动模式,导致网页响应时间差。

最后,网络设备的缓存并不能提升网络传输效率,即使CUBIC 算法充分利用设备缓存,也对数据传输毫无意义。设备缓存就像高速公路中的应急车道,其本身对增加并发流量并无作用。CUBIC 算法尝试灌满所有设备的缓存,并在拥塞时降低数据发送量并等待设备清空缓存,但数据传输效率依然受限于链路带宽。因此,缓存只增加了网络延迟,有弊无利。

BBR(Bottleneck Bandwidth and Round-trip)是谷歌于2016 年底提出的新的拥塞控制算法,该算法不再基于数据丢包或重复确认,而是通过算法估算链路的数据包传输往返时间和带宽,并基于这两个值进行拥塞控制。

由于网络链路是共享的,所以某个特定的网络链路的带宽是随时间变化的。当链路空闲时,数据包传输的往返时间体现了通信双方的网络延时,只要链路未发生拥塞,无论发送多少次数据传输,其往返时间都应该是相对稳定的。如果链路发生了拥塞,则数据往返时间会因网络设备缓存带来的延迟而增加。

随着传输数据包的数量不断增大,网络传输速率也随之增长,直到达到网络链路传输带宽的上限,传输速率会保持在一个稳定的数值。

从图2-41 中可以看出,O 点即为网络工作的最优点,此时数据包传输速率为链路带宽的上限,数据传输往返时间最短,此时具有最佳的传输效率,并不占用网络设备缓存。两者不能被同时测量,因此如何查找最优点就是BBR 算法的核心。如果要测量最大带宽就需要不断尝试提升传输速率,直至其不再增加。但是当速率不再增加时,缓存已经被占用,如果要计算最低延迟就需要保证当前只有很少的数据包在被传输——这会降低传输效率。

img

图2-41 带宽计算

BBR 解决此问题的方法是交替测量,用一段时间内的带宽极大值和延迟极小值作为估计值。

如图2-42 所示,BBR 分为四个阶段:启动阶段,排空阶段,带宽探索阶段,延迟探索阶段。

img

图2-42 BBR

下面详细介绍这四个阶段。

(1)启动阶段:连接建立完成后,BBR 采用与CUBIC 类似的慢启动机制,同样以指数级递增的方式来增加发送速率,目的是尽快占满网络链路。与CUBIC 不同的是,退出启动阶段的条件不再是丢包或重复确认包,而是连续三次发现传输速率不再增长,说明链路拥塞已经开始进入排空阶段。

(2)排空阶段:启动阶段的逆过程,其按指数级递减降低发送速率,等待时间将多占的设备缓存排空。排空阶段结束后,传输进入稳定状态。BBR 算法按固定周期依次进行带宽和延时测算,并按测算结果控制传输速率。

(3)带宽探索阶段:BBR 会尝试周期性地探索新的带宽瓶颈,如果没有产生队列挤压,则当前传输速率尚未达到带宽上限,下一周期的传输速率则应增加25%。

(4)延迟探索阶段:BBR 每隔10s 就进入延迟探索阶段,为了探索最小延迟,BBR在延迟探索阶段发送窗口固定为4。

CUBIC 在丢包率较高的环境中无法高效利用网络带宽,在丢包率为1‰时带宽利用率只有10%,在1%丢包率时带宽利用率就几乎为0。而BBR 与CUBIC 算法相比,有相同的启动速度,但不再依赖于丢包的特性,适用性更广。BBR 对网络延迟和传输带宽不断探索,并及时排空占用的网络缓存,有效避免了因网络设备缓存膨胀(Bufferbloat)而造成的网络延迟提升。

BBR 在互联网公司已经得到了广泛的应用,其在广域网的传输效率是CUBIC 算法的1.3 倍。Linux 内核4.9 以上版本已经实现内置化,可以通过编辑/etc/sysctl.conf 脚本开启内核:

img