天天看點

解Bug之路-記一次調用外網服務機率性失敗問題的排查解Bug之路-記一次調用外網服務機率性失敗問題的排查

解Bug之路-記一次調用外網服務機率性失敗問題的排查

前言

和外部聯調一直是令人困擾的問題,尤其是一些基礎環境配置導緻的問題。筆者在一次偶然情況下解決了一個調用外網服務機率性失敗的問題。在此将排查過程發出來,希望讀者遇到此問題的時候,能夠知道如何入手。

起因

筆者的新系統上線,需要PE執行操作。但是負責操作的PE确和另一個開發在互相糾纏,讓筆者等了半個小時之久。本着加速系統上線的想法,就想着能不能幫他們快速處理掉問題,好讓筆者早點發完回去coding。一打聽,這個問題竟然扯了3個月之久,問題現象如下:

解Bug之路-記一次調用外網服務機率性失敗問題的排查解Bug之路-記一次調用外網服務機率性失敗問題的排查

每個client都會以将近1/2的機率失敗,而且報錯都為:

java.net.SocketTimeoutException: Read timed out           

着手排查

和appserver開發以及對應的PE交流發現,appserver和nginx之間是短連接配接,由于是socketTimeOutException,于是能夠排除appserver和nginx建立連接配接之間的問題。去nginx上排查日志,發現一個奇異的現象,如下圖所示:

解Bug之路-記一次調用外網服務機率性失敗問題的排查解Bug之路-記一次調用外網服務機率性失敗問題的排查

所有的appserver都是調用一台nginx一直成功,而調用另一台nginx大機率失敗。而兩台nginx機器的配置一模一樣,還有一個奇怪的點是,隻有在調用出問題的對端伺服器時才會失敗,其它業務沒有任何影響,如下圖所示:

解Bug之路-記一次調用外網服務機率性失敗問題的排查解Bug之路-記一次調用外網服務機率性失敗問題的排查

由于這兩個詭異的現象導緻開發和PE争執不下,按照第一個現象一台nginx好一台nginx報錯那麼第二台nginx有問題是合理的推斷,是以開發要求換nginx。按照第二個現象,隻有調用這個業務才會出錯,其它業務沒有問題,那麼肯定是對端業務伺服器的問題,PE覺得應該不是nginx的鍋。争執了半天後,初步拟定方案就是擴容nginx看看效果-_-!筆者覺得這個方案并不靠譜,盲目的擴容可能會引起反效果。還是先抓包看看情況吧。

抓包

其實筆者覺得nginx作為這麼通用的元件不應該出現問題,問題應該出現在對端伺服器上。而根據對端開發反應,他自己curl沒問題,并現場在他自己的伺服器上做了N次curl也沒有任何問題(由于這個問題僵持不下,他被派到我們公司來協助排查)。于是找網工在防火牆外抓包,抓包結果如下:

時間點 源ip 目的ip 協定 info
2019-07-25 16:45:41 20.1.1.1 30.1.1.1 tcp 58850->443[SYN]
2019-07-25 16:45:42 [TCP Retransmission]58850->443[SYN]
2019-07-25 16:45:44

由于appserver端設定的ReadTimeOut逾時時間是3s,是以在2次syn重傳後,對端就已經報錯。如下圖所示:

解Bug之路-記一次調用外網服務機率性失敗問題的排查解Bug之路-記一次調用外網服務機率性失敗問題的排查

(注:nginx所在linux伺服器設定的tcp_syn_retries是2)

抓包結果分析

從抓包得出的資料來看,第二台nginx發送syn包給對端服務,對端服務沒有任何響應,導緻了nginx2建立連接配接逾時,進而導緻了appserver端的ReadTimeOut逾時(appserver對nginx是短連接配接)。

按照正常推論,應該是防火牆外到對端服務的SYN丢失了。而阿裡雲作為一個非常穩定的服務商,應該不可能出現如此大機率的丢失現象。而從對端伺服器用的是非常成熟的SpringBoot來看,也不應該出現這種bug。那麼最有可能的就是對端伺服器本身的設定有問題。

登陸對端伺服器進行排查

由于對方的開發來到了現場,于是筆者就直接用他的電腦登入了服務所在的阿裡雲伺服器。首先看了下dmesg,如下圖所示,有一堆報錯:

dmesg:
__ratelimit: 33491 callbacks suppressed
TCP: time wait bucket table overflow
TCP: time wait bucket table overflow
TCP: time wait bucket table overflow
......           

感覺有點關聯,但是僅靠這個資訊無法定位問題。緊接着,筆者運作了下netstat -s:

netstat -s
......
16990 passive connections rejected because of time stamp 
......           

這條指令給出了非常關鍵的資訊,翻譯過來就是有16990個被動連接配接由于時間戳(time stamp)而拒絕!查了下資料發現這是由于設定了

tcp_timestamps == 1 && tcp_tw_recycle == 1           

在NAT情況下将會導緻這個被動拒絕連接配接的問題。而為解決上面的dmesg日志,網上給出的解決方案就是設定tcp_tw_recycle=1而tcp_timestamps預設就是1,同時我們的用戶端調用也是從NAT出去的,符合了這個問題的所有特征。

于是筆者嘗試着将他們的tcp_timestamps設為0,

echo '0' > /proc/sys/net/ipv4/tcp_timestamps
 or
echo '0' > /proc/sys/net/ipv4/tcp_tw_recycle           

又做了幾十次調用,再也沒有任何報錯了!

linux源碼分析

