Linux TC 流量控制介绍

前段时间在做一些测试的时候接触到了Linux tc,因为需要对数据包添加延迟,用到了tc中的netem。添加简单的延迟非常简单,像这样一条命令就搞定了:$ tc qdisc add dev eth0 root netem delay 1s,你甚至不需要完全理解命令中参数的含义。但是当你想做一些更加特殊的限制的时候,(比如只对某个特定的ip端口添加延迟、或者只对入站的流量添加延迟),事情就变得有些棘手了,简单的百度貌似已经满足不了要求了。你不得不了解TC中的一些基本概念,以及tc[2]命令中相关参数的含义。

本文正是带你了解这些TC中的基本概念,并通过一个实际例子,将这些概念与tc命令联系起来。

示例命令

考虑到这是一个科普向的介绍,这里只举了一个最简单的例子,但是基本上包含了重要的概念。本文的期望是,让读者在阅读后可以完全理解下面的例子,并且知道如何根据自身的需求编写自己的命令。

1
2
3
sudo tc qdisc add dev eth0 root handle 1: prio bands 4
sudo tc qdisc add dev eth0 parent 1:4 handle 40: netem loss 10% delay 40ms
sudo tc filter add dev eth0 protocol ip parent 1:0 prio 4 u32 match ip dst 192.168.190.7 match ip dport 36000 0xffff flowid 1:4

TC基本概念

QDISCS

全称是queueing discipline,我们姑且称其为排队规则吧。它是协议栈和网络接口之间的一个缓冲层。你可以在qdisc上对数据包做一些你想做的操作,比如分类、整形、调度等。

qdisc分为无类(classless)qdisc和有类(classful)qdisc。无类qdisc不再内部细分类,有类qdisc可进一步包含多个分类,每个class上可以进一步包含子qdisc,子qdisc也可以是有类qdisc,这样就形成了树状的分层结构。

CLASSES

有类qdisc可以有多个子类(class),有些qdisc预定义了子类(如prio),有些则需要用户添加类。一个类上又可以附加其他类。最末端没有子类的类称为叶子类,它上面附加了一个qdisc。当创建一个class的时候,默认会附加一个fifo qdisc,它只是一个简单的队列,不对数据包进行任何的操作。当在这个类上增加子类的时候,这个默认的qdisc被移除。你可以将这个默认的fifo qdisc替换成其他任意你想用的qdisc。

FILTERS

过滤器,用于有类qdisc中,决定将包入队到哪个类中。每当一个包到达有子类的类时,就需要进行分类。其中一种分类的方法就是使用过滤器(另外两个是ToS和skb->priority)。所有附加到类上的过滤器会被依次调用,直到其中一个返回裁决。一个filter包含了一些条件,当一个包到达该节点时,会根据包的特征判断是否匹配。

以上3个是TC中最基本的3个概念,任何复杂的流量控制都是通过这个三元组递归实现的。

层级结构

每个接口有一个egress ‘root qdisc’,默认是pfifo_fast。每个qdisc和class都分配一个句柄handle,句柄用于在后续的配置语句中进行引用。除了egress qdisc,一个接口也可以有一个ingress qdisc,负责管制入站的流量。但是ingress qdisc相比classful qdisc其可能性是非常有限。(所以才有所谓的控发不控收,对入站流量进行控制通常需要借助ifb[6]或者imq)。

这些qdisc的handles有两个部分组成,一个major数和一个minor数:<major>:<minor>。习惯上将root qdisc命名为1:,等价于1:0。一个qdisc的minor数总是0。

子类需要跟它们的parent有相同的major数。major数在一个egress或ingress内必须是唯一的,minor数在一个qdisc和它的class中必须是唯一的。

一个典型的层级结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
          1:   root qdisc
|
1:1 child class
/ | \
/ | \
/ | \
/ | \
1:10 1:11 1:12 child classes
| | |
| 11: | leaf class
| |
10: 12: qdisc
/ \ / \
10:1 10:2 12:1 12:2 leaf classes

内核只跟root qdisc进行通信,每当包需要入队或者出队的时候,都需要从root节点开始,最终到达叶子节点,从而决定入队到哪里,或者从哪里出队。

比如当一个包入队时,它可能会经过如下路径:

1
1: -> 1:1 -> 1:12 -> 12: -> 12:2

当然也可能直接走如下路径:

1
1: -> 12:2

这种情况,就是root qdisc上的过滤器决定把包直接送到12:2

注意:入队和出队时虽然节点的拓扑图是一样的,但是每个节点表示的含义却有所不同[4]。入队时是根据过滤器和包的特征决定走哪条路径,而出队时则取决于qdisc本身的调度算法,比如FIFO、优先级队列、SFQ的顺序调度等。

过滤器

前面已经提到了过滤器用于将包分类到子类,那么具体是如何对包进行分类的呢?tc支持很多类型的分类器,它们根据数据包相关的不同信息来作出决策。其中最常用的就是u32分类器,它根据数据包中的字段做出决策(例如源IP地址等)。还有比如fw分类器,根据防火墙如何标记数据包来做出决策,你可以使用iptables标记目标数据包,然后通过fw分类器进行过滤。另外还有诸如route分类器cgroup分类器bpf分类器等,篇幅原因不再赘述。下面仅介绍最常见的u32分类器。

公共参数

分类器一般接收以下几个公共的参数:

  • protocol

    分类器接受的协议,通常你只接受IP流量。必须。

  • parent

    分类器附加到哪个handle上。这个handle必须是一个已经存在的类。必须。

  • prio|perf

    分类器的优先级。数字越小的越先进行匹配尝试。

  • handle

    这个handle对于不同的过滤器表示不同的含义。

u32分类器[3]

u32过滤器最简单的格式是设置一组选择器对包进行匹配,匹配的包分到特定的子类中,或者执行一个action。u32分类器提供了多种不同的选择器,可以大致分成特殊选择器和通用选择器两类。

特殊选择器

常用的有ip选择器和tcp选择器。特殊选择器简化了一些常用字段的设置,可以匹配包头中的各种字段,比如:

1
2
tc filter add dev eth0 protocol ip parent 1:0 prio 10 u32 \
match ip src 192.168.8.0/24 flowid 1:4

上例匹配ip源地址在192.168.8.0/24子网的包。

1
2
3
4
tc filter add dev eth0 protocol ip parent 1:0 prio 10 u32 \
match ip protocol 0x6 0xff \
match tcp dport 53 0xffff \
flowid 1:2

上例匹配TCP协议(0x6)、且目的端口为53的包。

通用选择器

特殊选择器总是可以改写成对应的通用选择器,通用选择器可以匹配 IP(或上层)头中的几乎任何位,不过相比特殊选择器较难编写和阅读。其语法如下:

1
match [ u32 | u16 | u8 ] PATTERN MASK at [OFFSET | nexthdr+OFFSET]

其中u32|u16|u8指定pattern的长度,分别为4个字节、2个字节、1个字节。PATTERN表示匹配的包的pattern,MASK告诉过滤器匹配哪些位,at表示从包的指定偏移处开始匹配。

来看一个例子:

1
2
tc filter add dev eth0 protocol ip parent 1:0 pref 10 u32 \
match u32 00100000 00ff0000 at 0 flowid 1:10

选择器会匹配IP头第二个字节为0x10的包,at 0表示从头开始匹配,mask为00ff0000所以只匹配第二个字节,pattern为00100000即第二个字节为0x10。

再来看另一个例子:

1
2
tc filter add dev eth0 protocol ip parent 1:0 pref 10 u32 \
match u32 00000016 0000ffff at nexthdr+0 flowid 1:10

nexthdr选项表示封装在IP包里的下一个头,即上层协议的头。at nexthdr+0表示从下一个头第一个字节开始匹配。因为mask为0000ffff,所以匹配发生在头的第三和第四个字节。在TCP和UDP协议中这两个字节是包的目的端口。数字是由大段格式给出的,所以pattern 00000016转换成十进制是22。即该选择器会匹配目的端口为22的包。

示例解析

好了,现在我们可以回过头来看最初的那个示例了,看看这些命令到底是什么意思。

1
2
3
sudo tc qdisc add dev eth0 root handle 1: prio bands 4
sudo tc qdisc add dev eth0 parent 1:4 handle 40: netem loss 10% delay 40ms
sudo tc filter add dev eth0 protocol ip parent 1:0 prio 4 u32 match ip dst 192.168.190.7 match ip dport 36000 0xffff flowid 1:4

我们一行行来看,第一行在设备eth0上添加了一个root qdisc,句柄为1:,qdisc类型为prio,bands数为4。

prio是一个有类的qdisc。它的作用跟默认的qdisc pfifo_fast类似。pfifo_fast有三个所谓的band,不同band的流量具有不同的优先级。每个band内,则应用FIFO规则。

prio qdisc,默认会创建3个子类,包含纯FIFO qdisc,默认根据ToS位进行分类。你可以使用过滤器来对流量进行分类,你也可以在子类上附加其他qdisc替换默认的FIFO。

接下来看第二个命令,parent 1:4表示在子类1:4上,handle 40:表示句柄为40:,netem表示添加一个netem qdisc,loss 10% delay 40ms则是netem的参数,表示丢包10%、延迟40ms。netem[5]是一个用于提供网络仿真功能的无类qdisc,可以模拟延迟、丢包、包重复、包失序等各种情况。

第三个命令则是添加了一个过滤器,parent 1:0表示在根节点上添加该过滤器,prio 4是过滤器的优先级,如果有很多过滤器会根据优先级的值按顺序进行尝试。u32表示使用u32分类器。match ip dst 192.168.190.7表示匹配ip地址为192.168.190.7的包,match ip dport 36000 0xffff表示匹配目的端口为36000的包,多个选择器之间是“与”的关系,flowid 1:4表示将匹配的包分类到1:4子类中。

所以最终的效果是,发往192.168.190.7且目的端口为36000的包,会分类到1:4子类,添加40ms的延迟,且有10%的丢包率。其他包则还是默认的行为,即根据ToS字段分类到1:1、1:2或1:3子类中,然后根据优先级依次发送。

画出该例子的分层结构图,大致如下:

1
2
3
4
5
6
7
8
9
          1:     root qdisc (prio)
/ | \ \
/ | \ \
/ | \ \
1:1 1:2 1:3 1:4 classes
| | | |
40: qdiscs
pfifo pfifo pfifo netem
band 0 1 2 3

后记

本文只介绍了tc的基本概念和简单用法。prio qdisc只对包做了一个分类,并没有进行整形。实际上,你也可以使用更复杂的带整形的qdisc,比如CBQ、HTB等,也可以增加更多的层级。你还可以在叶子节点上添加SFQ qdisc以实现会话级的带宽公平性。相信理解了TC的这些基本概念,再根据自身需求使用其他qdisc也不是什么难事了。

参考资料

  1. lartc

  2. tc(8)

  3. tc-u32(8)

  4. 数据包的分类和调度

  5. netem

  6. ifb

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道