Linux TC 流量控制介紹

前段時間在做一些測試的時候接觸到了Linux tc,因為需要對數據包添加延遲,用到了tc中的netem。添加簡單的延遲非常簡單,像這樣一條命令就搞定了:$ tc qdisc add dev eth0 root netem delay 1s,你甚至不需要完全理解命令中參數的含義。但是當你想做一些更加特殊的限制的時候,(比如只對某個特定的ip埠添加延遲、或者只對入站的流量添加延遲),事情就變得有些棘手了,簡單的百度貌似已經滿足不了要求了。你不得不了解TC中的一些基本概念,以及tc[2]命令中相關參數的含義。

本文正是帶你了解這些TC中的基本概念,並通過一個實際例子,將這些概念與tc命令聯繫起來。

本部落格已遷移至CatBro’s Blog,那是我自己搭建的個人部落格,歡迎關注。本文鏈接

示例命令

考慮到這是一個科普向的介紹,這裡只舉了一個最簡單的例子,但是基本上包含了重要的概念。本文的期望是,讓讀者在閱讀後可以完全理解下面的例子,並且知道如何根據自身的需求編寫自己的命令。

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:   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:12 -> 12: -> 12:2

當然也可能直接走如下路徑:

1: -> 12:2

這種情況,就是root qdisc上的過濾器決定把包直接送到12:2

{% label warning@注意:%}入隊和出隊時雖然節點的拓撲圖是一樣的,但是每個節點表示的含義卻有所不同[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選擇器。特殊選擇器簡化了一些常用欄位的設置,可以匹配包頭中的各種欄位,比如:

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子網的包。

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(或上層)頭中的幾乎任何位,不過相比特殊選擇器較難編寫和閱讀。其語法如下:

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

其中u32|u16|u8指定pattern的長度,分別為4個位元組、2個位元組、1個位元組。PATTERN表示匹配的包的pattern,MASK告訴過濾器匹配哪些位,at表示從包的指定偏移處開始匹配。

來看一個例子:

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。

再來看另一個例子:

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的包。

示例解析

好了,現在我們可以回過頭來看最初的那個示例了,看看這些命令到底是什麼意思。

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:     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