解決Istio中遇到的間歇性連線重置問題

語言: CN / TW / HK

作者|陳佑雄

編輯|陳樂

供稿|Network SIG/Service-Mesh team

本文共5623字,預計閱讀時間15分鐘

更多幹貨請關注“eBay技術薈”公眾號

導讀

如今Istio的應用閘道器在eBay的生產環境中已經得到了廣泛的應用,NuObject這一重要應用場景作為eBay新一代的儲存服務已經全面替換Swift,其中支撐NuObject的L7負載均衡正是Istio。然而作為Istio早期的上線案例,整個上線過程也斷斷續續遇到了各種各樣的問題,特別是在上傳檔案時候出現的間歇性連線重置(connection reset)問題。後來經過我們的分析和除錯,發現這個問題存在已久,所以分享出來希望能對大家有所幫助。

NuObject儲存服務的部署架構採用Istio Ingress Gateway模式,同時注入sidecar到後端服務,所以是一個api gateway的應用場景,其架構圖如圖所示:

圖1:Ingress Gateway架構

(點選可檢視大圖)

正常客戶端到服務端可能只會有一個TCP連線,但是因為有Ingress Gateway以及Sidecar,這中間涉及到了3個TCP連線,因此也增加了問題排查的難度。

問題描述

NuObject每天都會有很多客戶上傳大量檔案,通過日誌監控系統,發現使用者在上傳檔案的時候,客戶端會遇到連線重置的錯誤,特別是在上傳一個300MB的檔案時。這個錯誤可以通過一段指令碼迴圈上傳若干個300MB檔案重現,如下就是在上傳過程中客戶端遇到的錯誤:

01

上游服務返回503

通過Isito的dashboard我們能夠看到是上游的儲存服務返回的503,但無法確認到底是上游應用返回的還是sidecar返回的:

02

Istio Ingress Gateway訪問日誌

從Istio Ingress Gatweay的訪問日誌中我們可以看到503的報錯以及"response_flags":"UC",即upstream connection failure:

{"duration":1441,"response_code_details":"upstream_reset_before_response_started{connection_termination}","response_flags":"UC","protocol":"HTTP/2","user_agent":"curl/7.58.0","route_name":null,"request_id":"d0784d05-2b65-4121-b309-a28fe69c91b0","upstream_service_time":null,"connection_termination_details":null,"upstream_transport_failure_reason":null,"path":"/chang/reproduce503/round1/random300M_1","method":"PUT","response_code":503,"bytes_received":268435456,"bytes_sent":95,"start_time":"2021-12-07T08:37:28.358Z"}

03

Ingress Gateway/Sidecar Envoy日誌

Ingress Gateway本身也是一個Envoy叢集,通過開啟Ingress Gateway Envoy debug日誌,我們看到Gateway Envoy的響應碼是503,並且有連線重置的錯誤,這些錯誤也正是客戶端從Istio IngressGateway獲取到的:

因為在應用端注入了sidecar envoy,它負責進行流量劫持和轉發,在sidecar這一端我們也看到了連線重置的錯誤:

問題分析

基於收集到的Ingress Gateway以及Sidecar的metrics和訪問日誌,基本可以定位到是上游服務返回了503。為了進一步分析原因,我們在上游應用端tcpdump抓包分析。

01

Tcpdump抓包分析

為了簡化tcpdump抓包分析,我們假設只有一個Ingress Gateway Pod以及一個上游應用pod,並且在上游應用注入了sidecar:

Ingress Gateway IP: 10.0.0.1

Gateway埠:443

Sidecar/Application Pod IP: 10.0.0.2

應用監聽埠:9000

客戶端在訪問Ingress Gateway中IP和埠變化如下:

Client -> 10.0.0.1:443 -> 10.0.0.2:9000

其中在Ingress Gateway,通過DNAT,目的地址和埠都發生變化:

10.0.0.1 -> 10.0.0.2:9000

而由於在上游應用的sidecar(這裡sidecar和應用處在同一個容器網路)是REDIRECT模式,進站和出站流量都會被劫持,其中入站請求地址和埠的變化如下:

10.0.0.1 -> 10.0.0.2:15006 (iptables): DNAT to change the port to istio inbound

10.0.0.1 -> 10.0.0.2:15006 (envoy): Forward to envoy

127.0.0.1 ->127.0.0.1:9000 -> application: Envoy picks up a new TCP connection to application

下面是在上游應用tcpdump的結果:

通過分析tcpdump的結果,我們發現tcp連線重置的流程如下:

1、Sidecar傳送tcp rst到Ingress Gateway

2、Ingress Gateway傳送tcp rst到Sidecar

3、Sidecar 傳送 tcp fin到application server

4、Sidecar 傳送 tcp rst到application server

