kubelet連接apiserver報TLS錯誤


Background

kubernetes是master-slave結構,master node是集群的大腦, 當master node發生故障時整個集群都"out of control"。master node中最重要的當屬apiserver組件, 它負責處理所有請求, 並持久化狀態到etcd。 一般我們會部署多份apiserver實現高可用。官方建議在多個apiserver前面部署一個LB進行負載均衡,當其中一台apiserver發生故障之后,LB 自動將流量切換到其他實例上面。這樣雖然簡單,但是也引入了額外的依賴,如果LB發生故障將會導致全部apiserver不可用。我們知道在kubernetes中node節點上kubelet與apiserver心跳超時后, controller-manager會將該node狀態置為notReady, 隨后驅逐其上的pod,使這些pod在其他地方重建。 所以當LB發生故障時, 集群中所有的node都會變為notReady狀態,進而導致大規模的pod驅逐。

故障發生

無獨有偶,這樣的事情偏偏被我們碰到了,接到線上大量node not ready的報警后,立刻上線查看,發現所有的node kubelet都報如下錯誤:

E0415 17:03:11.351872 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?resourceVersion=0&timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers) E0415 17:03:16.352108 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers) E0415 17:03:21.352335 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers) E0415 17:03:26.352548 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers) E0415 17:03:31.352790 16624 kubelet_node_status.go:374] Error updating node status, will retry: error getting node "k8s-slave88": Get https://10.13.10.12:6443/api/v1/nodes/k8s-slave88?timeout=5s: net/http: request canceled (Client.Timeout exceeded while awaiting headers) E0415 17:03:31.352810 16624 kubelet_node_status.go:366] Unable to update node status: update node status exceeds retry count 

日志中顯示的10.13.10.12是LB的地址。通過這個日志判斷是kubelet連接apiserver失敗,初步懷疑是網絡故障,手動telnet 10.13.10.12 6443后發現一切正常,這就比較奇怪了,明明網絡通信正常, kubelet為什么連不上apiserver? 趕緊用tcpdump抓包分析了一下,發現kubelet不斷地給apiservre發送包卻沒有收到對端的ACK,登錄master查看apiserver服務也一切正常。后來同事發現重啟kubelet就好了,為了盡快解決問題只能把kubelet全部重啟了,后面再慢慢定位問題。

定位問題

集群恢復之后,發現有故障通報LB發生了故障,聯系了相關同學發現時間點剛好相符,懷疑是因為LB異常導致kubelet無法連接apiserver。 經過溝通后發現: LB會為其轉發的每一個connection維護一些數據結構,當新的一台LB server上線之后會均攤一部分原來的流量,但是在其維護的數據結構中找不到該connection的記錄就會認為這個請求非法,直接DROP掉。 類似的事確實還發生不少,在kubernetes的isuse里有不少這樣的案例,甚至需要公有雲的的LB也會有這樣的問題,參見這個kubernetes#41916kubernetes#48638kubernetes-incubator/kube-aws#598。大概明白原因之后, push LB的同學改進的同時, kubelet也應該做一些改進: 當kubelet連接apiserver超時之后, 應該reset 掉連接, 進行重試。簡單做了一個測試,使用iptables規則drop掉kubelet發出的流量來模擬網絡異常:

首先確保kubelet 與apiserver連接正常, 執行netstat -antpl | grep 6443可以看到kubelet與apiserver 10.132.106.115:6443連接正常

[root@c4-jm-i1-k8stest03 ~]# netstat -antpl  |grep kubelet
tcp        0      0 127.0.0.1:10248         0.0.0.0:*               LISTEN      23665/./kubelet     
tcp        0      0 10.162.1.26:63876       10.132.106.115:6443     ESTABLISHED 23665/./kubelet     
tcp6       0      0 :::4194                 :::*                    LISTEN      23665/./kubelet     
tcp6       0      0 :::10250                :::*                    LISTEN      23665/./kubelet     
tcp6       0      0 :::10255                :::*                    LISTEN      23665/./kubelet     
tcp6       0      0 10.162.1.26:10250       10.132.1.30:61218       ESTABLISHED 23665/./kubelet     

這時候執行iptables -I OUTPUT -p tcp --sport 63876 -j DROP將kubelet發出的包丟掉,模擬網絡故障,此時可以看到netstat的輸出中該連接的Send-Q正在逐步增加,並且kubelet也打印出日志顯示無法連接,

[root@c4-jm-i1-k8stest03 ~]# netstat -antpl  |grep kubelet
tcp        0      0 127.0.0.1:10248         0.0.0.0:*               LISTEN      23665/./kubelet     
tcp        0    928 10.162.1.26:63876       10.132.106.115:6443     ESTABLISHED 23665/./kubelet  

連接被hang住了,重啟kubelet之后,一切又恢復了。這個現象和當時發生故障的情況一模一樣:連接異常導致kubelet心跳超時,重啟kubelet后會新建連接, 恢復正常心跳。因為我們當前采用的kubernetes版本是v1.10.2,下載master分支的代碼編譯試了下,也是有這個問題的,感覺這個問題一直存在。

艱難的修復

接下來就是怎么修復這個問題了。網上找了一下相關的issue, 首先找到的是kubernetes/client-go#374這個issue,上面描述的情況和我們碰到的很相似,有人說是因為使用了HTTP/2.0協議(以下簡稱h2),查找了一下kubelet的源碼,發現kubelet默認是使用h2協議,具體的代碼實現在SetTransportDefaults這個函數中(點擊閱讀源碼)。可以通過設置環境變量DISABLE_HTTP2來禁用h2,簡單驗證了一下: 顯式設置該環境變量禁用h2后, 讓連接使用http1.1確實沒有這個問題了。 查閱文檔發現這是http1.1與http2.0的差異:在http1.1中,默認采用keep-alive復用網絡連接,發起新的請求時, 如果當前有閑置的連接就會復用該連接, 如果沒有則新建一個連接。當kubelet連接異常時,老的連接被占用,一直hang在等待對端響應,kubelet在下一次心跳周期,因為沒有可用連接就會新建一個,只要新連接正常通信,心跳包就可以正常發送。在h2中,為了提高網絡性能,一個主機只建立一個連接,所有的請求都通過該連接進行,默認情況下,即使網絡異常,他還是重用這個連接,直到操作系統將連接關閉,而操作系統關閉僵屍連接的時間默認是十幾分鍾,具體的時間可以調整系統參數net.ipv4.tcp_retries2, net.ipv4.tcp_keepalive_time, net.ipv4.tcp_keepalive_probes, net.ipv4.tcp_keepalive_intvl。(在上面issue中的回復中,確實有人這么做,通過調整操作系統斷開異常連接的時間實現快速恢復)。h2主動探測連接故障是通過發送Ping frame來實現,這是一個優先級比較高並且payload很少的包,網絡正常時是可以快速返回, 該frame默認不會發送, 需要顯式設置才會發送。 在一些gRPC等要求可靠性比較高的通信框架中都實現了Ping frame,在gRPC On HTTP/2: Engineering A Robust, High Performance Protocol中談到

The less clean version is where the endpoint dies or hangs without informing the client. In this case, TCP might undergo retry for as long as 10 minutes before the connection is considered failed. Of course, failing to recognize that the connection is dead for 10 minutes is unacceptable. gRPC solves this problem using HTTP/2 semantics: when configured using KeepAlive, gRPC will periodically send HTTP/2 PING frames. These frames bypass flow control and are used to establish whether the connection is alive. If a PING response does not return within a timely fashion, gRPC will consider the connection failed, close the connection, and begin reconnecting (as described above).

可以看到gRPC同樣存在這樣的問題,為了快速識別故障連接並恢復采用了Ping frame。 但是目前kubernetes所建立的連接中並沒有實現Ping frame, 導致了無法及時發現連接異常並自愈。

社區那個issue已經開了很長時間好像並沒有解決的痕跡,還得自己想辦法。我們知道一個http.Client本身其實只做了一些http協議的處理,底層的通信是交給Transport來實現, Transport決定如何根據一個request返回對應的response。在kubernetes client-go中關於Transporth2的設置只有這一個函數

// SetTransportDefaults applies the defaults from http.DefaultTransport // for the Proxy, Dial, and TLSHandshakeTimeout fields if unset func SetTransportDefaults(t *http.Transport) *http.Transport { t = SetOldTransportDefaults(t) // Allow clients to disable http2 if needed. if s := os.Getenv("DISABLE_HTTP2"); len(s) > 0 { klog.Infof("HTTP2 has been explicitly disabled") } else { if err := http2.ConfigureTransport(t); err != nil { klog.Warningf("Transport failed http2 configuration: %v", err) } } return t } 

只是調用了http2.ConfigureTransport來設置transport支持h2。這一句代碼似乎太過簡單,並沒有任何Ping frame相關的處理邏輯。查了下golang標准庫中TransportPing frame相關的方法,令遺憾的是,當前golang 對於一個tcp連接的抽象ClientConn已經支持發送Ping frame,但是連接是交由連接池clientConnPool管理的, 該結構是個內部的私有結構體,我們沒法直接操作,封裝連接池的Transport也沒有暴露任何的接口來實現設置連接池中的所有連接定期發送Ping frame。如果我們想實現這個功能就必須自定義一個Transport並實現一個連接池,要實現一個穩定可靠的Transport似乎並不容易。只能求助golang社區看有沒有解決方案, 提交了一個issue后:x/net/http2: make Transport occasionally send PING frames to server #31643, 很快就有人回復並提交了PR,查看了一下,實現還是比較簡單的,於是基於這個PR實現了clinet-goPing frame的探測。

峰回路轉

開發完畢准備上線的時候,想趁這次修復升級一下kubernetes版本到v1.10.11,一般patch release是保證兼容的。在測試v1.10.11的時候驚奇的發現,即使不改任何代碼, 這個問題也沒辦法復現了。說明在v1.10.2中是有問題的,在v1.10.11中恢復了,接着在master中又引入了這個問題,看來還得需要仔細閱讀一下這部分代碼了,到底是發生了什么。

經過閱讀代碼,發現這個邏輯曾經在track/close kubelet->API connections on heartbeat failure #63492這里修復過,並且backport到1.10.3的代碼中,當連接異常時會會調用closeAllConns強制關閉掉所有的連接使其重建。隨后在Restore bootstrap in the background with fix to preserve kubeadm behavior #71174這里又引入了regression:將closeAllConns置為nil,導致連接無法正常關閉。明白了這個邏輯之后修改就簡單了,將closeAllConns再置為正確的值即可,給官方提交了一個pr: kubelet: fix fail to close kubelet->API connections on heartbeat failure #78016。官方很樂意就接受了, 並backport到了1.14版本中。 至此這個就算完全修復了, 當然可以通過上文提到的給h2增加Ping frame的方式解決該問題, 這是這種方案可能比較復雜, 修復時間比較長。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM