天天看點

Linux核心協定棧丢棄SYN封包的主要場景剖析

作者:懷知

在排查網絡問題的時候,經常會遇見TCP連接配接建立不成功的場景。如果能擷取到兩端抓包,兩端抓包看起來如下:

  • 用戶端在一直按照指數退避重傳TCP SYN (因為首包沒有擷取到RTT及RTO,會在1, 2, 4, 8秒... 重傳,直到完成net.ipv4.tcp_syn_retries次重傳)
  • 伺服器端能看到TCP SYN封包已經到達網卡,但是TCP協定棧沒有任何回包。

因為這樣的問題出現的頻率不小,本文會從TCP協定棧方面總結常見原因。所謂的TCP協定棧方面的原因,就是TCP SYN封包已經到了核心的TCP處理子產品,但在伺服器端核心邏輯中不給用戶端回SYNACK。用戶端一直重傳TCP SYN也可能由别的原因造成,比如伺服器端有多塊網卡造成的出入路徑不一緻,或者SYN封包被iptables規則阻攔,這些場景都不在本文的讨論範圍之内。

Listen狀态下處理TCP SYN的代碼邏輯

本文以很多使用者使用的CentOS 7的核心版本為基礎,看看下TCP處理SYN的主要邏輯,結合案例處理的經驗來分析主要可能出問題的點。處于listen狀态的socket處理第一個TCP SYN封包的邏輯大概如下:

tcp_v4_do_rcv() @net/ipv4/tcp_ipv4.c
        |--> tcp_rcv_state_process() @net/ipv4/tcp_input.c // 這個函數實作了絕大TCP狀态下的接受封包的處理過程 (ESTABLISHED和TIME_WAIT除外),當然包括了我們關注的LISTEN狀态
                |--> tcp_v4_conn_request() @@net/ipv4/tcp_ipv4.c // 當TCP socket出于LISTEN狀态,且接收封包中TCP SYN flag是置位的,就來到這個函數中處理           

CentOS中核心代碼可能會有些調整,如果你需要跟蹤源代碼的确切行數,systemtap是一個很好的方法,如下:

# uname -r
3.10.0-693.2.2.el7.x86_64
# stap -l 'kernel.function("tcp_v4_conn_request")'
kernel.function("tcp_v4_conn_request@net/ipv4/tcp_ipv4.c:1303")           

來到tcp_v4_conn_request()的邏輯裡,函數邏輯的前幾行如下:

Linux核心協定棧丢棄SYN封包的主要場景剖析

進入到這個函數的前提條件是TCP socket出于LISTEN狀态,且接收封包中TCP SYN flag是置位的。在進入函數邏輯後,可以發現函數要考慮各種可能發生的異常情況,但在現實中很多并不常見。比如我們在前幾行看到的這兩種情況:

  1. 1482行:拒絕廣播群組播封包。
  2. 1490行:如果request queue (存放SYN封包的隊列)滿了,且isn為0,且want_cookie為flase, 則drop掉SYN封包。

    第一種情況意思比較明确,在實際中也沒見過,在這裡不讨論。第二種情況略為複雜,并且有小機率可能會碰到,下面簡單看看:

第一個條件request queue 滿實際是很容易發生的事情,syn flood攻擊很容易完成這件事情。而isn在函數開始被指派成TCP_SKB_CB(skb)->when,這個是TCP控制塊結構體中用于計算RTT的字段。want_cookie則代表這syn syncookies的使用與否。在tcp_syn_flood_action()中的定義如下,如果ifdef了CONFIG_SYN_COOKIES, 核心參數的net.ipv4.tcp_syncookies也設定成1,則概述的傳回是true, want_cookie則為true。

Linux核心協定棧丢棄SYN封包的主要場景剖析

是以在上面這種drop SYN封包的情況中,真正的前提條件是沒有開啟net.ipv4.tcp_syncookies這個核心參數。而在實際生産系統中,net.ipv4.tcp_syncookies預設是打開的。Syn syncookies是一種時間(CPU計算)換空間(request queue隊列)來抵禦syn flood攻擊的方式,在實際生産中看不到任何場景需要顯示地關閉這個開關。是以總的來講,1490行中這種請求在實際中也不太常見。

核心drop SYN封包的主要場景

本文的主要目的不是按照代碼邏輯依次描述drop SYN封包的所有場景,而是結合之前的實際經驗描述兩種主要可能丢SYN封包的場景以及如何迅速判斷的方法,幫助大家了解為什麼伺服器端會不回SYNACK。

1. Per-host PAWS檢查造成drop SYN封包

問題現象

