談到什么是意義,話題總顯得很大,近日每晚都和老城里的朋友聊老城的文化,老城的老房子,老城的叫賣聲,老城的方言…進行了很多的思考,也挺充實。至于技術方面,也有跟朋友以及前同事聊過,這些都是意義。又到了周末,早早起來寫一篇技術總結,至于老城的話題,我會在朋友圈零零散散地寫。
本文關鍵詞:Linux策略路由,nf_conntrack,socket,路由緩存
再談“哪里來的回哪里”
當人們部署雙線服務器時,比如一根線接電信,一根線接聯通,人們當然不希望同一個流的流量被跨運營商路由,一個自然而然的需求就是哪里來的回哪里,為了實現這個需求,一般采用的技術是策略路由(Policy Routing),基于Linux,我們可以通過下面的范式來實現:
iptables -t mangle -A PREROUTING -j CONNMARK --restore-mark
iptables -t mangle -A PREROUTING -m mark ! --mark 0 -j ACCEPT
iptables -t mangle -A PREROUTING -i XXX .... -j MARK --set-mark X
iptables -t mangle -A PREROUTING -m mark ! --mark 0 -j CONNMARK --save-mark
iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark
ip rule add fwmark X table X
ip route add $net/$mask via $gw dev $device table X
這樣,我們就可以通過不同的標簽來識別不同線路的流量,從而通過策略路由配置多個不同的默認網關。
以上這些在Linux運維圈子里幾乎成了一個典型的認知,但是仔細看上述的范式,好像少了點什么…
從一個錯誤的分析說起
如果我通過電信的線路去telnet一個在服務器上并不存在的端口,服務器會產生一條destination port unreachable的ICMP錯誤信息,然而這條ICMP信息嚴格來講并不屬于這個telnet引發的TCP流,因此貌似范式中的以下規則并不會起作用:
iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark
因此,后面的策略路由便不會被命中,最終這條ICMP報錯信息并不一定會通過電信的線路返回,這完全不符合我們的預期,怎么辦?
事情將在此反轉。
如果你親自試一下,并且加入下面的規則調試:
iptables -A OUTPUT -m mark ! --mark 0x0 -j LOG
發現ICMP消息也被打上了標簽,也就是說,我們上述的擔心并不真的存在!難道上面的邏輯分析哪里不對嗎?
在知識構成有缺失的時候,邏輯分析非常不靠譜,此時實際動手試一下更顯的真實!我之所以強調這句話,是因為這是我2013年時面對上述問題最終解決后的總結,它對我十分有用。
上面看似嚴謹的邏輯分析之所以是錯的,是因為我當時根本就不知道nf_conntrack實現的細節,更不曉得什么是RELATED狀態…這個ICMP錯誤消息雖然不屬于telnet引發的TCP流,但它跟該TCP流卻是RELATED的,而RELATED的流會繼承原始流的conntrack結構體表項,這就是問題的根本,如果缺失了這個細節,就會帶來錯誤的判斷。
在這里分析RELATED實現的細節會顯得喧賓奪主而不合時宜,我會在本文的附錄中給出詳細的解釋。
在初步理解了RELATED狀態的流域引發它們的原始流之間的關系后,我接下來給出另一個范式。
另一個范式-把ICMP錯誤信息引流到固定的地方
不要回答,但要審計!“不與陌生人說話”是信息安全領域的最高要求之一,讀過《三體-黑暗森林》的應該有所體會,保持沉默永遠是最安全的。
如果有人偽造源IP地址發動了針對你的服務器的攻擊,你難道真的要給這個偽造的源回復一條ICMP報錯消息嗎?不,這么做相當于你給陌生人說話了,偽造源的攻擊者正等著收到你的ICMP信息呢,至少他可以通過IP頭的TTL字段知道你離他真正有多遠吧…因此最好不要回答!
然而,我們卻可以把這個ICMP報錯消息發送到我們自己特定的審計服務器里,便于審計服務進行離線的模式分析,所以說這個ICMP消息某種意義上還是有用的。實現這個需求的方案依然是策略路由,和哪里來的回哪里范式不同的是,這個新的范式只需要標記由于錯誤的IP報文引發的ICMP報錯消息報文即可,并不需要標記原始的數據包,我先把范式列如下:
# 連接的第一個包記錄mark到conntrack
iptables -t mangle -A PREROUTING -m state --state NEW -j MARK --set-mark X
# 短路規則
iptables -t mangle -A PREROUTING -m mark --mark 0 -j ACCEPT
iptables -t mangle -A PREROUTING -j CONNMARK --save-mark
# 待將mark保存在conntrack中的任務完成后,解除mark,不影響原始流
iptables -t mangle -A PREROUTING -j MARK --set-mark 0
# 如果是ICMP related包,就先restore標簽,這里會恢復mark標簽到skb
iptables -t mangle -A OUTPUT -p icmp -j CONNMARK --restore-mark
# 配置策略路由
ip rule add fwmark X table X
# 審計服務器的路由
ip route add $net/$mask via $gw dev $device table X
嗯,以上的范式可以完美做到將發生的ICMP錯誤消息路由給審計服務器,值得注意的是,在部署審計服務器的時候,最好將其部署在獨立的區域,即通過服務器的某個網口僅能夠到達審計服務器。
雖然,我們完成了任務,然而,當需求滿足了的時候,便要考慮性能優化了。好吧,又一次,我們遇到了conntrack,不可避免地,有人要問,“如果不用conntrack,如何滿足上述兩個范式中滿足的需求,我真是受夠了conntrack了,能不用它就不用它”,這里先給出答案,完全可以!誠然,說conntrack不好的人可能并不是真的知道conntrack到底差在哪里,更別說它為什么差勁了,大多數情況,這些人都是聽別人說的,因此這并不能作為不用conntrack的理由,這就好比說別人說奧迪燒機油不要買,我聽了之后就徹底看扁這個品牌了,不,不是這樣,在說一個東西不好之前,你首先要了解它。
conntrack的毛病到底在哪兒
我可以客觀的說,conntrack完全沒毛病,說它有性能問題完全是胡扯,因為既然你想到了用conntrack幫你解決的問題,那就說明你還沒有到拼性能的時候,如果真到了拼性能的地步,別說conntrack,連整個內核協議棧都需要被繞過去,君不見諸多DPI(深度包解析)平臺,有哪個是基于傳統的Linux內核協議棧的,一般的思路難道不是眾核平臺結合DPDK嗎?
我個人認識到以上這一點是經歷了一個比較久的過程的,起初我也嘗試著用conntrack來完成一個諸如短路,信息分析和記錄這種事,后來我發現使用nf_queue來將skb上推到用戶態,在用戶態處理后再注入會更方便也更穩定,這個時候conntrack又退回到它本來的位置了,在接下來的優化中,我發現幾個鎖的開銷是繞不開的,在一番深思熟慮之后,我為conntrack加入了percpu的cache,性能得到了大幅提升,然而此時的瓶頸成了路由查表或者socket查表…總之,如果追求極致的性能,Linux內核協議棧可能是無法勝任的,在2014年初的時候,我接觸到了Tilera平臺,跟DPDK一樣的原理,只是沒有后者通用,基于這些平臺的解決方案無一例外地都是繞過傳統的內核協議棧,直接從網卡中把數據包拉到用戶態,充分利用核數越來越多的CPU,這是一種新的模式,它解決了傳統協議棧無法解決的在多核平臺上鎖的問題。做個比喻,DPDK的方式不僅僅是一輛代步的質量好的轎車,而是一輛不以代步為目的的專用跑車,可能它很便宜,但它仍然是跑車。
簡簡單單說conntrack性能差,就跟說寶馬3系比奧迪A4L要好一樣無聊。插曲在這里說正合適。
近期老婆要換車,主要在寶馬3系/4系和奧迪A4L/A5之間選擇,大多數人千篇一律地說什么寶馬操控上要比奧迪好什么什么的,我相信除了極少數人,絕大多數人都是聽別人說的,后來老婆的一個懂車的朋友推薦買奧迪,并說了句實話,“前驅或四驅或更穩一些,應對路況的突變或者雨雪天會更好,至于操控?!你以為你開賽車玩漂移啊?!”,是的,也就30萬,40萬的車,還沒到拼百公里加速的層次,就是個普通的質量好一些的代步工具而已,這個價位的車子主要功能就是代步,而不是品玩,簡單點說,它們都是差不多的,對手車之間差別不可能太大,人人也都不是傻子,安全舒適好看要比比拼那些相差毫厘的參數更重要。所以買哪個還看自己的品牌認同感,以及看哪個順眼了。我不懂車,還沒我老婆懂,關于換車的事,我就不摻和了,但我懂Linux協議棧和內嵌的nf_conntrack,下文中我就說說conntrack如果有問題,那么它的問題到底在哪里。
和內核協議棧其它的部分一樣,影響conntrack機制性能的罪魁禍首,簡單點說就是該機制的實現中內置的1把全局自旋鎖(請注意,自旋鎖問題并不是conntrack機制獨有的!但凡多核平臺,這就是個惡魔),即nf_conntrack_lock,該自旋鎖在3個地方會被lock,我分別說。
1. conntrack表項初始化時
當一個數據包進入協議棧并且沒有關聯任何conntrack表項時,會為其初始化一個conntrack表項結構體,雖然它還沒有必要被立即confirm,然而還是會被置入一個鏈表,整個過程大致如下:
init_conntrack()
{
spin_lock_bh(&nf_conntrack_lock);
// 這個expect機制我會在附錄里詳述,這里僅僅了解到所有expect流都在一個全局鏈表中即可
exp = nf_ct_find_expectation(net, zone, tuple);
if (exp) {
__set_bit(IPS_EXPECTED_BIT, &ct->status);
ct->master = exp->master;
}
// 為了管理方便,所有剛剛初始化的conntrack結構體都要置入一個叫做unconfirmed的鏈表中
hlist_nulls_add_head_rcu(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode,
&net->ct.unconfirmed);
spin_unlock_bh(&nf_conntrack_lock);
}
2. conntrack表項被confirm時
記住,unconfirmed鏈表僅僅是為了讓一個conntrack結構體可被追溯,而不至于脫離管理而游離,當它最終被confirm的時候,就意味著它要加入全局conntrack鏈表了,此時便可以將它從unconfirmed鏈表中安全摘除了。在confirm例程中,大致的邏輯如下:
__nf_conntrack_confirm()
{
spin_lock_bh(&nf_conntrack_lock);
// 從unconfirmed鏈表摘除
hlist_nulls_del_rcu(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode);
// 加入全局的confirm鏈表
__nf_conntrack_hash_insert(ct, hash, repl_hash);
spin_unlock_bh(&nf_conntrack_lock);
}
3. conntrack銷毀刪除的時候
這個不多說,非常顯然。
—————————————
以上幾處需要lock/unlock自旋鎖的地方意味著什么呢?
首先只有conntrack結構體在初始化創建或者confirm的時候,才會lock/unlock這把自旋鎖,如果說當前系統中的連接都是既有的且穩定的時候,這把自旋鎖根本就不會被觸動,因此執行流便根本不會落入它所帶來的串行化瓶頸區域,這就意味著這種情況下conntrack機制的瓶頸是不存在的,至少說是不嚴重的,懂了嗎?
那么什么時候使用conntrack才會影響性能?
很簡單,有大量連接新建或刪除的時候,比如說遭遇了DDoS攻擊的時候,比如說大量短鏈接的時候,比如說同時大量TCP timewait連接的時候…
對于另外的情況,也是有很多方案可化解的,比如汗牛充棟的解決TCP timewait的方案,比如HTTP協議將短鏈接聚合成長連接的方案(多個HTTP請求重用單獨的TCP連接)…只要我們避免了這類情況,就不必擔心conntrack帶來的性能損耗,然而,理想歸理想,你永遠也不能預料什么時候會有一個什么樣的數據包到達你的服務器,就像你永遠不能預料什么時候有什么人會敲你家的門一樣,所以說,這個全局自旋鎖的開銷平均下來是非常可觀的。Netfilter開發社區的猛士當然知道這個問題,當廣大使用者正在糾結于conntrack全局鎖如何在應用層面避開的時候,社區的內核開發者們早就在機制層面給予了優化,這是一件多么幸運的事情。
我們來看看優化的細節,告訴大家,Linux 3.10內核還沒有這個優化,但是發現4.3往后的內核就有了(我沒有具體確認從哪個版本開始引入了這個優化,手頭上有一個4.3版本的內核,確認了一下,這個優化已經被引入),所以還是那句話,盡量升級你的內核到最高版本吧。
優化的本質在于自旋鎖的細粒度拆分,仔細想想,unconfirmed鏈表需要全局鎖嗎?它只是為了確保conntrack結構體不要脫韁,因此只需要本地保存即可,沒必要搞成全局的。說再多不如看代碼。所以說新的優化版本邏輯如下:
init_conntrack()
{
if (net->ct.expect_count) { // 這里加了一個條件,確保只有在確實需要查詢expect鏈表的時候才會鎖定額外的全局自旋鎖,這是另一個優化
// 注意,這里仍然有個全局鎖,但是卻不是每次都必進的分支,因為新增了expect_count這個條件變量
spin_lock(&nf_conntrack_expect_lock);
if (exp) {
__set_bit(IPS_EXPECTED_BIT, &ct->status);
ct->master = exp->master;
}
spin_unlock(&nf_conntrack_expect_lock);
}
// 注意,以下的inline函數將全局的spinlock分解成了局部的percpu spinlock
{
ct->cpu = smp_processor_id();
pcpu = per_cpu_ptr(nf_ct_net(ct)->ct.pcpu_lists, ct->cpu);
// 僅僅鎖定本地的自旋鎖
spin_lock(&pcpu->lock);
// 僅僅將conntrack加入到本地的unconfirmed鏈表中
hlist_nulls_add_head(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode,
&pcpu->unconfirmed);
spin_unlock(&pcpu->lock);
}
}
可以看到,unconfirmed鏈表成了percpu本地鏈表了,因此自旋鎖的鎖定粒度大大減小了,這并不影響其它的執行邏輯,比如當需要dump所有的unconfirmed表項時,完全可以先鎖定全局的自旋鎖再獲取,要知道,全局自旋鎖是可以囊含本地自旋鎖的。好了,我們再看下優化后的confirm例程:
__nf_conntrack_confirm()
{
{
// 從conntrack的cpu字段獲取當初它加入的本地cpu(conntrack結構體中保留cpu字段是個創舉)
pcpu = per_cpu_ptr(nf_ct_net(ct)->ct.pcpu_lists, ct->cpu);
spin_lock(&pcpu->lock);
// 從conntrack當初加入的cpu的本地unconfirmed鏈表刪除
hlist_nulls_del_rcu(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode);
spin_unlock(&pcpu->lock);
}
{
// 全局鏈表的自旋鎖也分解成了orig以及reply兩個方向的鎖
spin_lock(nf_conntrack_locks_orig);
spin_lock(nf_conntrack_locks_reply);
__nf_conntrack_hash_insert(ct, hash, repl_hash);
spin_unlock(nf_conntrack_locks_reply);
spin_unlock(nf_conntrack_locks_orig);
}
}
通過以上的事實以及針對這些事實的論述,會發現影響conntrack性能的就是自旋鎖,然而隨著自旋鎖在不斷的細粒度化分解,這些問題將越來越不是問題,我相信未來追究會解決所有關于conntrack的性能問題的。
也許你會說,除了鏈表插入,刪除的自旋鎖開銷,難道不得不做的查詢行為沒有開銷嗎?哈哈,開銷肯定是有的,但是你難道就不會變通一下嗎?查什么不是查,就算你避開了查conntrack鏈表,難道你能避開查路由表或者socket鏈表嗎?所以說,干嘛不把conntrack當成一個cache呢?把路由表以及socket表的查詢結果都保存在conntrack表項中,這樣就可以只查conntrack鏈表了,順帶取出的還有路由(轉發時)以及socket(本地接收時),這個優化我親自實現過,效果非常好,并且conntrack是所有包括路由表和socket鏈表在內第一個被查詢的,所有這樣的優化非常容易實現,沒有實際操作過的人就不要人云亦云地詬病conntrack了,先試試我的方案再說!Together with L4_early_demux!
看到conntrack在持續優化,我就欣慰了,因為這樣我就可以將下文的方案作為另一種稍微好的做法,而不至于作為不得已的退避手段了。好了,接下來讓我們看一下如何不使用conntrack來實現‘哪里來哪里去’
不使用conntrack實現“哪里來哪里去”范式
接著上一節的最后論調繼續說。conntrack連接跟蹤記錄了一個五元組,而對于一個服務器而言,一個socket在全部意義上就扮演了conntrack的角色,因此在服務器上,即那些非轉發設備上,可以讓socket替代conntrack表項來保存一些必要的信息。在這些信息中,對于策略路由而言最關鍵的信息就是mark!
昔日,我們用iptables為匹配的skb打上mark,然后將mark保存在conntrack結構體中,此后對于reply方向的包就可以將conntrack的mark給restore到skb中,對于服務器而言,我們完全可以把匹配的skb的mark保存在socket中,更加輕松的是,一旦socket有了mark,只要是本socket發出的skb,就會被自動打上相應的socket的mark,根本連iptables的restore-mark規則都不需要!
這簡直是如魚得水,全程沒有使用conntrack,讓某些詬病conntrack的吃瓜群眾放了心。
在具體實現上,上一節同樣說過,查什么不是查,只要保證查一次即可,既然在early demux以及L4 recv函數中都能查socket,那么我們自己寫一個iptables的target,在該target中實現如下的邏輯豈不是妙哉:
sk = nf_tproxy_get_sock_v4(net, skb, hp, iph->protocol, iph->saddr, laddr, hp->source, lport, skb->dev, NFT_LOOKUP_LISTENER);
if (sk) {
sk->sk_mark = mark_value;
}
請注意,mark并沒有為skb來打,而是直接打給了socket,這是為了該socket往外發包時,這些數據包會自帶該mark!這里的socket就完全扮演了conntrack的角色。
如此一來,只要當進入的數據包匹配了iptables的規則,那么既定的mark就會被打入socket,然后該socket在發包的時候,會自動繼承這個mark,從而去匹配特定于mark的策略路由表等。
這是一種實現“哪里來哪里去”范式的良好手段,然而對于實現第二個范式,即為ICMP錯誤消息設定策略路由這種事就不好辦了,因為ICMP消息的發送完全是內核來自動完成的,并不是通過某個socket來發送的。這個并沒有好的手段來實現,因此必然要修改代碼才能實現,我側重于修改icmp_send函數:
void icmp_send(struct sk_buff *skb_in, int type, int code, __be32 info)
{
...
// 這里在查路由表時并沒有體現出mark的意義
rt = icmp_route_lookup(net, &fl4, skb_in, iph, saddr, tos, type, code, &icmp_param);
...
}
因此我只需在icmp_route_lookup增加mark就好了,也比較簡單,就是取skb_in的mark字段作為參數傳入即可…
本來我正準備改代碼并編譯呢,在此之前,我看了4.9/4.14的代碼…所有這些都已經實現了:
void icmp_send(struct sk_buff *skb_in, int type, int code, __be32 info)
{
...
mark = IP4_REPLY_MARK(net, skb_in->mark);
sk->sk_mark = mark;
rt = icmp_route_lookup(net, &fl4, skb_in, iph, saddr, tos, mark,
type, code, &icmp_param);
...
}
代碼就不用注釋了。關鍵在于IP4_REPLY_MARK宏:
#define IP4_REPLY_MARK(net, mark) \
((net)->ipv4.sysctl_fwmark_reflect ? (mark) : 0)
啊哈,抓住了大魚!就是sysctl_fwmark_reflect,sysctl參數名是net.ipv4.fwmark_reflect,是不是在高版本中我只需要把net.ipv4.fwmark_reflect設置成1就OK了呢?答案顯然是肯定的。
因此,使用sysctl的fwmark_reflect參數來實現第二范式可以完美解決!那么第一范式呢?顯然剛才我們就知道,第一范式可以通過重新編寫一個target模塊來解決,而不是用參數來解決,但是既然可以用參數解決ICMP消息的標記問題,是不是也有參數能滿足第一范式的需求呢?即便沒有,我覺得應該也不難,自己實現的話,最多也就一天吧,原因很簡單,只需要把conntrack中轉交mark的邏輯置于socket中即可。無比幸運的是,就連這也都不需要我自己來做了,系統針對TCP早就有了這樣的支持機制,這就是net.ipv4.tcp_fwmark_accept所起的作用。
net.ipv4.tcp_fwmark_accept參數實現“哪里來哪里去”范式
我先給出范式的配置方法吧,列如下:
# 開啟mark轉交功能
sysctl -w net.ipv4.tcp_fwmark_accept=1
# 僅僅為新入的SYN包來進行標記,后續包無需標記,因為fwmark_accept會將mark賦值給socket
iptables -t mangle -A PREROUTING -i $dev1 -p tcp --syn -j MARK --set-mark $mark_dev1
iptables -t mangle -A PREROUTING -i $dev2 -p tcp --syn -j MARK --set-mark $mark_dev2
# 配置策略路由
ip rule add fwmark $mark_dev1 tab dev1
ip rule add fwmark $mark_dev1 tab dev1
# 分別添加路由項
ip route add default via $gateway1 dev $dev1 tab dev1
ip route add default via $gateway2 dev $dev2 tab dev2
我打算本節就此打住,但是還是忍不住想再多說一點。
tcp_fwmark_accept這個參數帶來的功能是如何實現的呢?非常簡單,TCP連接在初始化一個request socket的時候,會調用以下函數來為這個將來的客戶端socket選擇一個mark:
static inline u32 inet_request_mark(const struct sock *sk, struct sk_buff *skb)
{
if (!sk->sk_mark && sock_net(sk)->ipv4.sysctl_tcp_fwmark_accept)
return skb->mark;
return sk->sk_mark;
}
可見,skb上的mark在tcp_fwmark_accept參數啟用的情況下成為首選。
本節畢!
socket上的路由cache
很久以前,在我想到將路由查詢的結果放在conntrack結構體中的同時,我就想到在作為服務器的場景下,把路由查詢結果放在socket中了,但其實,Linux在實現四層協議時,天然地就支持一個路由cache。對于TCP而言,系統會將SYN-ACK的結果路由緩存到socket中,這是通過下面的代碼實現的:
tcp_v4_syn_recv_sock()
{
// 接上一節,在tcp_fwmark_accept開啟時,req中便已經有了mark了,會影響接下來的路由查找
dst = inet_csk_route_child_sock(sk, newsk, req);
// 緩存路由到socket!
sk_setup_caps(newsk, dst);
}
非常干凈直接的一個邏輯,但是同樣要考慮到的是,既然是緩存,就有什么時候過期刪除的問題,TCP和IP很好的處理了這一點,在使用這個路由緩存的時候,會進行判斷,我們來看下ip_queue_xmit函數:
ip_queue_xmit()
{
rt = skb_rtable(skb);
if (rt) // 這是已經路由好的數據包
goto packet_routed;
// 這里是重點,在使用socket的路由cache前,首先要check一下。
rt = (struct rtable *)__sk_dst_check(sk, 0);
if (!rt) {
}
有兩種情況會導致socket的路由緩存失效:
1. 系統路由表發生了變動
這是很好理解的,在實現上也很精妙。系統維護了一個全局計數器,每次路由表變動時都會遞增,而每一個路由緩存都有一個ID字段,其值就等于當前的全局計數器的值,在使用路由緩存前發現緩存的ID和全局計數器不一致,便可將緩存廢了,說明路由表發生了變化,要重新路由了。
2. TCP連接發生了超時重傳
一般情況下,除了尾部丟包和鏈路完全堵死,TCP在丟包時都會觸發快速重傳機制的,一旦發生超時重傳,意味著發生了嚴重的事情,下層的IP層路由發生了變化只是原因之一,但是發生了嚴重事情后的行為需要更加保守,所以這個時候廢除socket的路由緩存是一個好主意。
附錄
附1:RELATED實現的細節
什么是RELATED流?
所謂的RELATED流并不是自發產生的流,而是由別的流引起的流,典型地,我將RELATED流分為兩類:
1. 可預期的帶內流
這類的典型例子就是FTP協議,在一個連接中發起另一個連接,另外還有H.323協議,SIP協議等等,也屬于這類。也就是說通過解析原始的數據包就能知道后面會有什么樣的流通過,換句話說就是這些RELATED流是可預期的。
2. 不可預期的帶外控制流
如果一個數據包在某跳發生了路由不可達事件,當前節點會向源端發送一個ICMP消息,鑒于IP是無連接無狀態的,對于它已經經過的所有節點而言,這個ICMP完全是不可預期的,只能通過分析這個ICMP數據包的內容來判斷它和哪個流相關聯。
以上兩類都屬于RELATED流,那么Linux是如何處理它們的呢?
我們先看數據包進入conntrack處理邏輯后第一個要干的事,即nf_conntrack_in里面發生的事:
nf_conntrack_in()
{
l4proto = __nf_ct_l4proto_find(pf, protonum);
// 首先調用特定四層協議的error函數
if (l4proto->error != NULL) {
ret = l4proto->error(net, tmpl, skb, dataoff, pf, hooknum);
if (ret <= 0) {
return ret;
}
}
// 然后再關聯conntrack表項
ret = resolve_normal_ct(net, tmpl, skb, dataoff, pf, protonum,
l3proto, l4proto);
...
}
從error回調的調用來看,如果收到的數據包是一個ICMP包且ICMP協議有error回調的話,它是一定會被調用的。ICMP當然有error回調:
icmp_error()
{
struct nf_conntrack_tuple innertuple...
innertuple = ICMP包內容中解析出來的引發它的原始數據包的元組
ctinfo = IP_CT_RELATED;
h = nf_conntrack_find_get(net, zone, &innertuple);
// 關聯RELATED包到原始的conntrack表項
nf_ct_set(skb, nf_ct_tuplehash_to_ctrack(h), ctinfo);
}
這意味著,對于ICMP錯誤消息而言,并不產生新的conntrack表項,而是重用引發它的原始conntrack表項,但這并不是RELATED的全部,對于可預期的RELATED流而言,事情遠沒有這么簡單。
假設來了一個新的流,并為其創建了一個新的conntrack表項,內核是怎么知道這個新的流是真正獨立的新流還是由前面某個舊的流引發的預期中的流呢?
為了實現上述的判斷,系統需要把所有預期中的流全部保存到一個容器中,然后每到來一個新流都會去檢索這個預期流容器,只要能在這個容器中被檢索到,就為其打上RELATED標簽,說明這個流并不是獨立的。新流的檢索邏輯如下:
init_conntrack()
{
... // 省略例行創建部分,這里只關注expect流
if (net->ct.expect_count) {
// 全局自旋鎖
spin_lock(&nf_conntrack_expect_lock);
// 檢索預期中的流表
exp = nf_ct_find_expectation(net, zone, tuple);
if (exp) {
// 打上RELATED標簽,過程省略
...
}
spin_unlock(&nf_conntrack_expect_lock);
}
...
}
雖然在理論上,每到來一個新流都要去檢索預期表,但是內核在這里做了一個大大的優化,如果你知道你的預期表是空的,你還要去檢索它嗎?雖然查一個空表也不費什么事兒,但問題的關鍵在于這個全局的自旋鎖,在多核環境下,100個CPU同時鎖一下再釋放,玩呢?!所以說,這個優化在于,如果沒有查表的必要,就不去查了。這個優化依托于這樣一個事實,即內核在往預期流表中添加或者刪除一個項時,它是知道這件事的,也就是說,內核知道預期表的當前項目數量,如果項目數量為0,那便可以避開全局自旋鎖了!
擼一下代碼,就知道expect_count這個變量在nf_ct_expect_insert(當特定協議【比如FTP】的helper回調中發現了一個可以預期的流時,會創建一條預期流項,插入到一個expect流表中)中遞增,這正是新建一個預期表項的時刻。
這個優化太有深意了,幾乎是一個通用的范式!
言歸正傳,但也沒幾句話了,在預期的流和不可預期的流都被打上了RELATED標識后,就可以用以下的iptables規則識別了:
iptables -t mangle -A PREROUTING -m state --state RELATED ...
以上就是RELATED機制實現的全部了。
附2:local路由表可以添加刪除啦
曾經,我抱怨過一個事實,那就是數據包到達,首先要無條件判斷的就是這個包是不是發給本機的,如果是發給本機的,除非用iptables重新折騰它,便被本機無條件接收而根本就沒有機會去查策略路由表。
但是現在,事情起了變化,local表現在可以刪除和重新添加了:
ip ru del pref 0 tab local
從而,路由表排序可以變成下面的樣子:
10: from all fwmark 0x7b lookup 123
32765: from all lookup local
32766: from all lookup main
32767: from all lookup default
這不知給多少玩家帶來了福音啊!
后記
通過世俗的方式,將宿命轉化為連續,把偶然轉化為意義!
本文原創作者:Bomb250