Poema IX 升級之旅

Poema IX 是一個非營利的分部式虛擬 IX,讓散佈在各地的節點透過 L2 overlay 連成一個大二層交換網路,提供 BGP 實驗交流和學習的平台。  

最一開始,內網用的是 FRR 的 OSPF + VXLAN + EVPN,很標準的資料中心做法

隧道疊了三層:最內層 VXLAN 做 L2 overlay,中間包一層 GRE,最外面再套 WireGuard。

為什麼要這麼多層呢?

套 GRE 是為了借用 GRE 的 nopmtudisc 選項(就是那個 DF bit 的控制)。內網可以跑 MTU 9000 的 jumbo frame,GRE 層負責切片再送出去。OSPF 跑在 GRE 層上面,用來保證節點之間的可達性
EVPN 則靠 VXLAN 做 MAC 學習和轉發。
再來,節點是 NAT 環境,所以外面再套一層 WireGuard,把整個東西變成 UDP,順便加密。

這套架構功能上是 work 的。EVPN 自動學 MAC、自動填 FDB、OSPF 自動選路,但是速度很慢

以前為了相容性,IX LAM MTU 9000,靠 GRE 層切片。三層封裝疊起來 overhead 不小,還有切片重組的開銷

現在回頭看,為了一個虛無縹緲的「相容性」犧牲速度,實在不值得

能不能只用一層 VXLAN?


我開始想:能不能把 GRE 和 WireGuard 都拿掉,只用純 VXLAN?
問題是,一般的 VXLAN 要求所有節點都能互相直連。傳統做法是靠上層 OSPF 來保證可達性。但我們的節點散佈在公網上,顯然不可能在公網直接跑 OSPF。

自己跑 OSPF 就又要多一層隧道——GRE 或 WireGuard 層跑 OSPF,VXLAN 層跑 EVPN。這不就繞回原點了嗎?

所以我在想:能不能只靠 VXLAN 本身,不依賴 OSPF,做到依據 MAC 轉發的效果?

本地測試了一下,發現只要 bridge 上面的 vxlan port 設了 hairpin,就可以做到轉發。封包從一個 VXLAN port 進來,bridge 查 FDB,如果目標 MAC 在另一個 VXLAN port 後面,就從那個 port 轉發出去。不需要所有節點全互連,只要 FDB 填對了,中間節點可以幫忙轉發。

但是馬上就碰到一個新問題。

廣播風暴

L2 有一個 L3 沒有的行為:廣播。ARP、NDP 的 Neighbor Solicitation、DHCP,這些都是廣播或組播。在傳統 VXLAN + EVPN 環境下,EVPN 知道所有節點的位置,廣播封包直接複製 N 份分發給其他所有人。因為不轉發(hairpin off),所以不會 loop。
但我們的 VXLAN 會轉發

假設 A 發了一個廣播包,B 和 C 都收到了。B 轉發給 C,C 也轉發給 B。B 收到 C 轉來的再轉給 A⋯⋯無限迴圈,直接 loop 炸掉

所以我設計了 tap-inject + multicast channel 的架構來解決這個問題。
思路是這樣的:在每個節點的 bridge 上多加一個 tap-inject port,設定 nolearning。所有的廣播/組播流量,一律由 controller 集中管理。

具體來說:

  1. client 創建 tap device 名叫  tap-inject,掛到 bridge 底下
  2. 廣播包從 tap-inject 蒐集,送給 controller
  3. Controller 負責把廣播包轉發給其他所有 client
  4. 因為廣播流量統一從 controller 出發,不會經過中間節點轉發,就不會 loop
  5. nolearning 防止 bridge 從 tap-inject 這個特殊用途的 port 學到 MAC,避免 FDB 被污染
  6. 同時 vxlan 上只有 unicast mac 的路由,不會再轉發

VXLAN Controller 的設計

有了基本思路,就開始設計完整的 VXLAN Controller。

架構分成 Controller 和 Client 兩個角色,用了 tcp/ip 上面的 4 個 port ,開4個通訊頻道

