當前位置: 妍妍網 > 碼農

什麽!TCP又發reset包?

2024-03-27碼農

導語: TCP的經典異常問題無非就是丟包和連線中斷,在這裏我打算與各位聊一聊TCP的RST到底是什麽?現網中的RST問題有哪些模樣?我們如何去應對、解決?本文將從RST原理、排查手段、現網痛難點案例三個板塊自上而下帶給讀者一套完整的分析。

一、背景

最近一年的時間裏,現網碰到RST問題屢屢出現,一旦TCP連線中收到了RST包,大機率會導致連線中止或使用者異常。如何正確解決RST異常是較為棘手的問題。

本文關註的不是細節,而是方法論,也確實方法更為重要。筆者始終相信,一百個人眼中的哈姆雷特最終還是一個具體的人物形象,一百個RST異常最終也會是一個簡短的小問題。

二、原理

首先,我們需要確定的RST問題一定就是問題嗎?如果RST發生了你會如何去解決?讀者可以嘗試問下自己並解答這個問題,這裏」停頓、停頓、停頓「來給大家一點時間思考,好了,時間到,我們繼續往下看。

RST分為兩種,一種是active rst,另一種是passive rst。 前者多半是指的符合預期的reset行為,此種情況多半是屬於機器自己主動觸發,更具有先前意識,且和協定棧本身的細節關聯性不強;後者多半是指的機器也不清楚後面會發生什麽,走一步看一步,如果不符合協定棧的if-else實作的RFC中條條杠杠的規則的情況下,那就只能reset重設了。

這裏貼上RFC 793最經典的最初對RST包的解釋:

active rst

那具體什麽是active rst?如果從tcpdump抓包上來看表現就是(如下圖)RST的報文中含有了一串Ack標識。

這個對應的內核程式碼為(如果感興趣):

tcp_send_active_reset() -> skb = alloc_skb(MAX_TCP_HEADER, priority); -> tcp_init_nondata_skb(skb, tcp_acceptable_seq(sk), TCPHDR_ACK | TCPHDR_RST); -> tcp_transmit_skb()

通常發生active rst的有幾種情況:

1)主動方呼叫close()的時候,上層卻沒有取走完數據;這個屬於上層user自己犯下的錯。

2)主動方呼叫close()的時候,setsockopt設定了linger;這個標識代表我既然設定了這個,那close就趕快結束吧。

3)主動方呼叫close()的時候,發現全域的tcp可用的記憶體不夠了(這個可以sysctl調整tcp mem第三個參數),或,發現已經有太多的orphans了,這時候系統就是擺爛的意思:我也沒轍了」,那就只能幹脆點長痛不如短痛,結束吧。這個案例可以搜尋(dmesg日誌)「too many orphaned sockets」或「out of memory -- consider tuning tcp_mem」,匹配其中一個就容易中rst。

註:這裏省略其他使用diag相關(如ss命令)的RST問題。上述三類是主要的active rst問題的情況。

passive rst

現在繼續說說另一種passive rst吧。如果從抓包上來看表現就是(如下圖)rst的報文中無ack標識,而且RST的seq等於它否定的報文的ack號(紅色框的rst否定的黃色框的ack),當然還有另一種極小機率出現的特殊情況的表現我這裏不貼出來了,它的表現形式就是RST的Ack號為1。

這個對應的內核程式碼為(如果感興趣):

