一次"Connection Reset"的根因和修改方式調查

1. 背景

在我們的一個項目中提供了REST接口,並通過 VIP(浮動IP) 後接四臺Server的方式對外提供服務。客戶端是一個自動化系統,會週期性地傳遞大量需要自動化運行的任務。在測試一段時間後,提出一個 Issue。其主要內容爲: Client上運行週期性的測試任務(10分鐘一次),運行一段時間後,其到Server的連接會隨機性地產生 Connection Reset 異常
日誌如下(已將敏感信息進行了替換):

2018-11-14 09:09:24.504 [WARN ] com.xxxx.XxxException: Failed to get Server's response: javax.ws.rs.ProcessingException: java.net.SocketException: Connection reset
...
2018-11-14 08:39:24.352 [WARN ] com.xxxx.XxxException: Failed to get Server's response: javax.ws.rs.ProcessingException: java.net.SocketException: Connection reset
...
2018-11-14 05:30:21.564 [WARN ] com.xxxx.XxxException: Failed to get Server's response: javax.ws.rs.ProcessingException: java.net.SocketException: Connection reset
...
2018-11-14 04:10:07.279 [WARN ] com.xxxx.XxxException: Failed to get Server's response: javax.ws.rs.ProcessingException: java.net.SocketException: Connection reset

2. 問題確認

  • 在Kerbose服務器上, 通過 dsh 運行netstat 確認,發現服務器集羣上有大量從客戶端來的連接(替換了Server和IP,並裁剪了部分數據,實際單客戶端連接到服務器集羣上的連接數爲 80+)
$ dsh -M -g our-real-servers "netstat -anp | grep ESTABLISHED | grep :8080 | grep 192.168.224.108" 2>/dev/null

server01: tcp        0      0 192.168.105.67:8080      192.168.224.108:4792      ESTABLISHED 5765/java
server01: tcp        0      0 192.168.105.67:8080      192.168.224.108:57492     ESTABLISHED 5765/java
server01: tcp        0      0 192.168.105.67:8080      192.168.224.108:60752     ESTABLISHED 5765/java
server02: tcp        0      0 192.168.105.67:8080      192.168.224.108:22964     ESTABLISHED 15663/java
server02: tcp        0      0 192.168.105.67:8080      192.168.224.108:6395      ESTABLISHED 15663/java
server02: tcp        0      0 192.168.105.67:8080      192.168.224.108:10086     ESTABLISHED 15663/java
server03: tcp        0      0 192.168.105.67:8080      192.168.224.108:8398      ESTABLISHED 159482/java
server03: tcp        0      0 192.168.105.67:8080      192.168.224.108:63721     ESTABLISHED 159482/java
server04: tcp        0      0 192.168.105.67:8080      192.168.224.108:40387     ESTABLISHED 17637/java
server04: tcp        0      0 192.168.105.67:8080      192.168.224.108:13365     ESTABLISHED 17637/java
...
  • 和客戶確認後知道其也有四臺機器,使用 HttpClient 的連接池技術連接到VIP,連接池數量爲 100.此時的網絡拓撲爲:
    網絡拓撲

3. 初步調查

  • 經過Google和度娘搜索,發現很多使用 HttpClient 調用後臺resetful服務時常會出現這個錯誤,解釋一般都是服務器端因爲某種原因(如網絡中沒有數據)關閉了Connection,但具體原因及確認方法多半語焉不詳。
  • 至於解決方案,很多時候是要求客戶端重試處理(如加入 retryHandler )或使用短鏈接(HTTP.CONN_CLOSE),或要求設置 IdelTime 等。
  • 和客戶溝通後,客戶認爲他們正確的使用了連接池,懷疑是服務器在某些時候(比如空閒時)關閉了連接,希望我們檢查服務器的超時策略,最大請求設置及服務器的KeepAlive設置等,當然他們也會加入重試邏輯來儘量從這種錯誤中恢復。
  • 我們的服務器使用了 vertx, 經過檢查和測試,在相關位置使用的是缺省值:沒有 keep-alive(如 HTTP/1.0) 的連接當響應結束的話,會自動關閉連接;但如果是 HTTP/1.1 或有 Keep-alive的話,就不會主動關閉了。而且也沒有配置 TCPSSLOptions.idleTimeout 的值,保持默認(0),根本不會主動斷開連接。
  • 此時和客戶似乎陷入互不認可調查結論的境地,雙方都認爲自己的代碼正確,那問題到底出在哪裏?難道真得只能像網上所說,在不清楚根因的情況下使用 短鏈接(Connection:Close) ,每次都關閉連接,犧牲性能來換穩定性?

4. 深入調查

4.1. 調查目的

  • 確認整個系統中,究竟在哪個部分,在什麼情況下會斷開客戶端的連接;
  • 在找到斷開連接根因的情況下,確認最佳的修改方式

4.2. 背景知識介紹

4.2.1. TCP Keep-Alive Vs. HTTP Keep-Alive

很多人看到這個標題,可能會突然發現居然有兩個 Keep-Alive 概念。這兩個是完全不同的概念,在不同的層起作用,也受不同的參數影響。

  • TCP Keep-Alive: 在TCP層的Socket上設置,設置參數爲: SO_KEEPALIVE. 參見 Socket.setKeepAlive,相關的參數還有 TCP_KEEPIDLE 等。在TCP連接建立之後, 如果網絡上沒有傳遞數據, TCP會自動發送一個數據爲空的報文(偵測包)給對方,如果對方迴應了這個報文,說明對方還在線,連接可以繼續保持,如果對方沒有報文返回,並且重試了多次之後則認爲鏈接丟失,沒有必要保持連接。. 在Wireshark 中抓包的話, 會顯示 [TCP Keep-Alive] 和 [TCP Keep-Alive ACK].
    TCP Keep-Alive

  • HTTP Keep-Alive:在 Http 層通過 Connection: keep-alive 的Header設置, 表示客戶端到服務器端的連接持續有效,當出現對服務器的後繼請求時,Keep-Alive功能避免了建立或者重新建立連接。WireShare 截圖爲:
    Http Keep-Alive

  • 從上面的描述可知, TCP Keep-Alive 是爲了保持連接不會因爲沒有數據而被關閉,是真正的“保活”,而HTTP Keep-Alive 是爲了提高性能,而重用已經打開的連接。那麼常見的 “Connection Reset” 問題的根因應該就和 TCP Keep-Alive相關,之後的分析我也會優先從此入手。此時可能有人已有疑問,網上常見的解決方式都是更改 Http 層的 Keep-Alive, 爲什麼我會說根因是 TCP 層的,具體原因之後會分析。

4.2.2. netstat 的 -o | --timers 選項

注意,這個選項似乎只有Linux上有,Win/Mac都沒有,其作用是顯示連接中網絡時間相關的部分(Include information related to networking timers). 其中Timer列主要有以下幾種類型:

  • off: 表示沒有啓用TCP層的 KeepAlive
  • keepalive : 啓用了 TCP層的 KeepAlive,第一個參數就是倒計時,減到0時會發送 [TCP Keep-Alive] 包;
  • timewait: 對應等待(TIME_WAIT)時間計時
  • on: 重發(retransmission)的時間計時
    netstat -ant --timers經過檢查,客戶SDK連接我們的Servers時,都是off狀態,即沒有啓用 TCP Keep-Alive 的。

4.3. 實測驗證

4.3.1 測試方法

  • 考慮到 Connection Reset 的發生是Socket上很長時間沒有數據傳輸,從而被服務器超時關閉,那就編寫一個簡單的程序來進行測試驗證。
  • 代碼功能爲:創建一個簡單的socket,連接到指定服務器,睡眠指定時間(0, 33, 65, 125, 185, 305, 605, 1790, 1805, 3605) 等來模擬在一段時間內不收發數據,然後再嘗試讀寫,檢查是否出現異常,期間通過 wireshark 進行抓包。
  • 考慮到 TCP KeepAlive 的問題,在啓用 TCP KeepAlive 和不啓用的情況下分別測試一次
  • 考慮到網絡拓撲中有L4,可能會對連接產生影響。因此需要測試通過L4連接和直接連接Server兩種方式,此時的拓撲如圖所示:
    測試網絡拓撲

