追蹤將伺服器CPU耗光的兇手

daqianmen發表於2021-09-09

前面我們討論系統呼叫的時候結論是耗時200ns-15us不等。不過我今天說的我的這個遭遇可能會讓你進一步認識系統呼叫的真正開銷。在本節裡你會看到一個耗時2.5ms的connect系統呼叫,注意是毫秒,相當於2500us!

問題描述

當時是我的一個線上雲控介面,是nginx+lua寫的。正常情況下,單虛機8核8G可以抗每秒2000左右的QPS,負載還比較健康。但是該服務近期開始出現一些500狀態的請求了,監控時不時會出現報警。透過sar -u檢視峰值時cpu餘量只剩下了20-30%。

第一步、迅速鎖定嫌疑人

top命令檢視cpu使用,透過top命令發現峰值的時候cpu確實消耗的比較多,idle只有20-30%左右。在使用的cpu裡,軟中斷的佔比也比較高,1/3左右。
再透過cat /proc/softirqs檢視到軟中斷是都是網路IO產生的NET_TX,NET_RX,和時鐘TIMER。
既然軟中斷這個賊人吃掉了我這麼多的CPU時間,所以案件的嫌疑人就這麼初步被我鎖定了。

處理,那既然是NET_TX,NET_RX和TIMER都高,那我們就挑可以削減的功能砍一砍唄。

  • 1.砍掉多餘的gettimeofday系統呼叫
  • 2.每個請求砍掉一次非必須Redis訪問,只留了必要的。

**結果:**峰值的cpu餘量從確實多出來一些了。報警頻率確實下來了,但是還是偶爾會有零星的報警。可見該嫌疑人並非主犯。。

第二步、幹掉一大片,真兇在其中

接著檢視網路連線的情況ss -n -t -a發現,ESTABLISH狀態的連結不是很多,但是TIME-WAIT有11W多。繼續研究發現針對*...122:6390的TIME-WAIT已經超過了3W。所以埠有限。原來呀,上一步執行時只幹掉了連線上的資料請求,但是tcp握手請求仍然存在。

處理:徹底幹掉了針對*...122:6390的網路連線請求,只保留了必須保留的邏輯。
結果:問題徹底解決。sar -u檢視cpu的idle餘量竟然達到了90%多。

>Tips:單臺機器如果作為TCP的客戶端,有如下限制

  1. ESTABLISH狀態的連線只能有ip_local_port_range範圍內的個數。
  2. 只有針對特定ip,特定port的TIME-WAIT過多,超過或接近ip_local_port_range,再新建立連線可能會出現無埠可用的情況。( 總的TIME-WAIT過多並不一定有問題 )

沒想到一個簡單砍掉一個對redis server的tcp連線,能把cpu最佳化到這麼多。大大出乎意料,而且也想不明白。 根據我之前的效能測試經驗,每個tcp連線的建立大約只需要消耗36usec的cpu時間。我們來估算一下:

> 當時server的qps大約在2000左右,假設是均勻分佈的,則8個核每個核每秒只需要處理250個請求。也就是說每秒一條tcp連線需要消耗的cpu時間為:250*36usec = 9ms.

也就是說,正常來講砍掉這些握手開銷只能節約1%左右的cpu,不至於有這麼大的提升。(即使我上面的估算只考慮了建立連線,沒有統計釋放連線的cpu開銷,但是連線釋放cpu開銷也和建立連線差不多。)

總之,這一步確實解決了問題,但是代價是犧牲了一個業務邏輯。

最終、審出真兇,真相大白於天下

我在某一臺機器上把老的有問題的程式碼回滾了回來,恢復問題現場。然後只修改一下ip_local_port_range。 然後請出了strace這個命令。
透過strace -c 統計到對於所有系統呼叫的開銷彙總。 結果我們發現了connect系統呼叫這個二貨,在正常的機器上只需要22us左右,在有問題的機器上竟然花掉來 2500us,上漲了100倍。我們用strace -c $PID檢視一下出問題時和正常時的connect系統呼叫耗時對比:

