1. 程式人生 > >Linux下使用虛擬網絡卡的ingress流控(入口流控)

Linux下使用虛擬網絡卡的ingress流控(入口流控)

Linux核心實現了資料包的佇列機制,配合多種不同的排隊策略,可以實現完美的流量控制和流量整形(以下統稱流控)。流控可以在兩個地方實現,分別為egress和ingress,egress是在資料包發出前的動作觸發點,而ingress是在資料包接收後的動作觸發點。Linux的流控在這兩個位置實現的並不對稱,即Linux並沒有在ingress這個位置實現佇列機制。那麼在ingress上就幾乎不能實現流控了。
    雖然使用iptables也能模擬流控,但是如果你就是想用真正的佇列實現流控的話,還真要想想辦法。也許,就像電子郵件的核心思想一樣,你總是能完美控制傳送,卻對接收毫無控制力,如果吸收了這個思想,就可以理解ingress佇列流控的難度了,然而,僅僅是也許而已。
    Linux在ingress位置使用非佇列機制實現了一個簡單的流控。姑且不談非佇列機制相比佇列機制的弊端,僅就ingress的位置就能說明我們對它的控制力幾乎為0。ingress處在資料進入IP層之前,此處不能掛接任何IP層的鉤子,Netfilter的PREROUTING也在此之後,因此在這個位置,你無法自定義任何鉤子,甚至連IPMARK也看不到,更別提關聯socket了,因此你就很難去做排隊策略,你幾乎只能看到IP地址和埠資訊。
    一個現實的ingress流控的需求就是針對本地服務的客戶端資料上傳控制,比如上傳大檔案到伺服器。一方面可以在底層釋放CPU壓力,提前丟掉CPU處理能力以外的資料,另一方面,可以讓使用者態服務的IO更加平滑或者更加不平滑,取決於策略。

    既然有需求,就要想法子滿足需求。目前我們知道的是,只能在egress做流控,但是又不能讓資料真的outgoing,另外,我們需要可以做很多策略,這些策略遠不是僅由IP,協議,埠這5元組可以給出。那麼一個顯而易見的方案就是用虛擬網絡卡來完成,圖示如下:


以上的原理圖很簡單,但是實施起來還真有幾個細節。其中最關鍵是路由的細節,我們知道,即使是策略路由,也必須無條件從local表開始查詢,在目標地址是本機情況下,如果希望資料按照以上流程走的話,就必須將該地址從local表刪除,然而一旦刪除,本機將不再會對該地址迴應ARP請求。因此可以用幾個方案:

1.使用靜態ARP或者使用ebtables更改ARP,或者使用arping主動廣播arp配置;
2.使用一個非本機的地址,然後修改虛擬網絡卡的xmit函式,內部使其DNAT成本機地址,這就繞開了local表的問題。

不考慮細節,僅就上述原理圖討論的話,你可以在常規路徑的PREROUTING中做很多事情,比如使用socket match關聯socket,也可以使用IPMARK。
       下面,我們就可以按照上述圖示,實際實現一個能用的。首先先要實現一個虛擬網絡卡。仿照loopback介面做一個用於流控的虛擬介面,首先建立一個用於ingress流控的虛擬網絡卡裝置
dev = alloc_netdev(0, "ingress_tc", tc_setup);
然後初始化其關鍵欄位
static const struct net_device_ops tc_ops = {
        .ndo_init      = tc_dev_init,
        .ndo_start_xmit= tc_xmit,
};
static void tc_setup(struct net_device *dev)
{
        ether_setup(dev);
        dev->mtu                = (16 * 1024) + 20 + 20 + 12;
        dev->hard_header_len    = ETH_HLEN;     /* 14   */
        dev->addr_len           = ETH_ALEN;     /* 6    */
        dev->tx_queue_len       = 0;
        dev->type               = ARPHRD_LOOPBACK;      /* 0x0001*/
        dev->flags              = IFF_LOOPBACK;
        dev->priv_flags        &= ~IFF_XMIT_DST_RELEASE;
        dev->features           = NETIF_F_SG | NETIF_F_FRAGLIST
                | NETIF_F_TSO
                | NETIF_F_NO_CSUM
                | NETIF_F_HIGHDMA
                | NETIF_F_LLTX
                | NETIF_F_NETNS_LOCAL;
        dev->ethtool_ops        = &tc_ethtool_ops;
        dev->netdev_ops         = &tc_ops;
        dev->destructor         = tc_dev_free;
}

接著構建其xmit函式
static netdev_tx_t tc_xmit(struct sk_buff *skb,
                                 struct net_device *dev)
{
    skb_orphan(skb);
    // 直接通過第二層!
    skb->protocol = eth_type_trans(skb, dev);
    skb_reset_network_header(skb);
    skb_reset_transport_header(skb);
    skb->mac_len = skb->network_header - skb->mac_header;
    // 本地接收
    ip_local_deliver(skb);
   