tcp_v4_send_reset()if (th->ack) {// 這裏對應的就是上圖中為何出現Seq==Ack rep.th.seq = th->ack_seq; } else {// 極小機率,如果出現,那麽RST包的就沒有Seq序列號 rep.th.ack = 1; rep.th.ack_seq = htonl(ntohl(th->seq) + th->syn + th->fin + skb->len - (th->doff << 2)); }

通常發生passive rst的有哪些情況呢?這個遠比active rst更復雜,場景更多。具體的需要看TCP的收、發的協定,文字的描述可以參考rfc 793即可。

三、工具

(僅對內核感興趣的同學)

我們針對線上這麽多的rst如何去分析呢?首先tcpdump的抓捕是一定需要的,這個可以在整體流程上給我們縮小排查範圍,其次是,必須要手寫抓捕異常呼叫rst的點,文末我會分享一些源碼出來供參考。

那如何抓呼叫RST的點?這裏只提供下思路。

active rst

使用bpf*相關的工具抓捕tcp_send_active_reset()函式並打印堆疊即可,透過crash現場機器並輸入「dis -l [addr]」可以得到具體的函式位置,比對源碼就可以得知了。

可以使用bpftrace進行快速抓捕

sudo bpftrace -e 'k:tcp_send_active_reset { @[kstack()] = count(); }'

堆疊結果如圖:

我們可以根據堆疊資訊推算上下文。

passive rst

使用bpf*相關的工具抓捕抓捕tcp_v4_send_reset()和其他若幹小的地方即可,原理同上。

sudo bpftrace -e 'k:tcp_v4_send_reset { @[kstack()] = count(); }'

效果如圖

當然,無論那種,我們抓到了堆疊後依然需要輸出很多的關於skb和sk的資訊,這個讀者自行考慮即可。再補充一些抓捕小技巧,如果現網機器的rst數量較多時候,盡量使用匹配固定的ip+port方式或其它關鍵字來減少打印輸出,否則會消耗資源過多!

註:切記不能去抓捕reset tracepoint(具體函式:trace_tcp_send_reset()),這個tracepoint實作是有問題的,這個問題已經在社群內核中存在了7年之久!目前我正在修復中。

四、案例分析

本章節我將用現網實際碰到的三個」離譜「的case作為案例分析,讓各位讀者可以看下極為復雜的RST案例到底長成什麽樣?對內核不感興趣的同學可以不用糾結具體的細節,只需要知道一個過程即可;對內核感興趣的同學不妨可以一起構造RST然後自己再抓取的試試。

第一個案例:小試牛刀—— close階段RST

背景: 這是線上出現機率/次數較多的一種型別的RST,業務總是抱怨為何我的連線莫名其妙的又沒了。

我們先使用網路異常檢測中最常用的工具:tcpdump。如下抓包的圖片再結合前文對RST的兩種分類(active && passive)可知,這是active rst。

好,既然知道了是active rst,我們就針對性的線上上對關鍵函式抓捕,如下:

透過crash命令找到了對應的源碼,如下:

這時候便知是使用者設定了linger,主動預期內的行為觸發的rst,所以本例就解決了。不過插曲是,使用者並不認為他設定了linger,這個怎麽辦?那就再抓一次sk->sk_lingertime值就好咯,如下:

計算:socket的flag是784,第5位(從右往左)是1,這個是SO_LINGER位置位成功,但是同時linger_time為0。這個條件預設(符合預期)觸發:上層使用者結束時候,不走四次揮手,直接RST結束。

結論:linger的預設機制觸發了加速結束TCP連線從而RST報文發出。

第二個案例:TCP 兩個bug —— 握手與揮手的RS

背景: 某重點業務報告他們的某重點使用者出現了莫名其妙的RST問題,而且每一次都是出現在三次握手階段,復現機率約為——」按請求數來算的話差不多百萬級別分之1的機率,機率極低「(這是來自業務的原話)。

這裏需要劇透一點的是,後文提到的兩個場景下的rst的bug,都是由於相同的race condition導致的。rcu保護關註的是reader&writer的安全性(不會踩錯地址),而不保護數據的即時性,這個很重要。所以 當rcu與hashtable結合的時候,對整個表的增刪和讀如何保證數據的絕對的同步顯得很重要!

握手階段的TCP bug

問題的表象是,三次握手完畢後client端給server端發送了數據,結果server端卻發送了rst拒絕了。

分析: 註意看上圖最左邊的第4和5這兩行的時間間隔非常短,只有11微妙,11微妙是什麽概念?查一次tcp socket的hash表可能都是幾十微妙,這點時間完全可能會停頓在一個函式上。

當server端看到第三行的ack的時候幾乎同時也看到了第四行的數據,詳細來說,這時候server端在握手最後一個環節,會在socket的hash表中刪除一個老的socket(我們叫req sk),再插入一個新的socket(我們叫full sk),在刪除和插入之間的這短暫的幾微妙發生的時候,server收第行的數據的時候需要去到這個hash表中尋找(根據五元組)對應的socket來接受這個報文,結果在這個空檔期間沒有匹配到應該找到的socket,這時候沒辦法只能把當時上層最初監聽的listener拿出來接收,這樣就出現了錯誤,違背了協定棧的基本的設計:對於listener socket接收到了封包,那麽這個封包是非預期的,應該發送RST!

CPU 0 CPU 1 ----- -----tcp_v4_rcv() syn_recv_sock() inet_ehash_insert() -> sk_nulls_del_node_init_rcu(osk)__inet_lookup_established() -> __sk_nulls_add_node_rcu(sk, list)

對應上圖的cpu0就是server的第四行的讀者,cpu1就是寫者,對於cpu0而言,讀到的數據可能是三種情況:1)讀到老的sk,2)讀到新的sk,3)誰也讀不到,前兩個都是可以接收,但是最後一個就是bug了——我們必須要找到兩者之一!如下就是一種場景,無法正確找到new或者old。