4.3.2 測試代碼(Python)

import unittest
import socket
from time import ctime,sleep

def keepaliveTestFunc(target, isKeepAlive):
   sleepTimes = [0, 33, 65, 125, 185, 305, 605, 1790, 1805, 3605]

   for index in range(len(sleepTimes)):
       sleeptime = sleepTimes[index]
       issuccessful = True
       try:
           sock = socket.socket()
           if isKeepAlive:   #如果要測試打開TCP KeepAlive 的情況, 則啓用並設置相關參數
               sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 
               sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1 * 60)  # 如果60秒沒有數據,則發送探測分組
               sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 1 * 60)  #前後兩次探測之間的時間間隔, 60秒
               sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5) #關閉一個非活躍連接之前的最大重試次數

           print("[%s]: isKeepAlive=%s, Before connect %s:%d" % (ctime(), isKeepAlive,
                                                                 target["host"], target["port"]))
           sock.connect((target["host"], target["port"]))
           print("[%s]: isKeepAlive=%s, Before Sleep %s seconds for read from %s:%d"
                 % (ctime(), isKeepAlive, sleeptime, target["host"], target["port"]))
           sleep(sleeptime)  # 睡眠指定時間
           print("[%s]: isKeepAlive=%s, After Sleep %s seconds, try to get data from %s:%d"
                 % (ctime(), isKeepAlive, sleeptime, target["host"], target["port"]))

           #一個簡單的讀寫請求,請求內容和方式不重要,重要的是在該socket上能有讀寫操作
           text = "GET / HTTP/1.1\r\n\r\n"
           sock.sendall(text)
           ret_bytes = sock.recv(1000)
           ret_str = str(ret_bytes)
           #print("[%s]: result=%s" % (ctime(), ret_str))
       except Exception, e:
           issuccessful = False  # 如果發生錯誤,會捕獲異常並設置標誌變量
           print str(e)
       finally:
           sock.close()
           # 最後打印本次測試的結果
           print("[%s]: isKeepAlive=%s, sleep %s seconds and try to read %s:%d, issuccessful=%s"
                 % (ctime(), isKeepAlive, sleeptime, target["host"], target["port"], issuccessful))
           print("")


class KeepAliveTester(unittest.TestCase):
   '''Python KeepAlive Tester'''

   def test_ServerKeepAlive(self):
       targets = [
           # VIP Address, 換成自己的VIP、L4、Nginx 等機器地址
           {"host": "192.168.105.67", "port": 8080},
           # server01(換成自己的測試服務器地址)
           {"host": "192.168.106.178", "port": 8080},
           #{"host": "www.baidu.com", "port": 80}
       ]

       isKeepAlives = [
           False,
           True
       ]

       for i in range(len(targets)):
           for j in range(len(isKeepAlives)):
               keepaliveTestFunc(targets[i], isKeepAlives[j])


def suite():
   suite = unittest.makeSuite(KeepAliveTester, 'test')
   return suite


if __name__ == "__main__":
   unittest.main()

4.3.3 測試結果

  • 從日誌來看,有兩次失敗,在不啓用TCP KeepAlive的情況下,通過VIP連接服務器時,超過1800秒後就會失敗,錯誤碼類型也是 “Connection reset”。
[Thu Nov 15 20:35:21 2018]: isKeepAlive=False, After Sleep 1805 seconds, try to get data from "VIP"
[Errno 104] Connection reset by peer
[Thu Nov 15 20:35:21 2018]: isKeepAlive=False, sleep 1805 seconds and try to read "VIP", issuccessful=False
...
[Fri Nov 16 00:59:32 2018]: isKeepAlive=False, After Sleep 1805 seconds, try to get data from "Server-01"
[Fri Nov 16 00:59:32 2018]: isKeepAlive=False, sleep 1805 seconds and try to read "Server-01", issuccessful=True
...
[Thu Nov 15 23:17:29 2018]: isKeepAlive=True, After Sleep 3605 seconds, try to get data from "VIP"
[Thu Nov 15 23:17:29 2018]: isKeepAlive=True, sleep 3605 seconds and try to read "VIP", issuccessful=True

