從擁塞控制算法熱交換到內核錯誤修復
最近在嗶哩嗶哩,我們開發了一種改進的 BBR 擁塞控制算法,需要在真實環境中進行測試。該算法本身以內核模塊的形式存在,因此將其安裝到服務器上不是問題。然而,在快節奏的迭代過程中,我們遇到了一系列問題,最終發現了一個內核錯誤。本文將帶您了解我們解決問題的整個過程,從擁塞控制算法熱交換到內核錯誤修復。下方列出了本文所處的實驗環境,可以幫助您復現實驗。
實驗環境
我們使用的 Linux 版本是 5.10。為了隔離測試環境,我們使用 ip netns 創建一個名為 ns 的網絡命名空間,并創建一對 veth ve_o 和 ve_i 來運行 TCP 連接。
圖片
ip netns add ns
ip link add ve_o type veth peer name ve_i
ip link set ve_i netns ns
ip link set ve_o up
ip addr add dev ve_o 192.168.0.2/24
ip -n ns link set ve_i up
ip -n ns addr add dev ve_i 192.168.0.1/24
通過這樣做,大多數情況下我們可以在 ns 命名空間中運行 ss 命令而無需指定任何過濾器。
第一個問題:內核模塊 (kmod) 加載和卸載
加載和使用 kmod 很簡單:
# 加載模塊
$ insmod tcp_bbr_bili.ko
# 使其成為默認的擁塞控制算法
$ sysctl -w net/ipv4/tcp_congestion_cnotallow=bbr_bili
借助 ss 的強大功能,我們可以看到擁塞控制算法的實際效果:
$ ip netns exec ns ss -npti
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.0.1:1000 192.168.0.2:50916 users:(("socat",pid=692883,fd=6))
bbr_bili ...
在上面的示例中,我們使用 socat 來模擬 TCP 連接,可以看到擁塞控制算法是 bbr_bili。
現在假設我們有了一個修復了一些錯誤的新版本算法,我們來加載它:
$ insmod tcp_bbr_bili.ko
insmod: ERROR: could not insert module tcp_bbr_bili.ko: File exists
糟糕,我們無法加載更新后的模塊,因為它與舊模塊同名。為了迭代算法,我們需要卸載舊模塊并加載新模塊。
$ rmmod tcp_bbr_bili
rmmod: ERROR: Module tcp_bbr_bili is in use
這是有道理的;某個進程正在使用該模塊,所以我們無法卸載它。lsmod 也證實了該模塊正在使用中:
$ lsmod | grep bili
tcp_bbr_bili 20480 2
在這種情況下,我們可以將擁塞控制算法更改為 cubic 或 bbr,等待使用 bbr_bili 的套接字關閉,然后卸載模塊?;蛘呶覀兛梢杂貌煌拿Q重新編譯模塊,但這會很麻煩。由于我們迭代算法的速度比較快,等待套接字關閉不是一個好選擇;重新編譯模塊會在內核中產生大量垃圾。我想知道是否有更好的方法可以在不等待或重新編譯的情況下卸載模塊? 有的兄弟,有的。
第二個問題:算法熱交換和套接字竊取
有一種方法可以在不等待套接字關閉的情況下釋放模塊。我們可以使用 setsockopt 直接更改套接字的擁塞控制算法。
setsockopt(sockfd, IPPROTO_TCP, TCP_CONGESTION, "bbr_bili", strlen("bbr_bili"));
然而,這需要我們擁有該套接字才能執行 setsockopt 系統調用,而且我們無法修改每個使用該算法的程序來添加此代碼。因此,我們需要一種方法從使用它的進程中“竊取”套接字。這就是 pidfd_getfd 發揮作用的地方。
不久前在瀏覽 Cloudflare 博客時,我遇到了一種稱為“套接字竊取”的技術,它使用 pidfd_getfd 系統調用從另一個進程復制套接字。我將從演講(https://www.usenix.org/system/files/srecon23emea-slides_sitnicki.pdf)中“竊取”一張幻燈片。該演講本身是關于“SOCKMAP”的,與我們的主題無關,但我建議您閱讀一下,了解一些 eBPF 的魔力。
圖片
如幻燈片所示,為了從另一個進程“竊取”(復制)套接字,我們需要目標進程的 PID 和套接字的文件描述符。幸運的是,我們可以從 ss 的 Process 列中獲取所有這些信息:
$ ip netns exec ns ss -npt
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.0.1:1000 192.168.0.2:50916 users:(("socat",pid=692883,fd=6))
pid=692883 是進程的 PID,fd=6 是套接字的文件描述符。我們可以使用 pidfd_open 獲取進程的 PIDFD,然后使用 pidfd_getfd 復制套接字。結合這些步驟,代碼如下所示:
// 獲取目標進程的 PIDFD
pidfd = syscall(SYS_pidfd_open, pid, 0);
// 復制套接字 fd
fd = syscall(SYS_pidfd_getfd, pidfd, targetfd, 0);
// 設置擁塞控制算法
setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, "bbr_bili", strlen("bbr_bili"));
我們將其制作成一個小工具,名為 changeling,它接受 ./changeling <pid> <fd> <congestion_algorithm> 作為參數,并更改目標套接字的擁塞控制算法。代碼可在 Github(https://github.com/kuroa-me/bilibili-blog) 上找到。讓我們看看它的實際效果:
$ ./changeling 6928836 cubic
setsockopt success
$ ip netns exec ns ss -npti
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.0.1:1000 192.168.0.2:50916 users:(("socat",pid=692883,fd=6))
cubic ...
妙!我們成功更改了一個不屬于我們的套接字的擁塞控制算法。現在,讓我們將其編寫成腳本,并在每個使用 bbr_bili 的套接字上調用它,然后就可以收工了。
等等,那是什么?一個沒有進程的套接字?
$ ip netns exec ns ss -np
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
tcp FIN-WAIT-1 0 20481 192.168.0.1:58732 192.168.0.2:65432
第三個問題:孤立套接字
孤立套接字是“由系統持有但未附加到任何用戶文件句柄的套接字”(LARTC:https://lartc.org/howto/lartc.kernel.obscure.html)。當進程退出并留下一個由于某種原因內核未清理的套接字時,可能會發生這種情況。我們在生產環境中只觀察到少數此類孤立套接字。然而,即使只有一個孤立套接字也足以將模塊的使用計數提高到 1,從而阻止我們卸載模塊。
系統中的罪魁禍首是 TCP 窗口,它導致一些孤立套接字存活時間過長而成為問題。讓我們一起看看這個問題,參考下面的 TCP 有限狀態機(http://www.tcpipguide.com/free/t_TCPOperationalOverviewandtheTCPFiniteStateMachineF-2.htm)。
圖片
在 ESTABLISHED 狀態下,用戶進程可以調用 close() 來關閉套接字。然后內核會將一個 FIN 附加到套接字的發送隊列,并將狀態更改為 FIN-WAIT-1。然后內核將等待對等方 ACK 該 FIN。但是由于 FIN 位于發送隊列的末尾,如果 TCP 窗口非常小或為零,則需要很長時間才能發送 FIN,從而阻止對等方 ACK 它,并使套接字停滯在 FIN-WAIT-1 狀態。
上一節中的示例是通過使用 2 個 socat 命令模擬零窗口場景創建的。一個是“壞壞”服務器,在接受連接后不會從套接字讀取任何數據。引自 socat 手冊頁(http://www.dest-unreach.org/socat/doc/socat.html):
# 終端 1 - 服務器
$ socat -u \ # 使用單向模式。第一個地址僅用于讀取,第二個地址僅用于寫入。
- \ # 第一個地址,即 STDIO (-)。
"TCP-LISTEN:65432,fork" # 第二個地址,我們的偵聽服務器。
另一個是客戶端,它只是連接到服務器并不斷從 /dev/zero 向服務器轉儲 0。
# 終端 2 - 客戶端
$ ip netns exec ns socat \
"/dev/zero" \
"TCP:192.168.0.2:65432"
# 等待幾秒鐘后使用 Ctrl+C 終止客戶端
^C
由于服務器沒有在套接字上調用接收,因此接收隊列 (Recv-Q) 沒有被清空,從而阻止發送隊列 (Send-Q) 清空,有效地模擬了零窗口 TCP 連接。幾秒鐘后,我們可以手動終止客戶端進程,剩下的將是一個孤立的類零窗口套接字。
$ ip netns exec ns ss -n4tpe
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
FIN-WAIT-1 0 883585 192.168.0.1:60820 192.168.0.2:65432 timer:(persist,1min50sec,0) ...
$ ss -n4tpe '( sport = :65432 )'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 124032 0 192.168.0.2:65432 192.168.0.1:60820 users:(("socat",pid=1509536,fd=6)) ...
幸運的是,內核最終會超時并清理孤立套接字。(請注意上面輸出中的 timer:(persist,1min9sec,0))。這主要由 tcp_orphan_retries sysctl (https://sysctl-explorer.net/net/ipv4/tcp_orphan_retries/)控制。如果我們不等待那么長時間怎么辦?或者如果套接字是一個不會超時的近零窗口套接字怎么辦?
ss 是一個不斷帶來驚喜的寶庫。它有一個 -K 選項可用于終止套接字。
# 在此處添加過濾器以確保。
$ ip netns exec ns ss -n4tpe -K '( dport = :65432 )'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
FIN-WAIT-1 0 883585 192.168.0.1:60820 192.168.0.2:65432
ss 向我們顯示了它找到并成功終止的套接字。現在我們可以修改我們最初的腳本,在調用 changeling 之后對每個孤立套接字調用 ss -K,太棒了!
等等,為什么孤立套接字仍然存在?為什么在多次調用 ss -K 后它仍然存在?
$ ip netns exec ns ss -n4tpe -K '( dport = :65432 )'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
FIN-WAIT-1 0 883585 192.168.0.1:60820 192.168.0.2:65432 ino:0 sk:531a ---
$ ip netns exec ns ss -n4tpe -K '( dport = :65432 )'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
FIN-WAIT-1 0 883585 192.168.0.1:60820 192.168.0.2:65432 ino:0 sk:531a ---
$ ip netns exec ns ss -n4tpe -K '( dport = :65432 )'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
FIN-WAIT-1 0 883585 192.168.0.1:60820 192.168.0.2:65432 ino:0 sk:531a ---
第四個問題:“套接字已死,套接字萬歲!”
無法終止套接字是一個問題,但我必須專注于手頭的任務,所以我決定給它一天時間讓它超時。第二天,我回到辦公室,發現套接字仍然存在。驚恐之下,我開始調查到底發生了什么。
起初,我以為這是 ss 中的一個 bug,并檢查了 ss 實際是如何終止套接字的。代碼位于 https://github.com/iproute2/iproute2/blob/main/misc/ss.c
staticintkill_inet_sock(struct nlmsghdr *h, void *arg, struct sockstat *s)
{
...
DIAG_REQUEST(req, struct inet_diag_req_v2 r);
req.nlh.nlmsg_type = SOCK_DESTROY;
...
return rtnl_talk(rth, &req.nlh, NULL);
}
staticintshow_one_inet_sock(struct nlmsghdr *h, void *arg)
{
...
if (diag_arg->f->kill && kill_inet_sock(h, arg, &s) != 0) {
if (errno == EOPNOTSUPP || errno == ENOENT) {
/* Socket can't be closed, or is already closed. */
return0;
} else {
perror("SOCK_DESTROY answers");
return-1;
}
}
...
err = inet_show_sock(h, &s);
if (err < 0)
return err;
return0;
}
從代碼中我們可以看到 ss 正在使用 Netlink 公開的 SOCK_DIAG 基礎結構。當調用 show_one_inet_sock 時,它將嘗試通過發送帶有 SOCK_DESTROY (kill_inet_sock) 的 nlmsg 來終止套接字。成功后,它將始終打印已終止套接字的信息,這與我們在上一節中看到的最后輸出相匹配。也就是說,內核向 ss 確認它已經終止了套接字。現在我們需要查看內核代碼以了解發生了什么。下面的函數按我跟蹤整個過程的方式排序;更有經驗的開發人員可能有更好的方法來執行此操作。(主要查看 IPv4 TCP 代碼)。
// net/ipv4/inet_diag.c
staticintinet_diag_cmd_exact(){
err = handler->destroy(in_skb, req);
}
// net/ipv4/tcp_diag.c
staticconststructinet_diag_handlertcp_diag_handler = {
.destroy = tcp_diag_destroy,
};
// net/ipv4/tcp_diag.c
staticinttcp_diag_destroy(struct sk_buff *in_skb,
const struct inet_diag_req_v2 *req) {
err = sock_diag_destroy(sk, ECONNABORTED);
}
// net/core/sock_diag.c
intsock_diag_destroy(struct sock *sk, int err){
return sk->sk_prot->diag_destroy(sk, err);
}
// net/ipv4/tcp_ipv4.c
structprototcp_prot = {
.diag_destroy = tcp_abort,
};
// net/ipv4/tcp.c
inttcp_abort(struct sock *sk, int err)
{
...
if (!sock_flag(sk, SOCK_DEAD)) {
...
if (tcp_need_reset(sk->sk_state))
tcp_send_active_reset(sk, GFP_ATOMIC);
tcp_done(sk);
}
...
tcp_write_queue_purge(sk);
release_sock(sk);
return0;
}
EXPORT_SYMBOL_GPL(tcp_abort);
// net/ipv4/tcp.c
voidtcp_done(struct sock *sk)
{
...
if (!sock_flag(sk, SOCK_DEAD))
sk->sk_state_change(sk);
else
inet_csk_destroy_sock(sk);
}
EXPORT_SYMBOL_GPL(tcp_done);
這里的關鍵角色是 tcp_abort 和 tcp_done。它們負責在 TCP 的不同狀態下關閉套接字;為簡潔起見,我省略了不相關的代碼。SOCK_DEAD 是一個重要的標志,它決定了代碼的流向。要找出它在正在運行的機器中的值,我們可以使用 bpftrace(https://bpftrace.org/) 來打印 sock_flag 的值。
// 完整代碼在 github 上
kprobe:tcp_abort{
printf("aborting: %x\n", ((struct sock *)arg0)->sk_flags);
}
# 附加 bpftrace 后嘗試終止孤立套接字
$ bpftrace tcp_abort.bt
Attaching 1 probe...
aborting: 0x301
內核將 SOCK_DEAD 放在 enum sock_flags 的最低有效位,因此 0x301 表示設置了 SOCK_DEAD。我們可以嘗試相應地遵循代碼路徑。 在 tcp_abort 中,由于設置了 SOCK_DEAD,它只會使用 tcp_write_queue_purge 清除隊列,而不會通過調用 tcp_done 實際關閉套接字。這就解釋了為什么在多次成功調用 ss -K 后套接字仍然存在。但是為什么套接字不會超時呢?
答案在于 tcp_timer.c 文件。
// net/ipv4/tcp_timer.c
staticvoidtcp_probe_timer(struct sock *sk)
{
...
if (tp->packets_out || !skb) {
icsk->icsk_probes_out = 0;
return;
}
...
if (icsk->icsk_probes_out >= max_probes) {
// tcp_write_err() - 關閉套接字并保存錯誤信息
abort: tcp_write_err(sk);
} else {
/* 僅當我們沒有關閉連接時才發送另一個探測。*/
tcp_send_probe0(sk);
}
}
在這里,如果 packets_out 為 0,tcp_probe_timer 將提前返回,而不會檢查計數器以決定是使套接字超時還是發送另一個探測。而我們的 tcp_write_queue_purge 恰好清除了 packets_out 計數器。因此,在當前計時器到期后,套接字將不會獲得另一個計時器或超時,從而變得不朽。
// net/ipv4/tcp.c
voidtcp_write_queue_purge(struct sock *sk)
{
...
tcp_sk(sk)->packets_out = 0;
inet_csk(sk)->icsk_backoff = 0;
}
如果我們仔細查看第 3 節的最后輸出,我們可以看到 timer 確實在 ss 的輸出中不復存在。
結束問題鏈
要修復此內核錯誤,我們只需在 tcp_abort 中刪除 SOCK_DEAD 檢查。此補丁已提交給內核并被接受,您可以在此處(https://patchwork.kernel.org/project/netdevbpf/patch/20240812105315.440718-1-kuro@kuroa.me/)找到更多詳細信息。在開發補丁時,virtme-ng 是測試補丁的一個很好的工具,使用 virtme-ng 更快地進行內核測試(https://lwn.net/Articles/951313/)。
要點:
我們的 changeling 仍然可以用來更改 cc 算法或任何其他套接字選項,并且非常方便。
如果是沒有打過補丁的內核,請不要在孤立套接字上使用 ss -K。
ss、bpftrace 和 virtme-ng 是調試內核問題的好工具。
感謝您的閱讀;整個冒險從一個簡單的 cc 交換工具開始,到內核錯誤修復結束。我希望您能學到一些可以玩的新工具。
附言:在此補丁被添加到最新的內核樹之后,三星也在他們的測試中遇到了這個錯誤,并且是他們將該補丁向下移植到了 5.15 和 6.1。