Client 端需要設定以下2個 port forward,用於 client 之間互相通訊

  • VXLAN data channel(UDP 4789):實際的資料傳輸通道,明文
  • Probe channel (UDP 4790):Client 之間互相發送 latency probe,計算單向延遲

Controller 端需要設定以下2個 port forward,用於 client 和 Controller 之間通訊

    • Multicast channel (UDP 6789 ): 上報 tap-inject 蒐集來的廣播包,下載別人的廣播包注入 bridge
    • Communication channel (TCP 6789):上報本地 MAC、下載路由表
    每個 Client 用 public key 作為身份標識。Client 啟動後和 Controller 建立 TCP 連線,上報自己擁有的 MAC address。Controller 蒐集所有 Client 的 MAC ownership 之後,用 Floyd-Warshall 演算法計算出全網的最短路徑 RouteMatrix,再把 FDB 分發給每個 Client,寫入 Linux kernel 的 bridge FDB。

    延遲探測那邊,Controller 定期發 ControllerProbeRequest,觸發所有 Client 互相探測延遲。拿到延遲矩陣之後,Floyd-Warshall 算出 RouteMatrix[src][dst] = next_hop,每個 Client 只需要知道「我收到發給某個 MAC 的封包,要從哪個 VXLAN port 轉出去」

    支援 NAT 環境

    而且我們的設計比傳統 VXLAN + EVPN 更優秀的一點是:原生支援 NAT 環境

    傳統的 VXLAN EVPN,每個節點自己上報自己的 IP 給控制平面。但如果節點在 NAT 後面,上報的是內網 IP,其他節點根本連不到。

    我們改成從 Controller 蒐集。Controller 看到 Client 的 TCP 連線來源 IP,那就是它的外網 IP。只要每個 NAT 節點設定 port forward,Controller 蒐集到外網 IP 和 port 之後分發給其他 Client,它們就能用各自的外網 IP 互連。有 DNAT,封包可以正確抵達。

    一切就緒,BGP 起不來

    所有東西都搞定以後,VXLAN Controller 跑起來,FDB 正確下發,節點之間互相 ping 也通了。

    開心地開始跑 BGP session

    結果 BGP session 起不來。

    抓包一看:ICMP 有發有收,TCP 有發沒收。

    奇怪,ping 明明是通的

    更詭異的是,ping -M do -s 1394 這種大封包也完全沒問題。所以不是 MTU 問題,不是路由問題,封包確實有在走 VXLAN overlay。就是 TCP 不通


    直覺反應是 ISP 的防火牆在搞鬼。畢竟 VXLAN 封裝後的 UDP 封包裡面包著完整的 Ethernet frame,DPI(Deep Packet Inspection)如果解開來看到裡面有 TCP,可能會覺得可疑。

    換了 port 也沒用。開始懷疑是 DPI 防火牆

    nftables XOR 改包

    既然懷疑是 DPI,那就把封包混淆一下。

    我開始用 nftables 的 @th payload expression 做 XOR 混淆。思路很簡單:在 VXLAN 封包發出去之前,用 nftables 對 payload 做 XOR,對面收到之後再 XOR 回來還原。DPI 看到的就是一堆亂碼,認不出裡面是 TCP

    XOR 的範圍要考慮清楚。VXLAN header 之後就是內層 Ethernet frame,從 inner Ethernet header 開始 XOR,這樣 DPI 看不到內層的 protocol 資訊。

    但是 XOR 改了 payload 之後,UDP checksum 就不對了。所以還要處理 checksum。nftables 有 update @th 可以改 checksum,但順序很重要:要先把 UDP checksum 清零,再做 XOR,不然算出來的 checksum 是錯的。

    在本地用 network namespace 測試都通。搬到生產環境不通

    反覆調整 XOR 的範圍、checksum 清零的順序、nftables 規則的 chain priority⋯⋯無論怎麼改,本地 netns 模擬環境都通,生產環境就是不通

    放棄 nftables,改 userspace

    nftables 的方案折騰了很久,實在是通不了。

    決定換個思路:既然 kernel 層面搞不定,那就寫一個 userspace 的 udp-obfus

    架構也不複雜:用 TUN device 建一個 udp-obfus 介面,VXLAN 封裝後的封包透過 policy routing 導到這個 TUN device。udp-obfus 在 userspace 讀出封包、做 XOR 混淆、從自己的 UDP socket 發出去。對面的 udp-obfus 收到後做逆向 XOR 還原、寫回 TUN device、注入回 kernel 網路棧。

    Policy routing 的部分用 ip rule 配合 fwmarkSO_MARK,把 VXLAN 封包(sport/dport 4789)導到 table 1900,table 1900 的 default route 指向 udp-obfus 這個 TUN device。udp-obfus 自己發出去的封包帶不同的 mark,不會被自己的 rule 捕獲,避免無限迴圈。

    套上去以後——通了!BGP session 起來!TCP 握手成功!

    但速度只有 400 Mbps
    Userspace TUN 的 overhead 太大了,每個封包都要經過 kernel → userspace → kernel 的來回拷貝。

    不過至少確認了一件事:ISP 確實有在搞 DPI(當時我這麼認為),因為混淆之後就通了


    eBPF 版本

    既然 userspace 太慢,那就上 eBPF。

    用 TC eBPF hook 掛在網卡的 egress/ingress 上,直接在 kernel 的資料路徑上改包。不需要 TUN device,不需要 userspace 拷貝,性能應該接近 line rate。

    eBPF 讀 UDP payload、XOR、更新 checksum。用 Aya (Rust 的 eBPF 框架) 寫好,載入、掛載⋯⋯

    又不通了

    Partial Checksum 問題

    抓包開始分析。發現封包確實有經過 eBPF 處理,XOR 也做了,但對面收到之後 checksum 驗證失敗。

    仔細一查,撞到了 partial checksum offload 的問題。

    Linux kernel 在處理封包的時候,為了效能,不一定會在軟體層算完整的 checksum。如果封包最終要透過支援 checksum offload 的網卡發出去,kernel 會在 skb 裡標記「checksum 還沒算完,只算了 pseudo header 的部分,剩下的讓網卡硬體算」。這就是 partial checksum。

    eBPF 在 TC hook 上改了 payload,但 skb->csum 記錄的 partial checksum 值沒有更新。網卡最後根據過時的 partial checksum 算出來的結果自然是錯的。

    Partial checksum 的運作方式:

    1. Kernel 產生封包時,計算 pseudo header checksum(src IP + dst IP + protocol + length),存在 skb->csum
    2. skb->ip_summed 設成 CHECKSUM_PARTIAL
    3. 網卡收到 skb 後,根據 skb->csum_startskb->csum_offset 知道要算哪段,用硬體加速完成剩餘的 checksum 計算
    4. 最終的 checksum = pseudo header checksum + payload checksum

    eBPF 改了 payload 但沒更新 skb->csum,步驟 4 算出來的就是錯的。

    在 eBPF 裡正確處理 partial checksum 需要用 bpf_l4_csum_replace 或手動計算差值更新 skb->csum

    靈光一現

    改了 checksum 之後,有些節點通了,有些還是不通

    不通的節點有什麼共同點?
    我突然想到:不通的節點上網卡都比較高級! 基本上都是 Mallonx CX4 等級的卡

    開始懷疑 offload 在搞怪。但很奇怪, offload 會什麼會把封包弄的無法通訊?
    然後靈光一現,會不會是 NAT導致 offload 壞掉?

    但是  KSKB-Home 節點也有 NAT ,能正常通訊

    最終修復

    最後,我決定先不管了。直接在所有節點上面執行.

    ethtool -K eth0 tx off

    然後把 nftables XOR、userspace udp-obfus、eBPF 混淆全部撤掉。
    裸的 VXLAN,不做任何混淆。

    全部都通了。 BGP session 全起來了。TCP 完美握手。速度跑滿

    真相大白

    至此問題弄清楚了

    我們的環境裡,節點的網路拓撲是這樣的:

    • 宿主機bridge:
      • VM-IXNode: VXLAN endpoint,跑 VXLAN Controller client,做 L2 overlay
      • VM-Router: 軟路由做 NAT ,負責幫 VM-A 做 SNAT 出公網+dstnat

    IXNode 和 Router 都跑在同一台 PVE 宿主機上,之間透過 veth / virtio bridge 互連。VM-IXNode 的 VXLAN 封裝完的 UDP 封包,default route 指向 Router,Router 做 SNAT 改外層 src IP 之後從自己的 eth0 出去走物理網卡。宿主機本身不做任何改包,單純橋接。

    問題路徑

    封包的完整路徑:

    1. VM-IXNode:VXLAN 封裝完成,kernel 產生 UDP 封包。因為 IXNode 的 eth0 是 virtio,kernel 不算完整 checksum,標記為 CHECKSUM_PARTIAL(只算了 pseudo header 的部分,剩下的留給硬體算)
    2. 封包帶著 partial checksum 經過 virtio → 宿主機 bridge → virtio → 到達 VM-Router
    3. VM-Router 的 netfilter 做 SNAT,改寫了外層 src IP
    4. NAT 邏輯增量更新了 skb->csum(用新舊 IP 的差值調整),對外層 UDP checksum 來說是正確的
    5. 封包從 Router 的 virtio eth0 出去,到達物理網卡
    6. 物理網卡偵測到 CHECKSUM_PARTIAL 執行 vxlan offload

    問題就出在最後一步。

    物理網卡收到這個帶 CHECKSUM_PARTIAL 的封包,要幫忙算完 checksum。如果網卡支援 VXLAN tx offload,它知道 UDP payload 裡面是 VXLAN 封裝,會嘗試同時處理外層 UDP checksum 和內層 TCP/UDP checksum

    但 VM-Router 的 NAT 已經動過 skb->csum 了——這個增量更新對外層 UDP 是正確的,但網卡用同一個被動過的 skb->csum 去推算內層 TCP checksum 時,base 就是錯的。

    結果:外層 UDP checksum 是對的,但內層 TCP checksum 是錯的。

    為什麼會有問題

    VXLAN offload 原本的設計假設:VXLAN 封裝後的封包不會再經過 NAT

    在正常的資料中心環境裡,VXLAN underlay 就是直連的 L3 網路,不需要 NAT。所以 offload 邏輯根本沒考慮過「外層被 NAT 改寫」的場景。

    但我們的架構裡,VM-IXNode(VXLAN endpoint)和 VM-Router(NAT router)是兩台獨立的 VM,VXLAN 封裝後的封包必須經過 VM-Router 做 NAT 才能出公網。這直接違反了 offload 的假設。

    回頭看所有的線索,解釋都說得通:

    ICMP 能通但 TCP 不通——問題出在 VXLAN offload 對內層封包 checksum 的處理。網卡做 VXLAN offload 時會試圖幫內層的 TCP/UDP 算 checksum,但因為外層被 NAT 改過,算出來的就是錯的。而 ICMP 的 checksum 不走同一套 offload 路徑,所以不會壞

    大 ICMP 包也能通——因為 ICMP 不走同一套 offload 路徑。

    本地 netns 測試都通——因為 veth pair 之間不經過 NAT,也不走硬體 offload。CHECKSUM_PARTIAL 在 veth 上會被軟體完成,不會留到硬體層。

    Userspace 混淆(udp-obfus)套上去就通了——這是最迷惑的。TUN device 不支援 checksum offload,封包進入 TUN 的時候 kernel 會計算完整 checksum,先計算 checksum 再 NAT ,所以通了——但原因不是「混淆繞過了 DPI」,而是「TUN 提前觸發 checksum 計算」


    後來仔細回想各個節點的物理拓撲,才發現關鍵差異。

    我們的 IX 有四個 backbone 節點:KSKB-Home、Jord-Home、Luobo-TWDS、JK-Lab。每個節點都有一台 VM 跑 VXLAN(稱為 VXLAN node),封包要出公網通常還需要經過一台 NAT 軟路由(稱為 NAT router)做 SNAT。

    但各節點的物理部署方式不一樣

    • KSKB-Home:VXLAN node 和 NAT router 跑在不同的物理機
      • VXLAN node -> 物理網卡(inner checksum) -> NAT router -> 物理網卡 -> 外網

      • 封包離開 VXLAN node 的 VM 時,virtio 把封包交給物理網卡,物理網卡在這一步就把 partial checksum 算完了。等到封包抵達另一台物理機的 NAT router 時,checksum 已經是完整的。NAT 改外層 header 時做的增量更新,是基於一個正確且完整的 checksum,所以不會出問題。

    • Jord-Home:直接拿公網IP,沒有 NAT。完全不會有 checksum 被動到的問題

    • Luobo-TWDS / JK-Lab:VXLAN node 和 NAT router 跑在同一台物理機的不同 VM 上
      • VXLAN node -> 虛擬網卡 -> NAT router -> 物理網卡(inner checksum) -> 外網

      • 這就是出問題的拓撲——封包從 VXLAN node 出來,走 virtio → 宿主機 bridge → virtio 到達 NAT router,全程都在同一台物理機內部,沒有經過物理網卡。partial checksum 一路帶著沒被算完,NAT router 改了外層 header 污染了 skb->csum,最後到物理網卡時內層 TCP checksum 就算錯了。

    導致 KSKB-HOME <-> JORD-HOME 可以正常通訊,但 LUOBO-TWDS / JK-Lab 節點一直無法正常通訊

    只有部分節點不通 ,KSKB-Home ↔ Jord-Home 完全正常,但牽涉到 Luobo-TWDS / JK-Lab 的就不通。當時以為是「那邊的 ISP 比較嚴格」,其實是那邊的物理拓撲不一樣

    「有些 pair 通有些不通」的現象更加讓人以為是 ISP 防火牆在搞鬼「中嘉那邊的 ISP 是不是比較嚴格?」完全是錯誤的方向

    事後復盤

    原來根本不是 ISP 有 DPI。自始至終就是網卡的 VXLAN checksum offload 和 NAT 在打架,導致內層 TCP 的 checksum 算錯了。

    而且只有 VXLAN node 和 NAT router 在同一台物理機上(中間走 virtio 不經過物理網卡)的節點才會出問題。

    整個升級過程踩了一連串的坑,每個都有各自的教訓:

    1. Bridge FDB 跨節點污染:L2 overlay 搭配實體 bridge 的時候,hairpin 和 MAC learning 的交互作用會導致遠端 MAC 被錯誤學習到本地 port 上。VXLAN Controller 在蒐集本地 MAC 的時候,要區分 overlay port 和 local port

    2. VXLAN + NAT + checksum offload:如果 VXLAN endpoint(VM-A)封裝後的封包要經過另一台 VM(VM-B)做 NAT 才出公網,一定要關掉 VM-A 的 tx checksum offload。VXLAN offload 的設計假設是封裝後不經過 NAT,VM-B 的 NAT 改了外層 header 會污染 partial checksum,導致內層 TCP checksum 被網卡算錯

    3. 也是最重要的 Debug 的認知偏差:最危險的不是不知道答案,而是以為自己知道答案
    「ICMP 通 ,但 TCP 不通 = 防火牆」這個推理看起來很合理,但實際上 partial checksum + NAT 的交互作用可以產生完全一樣的症狀。更糟的是,嘗試的解決方案(userspace TUN)恰好修復了真正的 root cause(重新計算 checksum),但給出了錯誤的歸因(繞過 DPI),讓人在錯誤的方向上越走越遠

    感謝群友願意被我煩,凌晨4點接受我的訊息轟炸

    一起討論程式架構,技術細節。
    當時為了弄增量更新也是搞了好久
    為了實作這些,還花了 NT$3000 買 claude max 會員

    在這個升級之路上搓出來的工具,我也開源出來放在這邊:


    感謝大家的閱讀(要轉載,附帶個網址感恩)

    留言