問題雖然解決了,但是筆者想從源碼層面看一看這個問題到底是怎麼回事,于是就開始研究對應的源碼(基于linux-2.6.32源碼)。

由于問題是發生在nginx與對端伺服器第一次握手(即發送第一個syn)的時候,于是我們主要跟蹤下這一處的相關源碼:

// 三次握手第一個SYN kernel走的分支
tcp_v4_do_rcv
    |->tcp_v4_hnd_req
    |->tcp_rcv_state_process
        /** case TCP_LISTEN && th->syn */
        |->conn_request(tcp_v4_conn_request)           

關于tcp_timestamps的代碼就在tcp_v4_conn_request裡面,我們繼續追蹤(以下代碼忽略了其它不必要的邏輯):

int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
{
    ......
    /* VJ's idea. We save last timestamp seen
     * from the destination in peer table, when entering
     * state TIME-WAIT, and check against it before
     * accepting new connection request.
     * 注釋大意為:
     * 我們在進入TIME_WAIT狀态的時候将最後的時間戳記錄到peer tables中,
     * 然後在新的連接配接請求進來的時候檢查這個時間戳
     */
     // 在tcp_timestamps和tcp_tw_recycle開啟的情況下
    if (tmp_opt.saw_tstamp &&
        tcp_death_row.sysctl_tw_recycle &&
        (dst = inet_csk_route_req(sk, req)) != NULL &&
        (peer = rt_get_peer((struct rtable *)dst)) != NULL &&
        peer->v4daddr == saddr) {
        /** TCP_PAWS_MSL== 60 */
        /** TCP_PAWS_WINDOW ==1 */
        // 以下都是針對同一個對端ip
        // tcp_ts_stamp 對端ip的連接配接進入time_wait狀态後記錄的本機時間戳    
        // 目前時間在上一次進入time_wait記錄的實際戳後的一分鐘之内
        if (get_seconds() < peer->tcp_ts_stamp + TCP_PAWS_MSL &&
        // tcp_ts 最近接收的那個資料包的時間戳(對端帶過來的)
        // 對端目前請求帶過來的時間戳小于上次記錄的進入time_wait狀态後記錄的對端時間戳
            (s32)(peer->tcp_ts - req->ts_recent) >
                        TCP_PAWS_WINDOW) {
            // 增加被動連接配接拒絕的統計資訊
            NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_PAWSPASSIVEREJECTED);
            // 進入丢棄和釋放階段
            goto drop_and_release;
        }
    }    
    ......
}           

上述代碼的核心意思即是在tcp_timestamps和tcp_tw_recycle開啟的情況下,同樣ip的連接配接,在上個連接配接進入time_wait狀态的一分鐘内,如果有新的連接配接進來,而且新的連接配接的時間戳小于上個進入time_wait狀态的最後一個包的時間戳,則将這個syn丢棄,進入drop_and_release。我們繼續跟蹤drop_and_release:

int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb){
    ......
    drop_and_release:
        dst_release(dst);
    drop_and_free:
        reqsk_free(req);
    drop:
        return 0;
}           

我們繼續看下如果tcp_v4_conn_request傳回0的話,系統是什麼表現:

int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
              struct tcphdr *th, unsigned len)
{
    ......
    // 由于tcp_v4_conn_request是以不走下列分枝
    if (icsk->icsk_af_ops->conn_request(sk, skb) < 0)
        return 1
    // 是以此處也傳回0
    kfree_skb(skb);
    return 0;
}
// 再跳回tcp_v4_do_rcv
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
    // 由于tcp_rcv_state_process這邊傳回的是0,是以不走reset的邏輯
    if (tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) {
        rsk = sk;
        goto reset;
    }
    // 走到這邊之後,不發送reset包,不給對端任何響應
    TCP_CHECK_TIMER(sk);
    return 0;
}           

從源碼的跟蹤可以看出,出現此種情況直接丢棄對應的syn包,對端無法獲得任何響應進而進行syn重傳,這點和抓包結果一緻。

和問題表象一一驗證

為什麼會出現一台nginx一直okay,一台nginx失敗的情況

由于tcp的時間戳是指的并不是目前本機用date指令給出的時間戳。這個時間戳的計算規則就在這裡不展開了,隻需要知道每台機器的時間戳都不相同即可(而且相差可能極大)。由于我們調用對端采用的是NAT,是以兩台nginx在對端伺服器看來是同一個ip,那麼這兩台的時間戳發送到對端伺服器的時候就會混亂。nginx1的時間戳比nginx2的時間戳大,是以在一分鐘之内,隻要出現nginx1的連接配接請求(短連接配接),那麼之後的nginx2的連接配接請求就會一直被丢棄。如下圖所示:

解Bug之路-記一次調用外網服務機率性失敗問題的排查解Bug之路-記一次調用外網服務機率性失敗問題的排查

為什麼對端自測一直正常

因為本機調用本機的時時間戳是一台機器(本機)上的,是以不會出現混亂。

為什麼nginx2調用其它服務是正常的

因為其它外部服務所在伺服器并沒有開啟tcp_tw_recycle。這個問題事實上将tcp_tw_recycle置為0也可以解決。另外,高版本的linux核心已經去掉了tcp_tw_recycle這個參數。

總結

由于目前ip位址緊缺和DNS封包大小的限制(512位元組),大部分網絡架構都是采用NAT的方式去和外部互動,是以設定了tcp_tw_recycle為1基本都會出現問題。一般這種問題需要對tcp協定有一定的了解才能夠順藤摸瓜找到最終的根源。