4.3.4. 結論

  • L4(VIP) 在socket上沒有任何數據(業務數據或KeepAlive包)的情況下,會關閉連接,這個時間閾值爲 1800 秒;
  • 我們的 API Server 在相同的情況下不會關閉連接.
  • 如果啓用 TCP 的 keepalive(TCP_KEEPIDLE=60, TCP_KEEPINTVL=60), 那麼L4也不會再關閉連接了.
  • 由此可知,我們和Client對自己模塊的分析都正確,問題出在中間的 L4 VIP 上。

5. 確認最佳的更改方式

確認到這個問題的根因以後,就需要考慮和業務匹配的更改方式了。在自己實現以前,先調查網上常見的更改方式,並比較其優缺點。

5.1. 網上常見更改方式的調查和比較

5.1.1. 使用長連接(Connection:keep-alive),並保持網絡中持續有數據,從而始終激活連接

採用這種方式,一般有兩種方法:

  • 啓用TCP KeepAlive。在我測試問題根因時也採用了這種方式,但這種方式可能在內網中使用較多,在互聯網上,由於受限於網絡連接之間的路由、代理,以及服務器的限制(比如百度似乎也會檢測HTTP層是否有數據,來斷開連接),這種方式使用的不多。
  • 使用自定義的心跳協議,由於傳輸的數據是自定義的符合業務規範的數據,和普通數據一致,不受路由、代理等限制,而且心跳中可以攜帶業務數據,可以任意擴展,在Server和客戶端之間的連接數不多(比如只有1到2個)的場景時使用較多。

5.1.2. 使用短連接(Connection:close),每次業務交互時都進行TCP的三次握手,處理結束後斷開連接

採用這種方式,由於每次連接結束後都斷開,各個節點再也沒有機會因檢測到“網絡上沒有數據超時”而斷開連接,因此當然就不會出現 Connection Reset 的問題了,這是一種犧牲性能換穩定性的方式。但由於這種方式是 HTTP/1.0 的默認方式,而且每次連接的狀態無關,不需要Server做特別的處理,也存在很大的應用場景。而且如果在內網中交互的話,三次握手的交互時間花費也很短,因此也常用。

5.2.本應用場景中的最佳更改方式:

在我們的應用場景中,Client需要連接到Server,持續性地傳遞大量的任務Job,頻繁的連接肯定不好,因此Client使用HttpClient的連接池技術 PoolingHttpClientConnectionManager。但由於雙方採用的參數沒有調整到最佳,從而出現錯誤。因此,最佳的更改方式需要滿足以下幾點:

  • 客戶使用連接池,從而能快速地傳遞數據
  • 需要使用“資源過期策略”,釋放不需要的連接。一來避免Reset錯誤,二來可以節約服務器資源(畢竟我們的服務器不止服務一種客戶)。

經過多次調查和分析(具體步驟不再詳述),確認可以在 HTTP Header 中結合使用 Connection:keep-aliveKeep-Alive:timeout=超時值 的方式來滿足要求,在性能和穩定性之間達到平衡。具體的使用方式參見 HttpClientBuilder.setKeepAliveStrategy 接口。

實測代碼參見: keepalive-test。寫的非常簡陋,只是演示了基本的使用方式。通過設置客戶端的 SLEEP_TIME 和 SERVER 端返回的 Keep-Alive:timeout 爲不同的值, 然後通過 "watch -d -n 3 “netstat -anoplt | grep 8080” 命令即可查看連接的利用情況(釋放或重用)。

6. 後記

經過仔細的分析和調查,確認了我們這次問題的根因以及相對來說“最佳”的解決方式。但是,還是存在一些不確定的東西,需要後續繼續調查確認。

  • 按vertx默認的配置參數來看, 不會主動斷開無數據的客戶端連接,這樣的話似乎很容易被 DOS 攻擊 – 初步調查,似乎可以設置 TCPIdleTime 來避免這種問題;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章