02

Envoy trace日誌分析

通過分析tcpdump的結果,我們並不能確定到底是envoy還是application發出的rst。於是我們開啟了sidecar envoy的trace日誌,下面是報錯日誌:

通過分析envoy原始碼,程式碼呼叫順序如下:

通過分析Envoy的trace日誌以及原始碼,可以看到報錯是發生在呼叫SSL_read,嘗試從連線中讀取資料的時候,這裡我們推斷錯誤發生的順序是:socket is reset unexpectedly -> SSL_read return -1 -> doRead failed -> envoy close the connection”。所以當 sidecar envoy開始從ssl_socket 讀取資料時,出於某種未知的原因收到了rst包。並且我們嘗試了停用sidecar的mutal TLS,但即使是在plaint text的情況下,envoy在do_read的時候依然會出現同樣的錯誤。

由於tcpdump是以libpcap為基礎,工作在網路裝置層,而iptables是以netfilter為基礎,工作在協議層。在接收包的過程中,工作在網路裝置層的 tcpdump 先開始工作。還沒等 netfilter 過濾,tcpdump 就已經抓到包了。所以通過tcpdump我們並不能找到是誰傳送的reset,可能是kernel也可能是envoy,於是我們想到了藉助eBPF。

03

eBPF trace

通過之前的分析,我們可以得出是sidecar envoy給ingress gateway傳送TCP reset,但這是一種不正常的行為。基於我們的經驗,TCP reset通常發生在當一個連線已經被關閉後服務端仍然從socket中接收資料包。所以接下來我們需要驗證socket是否在TCP reset發生之前被關閉,也就是驗證是否tcp_close系統呼叫發生在TCP reset發出之前。

通過檢查核心程式碼,我們可以看到核心裡面有兩個函式會發送TCP reset,它們分別是tcp_send_active_reset()和tcp_v4_send_reset()

void tcp_send_active_reset(struct sock *sk, gfp_t priority) {

...

tcp_init_nondata_skb(skb, tcp_acceptable_seq(sk),

TCPHDR_ACK | TCPHDR_RST);  

}

對tcp_send_active_reset()來說,這種情況下TCP reset包必須要ACK以及RST標誌。但是通過抓包分析,我們並沒有看到TCP reset包有ACK標誌,所以可以斷定不是該函式返回的reset。由此可見,真正產生reset的函式只可能是tcp_v4_send_reset(),為了驗證socket是否被意外關閉,我們通過eBPF 對kprobe來分析tcp_v4_send_reset,tcp_close,inet_sk_set_state and inet_sk_state_store。

繼續我們之前的分析,ingress gateway地址是10.0.0.1,sidecar/app的地址是10.0.0.2。利用eBPF過濾連線10.0.0.1<->10.0.0.2之間的資料包,當TCP reset發生的時候,從eBPF的抓包分析中我們並沒有看到在tcp_v4_send_reset之前有tcp_close,所以我們可以排除掉reset的原因是因為連線被關閉導致的。

然後繼續檢查socket在發生TCP reset時候的具體資訊,這裡注意到socket的狀態是TCP_LISTEN,通過檢查核心程式碼以及TCP連線的狀態機我們可以知道,如果一個socket的狀態是TCP_LISTEN,它只會接收TCP SYN握手包以建立TCP連線,但是現在它從Ingress Gateway接收到的是資料包,於是導致了TCP reset。

下一個問題:為什麼socket的狀態會是TCP_LISTEN?當我們檢查了socket的地址之後,我們發現導致TCP reset的並不是最開始傳輸資料的連線,並且發現目的地址是9000而不是15006,到這裡就真相大白了。由於Sidecar envoy的攔截機制,envoy監聽在15006並且會重定向所有的入站請求到該埠,而應用監聽在9000埠,由於某種原因入站的資料包並沒有做目的地址的轉換(從9000到15006),而是直接到達了應用所監聽的socket於是產生了reset。

具體的流程如圖2所示:

圖2:連線重置

(點選可檢視大圖)

所以真正的原因是因為iptables沒有給進站的資料包做NAT。

Linux核心有一個引數:ip_conntrack_tcp_be_liberal,預設是0,也就意味著如果一旦有out of window的資料包,核心預設的行為是將其標誌為INVALID,iptables無法對INVALID的資料包進行NAT。又由於沒有任何iptables規則來drop這種資料包,最終它被轉發到了應用端,而應用端出於LISTENING狀態的socket並不能處理該資料包,這也就導致了客戶端的connection reset。

/* "Be conservative in what you do,

be liberal in what you accept from others."

If it's non-zero, we mark only out of window RST segments as INVALID. */

static int nf_ct_tcp_be_liberal __read_mostly = 0;