圖片描述

圖片描述

然後回想起了*...122:6390的TIME-WAIT已經超過了3W,會不會TIME_WAIT佔用了太多埠導致埠不足呢。因此檢視埠核心引數配置:

# sysctl -a | grep ip_local_port_range
net.ipv4.ip_local_port_range = 32768    65000

果然發現該機器上的埠範圍只開了3W多個,也就是說埠已經幾乎快用滿了。那就提高階口可用數量:

# vim /etc/sysctl.conf
net.ipv4.ip_local_port_range = 10000 65000

connect系統呼叫恢復理性狀態,整體伺服器的CPU使用率非常健康。

問題的根本原因是建立TCP連線使用的埠數量上(ip_local_port_range)不充裕,導致connect系統呼叫開銷上漲了將近100倍!

後來我們的一位開發同學幫忙翻到了connect系統呼叫裡的一段原始碼

int inet_hash_connect(struct inet_timewait_death_row *death_row,
               struct sock *sk)
{
    return __inet_hash_connect(death_row, sk, inet_sk_port_offset(sk),
            __inet_check_established, __inet_hash_nolisten);
}

int __inet_hash_connect(struct inet_timewait_death_row *death_row,
                struct sock *sk, u32 port_offset,
                int (*check_established)(struct inet_timewait_death_row *,
                        struct sock *, __u16, struct inet_timewait_sock **),
                int (*hash)(struct sock *sk, struct inet_timewait_sock *twp))
{
        struct inet_hashinfo *hinfo = death_row->hashinfo;
        const unsigned short snum = inet_sk(sk)->inet_num;
        struct inet_bind_hashbucket *head;
        struct inet_bind_bucket *tb;
        int ret;
        struct net *net = sock_net(sk);
        int twrefcnt = 1;

        if (!snum) {
                int i, remaining, low, high, port;
                static u32 hint;
                u32 offset = hint + port_offset;
                struct inet_timewait_sock *tw = NULL;

                inet_get_local_port_range(&low, &high);
                remaining = (high - low) + 1;

                local_bh_disable();
                for (i = 1; i <= remaining; i++) {
                        port = low + (i + offset) % remaining;
                        if (inet_is_reserved_local_port(port))
                                continue;
						......
		}
}

static inline u32 inet_sk_port_offset(const struct sock *sk)
{
        const struct inet_sock *inet = inet_sk(sk);  
        return secure_ipv4_port_ephemeral(inet->inet_rcv_saddr,  
                                          inet->inet_daddr,  
                                          inet->inet_dport);  
}

從上面原始碼可見,臨時埠選擇過程是生成一個隨機數,利用隨機數在ip_local_port_range範圍內取值,如果取到的值在ip_local_reserved_ports範圍內 ,那就再依次取下一個值,直到不在ip_local_reserved_ports範圍內為止。原來臨時埠竟然是隨機撞。出。來。的。。 也就是說假如就有range裡配置了5W個埠可以用,已經使用掉了49999個。那麼新建立連線的時候,可能需要呼叫這個隨機函式5W次才能撞到這個沒用的埠身上。

所以請記得要保證你可用臨時埠的充裕,避免你的connect系統呼叫進入SB模式。正常埠充足的時候,只需要22usec。但是一旦出現埠緊張,則一次系統呼叫耗時會上升到2.5ms,整整多出100倍。這個開銷比正常tcp連線的建立吃掉的cpu時間(每個30usec左右)的開銷要大的多。

> 解決TIME_WAIT的辦法除了放寬埠數量限制外,還可以考慮設定net.ipv4.tcp_tw_recycle和net.ipv4.tcp_tw_reuse這兩個引數,避免埠長時間保守地等待2MSL時間。

在這裡我不是單純介紹技術理論,也不只介紹實踐經驗。而是把理論與實踐結合起來,用實踐加深對理論的理解、用理論提高你的技術實踐能力。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/1727/viewspace-2826475/,如需轉載,請註明出處,否則將追究法律責任。

相關文章