這是在實際生産環境中最常見的一種問題:對于net.ipv4.tcp_tw_recycle和net.ipv4.tcp_timestamps都開啟的伺服器,并且有NAT用戶端通路時,這個問題出現的機率非常大。在用戶端看來,問題現象通常建立連接配接時通時不通。

Per-host PAWS原理

PAWS是Protect Against Wrapped Sequences的簡寫,字面意思是防止sequence number纏繞。per-host, 是相對per-connection來講的,就是對對端主機IP做檢查而非對IP端口四元組做檢查。

Per-host PAWS檢查的方法是:對于被快速回收掉的TIME_WAIT socket的五元組對端主機IP, 為了防止來自同一主機的舊資料幹擾,需要在60秒内新來的SYN封包TCP option中的timestamp是增長的。當用戶端是在NAT環境裡時這個條件往往不容易滿足。

理論上隻需要記住上面這句就能解掉很多用戶端的三次握手時通時不通的問題。如果想要了解得更多,請看下文的詳細解釋。

為什麼有per-host PAWS?

在RFC 1323中提到了per-host PAWS,如下:

(b) Allow old duplicate segments to expire.

To replace this function of TIME-WAIT state, a mechanism

would have to operate across connections. PAWS is defined

strictly within a single connection; the last timestamp is

TS.Recent is kept in the connection control block, and

discarded when a connection is closed.

An additional mechanism could be added to the TCP, a per-host

cache of the last timestamp received from any connection.

This value could then be used in the PAWS mechanism to reject

old duplicate segments from earlier incarnations of the

connection, if the timestamp clock can be guaranteed to have

ticked at least once since the old connection was open. This

would require that the TIME-WAIT delay plus the RTT together

must be at least one tick of the sender's timestamp clock.

Such an extension is not part of the proposal of this RFC.

Note that this is a variant on the mechanism proposed by

Garlick, Rom, and Postel [Garlick77], which required each

host to maintain connection records containing the highest

sequence numbers on every connection. Using timestamps

instead, it is only necessary to keep one quantity per remote

host, regardless of the number of simultaneous connections to

that host.

在tcp_minisocks.c的代碼注釋中也闡述了需要TIME_WAIT的原因,和快速回收TIME_WAIT的理論基礎:PAWS機制,如下:

Main purpose of TIME-WAIT state is to close connection gracefully,

when one of ends sits in LAST-ACK or CLOSING retransmitting FIN

(and, probably, tail of data) and one or more our ACKs are lost.

What is TIME-WAIT timeout? It is associated with maximal packet

lifetime in the internet, which results in wrong conclusion, that

it is set to catch "old duplicate segments" wandering out of their path.

It is not quite correct. This timeout is calculated so that it exceeds

maximal retransmission timeout enough to allow to lose one (or more)

segments sent by peer and our ACKs. This time may be calculated from RTO.

When TIME-WAIT socket receives RST, it means that another end

finally closed and we are allowed to kill TIME-WAIT too.

Second purpose of TIME-WAIT is catching old duplicate segments.

Well, certainly it is pure paranoia, but if we load TIME-WAIT

with this semantics, we MUST NOT kill TIME-WAIT state with RSTs.

If we invented some more clever way to catch duplicates

(f.e. based on PAWS), we could truncate TIME-WAIT to several RTOs.

根據上面RFC的描述和核心代碼的注釋描述,可以看出Linux kernel實作了TIME-WAIT狀态的快速回收機制,快速回收的細節可以參考文章《為何用戶端突然出現大量TIME_WAIT堆積》中的“TCP TIME_WAIT的快速回收”部分。而Linux可以抛棄60秒TIME-WAIT時間,直接縮短到3.5倍RTO時間,是因為Linux使用了一些“聰明”的方法來捕捉舊重複封包(例如:基于PAWS機制),而Linux中确實使用了per-host PAWS來防止前面連接配接中的封包串擾到新的連接配接中。

Linux核心的實作

在tcp_ipv4.c中,在接收SYN之前,如果符合如下兩個條件,需要檢查peer是不是proven,即per-host PAWS檢查:

  • 收到的封包有TCP option timestamp時間戳
  • 本機開啟了核心參數net.ipv4.tcp_tw_recycle
...
else if (!isn) {
/* 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.
 *
 * If "isn" is not zero, this request hit alive
 * timewait bucket, so that all the necessary checks
 * are made in the function processing timewait state.
 */
if (tmp_opt.saw_tstamp &&  // 收到的封包中有TCP timestamp option
    tcp_death_row.sysctl_tw_recycle &&  // 開啟了net.ipv4.tcp_tw_recycle核心參數
    (dst = inet_csk_route_req(sk, &fl4, req)) != NULL &&
    fl4.daddr == saddr) {
    if (!tcp_peer_is_proven(req, dst, true)) {  // peer檢查(per-host PAWS檢查)
        NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_PAWSPASSIVEREJECTED);
        goto drop_and_release;
    }
}           