類似的問題之前已經多次出現過,比如kubernetes社群kube-proxy元件的iptables實現,可以參考文章kube-proxy Subtleties:

Debugging an Intermittent Connection Reset

https://kubernetes.io/blog/2019/03/29/kube-proxy-subtleties-debugging-an-intermittent-connection-reset/

以及eBay流量管理之Kubernetes網路硬核排查案例: https://mp.weixin.qq.com/s/phcaowQWFQf9dzFCqxSCJA

解決問題

同社群kube-proxy問題一樣,這裡我們也同樣有兩個選項:

1、讓 conntrack 對資料包校驗更加寬鬆,不要將out of window的資料包標記為 INVALID。

對linux來說只需要修改核心引數 echo 1 > /proc/sys/net/ipv4/netfilter/ip_conntrack_tcp_be_liberal。但採用這種做法導致一些顧慮:

a、意味著更多out of window的資料包會被允許進入網路協議棧處理。

b、我們並不清楚linux核心預設值設定為0的原因。

2、專門新增一條 iptables 規則來丟棄標記為 INVALID 的資料包,這樣它就不會到達客戶端 Pod。

需要強調的一點是,Istio中的間歇性連線重傳的問題是因為sidecar以及iptables而引入的,sidecar在注入的過程中本身也會建立很多iptables的規則,所以第二個方案看起來更合理。於是我們採用方案2來解決這個問題。

01

Iptables的連結串列結構

在介紹解決方案實現之前,我們有必要回顧一下iptable的連結串列結構。iptables 實際上就是一種包過濾型防火牆,總體來說,由四表(raw,filter,nat,mangle)以及五鏈(PREROUTING,INPUT, FORWARD,OUTPUT,POSTROUTING)組成。iptables 防火牆使用表來組織其規則,這些表將不同決策型別的規則進行分類。比如,如果一個規則是處理網路地址的轉換,那麼它將被放到nat表中;如果一個規則決定是否允許繼續將資料包轉發到其目的地,那麼它很有可能會被新增到filter表中。不同的表有不同的處理優先順序,四張表的優先順序為raw > mangle > nat > filter。在每個 iptables 表中,規則被進一步組織在單獨的“鏈”中。總體上來說,表是將規則根據作用的目的進行分類,規則代表了對資料包的具體操作,掛載點(鏈)代表了操作的位置,而鏈決定了規則的執行順序。四表五鏈架構如圖3所示:

圖3:iptables的連結串列結構

(點選可檢視大圖)

這裡重點介紹一下PREROUTING鏈以及mangle表。我們可以看到來自網路介面(網絡卡)的資料包首先會經過 PREROUTING 鏈,經過 raw, mangle, nat 表中規則的處理然後進行路由判斷。mangle表主要用來更改資料包的 IP 表頭。比如,我們可以調整資料包的 TTL(生存時間)值來延長或縮短資料包可以存活的有效網路跳數。mangle表還可以在資料包上放置一個內部核心“標記”,從而使其可以在其他表或其他網路工具中進行進一步處理。核心裡主要有三個功能模組實現mangle表功能: mark match,mark target以及CONNMARK target,其中CONNMARK是針對連線的,而mark是針對單一資料包。比如當我們要對某一個連線進行處理,可以用以下兩種match target來實現:

-m conntrack --ctstate ESTABLISHED,INVALID,RELATED -j …

-m state --state ESTABLISHED,INVALID,RELATED -j …

其中ctstate是指連線狀態。從Linux2.6.15的核心版本後,iptables開始支援狀態跟蹤(conntrack), conntrack將資料流的狀態資訊以Hash表的形式儲存在記憶體中,包括五元組資訊以及超時時間等。這裡說的狀態跟蹤並非是指狀態協議(如TCP)中連線狀態的跟蹤,而是指conntrack特有的與網路傳輸協議無關的狀態跟蹤。

conntrack共可以為連線標記四種狀態,參考https://www.linuxtopia.org/Linux_Firewall_iptables/x1347.html,如下表所示:

02

Sidecar流量劫持

Istio通過引入sidecar機制,從而實現業務程式碼與服務網格的解耦。sidecar與應用執行在同一個容器網路,通過給容器網路注入iptables規則,將所有進站的請求重定向到15006埠,出站的請求重定向到15001埠,這兩個埠正是sidecar的Envoy所監聽。

下圖是社群一張很經典的圖,展示是  productpage 服務請求訪問:http://reviews.default.svc.cluster.local:9080/,當流量進入 reviews 服務內部時,reviews 服務內部的 Envoy Sidecar 是如何做流量攔截和路由轉發。

圖4:Sidecar的iptables鏈

(點選可檢視大圖)