    return NETDEV_TX_OK;
}
接下來考慮如何將資料包匯入到該虛擬網絡卡。有3種方案可選:
方案1:如果不想設定arp相關的東西,就要修改核心了。在此我引入了一個路由標誌,RT_F_INGRESS_TC,凡是有該標誌的路由,全部將其匯入到構建的虛擬網絡卡中,為了策略化,我並沒有在程式碼中這麼寫,而是改變了RT_F_INGRESS_TC路由的查詢順序,優先查詢策略路由表,然後再查詢local表,這樣就可以用策略路由將資料包匯入到虛擬網絡卡了。
方案2:構建一個Netfilter HOOK,在其target中將希望流控的資料NF_QUEUE到虛擬網絡卡,即在queue的handler中設定skb->dev為虛擬網絡卡,呼叫dev_queue_xmit(skb)即可,而該虛擬網絡卡則不再符合上面的圖示,新的原理圖比較簡單,只需要在虛擬網絡卡的hard_xmit中reinject資料包即可。(事實上,後來我才知道,原來IMQ就是這麼實現的,幸虧沒有動手去做無用功)
方案3:這是個快速測試方案,也就是我最原始的想法,即將目標IP地址從local表刪除,然後手動arping,我的測試也是基於這個方案,效果不錯。
不管上面的方案如何變化,終究都是一個效果,那就是既然網絡卡的ingress不能流控,那就在egress上做,而又不能使用物理網絡卡,虛擬網絡卡恰好可以自定義其實現,可以滿足任意需求。我們可以看到,虛擬網絡卡是多麼的功能強大,tun,lo,nvi,tc...所有這一切,精妙之處全在各自不同的xmit函式。

Linux核心協議棧處理流程的重構

個人覺得,Linux網路處理還有一個不對稱的地方,那就是路由後的轉發函式,我們知道Linux的網路處理在路由之後有個分叉,根據目的地的不同,處理邏輯就此分道揚鑣,如果路由結果帶有LOCAL標誌,那麼就呼叫ip_local_deliver,反之呼叫ip_forward(具體參看ip_route_input_slow中對rth->u.dst.input的賦值)。如此一來,LOCAL資料就徑直髮往本地了,這其實也是RFC的建議實現,它簡要的描述了路由演算法:先看目標地址是不是本地,如果是就本地接收...然而我認為(雖然總是帶有一些不為人知的偏見),完全沒有必要分道揚鑣,通過一個函式傳送會更加好一點,比如發往本地的資料包同樣發往一塊網絡卡處理,只是該塊網絡卡是一塊LOOPBACK網絡卡,這樣整個IP接收例程就可以統一描述了。類似的,對於本地資料傳送也可以統一由一個虛擬的LOOPBACK網絡卡傳送,而不是直接傳送給路由模組。整體如下圖所示:


       雖然這麼對稱處理看似影響了效率,邏輯上好像資料包到了第三層後又回到了第二層,然後第二層的本地LOOKBACK網絡卡呼叫ip_local_deliver本地接收,但是落實到程式碼上,也就是幾次函式呼叫而已,完全可以在從ip_forward到dev_queue_xmit這條路上為LOCAL設定直通路線,只要經過這條路即可,這樣一來有4個好處:
1.不再需要INPUT這個HOOK點;
2.不再需要FORWARD這個HOOK點;
3.不再需要OUTPUT這個HOOK點,和第1點,第2點一起讓Netfilter的整體架構也完全脫離了馬鞍面造型;由此,本機發出的資料在DNAT後再也不用reroute了,其實,本來NAT模組中處理路由就很彆扭...

4.策略路由可以更加策略化,因為即使目標地址是local表的路由,也可以將其熱direct到別的策略表中。
就著以上第3點再做一下引申,我們可以很容易實現N多種虛擬網絡卡裝置,在其中實現幾乎任意的功能,比如這個ingress的流控,就可以很容易實現,不需要改核心和做複雜的配置,只要寫一個虛擬網絡卡,配置幾條策略路由即可。如此重新實現的協議棧處理邏輯可能某種程度上違背了協議棧分層設計的原則,但是確實能帶來很多的好處,當然,這些好處是有代價的。值得注意的是,少了3個HOOK點是一個比較重要的問題,一直以來,雖然OUTPUT在路由後掛載,可是它實際上應該是路由前的處理,INPUT難道不是路由後嗎?為何要把POSTROUTING又區分為INPUT和FORWARD,另外FORWARD其實也是路由後的一種...真正合理的是,INPUT和FORWARD應該是掛載在POSTROUTING上的subHOOK Point,可以將它們實現成一個HOOK Operation,對於OUTPUT,直接去除!HOOK點並不區分具體的邏輯,也不應該區分,這種邏輯應該讓HOOK Operation來區分。

Linux的IMQ補丁

在實現了自己的虛擬網絡卡並配置好可用的ingress流控之後,我看了Linux核心的IMQ實現,鑑於之前從未有過流控的需求,一直以來都不是很關注IMQ,本著什麼東西都要先自己試著實現一個或者給出個自己的方案(起碼也要有一個思想實驗方案)然後再與標準實現(所謂的標準一詞並不是那麼經得起推敲,實際上它只是“大家都接受的實現”的另一種說法,並無真正的標準可言)對比的原則,我在上面已經給出了IMQ的思想。
       IMQ補丁,其核心側重在以下4點:
1.命名。IMQ中的Intermediate,我覺得非常好,明確指出使用一箇中間層來適配igress的流控;
2.實現一個虛擬網絡卡裝置。即所謂的Intermediate裝置;
3.NF_QUEUE的使用。使用Netfilter的NF_QUEUE機制將需要流控的資料包直接匯入虛擬裝置而不是通過策略路由間接將資料引入虛擬裝置。
4.擴充了skb_buff資料結構,引入和IMQ相關的管理欄位。個人認為這是它的不足,我並不傾向於修改核心程式碼。然而在我自己的虛擬裝置實現中,由於ip_local_deliver函式並沒有被核心匯出(EXPORT),導致我不得不使用/proc/kallsym來查詢它的位置,這麼做確實並不標準,我不得不修改了核心,雖然只是添加了一行程式碼:
EXPORT_SYMBOL(ip_local_deliver);
但是還是覺得不爽!
IMQ的總圖如下: