文章目錄標題
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].
-
HTTP Keep-Alive:在 Http 層通過 Connection: keep-alive 的Header設置, 表示客戶端到服務器端的連接持續有效,當出現對服務器的後繼請求時,Keep-Alive功能避免了建立或者重新建立連接。WireShare 截圖爲:
-
從上面的描述可知, 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)的時間計時
經過檢查,客戶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-alive 和 Keep-Alive:timeout=超時值 的方式來滿足要求,在性能和穩定性之間達到平衡。具體的使用方式參見 HttpClientBuilder.setKeepAliveStrategy 接口。
實測代碼參見: keepalive-test。寫的非常簡陋,只是演示了基本的使用方式。通過設置客戶端的 SLEEP_TIME 和 SERVER 端返回的 Keep-Alive:timeout 爲不同的值, 然後通過 "watch -d -n 3 “netstat -anoplt | grep 8080” 命令即可查看連接的利用情況(釋放或重用)。
6. 後記
經過仔細的分析和調查,確認了我們這次問題的根因以及相對來說“最佳”的解決方式。但是,還是存在一些不確定的東西,需要後續繼續調查確認。
- 按vertx默認的配置參數來看, 不會主動斷開無數據的客戶端連接,這樣的話似乎很容易被 DOS 攻擊 – 初步調查,似乎可以設置 TCPIdleTime 來避免這種問題;