從擁塞控制算法熱交換到內(nèi)核錯(cuò)誤修復(fù)
最近在嗶哩嗶哩,我們開(kāi)發(fā)了一種改進(jìn)的 BBR 擁塞控制算法,需要在真實(shí)環(huán)境中進(jìn)行測(cè)試。該算法本身以?xún)?nèi)核模塊的形式存在,因此將其安裝到服務(wù)器上不是問(wèn)題。然而,在快節(jié)奏的迭代過(guò)程中,我們遇到了一系列問(wèn)題,最終發(fā)現(xiàn)了一個(gè)內(nèi)核錯(cuò)誤。本文將帶您了解我們解決問(wèn)題的整個(gè)過(guò)程,從擁塞控制算法熱交換到內(nèi)核錯(cuò)誤修復(fù)。下方列出了本文所處的實(shí)驗(yàn)環(huán)境,可以幫助您復(fù)現(xiàn)實(shí)驗(yàn)。
實(shí)驗(yàn)環(huán)境
我們使用的 Linux 版本是 5.10。為了隔離測(cè)試環(huán)境,我們使用 ip netns 創(chuàng)建一個(gè)名為 ns 的網(wǎng)絡(luò)命名空間,并創(chuàng)建一對(duì) veth ve_o 和 ve_i 來(lái)運(yùn)行 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通過(guò)這樣做,大多數(shù)情況下我們可以在 ns 命名空間中運(yùn)行 ss 命令而無(wú)需指定任何過(guò)濾器。
第一個(gè)問(wèn)題:內(nèi)核模塊 (kmod) 加載和卸載
加載和使用 kmod 很簡(jiǎn)單:
# 加載模塊
$ insmod tcp_bbr_bili.ko
# 使其成為默認(rèn)的擁塞控制算法
$ sysctl -w net/ipv4/tcp_congestion_cnotallow=bbr_bili借助 ss 的強(qiáng)大功能,我們可以看到擁塞控制算法的實(shí)際效果:
$ 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 來(lái)模擬 TCP 連接,可以看到擁塞控制算法是 bbr_bili。
現(xiàn)在假設(shè)我們有了一個(gè)修復(fù)了一些錯(cuò)誤的新版本算法,我們來(lái)加載它:
$ insmod tcp_bbr_bili.ko
insmod: ERROR: could not insert module tcp_bbr_bili.ko: File exists糟糕,我們無(wú)法加載更新后的模塊,因?yàn)樗c舊模塊同名。為了迭代算法,我們需要卸載舊模塊并加載新模塊。
$ rmmod tcp_bbr_bili
rmmod: ERROR: Module tcp_bbr_bili is in use這是有道理的;某個(gè)進(jìn)程正在使用該模塊,所以我們無(wú)法卸載它。lsmod 也證實(shí)了該模塊正在使用中:
$ lsmod | grep bili
tcp_bbr_bili 20480 2在這種情況下,我們可以將擁塞控制算法更改為 cubic 或 bbr,等待使用 bbr_bili 的套接字關(guān)閉,然后卸載模塊。或者我們可以用不同的名稱(chēng)重新編譯模塊,但這會(huì)很麻煩。由于我們迭代算法的速度比較快,等待套接字關(guān)閉不是一個(gè)好選擇;重新編譯模塊會(huì)在內(nèi)核中產(chǎn)生大量垃圾。我想知道是否有更好的方法可以在不等待或重新編譯的情況下卸載模塊? 有的兄弟,有的。
第二個(gè)問(wèn)題:算法熱交換和套接字竊取
有一種方法可以在不等待套接字關(guān)閉的情況下釋放模塊。我們可以使用 setsockopt 直接更改套接字的擁塞控制算法。
setsockopt(sockfd, IPPROTO_TCP, TCP_CONGESTION, "bbr_bili", strlen("bbr_bili"));然而,這需要我們擁有該套接字才能執(zhí)行 setsockopt 系統(tǒng)調(diào)用,而且我們無(wú)法修改每個(gè)使用該算法的程序來(lái)添加此代碼。因此,我們需要一種方法從使用它的進(jìn)程中“竊取”套接字。這就是 pidfd_getfd 發(fā)揮作用的地方。
不久前在瀏覽 Cloudflare 博客時(shí),我遇到了一種稱(chēng)為“套接字竊取”的技術(shù),它使用 pidfd_getfd 系統(tǒng)調(diào)用從另一個(gè)進(jìn)程復(fù)制套接字。我將從演講(https://www.usenix.org/system/files/srecon23emea-slides_sitnicki.pdf)中“竊取”一張幻燈片。該演講本身是關(guān)于“SOCKMAP”的,與我們的主題無(wú)關(guān),但我建議您閱讀一下,了解一些 eBPF 的魔力。
圖片
如幻燈片所示,為了從另一個(gè)進(jìn)程“竊取”(復(fù)制)套接字,我們需要目標(biāo)進(jìn)程的 PID 和套接字的文件描述符。幸運(yùn)的是,我們可以從 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 是進(jìn)程的 PID,fd=6 是套接字的文件描述符。我們可以使用 pidfd_open 獲取進(jìn)程的 PIDFD,然后使用 pidfd_getfd 復(fù)制套接字。結(jié)合這些步驟,代碼如下所示:
// 獲取目標(biāo)進(jìn)程的 PIDFD
pidfd = syscall(SYS_pidfd_open, pid, 0);
// 復(fù)制套接字 fd
fd = syscall(SYS_pidfd_getfd, pidfd, targetfd, 0);
// 設(shè)置擁塞控制算法
setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, "bbr_bili", strlen("bbr_bili"));我們將其制作成一個(gè)小工具,名為 changeling,它接受 ./changeling <pid> <fd> <congestion_algorithm> 作為參數(shù),并更改目標(biāo)套接字的擁塞控制算法。代碼可在 Github(https://github.com/kuroa-me/bilibili-blog) 上找到。讓我們看看它的實(shí)際效果:
$ ./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 ...妙!我們成功更改了一個(gè)不屬于我們的套接字的擁塞控制算法。現(xiàn)在,讓我們將其編寫(xiě)成腳本,并在每個(gè)使用 bbr_bili 的套接字上調(diào)用它,然后就可以收工了。
等等,那是什么?一個(gè)沒(méi)有進(jìn)程的套接字?
$ 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第三個(gè)問(wèn)題:孤立套接字
孤立套接字是“由系統(tǒng)持有但未附加到任何用戶文件句柄的套接字”(LARTC:https://lartc.org/howto/lartc.kernel.obscure.html)。當(dāng)進(jìn)程退出并留下一個(gè)由于某種原因內(nèi)核未清理的套接字時(shí),可能會(huì)發(fā)生這種情況。我們?cè)谏a(chǎn)環(huán)境中只觀察到少數(shù)此類(lèi)孤立套接字。然而,即使只有一個(gè)孤立套接字也足以將模塊的使用計(jì)數(shù)提高到 1,從而阻止我們卸載模塊。
系統(tǒng)中的罪魁禍?zhǔn)资?TCP 窗口,它導(dǎo)致一些孤立套接字存活時(shí)間過(guò)長(zhǎng)而成為問(wèn)題。讓我們一起看看這個(gè)問(wèn)題,參考下面的 TCP 有限狀態(tài)機(jī)(http://www.tcpipguide.com/free/t_TCPOperationalOverviewandtheTCPFiniteStateMachineF-2.htm)。
圖片
在 ESTABLISHED 狀態(tài)下,用戶進(jìn)程可以調(diào)用 close() 來(lái)關(guān)閉套接字。然后內(nèi)核會(huì)將一個(gè) FIN 附加到套接字的發(fā)送隊(duì)列,并將狀態(tài)更改為 FIN-WAIT-1。然后內(nèi)核將等待對(duì)等方 ACK 該 FIN。但是由于 FIN 位于發(fā)送隊(duì)列的末尾,如果 TCP 窗口非常小或?yàn)榱悖瑒t需要很長(zhǎng)時(shí)間才能發(fā)送 FIN,從而阻止對(duì)等方 ACK 它,并使套接字停滯在 FIN-WAIT-1 狀態(tài)。
上一節(jié)中的示例是通過(guò)使用 2 個(gè) socat 命令模擬零窗口場(chǎng)景創(chuàng)建的。一個(gè)是“壞壞”服務(wù)器,在接受連接后不會(huì)從套接字讀取任何數(shù)據(jù)。引自 socat 手冊(cè)頁(yè)(http://www.dest-unreach.org/socat/doc/socat.html):
# 終端 1 - 服務(wù)器
$ socat -u \ # 使用單向模式。第一個(gè)地址僅用于讀取,第二個(gè)地址僅用于寫(xiě)入。
- \ # 第一個(gè)地址,即 STDIO (-)。
"TCP-LISTEN:65432,fork" # 第二個(gè)地址,我們的偵聽(tīng)服務(wù)器。另一個(gè)是客戶端,它只是連接到服務(wù)器并不斷從 /dev/zero 向服務(wù)器轉(zhuǎn)儲(chǔ) 0。
# 終端 2 - 客戶端
$ ip netns exec ns socat \
"/dev/zero" \
"TCP:192.168.0.2:65432"
# 等待幾秒鐘后使用 Ctrl+C 終止客戶端
^C由于服務(wù)器沒(méi)有在套接字上調(diào)用接收,因此接收隊(duì)列 (Recv-Q) 沒(méi)有被清空,從而阻止發(fā)送隊(duì)列 (Send-Q) 清空,有效地模擬了零窗口 TCP 連接。幾秒鐘后,我們可以手動(dòng)終止客戶端進(jìn)程,剩下的將是一個(gè)孤立的類(lèi)零窗口套接字。
$ 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)) ...幸運(yùn)的是,內(nèi)核最終會(huì)超時(shí)并清理孤立套接字。(請(qǐng)注意上面輸出中的 timer:(persist,1min9sec,0))。這主要由 tcp_orphan_retries sysctl (https://sysctl-explorer.net/net/ipv4/tcp_orphan_retries/)控制。如果我們不等待那么長(zhǎng)時(shí)間怎么辦?或者如果套接字是一個(gè)不會(huì)超時(shí)的近零窗口套接字怎么辦?
ss 是一個(gè)不斷帶來(lái)驚喜的寶庫(kù)。它有一個(gè) -K 選項(xiàng)可用于終止套接字。
# 在此處添加過(guò)濾器以確保。
$ 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:65432ss 向我們顯示了它找到并成功終止的套接字。現(xiàn)在我們可以修改我們最初的腳本,在調(diào)用 changeling 之后對(duì)每個(gè)孤立套接字調(diào)用 ss -K,太棒了!
等等,為什么孤立套接字仍然存在?為什么在多次調(diào)用 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 ---第四個(gè)問(wèn)題:“套接字已死,套接字萬(wàn)歲!”
無(wú)法終止套接字是一個(gè)問(wèn)題,但我必須專(zhuān)注于手頭的任務(wù),所以我決定給它一天時(shí)間讓它超時(shí)。第二天,我回到辦公室,發(fā)現(xiàn)套接字仍然存在。驚恐之下,我開(kāi)始調(diào)查到底發(fā)生了什么。
起初,我以為這是 ss 中的一個(gè) bug,并檢查了 ss 實(shí)際是如何終止套接字的。代碼位于 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 公開(kāi)的 SOCK_DIAG 基礎(chǔ)結(jié)構(gòu)。當(dāng)調(diào)用 show_one_inet_sock 時(shí),它將嘗試通過(guò)發(fā)送帶有 SOCK_DESTROY (kill_inet_sock) 的 nlmsg 來(lái)終止套接字。成功后,它將始終打印已終止套接字的信息,這與我們?cè)谏弦还?jié)中看到的最后輸出相匹配。也就是說(shuō),內(nèi)核向 ss 確認(rèn)它已經(jīng)終止了套接字。現(xiàn)在我們需要查看內(nèi)核代碼以了解發(fā)生了什么。下面的函數(shù)按我跟蹤整個(gè)過(guò)程的方式排序;更有經(jīng)驗(yàn)的開(kāi)發(fā)人員可能有更好的方法來(lái)執(zhí)行此操作。(主要查看 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);這里的關(guān)鍵角色是 tcp_abort 和 tcp_done。它們負(fù)責(zé)在 TCP 的不同狀態(tài)下關(guān)閉套接字;為簡(jiǎn)潔起見(jiàn),我省略了不相關(guān)的代碼。SOCK_DEAD 是一個(gè)重要的標(biāo)志,它決定了代碼的流向。要找出它在正在運(yùn)行的機(jī)器中的值,我們可以使用 bpftrace(https://bpftrace.org/) 來(lái)打印 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內(nèi)核將 SOCK_DEAD 放在 enum sock_flags 的最低有效位,因此 0x301 表示設(shè)置了 SOCK_DEAD。我們可以嘗試相應(yīng)地遵循代碼路徑。 在 tcp_abort 中,由于設(shè)置了 SOCK_DEAD,它只會(huì)使用 tcp_write_queue_purge 清除隊(duì)列,而不會(huì)通過(guò)調(diào)用 tcp_done 實(shí)際關(guān)閉套接字。這就解釋了為什么在多次成功調(diào)用 ss -K 后套接字仍然存在。但是為什么套接字不會(huì)超時(shí)呢?
答案在于 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() - 關(guān)閉套接字并保存錯(cuò)誤信息
abort: tcp_write_err(sk);
} else {
/* 僅當(dāng)我們沒(méi)有關(guān)閉連接時(shí)才發(fā)送另一個(gè)探測(cè)。*/
tcp_send_probe0(sk);
}
}在這里,如果 packets_out 為 0,tcp_probe_timer 將提前返回,而不會(huì)檢查計(jì)數(shù)器以決定是使套接字超時(shí)還是發(fā)送另一個(gè)探測(cè)。而我們的 tcp_write_queue_purge 恰好清除了 packets_out 計(jì)數(shù)器。因此,在當(dāng)前計(jì)時(shí)器到期后,套接字將不會(huì)獲得另一個(gè)計(jì)時(shí)器或超時(shí),從而變得不朽。
// net/ipv4/tcp.c
voidtcp_write_queue_purge(struct sock *sk)
{
...
tcp_sk(sk)->packets_out = 0;
inet_csk(sk)->icsk_backoff = 0;
}如果我們仔細(xì)查看第 3 節(jié)的最后輸出,我們可以看到 timer 確實(shí)在 ss 的輸出中不復(fù)存在。
結(jié)束問(wèn)題鏈
要修復(fù)此內(nèi)核錯(cuò)誤,我們只需在 tcp_abort 中刪除 SOCK_DEAD 檢查。此補(bǔ)丁已提交給內(nèi)核并被接受,您可以在此處(https://patchwork.kernel.org/project/netdevbpf/patch/20240812105315.440718-1-kuro@kuroa.me/)找到更多詳細(xì)信息。在開(kāi)發(fā)補(bǔ)丁時(shí),virtme-ng 是測(cè)試補(bǔ)丁的一個(gè)很好的工具,使用 virtme-ng 更快地進(jìn)行內(nèi)核測(cè)試(https://lwn.net/Articles/951313/)。
要點(diǎn):
我們的 changeling 仍然可以用來(lái)更改 cc 算法或任何其他套接字選項(xiàng),并且非常方便。
如果是沒(méi)有打過(guò)補(bǔ)丁的內(nèi)核,請(qǐng)不要在孤立套接字上使用 ss -K。
ss、bpftrace 和 virtme-ng 是調(diào)試內(nèi)核問(wèn)題的好工具。
感謝您的閱讀;整個(gè)冒險(xiǎn)從一個(gè)簡(jiǎn)單的 cc 交換工具開(kāi)始,到內(nèi)核錯(cuò)誤修復(fù)結(jié)束。我希望您能學(xué)到一些可以玩的新工具。
附言:在此補(bǔ)丁被添加到最新的內(nèi)核樹(shù)之后,三星也在他們的測(cè)試中遇到了這個(gè)錯(cuò)誤,并且是他們將該補(bǔ)丁向下移植到了 5.15 和 6.1。



























