1. 程式人生 > >packetdrill框架點滴剖析以及TCP重傳的一個細節

packetdrill框架點滴剖析以及TCP重傳的一個細節

本來週末想搞一下scapy呢,一個python寫的互動式資料包編輯注入框架,功能異常強大。然而由於python水平太水,對庫的掌握程度完全達不到信手拈來的水平,再加上前些天pending的關於OpenVPN的事情,還有一系列關於虛擬網絡卡的事情,使我注意到了一個很好用的packetdrill,可以完成本應該由scapy完成的事,恰巧這個東西跟我最近的工作也有關係,就拋棄scapy了,稍微研究了一下它的基本框架,寫下本文。

packetdrill的框架概覽

        喜歡packetdrill是因為它讓我想起了2010年5月份剛開始接觸OpenVPN的時候,那時我的預研進度一度阻塞在一個叫做TUN虛擬網絡卡的驅動上,後來當我玩轉它以後,發現TUN幾乎可以做所有的事情,每當我面臨一些諸如資料包注入,劫取,重放等需求的時候,只要我想到了TUN,它就一定可以幫我實現理想。當然,後來我在OpenVPN收穫頗豐,要不是程式設計水平不佳,我幾乎重構了它,也得意於我對TUN裝置的深度喜愛。現在,packetdrill也是使用了TUN,因此我知道,我可以快速使用它做比我想做的更多的事情了。TUN有這麼神奇?是的!
        TUN的神奇之處就在於它非常簡單,簡單到就像0和1那樣,不過它做的事情可不簡單,本質上說,TUN的作用在於“導包”,它可以模擬一條網線,將資料包輸出到一個字元裝置,至於字元裝置被誰讀取以及資料包作何處理,就完全依賴使用者態程式編寫者的想象力了。packetdrill正是利用了這一點,才讓它可以成為一個自封閉的協議測試系統,完全不需要外部的支援就可以測試整個協議棧,如果你是一個網路協議的初學者,利用packetdrill也可以讓你快速理解網路協議的動態行為,除此之外,它本身包含了很多的test case,幾乎涵蓋了你想要的所有,如果某個點沒有被涵蓋,你可以快速依葫蘆畫瓢寫一個指令碼,然後,就可以跑起來了。
        和當初OpenVPN的預研階段一樣,關於packetdrill的框架,我還是以一幅圖開始吧,這個圖讓我想起2010年低做關於OpenVPN培訓的時候畫的那個圖,可是去年,我把那個OpenVPN的框圖完善了。所以本文中這個packetdrill的框圖也只是個開始,也可能包括一些疏漏,後續會不斷勘誤,完善之。圖示如下:

你看,scapy做不到這些,完全做不到,scapy必須依賴另一臺機器,或者在本機啟用另外一些程式。但是從另一個角度,scapy+TUN+prog+...豈不是完爆packetdrill嗎?並且後者完全遵循UNIX的KISS小程式組合的原則,而packetdrill看起來有點像一個Windows風格的“大程式”!然而,以實用主義角度看問題,你覺得哪個更好用呢?哪個更方便呢?要知道,我只是想知道我的協議是否按照預期工作,我的目標並不是想炫耀我懂得那麼多Linux工具以及精通其每一個的用法,所以用packetdrill我花最多10分鐘下載原始碼並編譯,然後花5分鐘跑一遍test case,最後我幾乎可以瞬間寫一個我自己想要的case,整個過程也就不到半個小時,如果用scapy組合呢?...如果旁邊外行的人看著我,我起碼還是會炫下去,顯得自己很強,要是就我自己一人,我折騰這些就像個傻逼。事實如此,我昨天真的想用scapy組合的,結果兩個多小時沒有搞定,期間沒有一個人欣賞觀摩,用packetdrill,除了遇到了一個編譯時-lpthread的問題外,一切超級順利。最終,我決定,舍scapy而取packetdrill者也!昨晚用它搞定了我的新版OpenVPN協議(還記得我去年那鬼魅的殘缺嗎?有了packetdrill,我得到了一個OpenVPN在核心中的一個穩定版本,而且支援快速重連,這個請看我本文的最後),也算是值得欣慰。
        packetdrill哪哪都好,缺點在於出了問題不好分析。
        萬一輸出不符合預期怎麼辦?以TCP為例,如果你不懂TCP的每一個細節,那麼在你認為要重傳,然而對端沒有重傳的時候,你該怎麼辦?此時你能得到的資訊只是TCP_INFO這種資訊,你得到的就是一些值,這些值是你顯式呼叫getsockopt的時候得到的,你無法知道某個值是如何演化的,比如擁塞視窗如何隨著協議棧函式的執行而變化。我以一例以敘之。

一個TCP快速重傳的細節

// 建立連線
0   socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0  setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0

+0  bind(3, ..., ...) = 0
+0  listen(3, 1) = 0

// 完成握手
+0  < S 0:0(0) win 65535 <mss 1000,sackOK,nop,nop,nop,wscale 7>
+0  > S. 0:0(0) ack 1 <...>
+.1 < . 1:1(0) ack 1 win 65535
+0  accept(3, ..., ...) = 4

// 傳送1個段,不會誘發擁塞視窗增加
+0  write(4, ..., 1000) = 1000
+.1 < . 1:1(0) ack 1001 win 65535

// 再發送1個段,擁塞視窗還是初始值10!
+0  write(4, ..., 1000) = 1000
+.1 < . 1:1(0) ack 2001 win 65535

// .....
+0  write(4, ..., 1000) = 1000
+.1 < . 1:1(0) ack 3001 win 65535

// 不管怎麼發,只要是每次傳送不超過init_cwnd-reordering,擁塞視窗就不會增加,詳見上述的tcp_is_cwnd_limited函式
+0  write(4, ..., 1000) = 1000
+.1 < . 1:1(0) ack 4001 win 65535

// 多發一點,結果呢?自己用tcpprobe確認吧
+0  write(4, ..., 6000) = 6000
+.1 < . 1:1(0) ack 10001 win 65535

// 好吧,我們傳送10個段,可以用tcpprobe確認,在收到ACK後擁塞視窗會增加1,這正是慢啟動的效果!
+0  write(4, ..., 10000) = 10000
+.1 < . 1:1(0) ack 20001 win 65535

// 該步入正題了。為了觸發快速重傳,我們傳送足夠多的資料,一下子傳送8個段吧,注意,此時的擁塞視窗為11!
+0  write(4, ..., 8000) = 8000

// 在這裡,可以用以下的Assert來確認:
0.0 %{
assert tcpi_reordering == 3
assert tcpi_cwnd == 11
}%

// 以下為收到的SACK序列。由於我假設你已經通過上面那個簡單的packetdrill指令碼理解了SACK和FACK的區別,因此這裡我們預設開啟FACK!
// sack 1的效果:確認了27001-28001,此處距離ACK欄位20001為8個段,超過了reordering 3,會立即觸發重傳。
+.1 < . 1:1(0) ack 20001 win 257 <sack 27001:28001,nop,nop>                                         // ----(sack 1)
+0  < . 1:1(0) ack 20001 win 257 <sack 22001:23001 27001:28001,nop,nop>                             // ----(sack 2)
+0  < . 1:1(0) ack 20001 win 257 <sack 23001:24001 22001:23001 27001:28001,nop,nop>                 // ----(sack 3)
+0  < . 1:1(0) ack 20001 win 257 <sack 24001:25001 23001:24001 22001:23001 27001:28001,nop,nop>     // ----(sack 4)

// 收到了28001的ACK,注意,此時的reordering已經被更新為6了,另外,這個ACK也會嘗試觸發reordering的更新,但是並不成功,為什麼呢?詳情見下面的分析。
+.1 < . 1:1(0) ack 28001 win 65535

// 由於經歷了上述的快速重傳/快速恢復,擁塞視窗已經下降到了5,為了確認reordering已經更新,我們需要將擁塞視窗增加到10或者11
+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 33001 win 65535

// 由於此時擁塞視窗的值為5,我們連續寫入幾個等於擁塞視窗大小的資料,誘發擁塞視窗增加到10.
+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 38001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 43001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 48001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 53001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 58001 win 65535

+0  write(4, ..., 5000) = 5000
+.1 < . 1:1(0) ack 63001 win 65535

// 好吧!此時重複上面發生SACK的序列,寫入8個段,我們來看看同樣的SACK序列還會不會誘發快速重傳!
+0  write(4, ..., 8000) = 8000

// 依然可以通過python來確認reordering此時已經不再是3了

// 我們構造同上面sack 1/2/3/4一樣的SACK序列,然而等待我們的不是重傳被觸發,而是...
// 什麼?沒有觸發重傳?這不可能吧!你看,70001-71001這個段距離63001為8個段,而此時reordering被更新為6,8>6,依然符合觸發條件啊,為什麼沒有觸發呢?
// 答案在於,在於8>6觸發快速重傳有個前提,那就是開啟FACK,然而在reordering被更新的時候,已經禁用了FACK,此後就是要數SACK的段數而不是數最高被SACK的段值了,以下4個SACK只是選擇確認了4個段,而4<6,不會觸發快速重傳。
+.1 < . 1:1(0) ack 63001 win 257 <sack 70001:71001,nop,nop>
+0  < . 1:1(0) ack 63001 win 257 <sack 65001:66001 70001:71001,nop,nop>
+0  < . 1:1(0) ack 63001 win 257 <sack 67001:68001 65001:66001 70001:71001,nop,nop>
+0  < . 1:1(0) ack 63001 win 257 <sack 68001:69001 67001:68001 65001:66001 70001:71001,nop,nop>

