天天看點

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的框圖也隻是個開始,也可能包括一些疏漏,後續會不斷勘誤,完善之。圖示如下:

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

你看,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快速重傳的細節

在《通過packetdrill構造的包序列了解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表就好了吧,是的,而且我搞定了。雖然在純技術方面,我覺得自己的做法還挺好,但是很難實施,因為很多的系統,并不允許你去任意動核心的。是以說,請不要聯系我索要代碼,我依然隻是自己玩玩。

        如果你因為不精通一系列的架構而不能寫應用程式亂搞,那麼你就必須有能力在核心裡面胡來!

繼續閱讀