在tcp_metrics.c中,是Linux per-host PAWS的實作邏輯,如下。簡單描述下就是在這一節開始提到的:需要在60秒内新來的SYN封包TCP option中的timestamp是增長的。

bool tcp_peer_is_proven(struct request_sock *req, struct dst_entry *dst, bool paws_check)
{
    struct tcp_metrics_block *tm;
    bool ret;
    ...
    
    tm = __tcp_get_metrics_req(req, dst);
    if (paws_check) {
      if (tm &&
          // peer 資訊儲存的時間離現在在60秒(TCP_PAWS_MSL)之内
          (u32)get_seconds() - tm->tcpm_ts_stamp < TCP_PAWS_MSL &&
          // peer 資訊中儲存的timestamp 比目前收到的SYN封包中的timestamp大1(TCP_PAWS_WINDOW)
          (s32)(tm->tcpm_ts - req->ts_recent) > TCP_PAWS_WINDOW)
        ret = false;
      else
        ret = true;
    }
}           

對NAT環境中用戶端的影響

在Linux發明這個per-host PAWS機制來讓TIME-WAIT狀态快速回收時,認為這是"clever way",是基于IPv4位址池數量充足的網絡環境下來做的解決方案。而随着Internet的快速發展,NAT的應用越來越普遍,用戶端在SNAT裝置内部的來通路同個伺服器的環境非常普遍。

Per-host PAWS機制利用TCP option裡的timestamp字段的增長來判斷串擾資料,而timestamp是根據用戶端各自的CPU tick得出的值,對于NAT内部的裝置而言可以說是完全随機。當用戶端主機1通過NAT和伺服器建立TCP連接配接,然後伺服器主動關閉并且快速回收TIME-WAIT狀态socket後,其餘用戶端主機的新連接配接源IP和伺服器peer table裡記錄的一樣,但是TCP option裡的timestamp和當時伺服器記錄的主機1的timestamp比較是完全随機的,或者了解為50%機率。如果timestamp比主機1的小,則這個建立連接配接在60秒内就會被拒絕,60秒後建立連接配接又可以成功;如果timestamp比主機1的大,則建立連接配接直接成功。是以在用戶端看來,問題現象就是建立連接配接時通時不通。

這就是使用TIME-WAIT快速回收機制對NAT環境用戶端帶來的副作用。這個副作用不是在設計per-host PAWS機制之初就能預料到了,因為當時的網絡環境和現在大為不同。而在現在的網絡環境下,唯一的建議就是關閉TIME-WAIT快速回收,即讓net.ipv4.tcp_tw_recycle=0。關閉net.ipv4.tcp_timestamps來去掉TCP option中的timestamp時間戳也可以解決此問題,但是因為timestamp是計算RTT和RTO的基礎,通常不建議關閉。

Troubleshooting

在實際生産中,troubleshoot這個問題是一件不太容易的事情。但是對于net.ipv4.tcp_tw_recycle和net.ipv4.tcp_timestamps都開啟的伺服器,并且有NAT用戶端通路時,這個問題出現的機率非常大,是以如果擷取到這兩個核心參數的設定和用戶端網絡的NAT環境,就可以做個基本判定。

另外可以參考netstat -s中的統計,這個統計會彙集從/proc/net/snmp,/proc/net/netstat和/proc/net/sctp/snmp拿到的資料。如下,下面這個統計值表示由于timestamp的原因多少建立連接配接被拒絕,這是一個曆史統計總值,是以兩個時間點的內插補點對問題排查更加有意義。

xx passive connections rejected because of time stamp

2. Accept queue滿造成drop SYN封包

沒有統一且有規律的現象,發生在TCP accept queue滿的時候。這種情況往往發生在使用者空間的應用程式有問題的時候,總體來說發生的機率不是很大。

原理

Accept queue 翻譯成完全連接配接隊列或者接收隊列,為了避免歧義,本文統一用英文原名。新的連接配接完成3次握手後進入accept queue, 使用者空間的應用調用accept系統調用來擷取這個連接配接,并建立一個新的socket,傳回與socket關聯的檔案描述符(fd)。在使用者空間可以利用poll等機制通過readable event來擷取到有新完成3次握手的連接配接進入到了accept queue, 獲得通知後立即調用accept系統調用來擷取新的連接配接。

