原文連接:Kubernetes 網絡疑難雜症排查分享node
你們好,我是 roc,來自騰訊雲容器服務 (TKE) 團隊,常常幫助用戶解決各類 K8S 的疑難雜症,積累了比較豐富的經驗,本文分享幾個比較複雜的網絡方面的問題排查和解決思路,深刻分析並展開相關知識,信息量巨大,相關經驗不足的同窗可能須要細細品味才能消化,我建議收藏本文反覆研讀,當徹底看懂後我相信你的功底會更加紮實,解決問題的能力會大大提高。linux
本文發現的問題是在使用 TKE 時遇到的,不一樣廠商的網絡環境可能不同,文中會對不一樣的問題的網絡環境進行說明git
現象: 從 VPC a 訪問 VPC b 的 TKE 集羣的某個節點的 NodePort,有時候正常,有時候會卡住直到超時。github
緣由怎麼查?後端
固然是先抓包看看啦,抓 server 端 NodePort 的包,發現異常時 server 能收到 SYN,但沒響應 ACK:api
反覆執行 netstat -s | grep LISTEN
發現 SYN 被丟棄數量不斷增長:緩存
分析:bash
兩個 VPC 之間使用對等鏈接打通的,CVM 之間通訊應該就跟在一個內網同樣能夠互通。網絡
爲何同一 VPC 下訪問沒問題,跨 VPC 有問題? 二者訪問的區別是什麼?併發
再仔細看下 client 所在環境,發現 client 是 VPC a 的 TKE 集羣節點,捋一下:
client 在 VPC a 的 TKE 集羣的節點
server 在 VPC b 的 TKE 集羣的節點
由於 TKE 集羣中有個叫 ip-masq-agent
的 daemonset,它會給 node 寫 iptables 規則,默認 SNAT 目的 IP 是 VPC 以外的報文,因此 client 訪問 server 會作 SNAT,也就是這裏跨 VPC 相比同 VPC 訪問 NodePort 多了一次 SNAT,若是是由於多了一次 SNAT 致使的這個問題,直覺告訴我這個應該跟內核參數有關,由於是 server 收到包沒回包,因此應該是 server 所在 node 的內核參數問題,對比這個 node 和 普通 TKE node 的默認內核參數,發現這個 node net.ipv4.tcp_tw_recycle = 1
,這個參數默認是關閉的,跟用戶溝通後發現這個內核參數確實在作壓測的時候調整過。
解釋一下,TCP 主動關閉鏈接的一方在發送最後一個 ACK 會進入 TIME_AWAIT
狀態,再等待 2 個 MSL 時間後纔會關閉 (由於若是 server 沒收到 client 第四次揮手確認報文,server 會重發第三次揮手 FIN 報文,因此 client 須要停留 2 MSL 的時長來處理可能會重複收到的報文段;同時等待 2 MSL 也可讓因爲網絡不通暢產生的滯留報文失效,避免新創建的鏈接收到以前舊鏈接的報文),瞭解更詳細的過程請參考 TCP 四次揮手。
參數 tcp_tw_recycle
用於快速回收 TIME_AWAIT
鏈接,一般在增長鏈接併發能力的場景會開啓,好比發起大量短鏈接,快速回收可避免 tw_buckets
資源耗盡致使沒法創建新鏈接 (time wait bucket table overflow
)
查得 tcp_tw_recycle
有個坑,在 RFC1323 有段描述:
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.
大概意思是說 TCP 有一種行爲,能夠緩存每一個鏈接最新的時間戳,後續請求中若是時間戳小於緩存的時間戳,即視爲無效,相應的數據包會被丟棄。
Linux 是否啓用這種行爲取決於 tcp_timestamps
和 tcp_tw_recycle
,由於 tcp_timestamps
缺省開啓,因此當 tcp_tw_recycle
被開啓後,實際上這種行爲就被激活了,當客戶端或服務端以 NAT
方式構建的時候就可能出現問題。
當多個客戶端經過 NAT 方式聯網並與服務端交互時,服務端看到的是同一個 IP,也就是說對服務端而言這些客戶端實際上等同於一個,惋惜因爲這些客戶端的時間戳可能存在差別,因而乎從服務端的視角看,即可能出現時間戳錯亂的現象,進而直接致使時間戳小的數據包被丟棄。若是發生了此類問題,具體的表現一般是是客戶端明明發送的 SYN,但服務端就是不響應 ACK。
回到咱們的問題上,client 所在節點上可能也會有其它 pod 訪問到 server 所在節點,而它們都被 SNAT 成了 client 所在節點的 NODE IP,但時間戳存在差別,server 就會看到時間戳錯亂,由於開啓了 tcp_tw_recycle
和 tcp_timestamps
激活了上述行爲,就丟掉了比緩存時間戳小的報文,致使部分 SYN 被丟棄,這也解釋了爲何以前咱們抓包發現異常時 server 收到了 SYN,但沒有響應 ACK,進而說明爲何 client 的請求部分會卡住直到超時。
因爲 tcp_tw_recycle
坑太多,在內核 4.12 以後已移除: https://github.com/torvalds/linux/commit/4396e46187ca5070219b81773c4e65088dac50cc
現象: LoadBalancer 類型的 Service,直接壓測 NodePort CPS 比較高,但若是壓測 LB CPS 就很低。
環境說明: 用戶使用的黑石 TKE,不是公有云 TKE,黑石的機器是物理機,LB 的實現也跟公有云不同,但 LoadBalancer 類型的 Service 的實現一樣也是 LB 綁定各節點的 NodePort,報文發到 LB 後轉到節點的 NodePort, 而後再路由到對應 pod,而測試在公有云 TKE 環境下沒有這個問題。
client 抓包: 大量 SYN 重傳。
server 抓包: 抓 NodePort 的包,發現當 client SYN 重傳時 server能收到 SYN 包但沒有響應。
又是 SYN 收到但沒響應,難道又是開啓 tcp_tw_recycle
致使的?檢查節點的內核參數發現並無開啓,除了這個緣由,還會有什麼狀況能致使被丟棄?
conntrack -S
看到 insert_failed
數量在不斷增長,也就是 conntrack 在插入不少新鏈接的時候失敗了,爲何會插入失敗?什麼狀況下會插入失敗?
挖內核源碼: netfilter conntrack 模塊爲每一個鏈接建立 conntrack 表項時,表項的建立和最終插入之間還有一段邏輯,沒有加鎖,是一種樂觀鎖的過程。conntrack 表項併發剛建立時五元組不衝突的話能夠建立成功,但中間通過 NAT 轉換以後五元組就可能變成相同,第一個能夠插入成功,後面的就會插入失敗,由於已經有相同的表項存在。好比一個 SYN 已經作了 NAT 可是還沒到最終插入的時候,另外一個 SYN 也在作 NAT,由於以前那個 SYN 還沒插入,這個 SYN 作 NAT 的時候就認爲這個五元組沒有被佔用,那麼它 NAT 以後的五元組就可能跟那個還沒插入的包相同。
在咱們這個問題裏實際就是 netfilter 作 SNAT 時源端口選舉衝突了,黑石 LB 會作 SNAT,SNAT 時使用了 16 個不一樣 IP 作源,可是短期內源 Port 倒是集中一致的,併發兩個 SYN a 和 SYN b,被 LB SNAT 後源 IP 不一樣但源 Port 極可能相同,這裏就假設兩個報文被 LB SNAT 以後它們源 IP 不一樣源 Port 相同,報文同時到了節點的 NodePort 會再次作 SNAT 再轉發到對應的 Pod,當報文到了 NodePort 時,這時它們五元組不衝突,netfilter 爲它們分別建立了 conntrack 表項,SYN a 被節點 SNAT 時默認行爲是 從 port_range 範圍的當前源 Port 做爲起始位置開始循環遍歷,選舉出沒有被佔用的做爲源 Port,由於這兩個 SYN 源 Port 相同,因此它們源 Port 選舉的起始位置相同,當 SYN a 選出源 Port 但還沒將 conntrack 表項插入時,netfilter 認爲這個 Port 沒被佔用就極可能給 SYN b 也選了相同的源 Port,這時他們五元組就相同了,當 SYN a 的 conntrack 表項插入後再插入 SYN b 的 conntrack 表項時,發現已經有相同的記錄就將 SYN b 的 conntrack 表項丟棄了。
解決方法探索: 不使用源端口選舉,在 iptables 的 MASQUERADE 規則若是加 --random-fully
這個 flag 可讓端口選舉徹底隨機,基本上能避免絕大多數的衝突,但也沒法徹底杜絕。最終決定開發 LB 直接綁 Pod IP,不基於 NodePort,從而避免 netfilter 的 SNAT 源端口衝突問題。
DNS 解析偶爾 5S 延時
網上一搜,是已知問題,仔細分析,實際跟以前黑石 TKE 壓測 LB CPS 低的根因是同一個,都是由於 netfilter conntrack 模塊的設計問題,只不過以前發生在 SNAT,這個發生在 DNAT,這裏用個人語言來總結下緣由:
DNS client (glibc 或 musl libc) 會併發請求 A 和 AAAA 記錄,跟 DNS Server 通訊天然會先 connect (創建 fd),後面請求報文使用這個 fd 來發送,因爲 UDP 是無狀態協議, connect 時並不會建立 conntrack 表項, 而併發請求的 A 和 AAAA 記錄默認使用同一個 fd 發包,這時它們源 Port 相同,當併發發包時,兩個包都尚未被插入 conntrack 表項,因此 netfilter 會爲它們分別建立 conntrack 表項,而集羣內請求 kube-dns 或 coredns 都是訪問的 CLUSTER-IP,報文最終會被 DNAT 成一個 endpoint 的 POD IP,當兩個包被 DNAT 成同一個 IP,最終它們的五元組就相同了,在最終插入的時候後面那個包就會被丟掉,若是 dns 的 pod 副本只有一個實例的狀況就很容易發生,現象就是 dns 請求超時,client 默認策略是等待 5s 自動重試,若是重試成功,咱們看到的現象就是 dns 請求有 5s 的延時。
參考 weave works 工程師總結的文章 Racy conntrack and DNS lookup timeouts: https://www.weave.works/blog/racy-conntrack-and-dns-lookup-timeouts
解決方案一: 使用 TCP 發送 DNS 請求
若是使用 TCP 發 DNS 請求,connect 時就會插入 conntrack 表項,而併發的 A 和 AAAA 請求使用同一個 fd,因此只會有一次 connect,也就只會嘗試建立一個 conntrack 表項,也就避免插入時衝突。
resolv.conf
能夠加 options use-vc
強制 glibc 使用 TCP 協議發送 DNS query。下面是這個 man resolv.conf
中關於這個選項的說明:
use-vc (since glibc 2.14) Sets RES_USEVC in _res.options. This option forces the use of TCP for DNS resolutions.
解決方案二: 避免相同五元組 DNS 請求的併發
resolv.conf
還有另外兩個相關的參數:
single-request-reopen (since glibc 2.9): A 和 AAAA 請求使用不一樣的 socket 來發送,這樣它們的源 Port 就不一樣,五元組也就不一樣,避免了使用同一個 conntrack 表項。
single-request (since glibc 2.10): A 和 AAAA 請求改爲串行,沒有併發,從而也避免了衝突。
man resolv.conf
中解釋以下:
single-request-reopen (since glibc 2.9) Sets RES_SNGLKUPREOP in _res.options. The resolver uses the same socket for the A and AAAA requests. Some hardware mistakenly sends back only one reply. When that happens the client system will sit and wait for the second reply. Turning this option on changes this behavior so that if two requests from the same port are not handled correctly it will close the socket and open a new one before sending the second request. single-request (since glibc 2.10) Sets RES_SNGLKUP in _res.options. By default, glibc performs IPv4 and IPv6 lookups in parallel since version 2.9. Some appliance DNS servers cannot handle these queries properly and make the requests time out. This option disables the behavior and makes glibc perform the IPv6 and IPv4 requests sequentially (at the cost of some slowdown of the resolving process).
要給容器的 resolv.conf
加上 options 參數,最方便的是直接在 Pod Spec 裏面的 dnsConfig 加 (k8s v1.9 及以上才支持)
spec: dnsConfig: options: - name: single-request-reopen
加 options 還有其它一些方法:
在容器的ENTRYPOINT
或者CMD
腳本中,執行/bin/echo 'options single-request-reopen' >> /etc/resolv.conf
在 postStart hook 里加:
lifecycle: postStart: exec: command: - /bin/sh - -c - "/bin/echo 'options single-request-reopen' >> /etc/resolv.conf"
MutatingAdmissionWebhook
來自動給全部 Pod 注入resolv.conf
文件,不過須要必定的開發量。解決方案三: 使用本地 DNS 緩存
仔細觀察能夠看到前面兩種方案是 glibc 支持的,而基於 alpine 的鏡像底層庫是 musl libc 不是 glibc,因此即便加了這些 options 也沒用,這種狀況能夠考慮使用本地 DNS 緩存來解決,容器的 DNS 請求都發往本地的 DNS 緩存服務 (dnsmasq, nscd 等),不須要走 DNAT,也不會發生 conntrack 衝突。另外還有個好處,就是避免 DNS 服務成爲性能瓶頸。
使用本地 DNS 緩存有兩種方式:
每一個容器自帶一個 DNS 緩存服務
每一個節點運行一個 DNS 緩存服務,全部容器都把本節點的 DNS 緩存做爲本身的 nameserver
從資源效率的角度來考慮的話,推薦後一種方式。
現象:集羣 a 的 Pod 內經過 kubectl 訪問集羣 b 的內網地址,偶爾出現延時的狀況,但直接在宿主機上用一樣的方法卻沒有這個問題。
提煉環境和現象精髓:
在 pod 內將另外一個集羣 apiserver 的 ip 寫到了 hosts,由於 TKE apiserver 開啓內網集羣外內網訪問建立的內網 LB 暫時沒有支持自動綁內網 DNS 域名解析,因此集羣外的內網訪問 apiserver 須要加 hosts
pod 內執行 kubectl 訪問另外一個集羣偶爾延遲 5s,有時甚至 10s
觀察到 5s 延時,感受跟以前 conntrack 的丟包致使 dns 解析 5s 延時有關,可是加了 hosts 呀,怎麼還去解析域名?
進入 pod netns 抓包: 執行 kubectl 時確實有 dns 解析,而且發生延時的時候 dns 請求沒有響應而後作了重試。
看起來延時應該就是以前已知 conntrack 丟包致使 dns 5s 超時重試致使的。可是爲何會去解析域名? 明明配了 hosts 啊,正常狀況應該是優先查找 hosts,沒找到纔去請求 dns 呀,有什麼配置能夠控制查找順序?
搜了一下發現: /etc/nsswitch.conf
能夠控制,但看有問題的 pod 裏沒有這個文件。而後觀察到有問題的 pod 用的 alpine 鏡像,試試其它鏡像後發現只有基於 alpine 的鏡像纔會有這個問題。
再一搜發現: musl libc 並不會使用 /etc/nsswitch.conf
,也就是說 alpine 鏡像並無實現用這個文件控制域名查找優先順序,瞥了一眼 musl libc 的 gethostbyname
和 getaddrinfo
的實現,看起來也沒有讀這個文件來控制查找順序,寫死了先查 hosts,沒找到再查 dns。
這麼說,那仍是該先查 hosts 再查 dns 呀,爲何這裏抓包看到是先查的 dns? (若是是先查 hosts 就能命中查詢,不會再發起 dns 請求)
訪問 apiserver 的 client 是 kubectl,用 go 寫的,會不會是 go 程序解析域名時壓根沒調底層 c 庫的 gethostbyname
或 getaddrinfo
?
搜一下發現果真是這樣: go runtime 用 go 實現了 glibc 的 getaddrinfo
的行爲來解析域名,減小了 c 庫調用 (應該是考慮到減小 cgo 調用帶來的的性能損耗)
issue: net: replicate DNS resolution behaviour of getaddrinfo(glibc) in the go dns resolver
翻源碼驗證下:
Unix 系的 OS 下,除了 openbsd, go runtime 會讀取 /etc/nsswitch.conf
(net/conf.go
):
hostLookupOrder
函數決定域名解析順序的策略,Linux 下,若是沒有 nsswitch.conf
文件就 dns 比 hosts 文件優先 (net/conf.go
):
能夠看到 hostLookupDNSFiles
的意思是 dns first (net/dnsclient_unix.go
):
因此雖然 alpine 用的 musl libc 不是 glibc,但 go 程序解析域名仍是同樣走的 glibc 的邏輯,而 alpine 沒有 /etc/nsswitch.conf
文件,也就解釋了爲何 kubectl 訪問 apiserver 先作 dns 解析,沒解析到再查的 hosts,致使每次訪問都去請求 dns,剛好又碰到 conntrack 那個丟包問題致使 dns 5s 延時,在用戶這裏表現就是 pod 內用 kubectl 訪問 apiserver 偶爾出現 5s 延時,有時出現 10s 是由於重試的那次 dns 請求恰好也遇到 conntrack 丟包致使延時又迭加了 5s 。
解決方案:
換基礎鏡像,不用 alpine
掛載nsswitch.conf
文件 (能夠用 hostPath)
現象: 有個用戶反饋域名解析有時有問題,看報錯是解析超時。
第一反應固然是看 coredns 的 log:
[ERROR] 2 loginspub.gaeamobile-inc.net. A: unreachable backend: read udp 172.16.0.230:43742->10.225.30.181:53: i/o timeout
這是上游 DNS 解析異常了,由於解析外部域名 coredns 默認會請求上游 DNS 來查詢,這裏的上游 DNS 默認是 coredns pod 所在宿主機的 resolv.conf
裏面的 nameserver (coredns pod 的 dnsPolicy 爲 「Default」,也就是會將宿主機裏的 resolv.conf
裏的 nameserver 加到容器裏的 resolv.conf
, coredns 默認配置 proxy . /etc/resolv.conf
, 意思是非 service 域名會使用 coredns 容器中 resolv.conf
文件裏的 nameserver 來解析)
確認了下,超時的上游 DNS 10.225.30.181 並非指望的 nameserver,VPC 默認 DNS 應該是 180 開頭的。看了 coredns 所在節點的 resolv.conf
,發現確實多出了這個非指望的 nameserver,跟用戶確認了下,這個 DNS 不是用戶本身加上去的,添加節點時這個 nameserver 自己就在 resolv.conf
中。
根據內部同窗反饋, 10.225.30.181 是廣州一臺年久失修將被撤裁的 DNS,物理網絡,沒有 VIP,撤掉就沒有了,因此若是 coredns 用到了這臺 DNS 解析時就可能 timeout。後面咱們本身測試,某些 VPC 的集羣確實會有這個 nameserver,奇了怪了,哪裏冒出來的?
又試了下直接建立 CVM,不加進 TKE 節點發現沒有這個 nameserver,只要一加進 TKE 節點就有了 !!!
看起來是 TKE 的問題,將 CVM 添加到 TKE 集羣會自動重裝系統,初始化並加進集羣成爲 K8S 的 node,確認了初始化過程並不會寫 resolv.conf
,會不會是 TKE 的 OS 鏡像問題?嘗試搜一下除了 /etc/resolv.conf
以外哪裏還有這個 nameserver 的 IP,最後發現 /etc/resolvconf/resolv.conf.d/base
這裏面有。
看下 /etc/resolvconf/resolv.conf.d/base
的做用:Ubuntu 的 /etc/resolv.conf
是動態生成的,每次重啓都會將 /etc/resolvconf/resolv.conf.d/base
裏面的內容加到 /etc/resolv.conf
裏。
經確認: 這個文件確實是 TKE 的 Ubuntu OS 鏡像裏自帶的,可能發佈 OS 鏡像時不當心加進去的。
那爲何有些 VPC 的集羣的節點 /etc/resolv.conf
裏面沒那個 IP 呢?它們的 OS 鏡像裏也都有那個文件那個 IP 呀。
請教其它部門同窗發現:
非 dhcp 子機,cvm 的 cloud-init 會覆蓋/etc/resolv.conf
來設置 dns
dhcp 子機,cloud-init 不會設置,而是經過 dhcp 動態下發
2018 年 4 月 以後建立的 VPC 就都是 dhcp 類型了的,比較新的 VPC 都是 dhcp 類型的
真相大白:/etc/resolv.conf
一開始內容都包含 /etc/resolvconf/resolv.conf.d/base
的內容,也就是都有那個不指望的 nameserver,但老的 VPC 因爲不是 dhcp 類型,因此 cloud-init 會覆蓋 /etc/resolv.conf
,抹掉了不被指望的 nameserver,而新建立的 VPC 都是 dhcp 類型,cloud-init 不會覆蓋 /etc/resolv.conf
,致使不被指望的 nameserver 殘留在了 /etc/resolv.conf
,而 coredns pod 的 dnsPolicy 爲 「Default」,也就是會將宿主機的 /etc/resolv.conf
中的 nameserver 加到容器裏,coredns 解析集羣外的域名默認使用這些 nameserver 來解析,當用到那個將被撤裁的 nameserver 就可能 timeout。
臨時解決: 刪掉 /etc/resolvconf/resolv.conf.d/base
重啓
長期解決: 咱們從新制做 TKE Ubuntu OS 鏡像而後發佈更新
這下應該沒問題了吧,But, 用戶反饋仍是會偶爾解析有問題,但現象不同了,此次並非 dns timeout。
用腳本跑測試仔細分析現象:
請求loginspub.gaeamobile-inc.net
時,偶爾提示域名沒法解析
請求accounts.google.com
時,偶爾提示鏈接失敗
進入 dns 解析偶爾異常的容器的 netns 抓包:
dns 請求會併發請求 A 和 AAAA 記錄
測試腳本發請求打印序號,抓包而後 wireshark 分析對比異常時請求序號偏移量,找到異常時的 dns 請求報文,發現異常時 A 和 AAAA 記錄的請求 id 衝突,而且 AAAA 響應先返回
正常狀況下 id 不會衝突,這裏衝突了也就能解釋這個 dns 解析異常的現象了:
loginspub.gaeamobile-inc.net
沒有 AAAA (ipv6) 記錄,它的響應先返回告知 client 不存在此記錄,因爲請求 id 跟 A 記錄請求衝突,後面 A 記錄響應返回了 client 發現 id 重複就忽略了,而後認爲這個域名沒法解析
accounts.google.com
有 AAAA 記錄,響應先返回了,client 就拿這個記錄去嘗試請求,但當前容器環境不支持 ipv6,因此會鏈接失敗
那爲何 dns 請求 id 會衝突?
繼續觀察發現: 其它節點上的 pod 不會復現這個問題,有問題這個節點上也不是全部 pod 都有這個問題,只有基於 alpine 鏡像的容器纔有這個問題,在此節點新起一個測試的 alpine:latest
的容器也同樣有這個問題。
爲何 alpine 鏡像的容器在這個節點上有問題在其它節點上沒問題?爲何其餘鏡像的容器都沒問題?它們跟 alpine 的區別是什麼?
發現一點區別: alpine 使用的底層 c 庫是 musl libc,其它鏡像基本都是 glibc
翻 musl libc 源碼, 構造 dns 請求時,請求 id 的生成沒加鎖,並且跟當前時間戳有關:
看註釋,做者應該認爲這樣 id 基本不會衝突,事實證實,絕大多數狀況確實不會衝突,我在網上搜了好久沒有搜到任何關於 musl libc 的 dns 請求 id 衝突的狀況。這個看起來取決於硬件,可能在某種類型硬件的機器上運行,短期內生成的 id 就可能衝突。我嘗試跟用戶在相同地域的集羣,添加相同配置相同機型的節點,也復現了這個問題,但後來刪除再添加時又不能復現了,看起來後面新建的 cvm 又跑在了另外一種硬件的母機上了。
OK,能解釋通了,再底層的細節就不清楚了,咱們來看下解決方案:
換基礎鏡像 (不用 alpine)
徹底靜態編譯業務程序 (不依賴底層 c 庫),好比 go 語言程序編譯時能夠關閉 cgo (CGO_ENABLED=0),並告訴連接器要靜態連接 (go build
後面加-ldflags '-d'
),但這須要語言和編譯工具支持才能夠
最終建議用戶基礎鏡像換成另外一個比較小的鏡像: debian:stretch-slim
。
問題解決,但用戶後面以爲 debian:stretch-slim
作出來的鏡像太大了,有 6MB 多,而以前基於 alpine 作出來只有 1MB 多,最後使用了一個非官方的修改過 musl libc 的 alpine 鏡像做爲基礎鏡像,裏面禁止了 AAAA 請求從而避免這個問題。
現象: Pod 偶爾會存活檢查失敗,致使 Pod 重啓,業務偶爾鏈接異常。
以前從未遇到這種狀況,在本身測試環境嘗試復現也沒有成功,只有在用戶這個環境才能夠復現。這個用戶環境流量較大,感受跟鏈接數或併發量有關。
用戶反饋說在友商的環境裏沒這個問題。
對比友商的內核參數發現有些區別,嘗試將節點內核參數改爲跟友商的同樣,發現問題沒有復現了。
再對比分析下內核參數差別,最後發現是 backlog 過小致使的,節點的 net.ipv4.tcp_max_syn_backlog
默認是 1024,若是短期內併發新建 TCP 鏈接太多,SYN 隊列就可能溢出,致使部分新鏈接沒法創建。
解釋一下:
TCP 鏈接創建會通過三次握手,server 收到 SYN 後會將鏈接加入 SYN 隊列,當收到最後一個 ACK 後鏈接創建,這時會將鏈接從 SYN 隊列中移動到 ACCEPT 隊列。在 SYN 隊列中的鏈接都是沒有創建徹底的鏈接,處於半鏈接狀態。若是 SYN 隊列比較小,而短期內併發新建的鏈接比較多,同時處於半鏈接狀態的鏈接就多,SYN 隊列就可能溢出,tcp_max_syn_backlog
能夠控制 SYN 隊列大小,用戶節點的 backlog 大小默認是 1024,改爲 8096 後就能夠解決問題。
現象:用戶在 TKE 建立了公網 LoadBalancer 類型的 Service,externalTrafficPolicy 設爲了 Local,訪問這個 Service 對應的公網 LB 有時會超時。
externalTrafficPolicy 爲 Local 的 Service 用於在四層獲取客戶端真實源 IP,官方參考文檔:https://kubernetes.io/docs/tutorials/services/source-ip/#source-ip-for-services-with-type-loadbalancer
TKE 的 LoadBalancer 類型 Service 實現是使用 CLB 綁定全部節點對應 Service 的 NodePort,CLB 不作 SNAT,報文轉發到 NodePort 時源 IP 仍是真實的客戶端 IP,若是 NodePort 對應 Service 的 externalTrafficPolicy 不是 Local 的就會作 SNAT,到 pod 時就看不到客戶端真實源 IP 了,但若是是 Local 的話就不作 SNAT,若是本機 node 有這個 Service 的 endpoint 就轉到對應 pod,若是沒有就直接丟掉,由於若是轉到其它 node 上的 pod 就必需要作 SNAT,否則沒法回包,而 SNAT 以後就沒法獲取真實源 IP 了。
LB 會對綁定節點的 NodePort 作健康檢查探測,檢查 LB 的健康檢查狀態: 發現這個 NodePort 的全部節點都不健康 !!!
那麼問題來了:
爲何會全不健康,這個 Service 有對應的 pod 實例,有些節點上是有 endpoint 的,爲何它們也不健康?
LB 健康檢查全不健康,可是爲何有時仍是能夠訪問後端服務?
跟 LB 的同窗確認: 若是後端 rs 全不健康會激活 LB 的全死全活邏輯,也就是全部後端 rs 均可以轉發。
那麼有 endpoint 的 node 也是不健康這個怎麼解釋?
在有 endpoint 的 node 上抓 NodePort 的包: 發現不少來自 LB 的 SYN,可是沒有響應 ACK。
看起來報文在哪被丟了,繼續抓下 cbr0 看下: 發現沒有來自 LB 的包,說明報文在 cbr0 以前被丟了。
再觀察用戶集羣環境信息:
k8s 版本 1.12
啓用了 ipvs
只有 local 的 service 纔有異常
嘗試新建一個 1.12 啓用 ipvs 和一個沒啓用 ipvs 的測試集羣。也都建立 Local 的 LoadBalancer Service,發現啓用 ipvs 的測試集羣復現了那個問題,沒啓用 ipvs 的集羣沒這個問題。
再嘗試建立 1.10 的集羣,也啓用 ipvs,發現沒這個問題。
看起來跟集羣版本和是否啓用 ipvs 有關。
1.12 對比 1.10 啓用 ipvs 的集羣: 1.12 的會將 LB 的 EXTERNAL-IP
綁到 kube-ipvs0
上,而 1.10 的不會:
$ ip a show kube-ipvs0 | grep -A2 170.106.134.124 inet 170.106.134.124/32 brd 170.106.134.124 scope global kube-ipvs0 valid_lft forever preferred_lft forever
170.106.134.124 是 LB 的公網 IP
1.12 啓用 ipvs 的集羣將 LB 的公網 IP 綁到了kube-ipvs0
網卡上
kube-ipvs0
是一個 dummy interface,實際不會接收報文,能夠看到它的網卡狀態是 DOWN,主要用於綁 ipvs 規則的 VIP,由於 ipvs 主要工做在 netfilter 的 INPUT 鏈,報文經過 PREROUTING 鏈以後須要決定下一步該進入 INPUT 仍是 FORWARD 鏈,若是是本機 IP 就會進入 INPUT,若是不是就會進入 FORWARD 轉發到其它機器。因此 k8s 利用 kube-ipvs0
這個網卡將 service 相關的 VIP 綁在上面以便讓報文進入 INPUT 進而被 ipvs 轉發。
當 IP 被綁到 kube-ipvs0
上,內核會自動將上面的 IP 寫入 local 路由:
$ ip route show table local | grep 170.106.134.124 local 170.106.134.124 dev kube-ipvs0 proto kernel scope host src 170.106.134.124
內核認爲在 local 路由裏的 IP 是本機 IP,而 linux 默認有個行爲: 忽略任何來自非迴環網卡而且源 IP 是本機 IP 的報文。而 LB 的探測報文源 IP 就是 LB IP,也就是 Service 的 EXTERNAL-IP
猜測就是由於這個 IP 被綁到 kube-ipvs0
,自動加進 local 路由致使內核直接忽略了 LB 的探測報文。
帶着猜測作實現, 試一下將 LB IP 從 local 路由中刪除:
$ ip route del table local local 170.106.134.124 dev kube-ipvs0 proto kernel scope host src 170.106.134.124
發現這個 node 的在 LB 的健康檢查的狀態變成健康了! 看來就是由於這個 LB IP 被綁到 kube-ipvs0
致使內核忽略了來自 LB 的探測報文,而後 LB 收不到回包認爲不健康。
那爲何其它廠商沒反饋這個問題?應該是 LB 的實現問題,騰訊雲的公網 CLB 的健康探測報文源 IP 就是 LB 的公網 IP,而大多數廠商的 LB 探測報文源 IP 是保留 IP 並不是 LB 自身的 VIP。
如何解決呢? 發現一個內核參數: accept_local 可讓 linux 接收源 IP 是本機 IP 的報文。
試了開啓這個參數,確實在 cbr0 收到來自 LB 的探測報文了,說明報文能被 pod 收到,但抓 eth0 仍是沒有給 LB 回包。
爲何沒有回包? 分析下五元組,要給 LB 回包,那麼 目的IP:目的Port
必須是探測報文的 源IP:源Port
,因此目的 IP 就是 LB IP,因爲容器不在主 netns,發包通過 veth pair 到 cbr0 以後須要再通過 netfilter 處理,報文進入 PREROUTING 鏈而後發現目的 IP 是本機 IP,進入 INPUT 鏈,因此報文就出不去了。再分析下進入 INPUT 後會怎樣,由於目的 Port 跟 LB 探測報文源 Port 相同,是一個隨機端口,不在 Service 的端口列表,因此沒有對應的 IPVS 規則,IPVS 也就不會轉發它,而 kube-ipvs0
上雖然綁了這個 IP,但它是一個 dummy interface,不會收包,因此報文最後又被忽略了。
再看看爲何 1.12 啓用 ipvs 會綁 EXTERNAL-IP
到 kube-ipvs0
,翻翻 k8s 的 kube-proxy 支持 ipvs 的 proposal,發現有個地方說法有點漏洞:
LB 類型 Service 的 status 裏有 ingress IP,實際就是 kubectl get service
看到的 EXTERNAL-IP
,這裏說不會綁定這個 IP 到 kube-ipvs0,但後面又說會給它建立 ipvs 規則,既然沒有綁到 kube-ipvs0
,那麼這個 IP 的報文根本不會進入 INPUT 被 ipvs 模塊轉發,建立的 ipvs 規則也是沒用的。
後來找到做者私聊,思考了下,發現設計上確實有這個問題。
看了下 1.10 確實也是這麼實現的,可是爲何 1.12 又綁了這個 IP 呢? 調研後發現是由於 #59976 這個 issue 發現一個問題,後來引入 #63066 這個 PR 修復的,而這個 PR 的行爲就是讓 LB IP 綁到 kube-ipvs0
,這個提交影響 1.11 及其以後的版本。
kube-ipvs0
上,在自建集羣使用 MetalLB
來實現 LoadBalancer 類型的 Service,而有些網絡環境下,pod 是沒法直接訪問 LB 的,致使 pod 訪問 LB IP 時訪問不了,而若是將 LB IP 綁到 kube-ipvs0
上就能夠經過 ipvs 轉發到 LB 類型 Service 對應的 pod 去, 而不須要真正通過 LB,因此引入了 #63066 這個 PR。臨時方案: 將 #63066 這個 PR 的更改回滾下,從新編譯 kube-proxy,提供升級腳本升級存量 kube-proxy。
若是是讓 LB 健康檢查探測支持用保留 IP 而不是自身的公網 IP ,也是能夠解決,但須要跨團隊合做,並且若是多個廠商都遇到這個問題,每家都須要爲解決這個問題而作開發調整,代價較高,因此長期方案須要跟社區溝通一塊兒推動,因此我提了 issue,將問題描述的很清楚: #79783
小思考: 爲何 CLB 能夠不作 SNAT ? 回包目的 IP 就是真實客戶端 IP,但客戶端是直接跟 LB IP 創建的鏈接,若是回包不通過 LB 是不可能發送成功的呀。
是由於 CLB 的實現是在母機上經過隧道跟 CVM 互聯的,多了一層封裝,回包始終會通過 LB。
就是由於 CLB 不作 SNAT,正常來自客戶端的報文是能夠發送到 nodeport,但健康檢查探測報文因爲源 IP 是 LB IP 被綁到 kube-ipvs0
致使被忽略,也就解釋了爲何健康檢查失敗,但經過 LB 能訪問後端服務,只是有時會超時。那麼若是要作 SNAT 的 LB 豈不是更糟糕,全部報文都變成 LB IP,全部報文都會被忽略?
我提的 issue 有回覆指出,AWS 的 LB 會作 SNAT,但它們不將 LB 的 IP 寫到 Service 的 Status 裏,只寫了 hostname,因此也不會綁 LB IP 到 kube-ipvs0
:
可是隻寫 hostname 也得 LB 支持自動綁域名解析,而且我的以爲只寫 hostname 很彆扭,經過 kubectl get svc
或者其它 k8s 管理系統沒法直接獲取 LB IP,這不是一個好的解決方法。
我提了 #79976 這個 PR 能夠解決問題: 給 kube-proxy 加 --exclude-external-ip
這個 flag 控制是否爲 LB IP
建立 ipvs 規則和綁定 kube-ipvs0
。
但有人擔憂增長 kube-proxy flag 會增長 kube-proxy 的調試複雜度,看可否在 iptables 層面解決:
仔細一想,確實可行,打算有空實現下,從新提個 PR
至此,咱們一塊兒完成了一段奇妙的問題排查之旅,信息量很大而且比較複雜,有些沒看懂很正常,但我但願你能夠收藏起來反覆閱讀,一塊兒在技術的道路上打怪升級。