那如何修復這個問題?在排查完整個握手規則後,發現只需要先插入新的sk到hash桶的尾部,再刪除老的sk即可,這樣就會有幾種情況:1)兩個同時都在,一定能匹配到其中一個,2)匹配到新的。如下圖,無論reader在哪裏都能保證可以讀到一個。如下是正確的:

結論:第3行(client給server發生了握手最後一次ack)和第4行(client端給server發送了第一組數據)出現的並行問題。

揮手階段的bug

這個問題根因同上:rcu+hash表的使用問題,在揮手階段發起close()的一方競爭的亂序的收到了一個ack和一個fin ack觸發,導致socket在最後接收fin ack時候沒有匹配到任何一個socket,又只能拿出最初監聽的listener來收包的時候,這時候出現了錯誤。但是這個原始程式碼中,是先插入新的sk再刪除了老的sk,乍一聽沒有任何問題,但是實際上插入新的sk出現了問題,源碼中插入到頭部,這裏需要插入到尾部才行!出現問題的情景如下圖。

結論:這個是原生內核長達十多年的一個實作上的BUG,即為了效能考慮使用的RCU機制,由此必然引入的不準確性導致並行的問題, 我定位並分析出這個問題的並行的根因,由此送出了一份 bugfix patch 到社群被接收,連結:https://git.kernel.org/pub/scm/linux/kernel/git/netdev/net.git/commit/?id=3f4ca5fafc08881d7a57daa20449d171f2887043

第三個案例:netfilter兩個bug —— 數據傳輸RST

背景: 使用者報告有兩個痛點問題:偶發性出現1)根本無法完成三次握手連線,2)在傳輸數據的階段突然被RST異常中止。

分析: 我們很容易的透過TCP的設計推測到這種情況一定不是正常的、符合預期的行為。我抓取了passive rst後發現原因是TCP層無法透過收到的skb包尋找到對應的socket,要知道socket是最核心的TCP連線通訊的基站,它保存了TCP應有的資訊(wscale、seq、buf等等),如果skb無法找到socket,那麽就像小時候的故事小蝌蚪找媽媽但是找不到回家的路一樣。

那為什麽會出現找不到socket?

經過排查發現線上配置了DNAT規則,如下例子,凡是到達server端的1111埠或1112埠的都被轉發到80埠接收。

//iptables A port -> B portiptables... -p tcp --port 1111 -j REDIRECT --to-ports 80iptables... -p tcp --port 1112 -j REDIRECT --to-ports 80

DNAT+netfilter的流程是什麽樣?

那麽,有了DNAT之後,凡是進入到server端的A port會被直接轉發到B port,最後TCP完成接收。 完整的邏輯是這樣: DNAT的埠對映在ip層收包時候先進入prerouting流程,修改skb的dst_ip:dst_port為真正的最後對映的資訊,而後由ip early demux機制針對skb中的原始資訊src_ip:src_port(也就是A port)修改為dst_ip:dst_port(也就是使用B port),由此4元組hash選擇一個sk,繼而成功由TCP接收才對。

