[專題]網路 IO 高階篇:一次有趣的 Docker 網路問題排查原創

济南小老虎發表於2024-05-22

挖坑的張師傅
2年前
8824328

第一篇:基本原理篇:什麼是IO,為什麼 I/O 會經常被阻塞?

第二篇:Linux 網路IO 最佳化篇 : 一種本機網路 IO 方法,讓你的效能翻倍!

第三篇:網路IO 實戰篇 :Java電商系統:重大事故!IO問題引發線上20臺機器同時崩潰

第四篇:網路 IO 高階篇:一次有趣的 Docker 網路問題排查

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

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

現象描述

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

android-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 規則建立。

docker network 原理

深入 Netfilter 與 NAT

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

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

對於這個場景中的部署結構,它的原理如下圖所示。

docker network 2

經過檢視 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,過程如下圖所示。

docker network 3

這也印證了我們前面 __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;
  • }

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

後記

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

💥看到這裡的你,如果對於我寫的內容很感興趣,有任何疑問,歡迎在下面留言📥,會第一次時間給大家解答,謝謝!

相關文章