一次有趣的 Docker 網絡問題排查的經歷,感覺像是做夢一樣 現象描述 初步的排查分析 Docker 橋接模式網絡包流通方式 深入 Netfilter 與 NAT 如何修改 後記

前段時間公司的安卓打包服務出現問題,現象是在上傳 360 服務器進行加固的時候,非常大概率會卡在上傳階段,長時間重試最後失敗。我對這個情況進行了一些排查分析,解決了這個問題,寫了這篇長文覆盤了排查的經歷,會涉及到下面這些內容。

  • Docker 橋接模式網絡模型
  • Netfilter 與 NAT 原理
  • Systemtap 在內核探針中的用法

現象描述

打包服務的部署結構是這樣的:安卓打包環境被打包爲一個 docker 鏡像,部署在某臺物理機上,這鏡像會完成代碼編譯打包、加固、簽名、生成渠道包的功能,如下圖所示:

問題就出在上傳 APK 這一步,傳到一部分就卡住,360 的 sdk 提示超時等異常,如下圖所示。

通過在宿主機和容器內分別抓包,我們發現了這樣一些現象。

宿主機的抓包如下,序號爲 881 的包是一個延遲的 ACK,它的 ACK 值爲 530104,比這個 ACK 號更大的序列號在 875 的那個包已經確認過了(序列號爲 532704,隨後宿主機發送了一個 RST 包括遠程的 360 加固服務器。

再後面就是不停重試發送數據,上傳卡住也就對應這個不斷重試發送數據的階段,如下圖所示

在容器側抓包,並沒有出現這個 RST,其它的包一樣,如下圖所示

因爲容器側沒有感知到連接的異常,容器內的服務就一直在不停地重試上傳,經過多次重試以後依然是失敗的。

初步的排查分析

一開始的疑慮是,是不是因爲收到了延遲到達的 ACK,所以回覆 RST呢?

這不應該,在 TCP 協議規範中,收到延遲到達的 ACK,忽略即可,不必回覆 ACK,那到底爲什麼會發 RST 包呢?

那是不是這個包本來就不合法呢?經過仔細分析這個包的信息,沒有發現什麼異常。從已有的 TCP 原理知識,已經沒法推斷這個現象了。

黔驢技窮,沒有什麼思路,這個時候就該用上 systemtap,來看看 rst 包到底是哪裏發出來。

通過查看內核的代碼,發送 rst 包的函數主要是下面這兩個

tcp_v4_send_reset@net/ipv4/tcp_ipv4.c

static void tcp_v4_send_reset(struct sock *sk, struct sk_buff *skb) {
}

tcp_send_active_reset@net/ipv4/tcp_output.c

void tcp_send_active_reset(struct sock *sk, gfp_t priority) {
}

接下來 systemtap 注入這兩個函數即可。

probe kernel.function("tcp_send_active_reset@net/ipv4/tcp_output.c").call {
    printf ("\n%-25s %s<-%s\n", ctime(gettimeofday_s()) ,execname(), ppfunc());
    if ($sk) {
        src_addr = tcp_src_addr($sk);
        src_port = tcp_src_port($sk);
        dst_addr = tcp_dst_addr($sk);
        dst_port = tcp_dst_port($sk);
        if (src_port == 443 || dst_port == 443) {
          printf (">>>>>>>>>[%s->%s] %s<-%s %d\n", str_addr(src_addr, src_port), str_addr(dst_addr, dst_port), execname(), ppfunc(), dst_port);
          print_backtrace();
        }
    }
}

probe kernel.function("tcp_v4_send_reset@net/ipv4/tcp_ipv4.c").call {
    printf ("\n%-25s %s<-%s\n", ctime(gettimeofday_s()) ,execname(), ppfunc());
    if ($sk) {
        src_addr = tcp_src_addr($sk);
        src_port = tcp_src_port($sk);
        dst_addr = tcp_dst_addr($sk);
        dst_port = tcp_dst_port($sk);
        if (src_port == 443 || dst_port == 443) {
          printf (">>>>>>>>>[%s->%s] %s<-%s %d\n", str_addr(src_addr, src_port), str_addr(dst_addr, dst_port), execname(), ppfunc(), dst_port);
          print_backtrace();
        }
    } else if ($skb) {
        header = __get_skb_tcphdr($skb);
        src_port = __tcp_skb_sport(header)
        dst_port = __tcp_skb_dport(header)
        if (src_port == 443 || dst_port == 443) {
            try {
                iphdr = __get_skb_iphdr($skb)
                src_addr_str = format_ipaddr(__ip_skb_saddr(iphdr), @const("AF_INET"))
                dst_addr_str = format_ipaddr(__ip_skb_daddr(iphdr), @const("AF_INET"))

                tcphdr = __get_skb_tcphdr($skb)
                urg = __tcp_skb_urg(tcphdr)
                ack = __tcp_skb_ack(tcphdr)
                psh = __tcp_skb_psh(tcphdr)
                rst = __tcp_skb_rst(tcphdr)
                syn = __tcp_skb_syn(tcphdr)
                fin = __tcp_skb_fin(tcphdr)

                printf ("skb [%s:%d->%s:%d] ack:%d, psh:%d, rst:%d, syn:%d fin:%d %s<-%s %d\n",
                        src_addr_str, src_port, dst_addr_str, dst_port, ack, psh, rst, syn, fin, execname(), ppfunc(), dst_port);
                print_backtrace();
            } 
            catch { }
    }
    } else {
          printf ("tcp_v4_send_reset else\n");
          print_backtrace();
    }
}

一運行就發現,出問題時,進入的是 tcp_v4_send_reset 這個函數,調用堆棧是

Tue Jun 15 11:23:04 2021  swapper/6<-tcp_v4_send_reset
skb [36.110.213.207:443->10.21.17.99:39700] ack:1, psh:0, rst:0, syn:0 fin:0 swapper/6<-tcp_v4_send_reset 39700
 0xffffffff99e5bc50 : tcp_v4_send_reset+0x0/0x460 [kernel]
 0xffffffff99e5d756 : tcp_v4_rcv+0x596/0x9c0 [kernel]
 0xffffffff99e3685d : ip_local_deliver_finish+0xbd/0x200 [kernel]
 0xffffffff99e36b49 : ip_local_deliver+0x59/0xd0 [kernel]
 0xffffffff99e364c0 : ip_rcv_finish+0x90/0x370 [kernel]
 0xffffffff99e36e79 : ip_rcv+0x2b9/0x410 [kernel]
 0xffffffff99df0b79 : __netif_receive_skb_core+0x729/0xa20 [kernel]
 0xffffffff99df0e88 : __netif_receive_skb+0x18/0x60 [kernel]
 0xffffffff99df0f10 : netif_receive_skb_internal+0x40/0xc0 [kernel]
...

可以看到是在收到 ACK 包以後,調用 tcp_v4_rcv 來處理時發送的 RST,那到底是哪一行呢?

這就需要用到一個很厲害的工具 faddr2line ,把堆棧中的信息還原爲源碼對應的行數。

wget https://raw.githubusercontent.com/torvalds/linux/master/scripts/faddr2line

bash faddr2line /usr/lib/debug/lib/modules/`uname -r`/vmlinux tcp_v4_rcv+0x536/0x9c0
 
tcp_v4_rcv+0x596/0x9c0:
tcp_v4_rcv in net/ipv4/tcp_ipv4.c:1740

可以看到是在 tcp_ipv4.c 的 1740 行調用了 tcp_v4_send_reset 函數,

int tcp_v4_rcv(struct sk_buff *skb)
{
    struct sock *sk;

    sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
    if (!sk)
        goto no_tcp_socket;

...

no_tcp_socket:
    if (!xfrm4_policy_check(NULL, XFRM_POLICY_IN, skb))
        goto discard_it;

    if (skb->len < (th->doff << 2) || tcp_checksum_complete(skb)) {
csum_error:
        TCP_INC_STATS_BH(net, TCP_MIB_CSUMERRORS);
bad_packet:
        TCP_INC_STATS_BH(net, TCP_MIB_INERRS);
    } else {
        tcp_v4_send_reset(NULL, skb);  // 1739 行
    }
}

唯一可能調用到的邏輯就是找不到這個包對應的套接字信息,sk 爲 NULL,然後走到 no_tcp_socket 標籤處,然後走到 else 的流程,纔有可能。

這怎麼可能呢?連接好好的存在,怎麼可能收到一個延遲到達的 ack 包處理的時候找不到這個連接套接字了呢?接下來我們來看 inet_lookup_skb 函數的底層實現,最終走到了 inet_lookup_established 這個函數。

struct sock *__inet_lookup_established(struct net *net,
                  struct inet_hashinfo *hashinfo,
                  const __be32 saddr, const __be16 sport,
                  const __be32 daddr, const u16 hnum,
                  const int dif)

刨去現有的現象,有一個很類似的 RST 的場景是,往一個沒有監聽某端口的服務發送包。這個包沒有對應的連接,內核就會回覆 RST,告知發送端無法處理這個包。

到這裏,排查陷入了僵局。爲什麼明明連接還在,內核協議棧就是找不到呢?

Docker 橋接模式網絡包流通方式

Docker 進程啓動時,會在主機上創建一個名爲 docker0 的虛擬網橋,這個主機上的 docker 容器會連接到這個虛擬網橋上。

容器啓動後,Docker 會生成一對 veth 接口(veth pair),本質相當於軟件實現的以太網連接,docker 通過 veth 把容器內的 eth0 連接到 docker0 網橋。外部的連接可以通過 IP 僞裝(IP masquerading)的方式提供,IP 僞裝是網絡地址轉換(NAT)的一種方式,以 IP 轉發(IP forwarding)和 iptables 規則建立。

深入 Netfilter 與 NAT

Netfilter 是一個 Linux 內核框架,它在內核協議棧中設置了若干hook 點,以此對數據包進行攔截、過濾或其他處理。從簡單的防火牆,到對網絡通信數據的詳細分析,到複雜的、依賴於狀態的分組過濾器,它都可以實現。

Docker 利用了它的 NAT(network address translation,網絡地址轉換)特性,根據某些規則來轉換源地址和目標地址。iptables 正是一個用戶態用於管理這些 Netfilter 的工具。

經過查看 netfilter 的代碼,發現它會把 out of window 的包標記爲 INVALID 狀態,源碼見
net/netfilter/nf_conntrack_proto_tcp.c:

/* Returns verdict for packet, or -1 for invalid. */
static int tcp_packet(struct nf_conn *ct,
              const struct sk_buff *skb,
              unsigned int dataoff,
              enum ip_conntrack_info ctinfo,
              u_int8_t pf,
              unsigned int hooknum,
              unsigned int *timeouts) {
    
    // ...  
              
    if (!tcp_in_window(ct, &ct->proto.tcp, dir, index,
               skb, dataoff, th, pf)) {
        spin_unlock_bh(&ct->lock);
        return -NF_ACCEPT;
    }
}

口說無憑,上面只是理論分析,你怎麼就能說是一個 ACK 導致的 invalid 包呢?

我們可以通過 iptables 的規則,把 invalid 的包打印出來。

iptables -A INPUT -m conntrack --ctstate INVALID -m limit --limit 1/sec   -j LOG --log-prefix "invalid: " --log-level 7

添加上面的規則以後,再次運行加固上傳的腳本,同時開始抓包,現象重現。

然後在 dmesg 中查看對應的日誌。

以第一行爲例,它的 LEN=40,也就是 20 IP 頭 + 20 字節 TCP 頭,ACK 位被置位,表示這是一個沒有任何內容的 ACK 包,對應於上圖中 RST 包的前一個 ACK 包。這個包的詳情如下圖,window 等於 187 也是對得上的。

如果是 INVALID 狀態的包,netfilter 不會對其做 IP 和端口的 NAT 轉換,這樣協議棧再去根據 ip + 端口去找這個包的連接時,就會找不到,這個時候就會恢復一個 RST,過程如下圖所示。

這也印證了我們前面 __inet_lookup_skb 爲 null,然後發送 RST 的代碼邏輯。

如何修改

知道了原因,修改起來就很簡單了,有兩個改法。第一個改法有點粗暴,使用 iptables 把 invalid 包 drop 掉,不讓它產生 RST。

iptables -A INPUT -m conntrack --ctstate INVALID -j DROP

這樣修改以後,問題瞬間解決了,經過幾十次的測試,一次都沒有出現過上傳超時和失敗的情況。

這樣修改有一個小問題,可能會誤傷 FIN 包和一些其它真正 invalid 的包。有一個更加優雅的改法是修改 把內核選項
net.netfilter.nf_conntrack_tcp_be_liberal 設置爲 1:

sysctl -w "net.netfilter.nf_conntrack_tcp_be_liberal=1"
net.netfilter.nf_conntrack_tcp_be_liberal = 1

把這個參數值設置爲 1 以後,對於窗口外的包,將不會被標記爲 INVALID,源碼見
net/netfilter/nf_conntrack_proto_tcp.c:

static bool tcp_in_window(const struct nf_conn *ct,
              struct ip_ct_tcp *state,
              enum ip_conntrack_dir dir,
              unsigned int index,
              const struct sk_buff *skb,
              unsigned int dataoff,
              const struct tcphdr *tcph,
              u_int8_t pf) {
        ...
        
        res = false;
        if (sender->flags & IP_CT_TCP_FLAG_BE_LIBERAL ||
            tn->tcp_be_liberal)
            res = true;
        ...
    return res;
}

最後來一個如絲般順滑的上傳截圖結束本篇文章。

後記

多看代碼,懷疑一些不可能的現象。以上可能說的都是錯誤,看看方法就好。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章