兩條流沖突觸發的bug

如下,如果這時候有兩條流量想要TCP建連,二者都是由同一個client端相同的ip和port發起連線,這時候第1條連線首先發起握手那麽肯定可以順利進行,而當第2條連線發起的時候抵達到server端的1112埠最終被轉化為80埠,但是根據80埠可以發現我們已經建立了連線,所以第2條流三次握手直接失敗。

1:saddr:12345-> daddr:802:saddr:12345-> daddr:1112

(對內核細節不感興趣的同學可以跳過此段) 詳細的來說,第一條連結成功建聯,第二條連結開始握手,server端的收到syn包,在prerouting之後,修改skb的dst_ip:dst_port將saddr:12345->daddr:1112改為saddr:12345->daddr:80,然後進入early demux根據這個四元組找到了第一條連結的sk(因為dst_port都被iptables的prerouting改為一樣),而這個是一個established socket,後面進入到tcp層就會接收syn包失敗。
所以內核對應的修復方式就是,在prerouting->early demux完成後的local in階段,如果辨識到了這個case,則放棄early demux的選擇結果(透過skb_orphan()),在local in裏完成修改skb的源port埠(saddr:1112 -> daddr:80),這時候的第二條連結和第一條連結就有區分了,這時候交給tcp層真正意義上可以針對四元組的skb找sk,這時候肯定會找不到established sk、只能找listener socket,因此完成建聯流程。

結論: 這個是early demux+DNAT的bug,它未能解決沖突問題,導致了異常RST的發生。

特殊skb觸發的bug

註:在這個場景裏面多了一個中間的gateway。

在本例中,我發現依然是熟知的一幕,skb無法lookup尋找到對應的socket,此時我們要相信一定不會lookup演算法出錯,因為此演算法僅僅是做簡單的4元組的hash計算與匹配。所以追溯異常的skb和socket的四元組資訊是頭等事情,經過對比果然發現skb的埠資訊未能成功被iptables轉化為B port,所以使用了含有A port的四元組資訊去找socket,而socket當初的建立是使用了B port,所以skb與sk的相遇就這麽擦身而過了。

(對內核細節不感興趣的同學可以跳過後面大段)

那麽為什麽 會DNAT無法轉化?

我們先看下,異常未被轉化的skb和應當能接收的socket的4元組資訊:

// 2.2.2.2是去敏後的server端ip地址,另外兩個是client的ipsk info: 1.1.1.1:1111 <-> 2.2.2.2:80// 我們可以知道真實的socket的建立是使用了80埠skb info: 1.1.1.2:2222 <-> 2.2.2.2:1112// 異常的skb未成功將1112埠轉化為80埠

client->gw->server的流程中,由於gw側發送了一些unknown skb再加上client端發送了一些out-of-window的包,導致進入到server的netfilter階段會被辨識出來INVALID異常,這個異常被辨識後直接清除netfilter保持的該有的流資訊,繼而異常的skb抵達DNAT階段後無法轉化埠(因為判斷轉化的流資訊沒有了),最終skb無法成功轉化port埠號。