我們可以看到Istio新建立了幾個iptables chain。這些chain由sidecar init container或由istio-cni注入。完整的iptables注入規則如下:

其中我們可以看到在注入sidecar的時候Istio新建了四個鏈,分別是ISTIO_INBOUND,ISTIO_REDIRECT,ISTIO_IN_REDIRECT和ISTIO_OUTPUT。而且這些鏈都是新建在nat表,主要用來負責資料包的地址轉換,包括DNAT以及SNAT。

03

Sidecar支援INVALID DROP

明確瞭解決問題的思路之後,我們可以通過兩個方案配置相應的iptables規則來解決該問題。

方案1

直接在mangle表的PREROUTING鏈增加一條invalid drop的規則,相應的iptables命令如下:

iptables -t mangle -A PREROUTING   -m conntrack --ctstate INVALID -j DROP

這種方案的好處是不需要建立額外的鏈,但是它會相應的修改系統預設建立的PREROUTING鏈。

方案2

在mangle表中建立新的ISTIO_INBOUND,然後建立一條預設的規則將所有mangle表中的資料包在經過PREROUTING後跳轉到ISTIO_INBOUND鏈,相應的iptables規則如下:

iptables -t mangle -N ISTIO_INBOUND

iptables -t mangle -A ISTIO_INBOUND   -m conntrack --ctstate INVALID -j DROP

iptables -t mangle -A PREROUTING -p tcp -j ISTIO_INBOUND 

這個方案的好處是專門建立了新的ISTIO鏈,儘量減少了修改系統預設建立的鏈。但是相應的後果是所有的資料包都需要額外再經過一條鏈。

兩種方案驗證後都是可工作的,綜合考慮了一下采用了方案1。通過重新執行之前重現該問題的指令碼,我們可以看到在上傳大概18GB檔案的時候出現了8個reset。在沒有該invalid drop iptables規則之前,客戶端會拿到8次connection reset並且需要客戶端重傳。但在增加了該規則之後,客戶端不再收到連線重置的錯誤,invalid drop的資料包不會對客戶端造成任何影響,而是通過tcp協議的重傳保證了資料傳輸的完整性。

再回過頭來思考一下為什麼需要在mangle表新建鏈,或者複用mangle表中系統預設建立的PREROUTING鏈,下面的這個嘗試其實告訴我們原因了:

iptables -t nat -A  ISTIO_INBOUND -m conntrack --ctstate INVALID -j DROP

iptables v1.4.21:

The "nat" table is not intended for filtering, the use of DROP is therefore inhibited.

後記

eBay正在大規模地將應用遷移到雲原生的部署方式以及服務網格治理中,我們正在通過Istio/Envoy替換資料中心的硬體負載均衡並且實現高安全高可用的雲原生網路。NuObject是我們第一個規模比較大的案例,儘管一個應用峰值的流量達到了將近40Gb,但我們基於IPVS以及Istio軟體負載均衡也經受住了壓力。

但是另外一方面,新的技術方案和架構在生產化實踐過程當中不可避免地會遇到一些未知的問題和挑戰。最難解決的是那種間歇性出現的錯誤,比如這次連線重置問題。實際上它從社群的第一天開始就存在,只是很多使用者場景可能資料並沒有我們這麼大,所以重現的概率相對較小;或者說應用場景對可用性的要求不是很高,通過客戶端重試來掩蓋了這個問題。但是對於我們來說,今後會有更重要的用例(比如支付相關的應用)上線到服務網格以及軟體負載均衡,我們需要回答裡面的每一個connection reset,每一個5xx以及每一個connection stacking的根因,每一個問題的出現其實也是我們的一個學習的機會。

相關的修復已經合併到最新的Istio 1.13

https://istio.io/latest/news/releases/1.13.x/announcing-1.13/change-notes/,如果在使用Istio Envoy sidecar的過程中剛好也遇到了connection reset的問題,可以通過配置meshConfig. defaultConfig.proxyMetadata.INVALID_DROP=true,嘗試開啟這個功能。

最後這篇文章是團隊合作的結果,包括NuObject團隊Hang Cun提供指令碼能夠讓我們重現問題,以及Zhang Bo同學通過eBPF最終找到了問題的根因,這也是我們能修復這個問題的關鍵。另外Zhao Yang同學也幫忙進行了很多tcpdump以及日誌分析的工作。這也讓我們更加有信心去解決將來我們在軟體負載均衡以及服務網格中遇到各種可能的問題。

往期推薦

韓志超:eBay基於圖神經網路的實時風控實踐

資料平臺管理之道|如何提高管理效率

我在 eBay 負責整體的 Web 流量的這幾年,學到了這些......

點選 “閱讀原文” ,  一鍵投遞

eBay大量優質職位虛席以待

我們的身邊,還缺一個你