// 這裡,這裡到底會不會觸發超時重傳呢?取決於packetdrill注入下面這個ACK的時機
// 如果沒有發生超時重傳,下面這個ACK將會再次把reordering從6更新到8
+.1 < . 1:1(0) ack 71001 win 65535

//從這裡往後,屬於神的世界...

且聽我下面的論述以提出問題。在收到第一個SACK的時候,FACK的值是8,reordering的值是3,標記為LOST的段數為FACK-reordering=5個。在收到SACK之前,擁塞視窗的值為11,核心版本為2.6.32,因此如果真的是用2.6.32核心的話,我可以確定降窗演算法不是PRR而是Rate halving。因此我知道,雖然此時擁塞視窗的大小為11,但是最終的擁塞視窗取的是:
tp->snd_cwnd = min(tp->snd_cwnd, tcp_packets_in_flight(tp) + 1);
來吧,我們依次來算,此時tp->snd_cwnd的值為11,問題是tcp_packets_in_flight是多少?
static inline unsigned int tcp_left_out(const struct tcp_sock *tp)
{
    return tp->sacked_out + tp->lost_out;
}
static inline unsigned int tcp_packets_in_flight(const struct tcp_sock *tp)
{
    return tp->packets_out - tcp_left_out(tp) + tp->retrans_out;
}
此時:
tp->packets_out:等於8
tp->sacked_out:等於1(記得嗎?段70001-71001)
tp->lost_out:等於5(它等於FACK-reordering)
tp->retrans_out:等於0(因為還沒有任何重傳)
in_flight:8-(1+5)+0=2個段
所以根據2.6.32程式碼的tcp_cwnd_down降窗函式,在降窗完成後,擁塞視窗的大小為in_flight+1=3個段。現在你應該質疑為什麼第一次收到70001-71001的SACK後,重傳了2個段而不是1個段!!我們看tcp_xmit_retransmit_queue函式:
tcp_for_write_queue_from(skb, sk) {
    ...
    if (tcp_packets_in_flight(tp) >= tp->snd_cwnd)
        return;
    transmit_skb(skb);
    tp->retrans_out++;
}

第一次進入tcp_for_write_queue_from時,tp->snd_cwnd為3,不滿足退出條件,故重傳1個數據段,待重傳後tp->retrans_out遞增1,in_flight遞增1,cwnd維持不變,因此第二次經過迴圈邏輯時會break退出,然而抓包發現確實重傳了2個數據段而不是1個!這是怎麼回事?!答案在於一個核心patch或者說一個實現細節!其實這裡重傳多少並不是重要的問題,重要的問題是,我們已經得到了足夠的通知,知道了丟包或者亂序,僅此足矣。至於說要不要重傳,重傳多少,這就是各種TCP實現的區別,我個人是很鄙視這種區別的,也不感興趣,因此,不予討論。為了解除經理以及質疑者的武裝,我把事實簡單說一下。RedHat系統使用的並不一定是社群的釋出核心,RH特別喜歡移植上游的patch到使用低版本核心的long term發行版,也就是說,當你看到社群的2.6.32核心的降窗演算法是由tcp_cwnd_down實現的時候,RH已經移植了PRR演算法!
        其實,我TMD的也是個愛較真兒的,這麼大熱的天,老婆帶著孩子去游泳了,我跟個傻逼一樣在家裡下載RH的kernel patch!無奈深圳這邊的網速真無法跟上海比,CTMD!好吧,我們繼續討論為什麼重傳的是2個段而不是1個段!
如果使用tcp_cwnd_down來實現降窗,最後的一個平滑操作是:
tp->snd_cwnd = min(tp->snd_cwnd, tcp_packets_in_flight(tp) + 1);
這是為了不往早已無能為力的網路上再添堵,也就是說,當你確定in_flight肯定比當前Rate Halving降窗操作之後的視窗值小的時候,擁塞視窗的值一定是in_flight加1!這也就是傳說中的資料包守恆,大多數情況都是如此,Rate Halving(它是一個本地作用)的效用遠遠不比in_flight表徵的實際網路情況(這是一個綜合作用)。因此你會發現,大多數情況下,在TCP快速恢復階段,都是一個一個段重傳的。然而RH移植了上游patch後,它使用的是PRR降窗演算法,下面我們來算一下PRR演算法下,應該重傳幾個段。
我們先看符合本例的精簡版PRR的程式碼:
static void tcp_cwnd_reduction(struct sock *sk, int newly_acked_sacked, int fast_rexmit)
{
    struct tcp_sock *tp = tcp_sk(sk);
    int sndcnt = 0;
    int delta = tp->snd_ssthresh - tcp_packets_in_flight(tp);

    tp->prr_delivered += newly_acked_sacked;
    if (tcp_packets_in_flight(tp) > tp->snd_ssthresh) {
        ... // in_flight此為2,而ssthresh的值卻是5(我使用了Reno,其實Cubic也一樣,11*0.7=?)
    } else {
        sndcnt = min(delta, max(tp->prr_delivered - tp->prr_out, newly_acked_sacked) + 1);
    }
    tp->snd_cwnd = tcp_packets_in_flight(tp) + sndcnt;
}

我們看下newly_acked_sacked,prr_out,prr_delivered,delta分別是多少?
newly_acked_sacked:等於1。被SACK了1個段。
prr_out:等於0,自發現ACK為dubious,還沒有傳輸一個段。
prr_delivered:等於1。
delta:等於3。很簡單的(5-2)。
我們算一下sndcnt吧,min(delta,max(prr_delivered-prr_out, newly_acked_sacked)+1),它等於min(3,max(1-0,1)+1)=2!最終的結果呢?擁塞視窗的值為in_flight+sndcnt!即in_flight+2!也就是說,可以額外多傳送2個段!這就是我們抓包的結果。
        額外的,我們可以看到PRR優於Rate Halving的地方,它可以動態算出來適合發多少段,而不僅僅是遵循資料守恆以及Rate Halving之間的較保守者。
        我們接著進行下去,進入重傳邏輯的時候,in_flight為2,而擁塞視窗為4,當它傳送了1個段後,in_flight為3,而擁塞視窗依然是4,然後可以再發送一個段,in_flight為4,擁塞視窗為4,退出!因此只是傳送了兩個段!而不是傳送了標記為LOST的5個段!
        是嗎?是的!我對這種玩意兒不感興趣,太TMD無聊!人活著,難道不該為一些有意思的事情付出多點時間嗎?我不明白為何大多數人總是會對顯而易見的事實產生質疑,但這種質疑浪費的不只是我的生命,更多的是經理的!
        好吧,這個週末太TMD假,我為了我的OpenVPN,扯出了packetdrill,進而又是TCP,...本想寫一篇軟文來催淚的,看來也算了吧,不管怎樣,我只要一想起OpenVPN,思緒就會延展兩年半...

附:為什麼我又重啟了將OpenVPN放入核心的“妄想”?

我去年離開了OpenVPN專案,後續的一切關於OpenVPN的問題我也不再跟進,然而這並不代表我對其不聞不問,我對OpenVPN專案還是感情很深的。當我得知我的多執行緒OpenVPN會有各種各樣的問題時,我想到了他們會用多程序來解決,用不用nf_conntrack我不知道,起碼多執行緒是無力解bug了,本來OpenVPN程式碼就夠亂,經我多執行緒化,亂上加亂!
        然而如果使用多程序,將會有一個很猛的特性無法使用,這就是快速重連。參見我之前的文章,你會知道,快速重連機制不再使用協議棧可識別的五元組來識別客戶端,而是使用一個應用層的Session ID來識別客戶端,如果使用多程序的話,就需要在多個程序之間同步這些Session ID資訊,而OpenVPN的程式碼框架讓這一切變得很難!其實,理論上,共享記憶體可以快速搞定這個問題,但是OpenVPN的混亂垃圾程式碼不適合你去用共享記憶體,當前我聽到的一個最大快人心的訊息就是,我的前同事和朋友已經決定用Nigix重構OpenVPN了,據我所知,進度還挺順利,V0.1已經可以跑起來了,這是一件多麼令人欣慰的事情,爆炸。
        但是對於我而言,我還有另一套方案,其實我在去年已經步入瞭解決這個問題的邊緣,那就是核心態的OpenVPN協議的實現!為什麼?因為核心態可以訪問所有的記憶體,記憶體完全共享,我只要實現一張hash表就好了吧,是的,而且我搞定了。雖然在純技術方面,我覺得自己的做法還挺好,但是很難實施,因為很多的系統,並不允許你去任意動核心的。所以說,請不要聯絡我索要程式碼,我依然只是自己玩玩。
        如果你因為不精通一系列的框架而不能寫應用程式亂搞,那麼你就必須有能力在核心裡面胡來!