這個是netfilter+DNAT的設計上的bug,我認為:無論是否有netfilter,都不應當是TCP的行為被改變,所以如果netfilter辨識到了問題所在,1)要麽忽視,直接傳給TCP,交給TCP處理,2)要麽丟棄,這樣也能避免RST的發生。但是,就這麽一個小小的細節上,我和社群的幾個維護者拉鋸戰的battle了三百回合(連結: https://lore.kernel.org/all/[email protected]/ ),可惜雖然有一個維護者ACK了我的修補程式,但是另外的維護者考慮netfilter不適合用於丟包功能,所以讓使用者去使用iptables --log功能、檢測出invalid異常包、繼而用iptables配置主動丟棄。就憑這點,我認為嚴重違背了user friendly的初衷,這些應該是default預設功能才對。此時的我雖然表面打不過,但是在內心世界裏很顯然我battle贏了...

結論:netfilter辨識異常的skb未能成功保留DNAT資訊,導致最後port埠不能成功被轉化,從而觸發了TCP的RST行為。

五、小結

RST問題並不可怕,只要思路理清楚, 先判斷型別,再抓取對應程式碼,繼而翻出RFC協定,最後分析源碼就能搞定,僅僅四步就可以了 。

希望這篇文章對大家有用!

六、附錄

這裏列一下bcc的工具源碼,感興趣的同學可以自行查閱。如下是針對4.14內核寫的,如果是更高版本需要調整一些python與c對照的格式問題。

  • #!/usr/bin/env pythonfrom __future__ import print_functionfrom bcc import BPFimport argparsefrom time import strftimefrom socket import inet_ntop, AF_INET, AF_INET6from struct import packimport ctypes as ctfrom time import sleepfrom bcc import tcp# argumentsexamples = """examples: ./tcpdrop # trace kernel TCP drops"""parser = argparse.ArgumentParser( description="Trace TCP drops by the kernel", formatter_ class=argparse.RawDescriptionHelpFormatter, epilog=examples)parser.add_argument("--ebpf", action="store_true", help=argparse.SUPPRESS)args = parser.parse_args()debug = 0# define BPF programbpf_text = """#include <uapi/linux/ptrace.h>#include <uapi/linux/tcp.h>#include <uapi/linux/ip.h>#include <net/sock.h>#include <bcc/proto.h>BPF_STACK_TRACE(stack_traces, 1024);struct ipv4_data_t { u32 pid; u64 is_sknull; u32 saddr; u32 daddr; u16 sport; u16 dport; u8 state; u8 tcpflags; u32 stack_id;};BPF_PERF_OUTPUT(ipv4_events);struct active_data_t { u32 pid; u32 saddr; u32 daddr; u16 sport; u16 dport; u32 stack_id;};BPF_PERF_OUTPUT(active_events);static struct tcphdr *skb_to_tcphdr(const struct sk_buff *skb){ // unstable API. verify logic in tcp_hdr() -> skb_transport_header(). return (struct tcphdr *)(skb->head + skb->transport_header);}static inline struct iphdr *skb_to_iphdr(const struct sk_buff *skb){ // unstable API. verify logic in ip_hdr() -> skb_network_header(). return (struct iphdr *)(skb->head + skb->network_header);}// from include/net/tcp.h:#ifndef tcp_flag_byte#define tcp_flag_byte(th) (((u_int8_t *)th)[13])#endifint trace_tcp_v4_send_reset(struct pt_regs *ctx, struct sock *sk, struct sk_buff *skb){ u8 is_sk_null = sk ? 0 : 1; u8 state = sk ? (u8)sk->__sk_common.skc_state : 1; u32 pid = bpf_get_current_pid_tgid(); struct iphdr *ip = skb_to_iphdr(skb); u32 daddr = ip->daddr; u32 saddr = ip->saddr; // pull in details from the packet headers and the sock struct u16 family = sk->__sk_common.skc_family; u16 sport = 0, dport = 0; struct tcphdr *tcp = skb_to_tcphdr(skb); u8 tcpflags = ((u_int8_t *)tcp)[13]; sport = tcp->source; dport = tcp->dest; sport = ntohs(sport); dport = ntohs(dport); if (family == AF_INET && (saddr == 16777343 && daddr == 16777343) && (sport == 8004 || dport == 8004)) { struct ipv4_data_t data4 = {}; data4.pid = pid; data4.saddr = saddr; data4.daddr = daddr; data4.dport = dport; data4.sport = sport; data4.state = state; data4.tcpflags = tcpflags; data4.stack_id = stack_traces.get_stackid(ctx, 0); ipv4_events.perf_submit(ctx, &data4, sizeof(data4)); } return 0;}int trace_tcp_send_active_reset(struct pt_regs *ctx, struct sock *sk, unsigned int priority){ u32 pid = bpf_get_current_pid_tgid() >> 32; u32 saddr = 0, daddr = 0; u16 family = AF_INET; u16 sport = 0, dport = 0; // sport is not right sport = sk->__sk_common.skc_num; dport = sk->__sk_common.skc_dport; dport = ntohs(dport); saddr = sk->__sk_common.skc_rcv_saddr; daddr = sk->__sk_common.skc_daddr; if (family == AF_INET && (saddr == 16777343 && daddr == 16777343)) { struct active_data_t data4 = {}; data4.pid = pid; data4.saddr = saddr; data4.daddr = daddr; data4.dport = dport; data4.sport = sport; data4.stack_id = stack_traces.get_stackid(ctx, 0); active_events.perf_submit(ctx, &data4, sizeof(data4)); } return 0;}"""if debug or args.ebpf: print(bpf_text)if args.ebpf: exit()# event data classData_ipv4(ct.Structure): _fields_ = [ ("pid", ct.c_uint), ("is_sknull", ct.c_ulonglong), ("saddr", ct.c_uint), ("daddr", ct.c_uint), ("sport", ct.c_ushort), ("dport", ct.c_ushort), ("state", ct.c_ubyte), ("tcpflags", ct.c_ubyte), ("stack_id", ct.c_ulong) ] classData_active(ct.Structure): _fields_ = [ ("pid", ct.c_uint), ("saddr", ct.c_uint), ("daddr", ct.c_uint), ("sport", ct.c_ushort), ("dport", ct.c_ushort), ("stack_id", ct.c_ulong) ]# process eventdefprint_ipv4_event(cpu, data, size): event = ct.cast(data, ct.POINTER(Data_ipv4)).contentsif event.is_sknull is1: print("%-8s %-7d %-20s > %-20s %s (%s)" % ( strftime("%H:%M:%S"), event.pid,"%s:%d" % (inet_ntop(AF_INET, pack('I', event.saddr)), event.sport),"%s:%s" % (inet_ntop(AF_INET, pack('I', event.daddr)), event.dport),"sk-is-null", tcp.flags2str(event.tcpflags)))else: print("%-8s %-7d %-20s > %-20s %s (%s)" % ( strftime("%H:%M:%S"), event.pid,"%s:%d" % (inet_ntop(AF_INET, pack('I', event.saddr)), event.sport),"%s:%s" % (inet_ntop(AF_INET, pack('I', event.daddr)), event.dport), tcp.tcpstate[event.state], tcp.flags2str(event.tcpflags)))for addr in stack_traces.walk(event.stack_id): sym = b.ksym(addr, show_offset=True) print("\t%s" % sym) print("")defprint_active_event(cpu, data, size): event = ct.cast(data, ct.POINTER(Data_active)).contents print("%-8s %-7d %-20s > %-20s" % ( strftime("%H:%M:%S"), event.pid,"%s:%d" % (inet_ntop(AF_INET, pack('I', event.saddr)), event.sport),"%s:%d" % (inet_ntop(AF_INET, pack('I', event.daddr)), event.dport)))for addr in stack_traces.walk(event.stack_id): sym = b.ksym(addr, show_offset=True) print("\t%s" % sym) print("")# initialize BPFb = BPF(text=bpf_text)if b.get_kprobe_functions(b"tcp_v4_send_reset"): b.attach_kprobe(event="tcp_v4_send_reset", fn_name="trace_tcp_v4_send_reset")else: print("ERROR: tcp_drop() kernel function not found or traceable. ""Older kernel versions not supported.") exit()if b.get_kprobe_functions(b"tcp_send_active_reset"): b.attach_kprobe(event="tcp_send_active_reset", fn_name="trace_tcp_send_active_reset")else: print("ERROR: tcp_v4_send_reset() kernel function") exit()stack_traces = b.get_table("stack_traces")# headerprint("%-8s %-6s %-2s %-20s > %-20s %s (%s)" % ("TIME", "PID", "IP","SADDR:SPORT", "DADDR:DPORT", "STATE", "FLAGS"))# read eventsb["ipv4_events"].open_perf_buffer(print_ipv4_event)#b["active_events"].open_perf_buffer(print_active_event)while1:try: b.perf_buffer_poll()except KeyboardInterrupt: exit()

    掃碼添加 「 鵝廠架構師小客服 」 ,加入【 鵝廠架構師圈 】,與技術愛好者、技術關註者分享交流,共同進步成長,歡迎大家!↓↓↓

    關於我們

    技術分享:關註微信公眾號 【鵝廠架構師】