Accept queue的長度本身是有限的,它的長度取決于min [backlog, net.core.somaxconn],即這個兩個參數中較小的值。

  • backlog 是應用調用listen系統調用時的第2個參數。參考#include 中的int listen(int sockfd, int backlog)。
  • net.core.somaxconn 是系統核心參數,預設是128。應用listen的時候如果設定的backlog比較大,如NGINX預設512,但是這個全局核心參數不調整的話,accept queue的長度還是會決定于其中較小的net.core.somaxconn。

    即使是并發連接配接量很大的情況,應用程式正常利用accpet系統調用取accept queue裡的連接配接都不會因為效率問題而擷取不及時。但是如果由于應用程式阻塞,發生取連接配接不及時的情況可能就可能會導緻accept queue滿的情況的,進而對新來的SYN封包進行丢棄。

在tcp_ipv4中,accept queue滿拒絕SYN封包的實作很簡單,如下:

/* Accept backlog is full. If we have already queued enough
 * of warm entries in syn queue, drop request. It is better than
 * clogging syn queue with openreqs with exponentially increasing
 * timeout.
 */
// 如果accept queue滿了,并且SYN queue中有未SYNACK重傳過的半連接配接,則丢棄SYN請求
if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
  NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
  goto drop;
}           

在sock.h中定義了accept queue滿的inline函數:

static inline bool sk_acceptq_is_full(const struct sock *sk)
{
    return sk->sk_ack_backlog > sk->sk_max_ack_backlog;
}           

在inet_connection_sock.h和request_sock.h中定義了判斷SYN queue中有未SYNACK重傳過的半連接配接的方法:

static inline int inet_csk_reqsk_queue_young(const struct sock *sk)
{
    return reqsk_queue_len_young(&inet_csk(sk)->icsk_accept_queue);
}

static inline int reqsk_queue_len_young(const struct request_sock_queue *queue)
{
    return queue->listen_opt->qlen_young;
}           

如上是3.10中的實作,其實需要判斷兩個條件,“accept queue滿”是一個,“SYN queue中有未SYNACK重傳過的半連接配接”是另外一個,因為通常accept queue滿的時候都是有大量新進連接配接的時候,是以第二個條件是通常是同時滿足的。如果accept queue滿的時候,SYN queue中不存在未SYNACK重傳過的半連接配接,則Linux核心還是會接受這個SYN并傳回SYNACK。這種情況在實際生産中非常少見,除非發生應用程序完全停滞的情況,比如用SIGSTOP信号來停程序,這樣在accept queue滿的時候TCP核心協定棧仍然不會直接drop SYN封包。

因為accept queue滿而drop SYN的邏輯,在比較新的核心版本中略微有變化。比如4.10的版本,核心的判斷條件從兩個變成了一個,即隻判斷accept queue是不是滿,是以在這些版本中,accept queue滿了後核心一定會直接drop SYN封包。

這類問題往往發生在使用者空間的應用程式有問題的時候,總體來說發生的機率不是很大。有如下兩種方式确認:

利用ss指令檢視實時問題

利用ss指令的選項-l檢視listening socket,可以看到Recv-Q和Send-Q,其中Recv-Q表示目前accept queue中的連接配接數量,Send-Q表示accept queue的最大長度。如下:可以看到幾個程序的accept queue預設是128,因為受到系統net.core.somaxconn=128的限制。

Linux核心協定棧丢棄SYN封包的主要場景剖析

netstat -s 統計

可以參考netstat -s中的統計,下面這個統計值表示由于socket overflowed原因多少建立連接配接被拒絕,同樣這是一個曆史統計總值,兩個時間點的內插補點對問題排查更加有意義。

xx times the listen queue of a socket overflowed

解決建議

如果确認是由于accept queue引起的SYN封包被drop的問題,很自然會想到的解決方案是增加accept queue的長度,同時增大backlog和net.core.somaxconn兩個參數能增加accept queue長度。但是通常這個隻能“緩解”,而且最有可能出現的局面是accept queue在增大後又迅速被填滿。是以解決這個問題最建議的方式是從應用程式看下為什麼accept新連接配接慢,從根源上解決問題。

總結

上面總結了per-host PAWS檢查和accept queue滿造成SYN被丢棄這兩種最主要的場景,并分别介紹了現象,原理,代碼邏輯和排查方法。這兩種場景能覆寫平時的絕大部分TCP協定棧丢SYN的問題,如果遇到其他協定棧裡丢SYN的情況,需要結合參數配置和代碼邏輯進一步case by case地排查。

繼續閱讀