單條日誌過長引發的 CLOSE-WAIT

一、背景

部分租戶稱他們的某個業務部署在 K8S 容器平臺後經常會重啓,部分租戶稱另一個業務在運行一段時間時會產生大量的 CLOSE-WAIT,還有的就是業務跑着就會 hang 住。

其實這三個問題,經過我們排查後,都是同一個問題引起,這也是我今天要分享的主題內容。

二、分析過程

大家都知道,重啓我們不好查,因爲原因太多了,比如:容器分配的內存不夠會重啓,運行中程序內存泄漏到將內存耗盡會重啓;在 K8S 中配置了容器運行時健康檢查時,如果條件未達到會重啓等等;總之不好排查。

那如果這三個問題有關聯的話,那麼會不會是服務 hang 住了觸發了其他兩個?即 hang 住了,服務自然沒法響應客戶端的關閉連接,此時就會產生 CLOSE-WAIT;另外,如果 hang 住了,而業務在 K8S 平臺又配置了 live probe 保活機制,那麼就會觸發容器自動重啓。這麼看來,我們首要解決的就是 CLOSE-WAIT 問題,也是我們比較好入手的地方。

當然,也可能由於 CLOSE-WAIT 過多,耗盡了分配的端口或者文件句柄或者產生了過高的 CPU 消耗,導致業務作爲服務端響應過慢,就像被 hang 住一樣?這個好排查,只要看下當前的容器中句柄數使用量及總量或者查看一下容器日誌是否會報端口被佔用及相關異常信息,甚至查看容器過往及當前的 CPU、內存資源使用情況等等,就可以排除這種情況。

所以,我們先從 CLOSE-WAIT 這個問題着手。這個問題其實有一年左右的時間了,之前排查下來,一般是服務端在處理 TCP 四次揮手過程中不恰當引起,因此業務代碼修改後,就可以解決。但現在我們這個租戶反饋,同樣的容器鏡像在我們公司內部的另一個 docker swarm 平臺不會出現,在 K8S 平臺卻時有發生。這個信息很重要,說明可能不是代碼本身的問題,所以我們決定深入排查這個問題。

CLOSE-WAIT 有很多原因引起,有可能是服務端代碼處理 TCP 揮手不合理引起,比如忘記了 close 相應的 socket 連接,那麼自然不會發出 FIN 包;還比如出現死循環之類的問題,導致 close 最後沒有被調用執行;還有可能是服務端程序響應過慢,或者存在耗時邏輯,導致 close 延後;也可能是 accept 的 backlog 設置的太大,導致服務端來不及消費的情況,引起多餘的請求還在隊列裏就被對方關閉了。還有可能是內核參數配置沒有做優化處理,比如調整 tcp_keepalive_time / tcp_keepalive_intvl / tcp_keepalive_probes 等, (這種情況治標不治本)。林林總總,但我們這裏的情況都不是。

三、開始復現

在和租戶一起復現的過程中,我們注意到一個問題,就是在 K8S 平臺的該業務容器對應的控制檯看不到業務日誌輸出了,這個信息很關鍵。這時執行 ss -ant 查看發現 CLOSE-WAIT 在不斷的增加,而此前在日誌正常輸出時,只會產生少量 CLOSE-WAIT,所以我們斷定是業務掛了引起了 CLOSE-WAIT 的增加。

四、構造測試程序

由於是租戶的業務並在生產環境,所以爲了證實這個問題,我們還編寫了 java 測試程序來模擬這種情況,即不斷的輸出日誌的同時暴露服務的某個可供客戶端查詢的 API 接口,這樣可以在復現問題時,查看該接口是否還能正常響應。結果確實沒有響應了,因此證明了是服務掛死引起了 CLOSE-WAIT

另外,我們也注意到一個非常關鍵的信息,就是業務的單條日誌太長了,目測單行有上萬個字符(當然租戶日誌在生產中輸出這麼 多日誌,本身就很不科學,正是這種不科學,纔給了我們排查這個問題的機會!),所以我們讓租戶把日誌輸出關閉後,重新壓測兩天後,並沒有產生 CLOSE-WAIT,到這裏我們基本斷定了和日誌輸出有關了。

接下來,我們構造測試日誌,一種是不斷的輸出日誌,但單條長度限制在 1 萬個字符內。另外一種是不斷的在前一行基礎上追加一個字符,並輸出每行字符總數,爲的是想知道單條字符在超過多少時纔會 hang 死,這個測試腳本如下:

docker run -t busybox /bin/sh -c 's="a"; while true; do echo $s | wc -c; echo $s; s="a$s"; done'

五、測試結果分析

經過測試發現,大概單條日誌在 2萬~3萬字符 的時候就會出現日誌不輸出的情況,也就是業務掛死了。至此,我們就把方向縮小到了單條日誌長度問題上了。這時再通過 google 就容易找到有效信息了,發現 docker 社區也報了這個問題 Logging long lines breaks container,從其中的描述來看,完全和我們產線中遇到的一樣。

至此,問題我們定位出來了。root cause 是 tty 的一個 bug,目前只在 docker-ce.18.06 這個版本中得到了修復,詳見console_linux: Fix race: lock Cond before Signal,這個主要是在 docker 寫日誌到日誌驅動中時,開啓了 tty,而 tty 是通過 signalRead()signalWrite() 函數來通知 tty console 當前是否可以讀寫。在這個過程中,由於都需要等待 Signal 信號來判斷是否可讀寫(通過調用 Signal() 函數來獲取),我們不難看出 Signal 已經成爲了一個競爭資源,有經驗的開發者馬上能看出這裏有問題了,沒錯!需要提前加鎖以防資源競爭產生,這樣程序就不會 hang 死。

所以,是由於我們在部署容器時開啓了 tty,長條日誌觸發了該 bug。我們都知道,tty 默認是關閉的。這時我們對比了公司內部的 Docker Swarm 平臺,發現在該平臺上也是關閉 tty 的,所以解釋了爲什麼在 K8S 平臺出現 CLOSE-WAIT 的原因了。

最後,我們可以通過升級 docker-ce 的版本到特定的 docker-ce.18.06 版本(其他版本還沒有 cherry-pick 過來,比如 docker-ce 19.x 後的版本該bug依舊存在),或者關閉 tty,即在創建容器時,tty=false 就可以了。具體可以在用 deployment 部署了業務後,通過 docker inspect | grep Tty 來查看該業務容器是否開啓。

六、後記

在容器部署的時候 tty 的開啓是沒有必要的,也就是不需要分配 tty 資源給容器,因爲我們不需要交互。只有在我們需要交互的使用場景,比如向控制檯輸入某些命令,需要容器給我們輸出時,才加上 -t 即可,比如 docker exec -it xxxx sh 或者 kubectl exec -it xxx -- sh 時才臨時分配一個用於交互。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章