普通视图

发现新文章,点击刷新页面。
昨天以前卡瓦邦噶!

LRO/GRO 对于网络吞吐的影响

作者 laixintao
2025年11月21日 15:39

打开这个抓包文件,可以马上确认这是一个发送的数据比较多的连接1,因为 TCP sequence number 上升的很快,IP 层的包都是用最大的 MTU 发送的。

抓包文件截图

分析长肥管道,可以使用之前介绍过的技术,用 tcptrace 来分析。

打开 Statistics > TCP Stream Graphs > Time Sequence (tcptrace),可以看到下图。(如果是一个直线,说明方向看反了,点击 Switch Direction.

tcptrace 图

由于没有抓到这个 stream 的 TCP 3次卧手包,我们不知道 window scaling 是多少,所以这条绿线就可以直接忽略了。剩余的看起来一点问题没有,cwnd 打开并且保持的很好,也没有很多 SACK。在 200ms 左右有一次丢包。但是看 sequence 上涨的趋势来说,并没有造成多大的影响,很快补回来了。所以这里不是主要原因。

Sequence 上涨的趋势没有太大问题,还会有超时,那么问题就可能出在——上涨的速度不够快。同样的转发链路,我们不禁怀疑,是不是新的设备比旧的设备转发性能低?每一个包都慢几个 us,总的吞吐就低?

可以打开正常的转发抓包做对比:

转发效率高的 tcptrace

这个线确实可能更加斜一点,但是斜多少呢?我们可以看吞吐的图。

性能低的 tcpdump throughput
性能正常的 tcpdump throughput

棕色的线对应实际的传输速率(右侧的 Y 轴)。可以看到,正常情况下吞吐可以达到 220Mbps 左右,但是换上新的设备只有 140Mbps 左右。在大部分 HTTP 请求中,对于小的包,延迟的变化不会特别大,但是在长肥管道中,吞吐低就会造成传输数据就会出现差距。导致部分请求超时。

其实,新旧设备的转发速度并没有根本的区别,造成吞吐不同的原因,发生在别处。

这两幅图的对比也揭示了更加深层次的原因:即左侧的 Y 轴。

左侧 Y 轴,以及图中的蓝色点,含义是 packet 的 size 的分布,每一个点代表了一个 packet size。第一幅图中,所有的 packet 都是使用最大的 MTU 发送的。内层 overlay(VxLAN Tunnel 里面)的 MTU 是 1450.

而下图中,packet 的 size 居然超过了 MTU!

之前的一篇有关 MTU 的讨论2,我们知道,发送超过 MTU 的包是会被其他的设备丢弃的,那么为什么我们从 tcpdump 能看到超过 MTU 的包呢?这是因为网卡帮我们把收到的多个小包给合并成了一个大包,再交给操作系统(Kernel)处理,这部分现在一般是在网卡的硬件上来完成的,所以我们抓包看到的(即操作系统看到的)是网卡合并处理之后的包。这叫做 Large Receive Offload,LRO。

LRO

为什么要这么做呢?因为 CPU 是通用处理器,它能做很多事情。很忙。为了提高性能,在硬件上做的很多优化都是让其他的硬件去分担 CPU 的工作。比如:

  • 让 GPU 来代替 CPU 做矩阵运算;
  • 用专用的设备来卸载 TLS3
  • 让网卡卸载 vlan,把小包合成大包,等等;

网卡擅长做重复但是简单的事情,合并小包再是再合适不过啦!

而 CPU 的工作量主要和处理多少包有关,和包的长度关系不大,长度是 1 的包(在 kernel 里面是 skb)和长度是 10000 的包,对于 cpu 来说,只是一个 length 的 value 不同而已。包的内容是业务逻辑,主要是由应用程序处理的,在 Kernel 里面,主要关注的是包的 header。假设 CPU 的能力是每秒处理 10 万个包,如果每一个包的长度是 1Kb,那么吞吐就是 10Mbps;但如果包的平均长度是 100Kb,那么吞吐可以达到 1Gbps。所以有了网卡给我们做 LRO,就可以有效提高 CPU 的吞吐。

到现在,原因就清晰了:新设备上了之后 LRO 失效,由于服务器的网卡不再执行 LRO 功能,吞吐就下降了很多,导致了部分请求超时。

那么为什么换了新的设备之后,服务器的网卡 LRO 就失效了呢?服务器网卡 LRO 和网络设备又有什么关系?

由于做不做 LRO 是服务器的网卡的硬件实现,我们无法查看硬件的设计。但是从其他地方对于 LRO/GRO 的描述,我们可以得到一些启发。

Linux 可以在没有硬件的支持下,用软件的方式实现 Generic Receive Offload, GRO (当然了,性能肯定是要差一些)。Kernel 的文档对于 GRO 的描述4如下:

Generic receive offload is the complement to GSO. Ideally any frame assembled by GRO should be segmented to create an identical sequence of frames using GSO, and any sequence of frames segmented by GSO should be able to be reassembled back to the original by GRO. The only exception to this is IPv4 ID in the case that the DF bit is set for a given IP header. If the value of the IPv4 ID is not sequentially incrementing it will be altered so that it is when a frame assembled via GRO is segmented via GSO.

除了 GRO,还有一种机制是 GSO,即 Kernel 在发送 TCP 流的时候,无须自己把每一个 Segment 切分成符合 MTU 大小再发送,而是可以直接发送,由网卡硬件来做这个切分操作。

为了让 GRO 和 GSO 是互相可逆的,即 GRO 之后的包可以通过 GSO 还原出来。需要保证:

  • IP 包的 DF 设置为1,禁止 IP Fragmentation;
  • IP 包的 DF 如果是0,那么 IP 的 ID 必须是连续的;

两个规则只要符合一条即可。

如果 DF 为1,很好理解,GRO 和 GSO 很容易逆向出来。

如果 DF 为0,ID 连续,比如 100,101,102,那么合成一个大包,大包的 ID 是 100,也可以逆向出来。但是如果 ID 不连续,比如 101,105,107,那么合成一个大包之后,就丢失原始的信息了。

对于 VxLAN 的包,在 DPDK 的文档5中,由明确要求外层的 IP 包和内层的 IP 包都要遵守这个规则:

  • outer IPv4 ID. The IPv4 ID fields of the packets, whose DF bit in the outer IPv4 header is 0, should be increased by 1.
  • inner TCP sequence number
  • inner IPv4 ID. The IPv4 ID fields of the packets, whose DF bit in the inner IPv4 header is 0, should be increased by 1.

查看吞吐慢的 tcpdump,可以发现 outer 的 ip.df 是0,而且 ID 不连续,所以无法做 LRO/GRO。

外层 ip.id 不连续

虽然我没有想到保证可逆可以带来哪些好处,但是从网上找到的资料来看,这个是在「ip.df=0 并且 ip.id 不连续的时候,不做 GRO」唯一的理由了。在另一处的邮件讨论中6,netdev 维护者以这个原则为理由拒绝了合并。起因是 Alexander Duyck 希望添加这个 patch,以达到效果:对于 overlay 的包,GRO 不再看外层包的 ip.id ,外层可以使用 fixed header,只看内层包的 ip.id 是否连续。这样,很多(实现不正确的)网络设备也可以享受 GRO 的好处了,但是因为会打破可逆的原则,所以没有被合并。

PS:上一篇文章问题中很多读者提到 GSO,为什么是 GRO 而不是 GSO 呢?因为 9999 是 server 端口,所以 192.168.1.100:9999 是 server 端,抓包文件显示的主要流量是 client 上传给 server 的,不是 server 发给 client 的。另一个细节是,.100 发给 .200 的 delta time,一般比 .200 发给 .100 的 delta time 要低,也可以佐证 .100 是 server 端。

  1. TCP 长肥管道性能分析 ↩
  2. 有关 MTU 和 MSS 的一切 ↩
  3. https://en.wikipedia.org/wiki/TLS_acceleration ↩
  4. Segmentation Offloads ↩
  5. VxLAN GRO ↩
  6. [RFC,7/9] GSO: Support partial segmentation offload ↩

==计算机网络实用技术 目录==

这篇文章是计算机网络实用技术系列文章中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网路分析。

如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!

没有链接的目录还没有写完,敬请期待……

  1. 序章
  2. 抓包技术以及技巧
  3. 理解网络的分层模型
  4. 数据是如何路由的
  5. 网络问题排查的思路和技巧
  6. 不可以用路由器?答案和解析
  7. 网工闯了什么祸?答案和解析阅读加餐!
  8. 延迟增加了多少?答案和解析
  9. 压测的时候 QPS 为什么上不去?答案和解析
  10. 重新认识 TCP 的握手和挥手答案和解析
  11. TCP 下载速度为什么这么慢?答案和解析
  12. 请求为什么超时了?答案和解析
  13. 0.01% 的概率超时问题答案和解析
  14. 后记:学习网络的一点经验分享
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。

PDF 电子书重排和裁剪

作者 laixintao
2025年8月2日 20:20

很久之前画重金买的阅读器1是 A4 纸大小,无论是阅读电子书还是 paper 都很好。但是后来莫名其妙地屏幕部分区域失灵了(情况和这里2介绍的差不多),维修找不到售后,京东推给 SONY,SONY 客服根本不知道有这么个产品,所以索性换了另一款阅读器:remarkable2.

这件事也加深了我 SONY 品控差的印象,之前的买过的 SONY 产品还包括 PSV,遥感漂移了(好像 switch 也有这类问题,所以可以饶恕吧);PS4 手柄莫名其妙也坏了,PS4 主机后来也坏了。以后不想再买 SONY 的产品了……

回到 Remarkable2,这款屏幕是 10.3 英寸,没有比之前的尺寸小很多,有一些 PDF 阅读起来就不太方便。有一些阅读器支持重排版和裁剪,有这个功能就解决问题了。但是仔细一想——重排版和裁剪不应该是一个软件功能吗?那么直接使用软件对 PDF 进行处理,然后阅读处理之后的文档不是也可以解决问题吗?

然后就发现了这个软件 K2pdfopt3,可以重新版本 PDF 为阅读器的尺寸。并且可以自动删除 PDF 的白边。

比如下面这个文档,对于印刷比较友好,左侧页面有右侧的留白,右侧页面有左侧的留白,但是使用阅读器,就浪费空间了。

K2pdfopt 可以自动裁切这种空白,命令是:

k2pdfopt input.pdf -h 1872 -w 1404  -dpi 226 -p 1-50 -wrap+ -m -o output.pdf -ui- 

效果如下。

裁剪之后就没有浪费的空白页面了

双栏的论文 PDF 页可以改成单栏的:

k2pdfopt paper1.pdf -mode 2col -col 2 -n -fc- -x -y -t -ds 2  -h 1872 -w 1404 -dpi 226 -m -o output_paper.pdf
对双栏 PDF 重排

最后在阅读器上的效果如下:

  1. https://www.kawabangga.com/posts/3161 ↩
  2. https://www.bilibili.com/video/BV1dt411R75L/ ↩
  3. https://www.willus.com/k2pdfopt/ ↩

IP 网段的几种常见表示方式

作者 laixintao
2025年7月19日 14:06

IP Network

也叫做 CIDR (Classless Inter-Domain Routing),表示一个网络段,比如 192.168.0.0/24。

路由设备通过网络掩码去匹配地址,所以子网的划分一般用这种形式。/24 有的地方也用掩码 255.255.255.0,表示的内容是一样的。

ipcalc1 这个工具可以帮助计算 IP 网络。

ipcalc

IP Range

表示一个 IP 范围,从起始 IP 到结束 IP。比如 192.168.1.1 到 192.168.1.100,一共 100 个 IP。

它可以表示如:192.168.1.100 – 192.168.2.10 这种连续的段,但是 Network 是无法表示出来的。

IP Glob

使用 * 通配符来匹配 IP 的某部分,语法类似于 shell 中对文件名的 glob 匹配。

比如 192.168.1.* 就等同与 192.168.1.0/24。但是 192.168.1.2* 就没有与之等同的 Network 表示。

反过来,192.168.1.0/26 的范围是 192.168.1.0 – 192.168.1.63, 也不能用 IP Glob 表示。

SSH 的 ~/.ssh/config 就是用 IP Glob 来定义不同的 IP (Host)登陆的配置的。

IPSet

IPSet 是一个 Set,一般来说是 IP 地址和 CIDR 的集合,所以可以表示任意 IP 的集合。ACL 一般用 IPSet 的方式来配置。

  1. https://formulae.brew.sh/formula/ipcalc ↩

Burn out 逃生指南

作者 laixintao
2025年7月19日 14:05

在同一家公司工作两年以上,有很大概率会 burn out(意思就是精疲力尽,俺不中了)。如果岗位又是 SRE,那么 burn out 时刻几乎是必然。

为什么会这样?一个是因为工作的时间越长,做的东西就越多,维护的东西也越多,维护的工作就越多,然而新的项目还是要做,就会忙不过来了。加上在大公司分工明确,没有人关心甲方的死活,所以你依赖的库时常会有不兼容更新,依赖的组件经常因为组织结构调整而下线,依赖的 IDC 也会下线,安全团队时不时也会找过来让做一些安全方面的加固。总有一天,会发现自己的 todo list 里面放满了待迁移的事项,自己的用户天天来问一些相同的问题,老板有新的想法需要马上实现。每天下了班都在想着工作,每天都不想上班。这个时候,你就知道这是 burn out 了。

作为一个资深的 SRE,我这里有两条靠谱的路可以逃生。

第一条:每两年换一次工作。

很显然,这样的话,上面这些工作就不会积累下来压死骆驼了。但是如果不想工作三十年打 15 份工的话,就需要一些技巧了。

建议一:提高工作效率,而不是工作时间

之所以放在第一条,是因为这是最重要的一条建议,也是常常被我们忽略的一条。

工作总量 = 工作效率 x 时间

在工作量大的时候,自然而然想到的是延长工作时间,这是非常不可取的,工作时间应该固定在每天 8小时,一周5天,不能再增加了。尤其是 SRE,工作时间越长,出错的概率越大,出了事故就得去救火,review incidents,提出改进措施,实施改进措施,带来更多的工作。此外,如果延长工作时间,那后面要讨论的心理管理等话题就都没有意义了。

所以工作量增加的时候,重点要放在提高工作效率,而不是增加工作时间,end of story.

建议二:安排工作的优先级和时间

在焦头烂额的时候,如果有人天天来跟你说「这个需求很重要,什么时候能完成?」可能就会先做这个需求。有段时间每天至少 5 个人来问我 xx 什么时候可以做完,我的回答每天都一样,「和最初承诺的时间一样,如果最近有空了可以加快一些」。因为最初的时间就是按照优先级排列的,不会因为有人天天来问就变得快一些。

优先级如何排列,也不是只看需求方说的。如果对方提了一个不合理的时间,要了解下为什么这样着急。很多 deadline 都是随意拍脑袋定的,可能是为了某人在某个时间点可以向大老板汇报,可能是依赖你的工作的人先承诺了一个 deadline,也可能就是随意定的一个日期。在焦头烂额的一堆工作中,有几个有着让人焦虑的 deadline,让人很难忽略这些工作。但是优先级不应该按照 deadline 来排列,而应该按照真正的重要程度来排列。

  • 项目的发布日期已经对外宣布,用户期待在这一天使用新功能;
  • 线上的系统摇摇欲坠,必须更新一个 fix;
  • 新的集群需要部署,但是如果晚几天部署,也不会 block 任何人的工作;
  • ……

遇到不合理的预期的时候,可以问这几个问题:

  • 这个需求是服务谁的?为什么要做?如果不按照这个时间上线会有什么后果?
  • 其他人的工作是否可以并行做,如果我的这个工作不做,会 block 谁?

有时候把项目在 deadline 之前做完了,却发现后续的一段时间并没有用起来,或者项目继续被其他人 block 着,原来给出的时间线本身就是不切实际的。在最开始就讨论好项目整体的计划,了解真正的紧急程度,避免这些问题。

按照优先级给出需求方截止时间,然后按时间交付工作。但是这之间难免会遇到其他事情,比如临时插入了更紧急的需求,线上发生了事故需要立即处理等等。这一般也不是问题,在时间线有变化,无法按时交付的时候,应该立即通知需求方遇到的困难,新的预估时间。忌讳的是没有和需求方同步,直到交付日期的时候才说,因为某某原因项目无法按时交付了。

建议三:大项目如何推进?

对于大型的项目,尤其是需要多方参与的那种,如果你不幸当了项目的 owner,那么这个建议很实用:用笔记软件记录每天的进展,记录每天遇到的问题,以及这些问题的进展。

以前有一次我们要新建一个数据中心,infra 把机器准备好,然后中间件团队部署好各种服务,缓存,队列,网关等等,然后业务团队部署好业务程序,最后上线。但是我们已经好几年没有完整地上线过一个数据中心了,很多代码中都已经编码了 IDC 的名字,所以这项工作异常困难,要么这个组件启动不了,要么那个组件存在硬编码问题。

负责这个项目的同事是一个很靠谱的人,每遇到一个问题,他都在文档中记录下来。问题原因,负责人,解决方法,解决进度。项目结束之后,这个文档列出来长长的一串问题。看到这个文档我的感受有二:项目真难,这位负责的同事真靠谱。

同时我也学会了这项工作方法, 那时候起我就开始写工作笔记(用的软件是 Roam Research,笔记经过整理记录在公司的文档系统中),每一个项目都有详细的记录,记录的问题也成千上万了。

工作笔记的好处多多,显而易见的是,没有人能记住如此多的问题和细节,所以必须追踪记录。另外也让工作进度和内容透明,如果项目不能如期完成,也能知道问题在哪里。如果没有项目文档,无法解释项目进展和问题,就只能项目负责人的问题了。

经过实践我发现一个额外的好处是,可以带来工作心态的变化。

如果没有记录——想起来这个项目满是头疼,已经经历过 x 问题,y 问题,天知道还要经历什么问题,感觉每走一步都困难重重,想起来就头疼。

有了记录——我们已经解决了 x 问题和 y 问题,我倒要看看还可能出现什么问题!

建议四:使用异步的沟通方式

前面提到过我们要提高工作效率。一个重要的方法就是不要破坏自己的整块时间,不要让自己总是处于被打断的情况。如果养成了过几分钟就要切换到聊天软件查看消息的状态,那工作效率就完蛋了。

要像使用邮件一样使用消息软件,异步轮询沟通。(证明:基于忙轮询的 DPDK 比基于中断的 Linux 网络栈,性能就高多了)。

怎样做呢?前面我们已经学会写工作笔记了,在被 block 需要与人沟通的地方,就在这里记录下需要沟通和确认的地方。然后在每天定时(比如早上刚来和午饭之后)遍历所有在 block 的点,对每一个点都问一遍相关的同事需要确认的问题。但是一定要把所有的细节说全,比如咨询一个网络问题,要提供自己的 IP,对方的 IP 端口,现象是什么,预期结果是什么,traceroute 是什么。防止对方缺少信息需要跟你再次确认。这就回到问问题的艺术的话题1了。这样就不需要等待回复,所有相关的消息发出去之后就可以继续做没有被 block 的工作,然后等下下次轮询的时间查看消息。

对于收到的信息也一样,几乎所有的消息都不必立即回复。也可以用轮询的方式处理。很多人问问题的时候都不懂如何一次性把信息都提供出来,比如,报告网络问题,连从哪里到哪里有问题都说不明白。不必在等待回复上浪费时间。

建议五:安排工作计划

这条建议可以让你带着一个好心情上班:每周安排好这周要做的事情,每天安排好明天要做的事情,可以已经确定的优先级来安排。

如果没有工作计划,那每天上班看到的就是一个长长的 todo list,怎么能让人不焦虑。

如果有工作计划的话,至少确定今天只要完成这些工作就好了。心理上的负担也会轻松很多。明天的工作就让明天的自己去担心好了。

建议六:每天至少完成一件事情

这条建议可以让你带着一个好心情下班:每天至少完成一件事情,比如解决集群搭建中一个 block 的点,比如完整实现一个需求。

如果一天的时间都在开会,和不同的人讨论细节,到下班的时候一事无成,是很挫败的。每天至少动手完成了点什么,这点满足感会带来很大的不同。

建议七:不要完全放弃有长期收益的事情

不要花所有的时间去做紧急的事情,要花时间去做不紧急但是重要的事情。

比如:

  • 提高监控的覆盖度;
  • 自动化一个操作;
  • 从根本解决一个性能问题;

每天忙于救火,就永远无法从这种工作状态中脱身。去从根本解决问题,工作也会越来越少,形成良性循环。

举个例子:在给产品值班的时候,会有很多用户来问问题。我一般会提供用户文档链接,文档中有答案。如果对于一个问题没有现有的文档可以回答,要么是产品设计出了问题——为什么用户会有此疑问?要么是文档不够全面,我会去写一个关于这个问题的文档,然后再给用户文档链接。虽然表面上可以直接回答的问题花了更长的时间去解决,但是长远来看,将来的用户可能因为这个文档就不来问这个问题了,即使有人问相同的问题,我也可以给文档链接。

有关操作的自动化,也不是所有的操作都应该自动化,也要看投入产出比。如果一个操作一个月才有机会操作一次,那么用文档记录下来如何手工操作,也可以。相较之下,手工操作反而可能成本更低。此外,如果使用频率不高,那么下次用到的时候,自动化的流程很可能是坏掉的,需要临时去 debug 哪里出了问题。

  1. 程序员如何高效和同行交流 ↩

c100m 问题

作者 laixintao
2025年7月19日 14:02

互联网诞生以来,如何让一台服务器服务更多的用户就成为了软件工程师一直试图解决的难题。c10k1 指的是如何让一台服务器同时服务 10k 个用户的连接。工程师们发明了一种又一种技术方案来挑战性能的极限,Event driven IO,Async IO,Erlang,等等。Whatsapp 用 Erlang 在 24 核心的机器上支持了 2百万 个连接,MigratoryData 用 12 核心的机器,Java 语言,支持了 1千万的连接。虽然技术进步,硬件也在进步,这项性能挑战来到了 c100m,一亿个连接……

一亿个连接是什么概念呢?就算微信这种超大体量的用户,只需要几台机器就可以提供接入服务了。

本文小试牛刀,用 Linux 网络栈来尝试建立和保持尽可能多的连接。但是止步于 TCP 连接的建立,不做数据传输,所以除了好玩,没有实际意义。

实验针对的是主动发起连接的一侧,这样比较好控制速率。

实验的环境是,client 端用一个程序发起 TCP 连接建立,server 端用上一篇博文介绍的 XDP bounce 程序来回复 SYN-ACK 包2,假装建立好连接。然后我们观察 client 端最多可以建立多少连接。仅仅是建立连接而已,不会做数据发送。

Client 端的代码如下,代码来自 c1000k3,稍微做了修改,支持了并发,这样建立连接速度快一些。

/*****************************************************
 * The TCP socket client to help you to test if your
 * OS supports c1000k(1 million connections).
 * @author: ideawu
 * @link: http://www.ideawu.com/
 *****************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <arpa/inet.h>
#include <netinet/tcp.h>
#include <sys/wait.h>

#define LOCAL_PORTS 10000
#define DST_PORTS 1000

int child_process(const char *ip, int dest_port){
    printf("create %d connections to port=%d\n", LOCAL_PORTS, dest_port);
	struct sockaddr_in addr;
	int opt = 1;
	int bufsize;
	socklen_t optlen;
	int connections = 0;

	memset(&addr, sizeof(addr), 0);
	addr.sin_family = AF_INET;
	inet_pton(AF_INET, ip, &addr.sin_addr);

	char tmp_data[10];
    for (int conns=0; conns<LOCAL_PORTS; conns++){
        int port = dest_port;
        //printf("connect to %s:%d\n", ip, port);

        addr.sin_port = htons((short)port);

        int sock;
        if((sock = socket(AF_INET, SOCK_STREAM, 0)) == -1){
            goto sock_err;
        }
        if(connect(sock, (struct sockaddr *)&addr, sizeof(addr)) == -1){
            goto sock_err;
        }

        connections ++;

        if(connections % 1000 == 999){
            //printf("press Enter to continue: ");
            //getchar();
            printf("connections to dest_port %d: %d, fd: %d\n", dest_port, connections, sock);
        }
        usleep(1 * 1000);

        bufsize = 0;
        setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize));
        setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));
    }
    
    sleep(3 * 60 * 60);

	return 0;
sock_err:
	printf("connections: %d\n", connections);
	printf("error: %s\n", strerror(errno));
	return 0;
}

int main(int argc, char **argv){
    if(argc <= 2){
        printf("Usage: %s ip port\n", argv[0]);
        exit(0);
    }

    const char *ip = argv[1];
    int base_port = atoi(argv[2]);

    for (int i = 0; i < DST_PORTS; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            child_process(ip, base_port+i);
            exit(0); 
        } else if (pid < 0) {
            perror("fork failed");
            exit(1);
        }
    }

    for (int i = 0; i < DST_PORTS; i++) {
        wait(NULL);
    }

    return 0;
}

代码的逻辑是:fork 1000 个进程,每一个进程对一个 dest port 建立 10000 个连接,总共是1千万个连接数。

实验需要两台机器对着发,因为 server 端使用的是 XDP,性能很高,所以 1核心的机器就足够了。

Client 端我用的 8 核心的机器。

需要进行配置的内容

  1. 修改 ulimit,这样程序才能用更多的 fd:ulimit -n 1048576;
  2. 修改 TCP keepalive,keepalive 会消耗额外的资源:sudo sysctl -w net.ipv4.tcp_keepalive_time=720000
  3. 如果内存不够,通过 swap 来扩展;
  4. 使用 cgroups 限制程序能用的 CPU,防止 ssh 都连不上:
mkdir /sys/fs/cgroup/testlimit
# 8 cores max
echo "2000000 100000" > /sys/fs/cgroup/testlimit/cpu.max
echo $$ > /sys/fs/cgroup/testlimit/cgroup.procs

几分钟就可以跑到 1千万连接,轻轻松松。

下面可以修改参数来尝试 1亿连接数:

#define LOCAL_PORTS 50000
#define DST_PORTS 2000

还是用上面的环境,跑到3千万连接的时候虚拟机崩溃了,只提示 fatal error。

我又去 DigitalOcean 开了台虚拟机,看能不能跑到更高。

这次跑到 5千万连接机器又崩溃了。

约5千万连接数

到之类就没办法继续研究是什么原因了,可能是虚拟化的问题,如果折腾一下物理机环境,说不定可以跑到1亿。

另外我发现一个有趣的地方,如果 abort client 程序,大量连接的 fd 需要 kernel 去回收,会造成所有的 CPU 100% kernel state,机器几乎卡死了,直到连接回收完毕。这部分好像是用 cgroups 限制不住的。所以,如果在不可信的共享的执行环境,通过建立大量的连接再退出进程,说不定有可能恶意挤兑其他租户的资源?

  1. https://en.wikipedia.org/wiki/C10k_problem ↩
  2. XDP 实现所有的 TCP 端口都接受 TCP 建立连接 ↩
  3. https://github.com/ideawu/c1000k ↩

XDP 实现所有的 TCP 端口都接受 TCP 建立连接

作者 laixintao
2025年6月8日 23:57

一个 XDP 练习程序:作为 TCP 的 server 端,用 XDP 实现所有的 TCP 端口都接受 TCP 建立连接。(只是能够建立连接而已,无法支持后续的 TCP 数据传输,所以不具有实际意义,纯粹好玩。)

建立 TCP 连接需要实现 TCP 的三次握手,对于 server 端来说,要实现:

  • 收到 SYN 包,回复 SYN-ACK 包;
  • 收到 ACK 包,因为这里不再需要对客户端回复什么,所以这个包收到之后直接 DROP 即可。

回复 SYN-ACK 包就有些麻烦。XDP 不能主动发出包,它能做的就是在收到包的时候,决定对这个包执行何种 action,支持的 action 如下:

  • XDP_DROP
  • XDP_PASS
  • XDP_TX – 将数据包直接从接收的网卡原路回送出去,等同于 MAC 层 loopback,适用于构造 L2 层反射或快速回应场景。注意并不支持构造完全新包,只能修改现有包;
  • XDP_REDIRECT – 将数据包重定向到其他网卡或用户空间(如使用 AF_XDP),常用于 zero-copy 的高速转发;
  • XDP_ABORTED – 用于调试,表示程序异常终止,包被丢弃;

为了实现 TCP 的 SYN-ACK 回复,这里我们可以选择 XDP_TX ——在收到包之后,对包的内容进行一些修改,比如把 SYN flag 改成 SYN+ACK flag,然后把包重新回送出去,对方收到这个包,其实也不知道是 XDP 返回的还是 Linux kernel 返回的。在 XDP_TX 程序的机器上,Kernel 网络栈根本不知道这个包的存在。

XDP 程序直接从网卡的驱动返回包

现在的重点在于如何修改这个 TCP SYN 包,并将其回送,使对方认为它是一个合法的 SYN-ACK 包。

我们可以从下往上一层一层看:

  • Ether 层:只需要交换 Src MAC 地址和 Dst MAC 地址就可以了。这样的话,直接从 LAN 主机发过来的包会发回去 LAN IP,从 LAN 网关发来的包也会发回网关;
    • CRC 校验码一般是网卡硬件负责计算的,所以 Linux 代码不需要处理;
  • IP 层:交换 Src IP 和 Dst IP 即可。
    • IP checksum 这里也不需要我们手动添加,现在的路由器大部分都是不计算 checksum 的1
  • TCP 层:
    • 交换 Src Port 和 Dst Port;
    • Flags 把 SYN 和 ACK 都设置为 1;
    • 把 ACK 字段,设置为 ack = SYN 包的 seq + 1,以确认对端的 SYN。;
    • 填写 seq 字段,因为不涉及后续的数据传输了,这里使用一个固定值即可;
    • 重新计算 TCP checksum。重新计算 TCP checksum 是最麻烦的一步,因为在 eBPF/XDP 程序中不能依赖内核自动计算,需要手动构造伪头部(pseudo-header)并累加 TCP 包体数据。所以我们要用 XDP 的代码重新实现 TCP 的 checksum。还要让 XDP 的 verifier2 认为我们写的代码是安全的,所以复杂一些。

因为这个程序直接把收到的 TCP SYN 包远路反弹,就叫它 tcp_bounce.c 吧。(这周末刚去了一个叫 Bounce 的地方团建……)

XDP 程序的源代码如下:

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/icmp.h>
#include <linux/tcp.h>
#include <linux/in.h>

#define MAX_CHECKING 4
#define MAX_CSUM_WORDS 750

static __always_inline __u32 sum16(const void* data, __u32 size, const void* data_end) {
    __u32 sum = 0;
    const __u16 *ptr = (const __u16 *)data;

    #pragma unroll
    for (int i = 0; i < MAX_CSUM_WORDS; ++i) {
        if ((const void *)(ptr + 1) > (data + size)) {
            break;
        }

        if ((const void *)(ptr + 1) > data_end) {
             return sum;
        }

        sum += *ptr;
        ptr++;
    }

    // Handle the potential odd byte at the end if size is odd
    if (size & 1) {
        const __u8 *byte_ptr = (const __u8 *)ptr; // ptr is now after the last full word

        // BPF Verifier check: Ensure the single byte read is within packet bounds
        if ((const void *)(byte_ptr + 1) <= data_end && (const void *)byte_ptr < data_end) {
            // In checksum calculation, the last odd byte is treated as the
            // high byte of a 16-bit word, padded with a zero low byte.
            // E.g., if the byte is 0xAB, it's treated as 0xAB00.
            sum += (__u16)(*byte_ptr) << 8;
        }
        // If the bounds check fails, we just return the sum calculated so far.
    }

    return sum;
}


SEC("xdp")
int tcp_bounce(struct xdp_md *ctx) {
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    if ((void *)eth + sizeof(*eth) > data_end)
        return XDP_PASS;  // not enough data

    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *iph = data + sizeof(*eth);
    if ((void *)iph + sizeof(*iph) > data_end)
        return XDP_PASS;

    if (iph->protocol != IPPROTO_TCP)
        return XDP_PASS;

    //check ip len
    int ip_hdr_len = iph->ihl*4;
    if((void *)iph + ip_hdr_len > data_end)
        return XDP_PASS;

    // convert to TCP
    struct tcphdr *tcph = (void *)iph + ip_hdr_len;
    if ((void *)tcph + sizeof(*tcph) > data_end)
        return XDP_PASS;

    if (!(tcph->syn) || tcph->ack)
        return XDP_DROP;

    // swap MAC addresses
    __u8 tmp_mac[ETH_ALEN];
    __builtin_memcpy(tmp_mac, eth->h_source, ETH_ALEN);
    __builtin_memcpy(eth->h_source, eth->h_dest, ETH_ALEN);
    __builtin_memcpy(eth->h_dest, tmp_mac, ETH_ALEN);

    // swap IP addresses
    __be32 tmp_ip = iph->saddr;
    iph->saddr = iph->daddr;
    iph->daddr = tmp_ip;

    // TCP
    // swap port
    __be16 tmpsrcport = tcph->source;
    tcph->source = tcph->dest;
    tcph->dest = tmpsrcport;

    // syn+ack
    tcph->ack = 1;
    __u32 ack_seq = bpf_ntohl(tcph->seq) + 1;
    tcph->ack_seq = bpf_htonl(ack_seq);


    // checksum pseudo header
    __u32 csum = 0;
    tcph->check = (__be16)csum;

    if ((void *)&iph->saddr + 8 > data_end)
        return XDP_PASS;
    csum = bpf_csum_diff(0, 0, (__be32 *)&iph->saddr, 8, csum);
    __u16 tcp_len = bpf_ntohs(iph->tot_len) - ip_hdr_len;
    csum += (__u32)(bpf_htons(IPPROTO_TCP) << 16) | bpf_htons(tcp_len);

    csum += sum16(tcph, tcp_len, data_end);

    while (csum >> 16)
        csum = (csum & 0xFFFF) + (csum >> 16);

    tcph->check = (__be16)~csum;

    return XDP_TX;
}

char _license[] SEC("license") = "GPL";

安装编译 XDP 程序需要的依赖:

apt-get install -y clang llvm libelf-dev libpcap-dev build-essential  m4 pkg-config \
  linux-headers-$(uname -r) \
  linux-tools-generic tcpdump linux-tools-common \
  xdp-tools

安装 libc 开发包依赖,如果是 x86 操作系统:apt-get install -y libc6-dev-i386;如果是 ARM 操作系统:apt-get install -y libc6-dev-arm64-cross.

编译程序:

clang -O2 -target bpf -g -c tcp_bounce.c -o tcp_bounce.o  -I /usr/include/aarch64-linux-gnu/

把 xdp 程序加载到网卡上:

xdp-loader load eth1 tcp_bounce.o --mode skb

然后从另一台机器对这个加载了 XDP 程序 tcp_bounce.o 发起 TCP 连接,对于任意端口,可以观察到连接建立成功了:

用 nc 随机对两个端口建立连接

也可以用 for 循环批量对端口建立连接,都可以连通。

for i in {5000..5010}; do nc -vz 172.16.199.22 ${i};done

XDP 的性能很高,客户端用 10000 个线程同时建立 TCP 连接,服务端的 XDP 程序使用了连 10% 都不到的 CPU。(Again,但是没有什么实际意义)

  1. 网络中的环路和防环技术 有提到过,IPv6 是直接取消了 checksum 字段。 ↩
  2. eBPF verifier ↩

Django 全局禁用外键

作者 laixintao
2025年6月1日 21:53

Django ORM 是我最喜欢的 ORM,它自带了全套数据库管理的解决方案,开箱即用。但是到了某一家公司里就有些水土不服。比如分享了如何 在你家公司使用 Django Migrate。这次我们来说说外键。

什么是外键

关系型数据库之所以叫「关系型」,因为维护数据之间的「关系」是它们的一大 Feature。

外键就是维护关系的基石。

比如我们创建两个表,一个是 students 学生表,一个是 enrollments 选课表。

MySQL root@(none):foreignkey_example1> select * from students;
+----+------+
| id | name |
+----+------+
| 1  | 张三 |
| 2  | 李四 |
+----+------+
2 rows in set
Time: 0.002s

MySQL root@(none):foreignkey_example1> select * from enrollments;
+----+------------+--------+
| id | student_id | course |
+----+------------+--------+
| 1  | 1          | 数学   |
| 2  | 2          | 语文   |
| 4  | 1          | 英语   |
+----+------------+--------+
3 rows in set
Time: 0.002s

选课表的 student_idstudent.id 关联。那么外键在这里为我们做了什么呢?

enrollments 创建的 SQL 如下:

CREATE TABLE `enrollments` (
  `id` int NOT NULL AUTO_INCREMENT,
  `student_id` int NOT NULL,
  `course` varchar(50) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `student_id` (`student_id`),
  CONSTRAINT `enrollments_ibfk_1` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`)
)

其中 CONSTRAINT enrollments_ibfk_1 FOREIGN KEY (student_id) REFERENCES 就是外键的意思。这样确保 enrollments 表中的 student_id 必须来自 students 表中的 idenrollments.student_id 里的值,必须是 students.id 表中已经存在的值。
否则数据库会报错,防止插入无效的数据。

如果我们试图插入一条不存在的 student_id,数据库会拒绝插入:

MySQL root@(none):foreignkey_example1> INSERT INTO enrollments (student_id, course) VALUES (3, '英语');
                                    ->
(1452, 'Cannot add or update a child row: a foreign key constraint fails (`foreignkey_example1`.`enrollments`, CONSTRAINT `enrollments_ibfk_1` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`))')

使用外键的好处有:

  • 数据库帮我们维护数据的完整性,不会存在孤儿数据,不会因为编程错误插入错误数据;
  • 可以实现级联删除,比如 ON DELETE CASCADE,上面的例子中当我们从 students 表删除 id=2 的学生,在 enrollments 表相关的数据也同事会被删除;
  • 清晰的业务逻辑表达,在数据库表定义就有二者的关联关系,在语义上就比较好维护。还有一些数据库工具可以直接根据我们表定义中的 FOREIGN KEY 关系来画出来表之间的关系,在入手一个新的项目的时候,非常有用。
使用 ChartDB 可视化例子中的表关系1

为什么 DBA 不喜欢外键?

很多大公司的数据库都是禁用外键的,FOREIGN KEY (student_id) REFERENCES 这种 DDL 语句执行会直接失败。这样,数据库的表从结构上看不再有关系,每一个表都是独立的表而已,enrollments 表的 student_id Column 只是一个 INT 值,不再和其他的表关联。

为什么要把这个好东西禁用呢?

主要原因是不好维护。修改表结构和运维的时候,因为外键的存在,都会有很多限制。分库分表也不好实现。如果每一个表都是一个单独的表,没有关系,那 DBA 运维起来就方便很多了。

外键也会稍微降低性能。因为每次更新数据的时候,数据库都要去检查外键约束。

退一步讲,其实数据的完整性可以通过业务来保证,级联删除这些东西也做到业务的逻辑代码中。这样看来,使用外键就像是把一部分业务逻辑交给数据库去做了,本质上和存储过程差不多。

所以,互联网公司的数据库一般都是没有 REFERENCES 权限的。

Revoke REFERENCE 权限如下这样操作:

REVOKE REFERENCES ON testdb1_nofk.* FROM 'testuser1'@'localhost';

这样之后,如果在执行 Django migration 的时候,会遇到权限错误:

django.db.utils.OperationalError: (1142, "REFERENCES command denied to user 'testuser1'@'localhost' for table 'testdb1_nofk.django_content_type'")

Django migration 如何不使用外键

在声明 Model 的时候,使用 ForeignKey 要设置 db_constraint=False2。这样在生成的 migration 就不会带外键约束了。

Django migration 如何全局禁用外键

每一个 ForeignKey 都要写这个参数,太繁琐了。况且,Django 会内置一些 table 存储用户和 migration 等信息,对这些内置 table 修改 DDL 比较困难。

Django 的内置 tables:

+-------------------------------+
| Tables_in_test_nofk           |
+-------------------------------+
| auth_group                    |
| auth_group_permissions        |
| auth_permission               |
| auth_user                     |
| auth_user_groups              |
| auth_user_user_permissions    |
| django_admin_log              |
| django_content_type           |
| django_migrations             |
| django_session                |
+-------------------------------+

在 Github 看到一个项目3,发现 Django 的 ORM 里面是有 feature set 声明的,其实,我们只要修改 ORM 的 MySQL 引擎,声明数据库不支持外键,ORM 在生成 DDL 的时候,就不会带有 FOREIGN KEY REFERENCE 了。

核心的原理是继承 Django 的 MySQL 引擎,写自己的引擎,改动内容其实就是一行 supports_foreign_keys = False

具体的方法如下。

新建一个 mysql_engine,位置在 Django 项目的目录下,和其他的 app 平级。这样 mysql_engine 就可以在 Django 项目中 import 了。

├── project_dir
│   ├── __init__.py
│   ├── __pycache__
│   ├── asgi.py
│   ├── settings
│   ├── urls.py
│   └── wsgi.py
├── manage.py
├── app_dir
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   ├── models.py
│   ├── serializers.py
│   ├── tests.py
│   └── views.py
└── mysql_engine
    ├── base.py
    └── features.py

我们要写自己的 mysql engine。为什么不直接使用 django_psdb_engine 项目呢?因为 django_psdb_engine 是继承自 Django 原生的 engine,就无法使用 django_prometheus4 的功能了。ORM 扩展的方式是继承,这就导致如果两个功能都是继承自同一个基类,那么只能在两个功能之间二选一了,或者自己直接基于其中一个功能去实现另一个功能。所以不如链式调用好,如 CoreDNS5 的 plugin,可以包装无限层,接口统一,任意插件可以在之间插拔。Django 自己的 middleware 机制也是这样。

engine 里面主要写两个文件。

base.py

from django_prometheus.db.backends.mysql.base import DatabaseWrapper as MysqlDatabaseWrapper
from .features import DatabaseFeatures


class DatabaseWrapper(MysqlDatabaseWrapper):
    vendor = 'laixintao'
    features_class = DatabaseFeatures

features.py

from django_prometheus.db.backends.mysql.base import (
    DatabaseFeatures as MysqlBaseDatabaseFeatures,
)


class DatabaseFeatures(MysqlBaseDatabaseFeatures):
    supports_foreign_keys = False

最后,在 settings.py 中,直接把 ENGINE 改成自己的这个包 "ENGINE": "mysql_engine"

DATABASES = {
    "default": {
        "ENGINE": "mysql_engine",
        "NAME": "testdb1_nofk",
        "USER": "testuser1",
        'HOST': 'localhost',
        'PORT': '',
        'OPTIONS': {
            'unix_socket': '/tmp/mysql.sock',
        },
    }
}

这样之后就完成了。

python manage.py makemgirations 命令不受影响。

python manage.py migrate 命令现在不会对 ForeignKey 生成 REFERENCE 了。

Django 的 migrate 可以正常执行,即使 Django 内置的 table 也不会带有 REFERENCE。

python3 corelink/manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, meta, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK

查看一个 table 的创建命令:

MySQL root@(none):testdb1_nofk> show create table auth_user_groups \G
***************************[ 1. row ]***************************
Table        | auth_user_groups
Create Table | CREATE TABLE `auth_user_groups` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` int NOT NULL,
  `group_id` int NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `auth_user_groups_user_id_group_id_94350c0c_uniq` (`user_id`,`group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

可以确认是没有 REFERENCE 的。

  1. chartdb 工具:https://app.chartdb.io/,其他类似的工具还有很多,比如 https://dbdiagram.io/ ↩
  2. db_constraint=False 文档:https://docs.djangoproject.com/en/5.2/ref/models/fields/#django.db.models.ForeignKey.db_constraint ↩
  3. https://github.com/planetscale/django_psdb_engine ↩
  4. https://github.com/korfuri/django-prometheus ↩
  5. https://www.kawabangga.com/posts/4728 ↩

请求为什么超时了?

作者 laixintao
2025年4月21日 06:03

小明是一名网络工程师,有一天,同事报告问题说:自己的程序发送 HTTP 请求在测试环境好好的,但是在线上环境就总是超时,而且很容易复现,需要网络工程师的帮助。

这里的场景是,在线上运行环境,去用 HTTP 请求一个第三方(在这个例子中,是 example.com 提供的服务)。

首先,小明和同事一起复现了问题,确定超时确实存在,然后他们在请求发送方进行抓包,在抓包的同时又复现了一次超时的情况。拿到抓包文件,小明一看,立即就发现问题了所在了……

请下载这个文件并分析超时问题的根因。(如果没有头绪,可以打开这个提示

==计算机网络实用技术 目录==

这篇文章是计算机网络实用技术系列文章中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网路分析。

如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!

没有链接的目录还没有写完,敬请期待……

  1. 序章
  2. 抓包技术以及技巧
  3. 理解网络的分层模型
  4. 数据是如何路由的
  5. 网络问题排查的思路和技巧
  6. 不可以用路由器?答案和解析
  7. 网工闯了什么祸?答案和解析
  8. 延迟增加了多少?答案和解析
  9. 压测的时候 QPS 为什么上不去?答案和解析
  10. 重新认识 TCP 的握手和挥手答案和解析
  11. TCP 下载速度为什么这么慢?答案和解析
  12. 请求为什么超时了?答案和解析
  13. 0.01% 的概率超时问题答案和解析
  14. 后记:学习网络的一点经验分享
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。

压测的时候 QPS 为什么上不去?答案和解析

作者 laixintao
2025年4月7日 19:17

这个问题实际的原因是客户端的端口不够用了。

为什么端口会不够用呢?因为一个 TCP 连接的标志是四元组:

(src ip, src port, dst ip, dst port)

在这个场景中,代理服务器去连接 Real Server:

  • 代理服务器的 src ip 确定
  • 代理服务器的 src port 是随机指定
  • dst ip 是 Real Server 的 ip
  • dst port 是 Real Server 的 port

所以,能让 TCP 四元组不一样的字段,就只有 src port 了。

那么 Linux 服务器在连接远程服务器的 80 端口的时候,本地端口会用什么呢?答案是随机指定的。但是我们可以设置随机指定的范围。通过 sysctl -w net.ipv4.ip_local_port_range="32768 65535" 命令,可以让 client port 使用 32768 到 65535 之间的值。这样,低于 32768 的端口可以让其他服务 listen。

Local Port 不够用的一些场景

默认的端口就有 3 万个可用,所以大部分的情况下是很够用的。况且,这是在 client ip, dst ip, dst port 都确定的情况下最多可以建 3 万个连接。如果 dst ip 和 dst port 不固定,比如同一个 HTTP 服务在同一个 Server IP listen 了两个端口,那么就是最多 6 万个连接。如果部署多个实例,不同的 IP,那么每一个 IP 都可以是 3 万个连接。这么大的连接数量,一般来说代码性能甚至硬件(网卡)性能会首先到达瓶颈。

什么情况下会遇到端口不够用呢?

一种就是如上所说,一个代理程序去直连另一个真实服务器,两边的 IP 固定了,一边的端口固定了,那么 client 侧端口最多 3 万的话,在 QPS 大的情况下可能会遇到端口不够用的情况。

理论上最多可以有 3 万个并发,为什么在实际的情况中达不到这么高的并发呢?因为在一个 TCP 连接结束之后,这个 client port 并不是马上可以用来创建一个新的 TCP 连接。在 TCP 的状态机中,主动关闭 TCP 连接的一方会进入 TIME_WAIT 状态。需要在这个状态等待 2MSL (Maximum Segment Lifetime,最大报文生存时间,在 Linux 中,默认是 1 分钟的等待时间),然后这个 TCP 连接才会完全释放,client 端口才可以被重新用来建立新的 TCP 连接。

为什么要等呢?原因主要有二:

  1. 最后回复的 ACK 可能丢失了,如果再收到对方发来的 FIN,还可以回复 ACK;
  2. 如果直接建立新的连接,那么属于当前连接的包由于乱序、延迟或者重复,可能会让对方收到,对方可能认为是属于自己的连接的包,造成问题。所以,等待 2MSL 可以确保连接相关的数据包在网络中完全消失;
图来自维基百科

那么这种情况该如何解决呢?

首先可以调整参数,sysctl -w net.ipv4.ip_local_port_range="10000 65535" 就可以有更多的可用端口。

另一种就是用长连接,不那么频繁地建立连接,也就没有反复创建连接的端口问题了。

TIME_WAIT 状态的行为是可以通过参数调整的,通过 sysctl -w net.ipv4.tcp_tw_reuse=1 设置,可以让处于 TIME_WAIT 状态的端口用于创建新的 TCP 连接。(但是可能带来其他问题)

还有一种情况会遇到 local port 不够用,就是 NAT 设备,source IP 可能有很多,但是经过了 NAT,NAT 上的 TCP 连接就都是 NAT 的 IP 了,很容易造成四元组不够用。NAT 上面的问题最好的办法是增加出口 IP。

抓包如何分析?

到这里,首先向读者致歉,在写分析的时候,我发现这个例子其实并不好完全通过抓包来分析解决。因为出问题的时候,客户端角度的包并没有发出来,抓包也就抓不到这个包。所以这个例子选的不合适。

这个例子最好的排查方法是通过客户端侧的网络状态来排查。直接通过 ss -s 命令,可以直接看到处于 timewait 状态的连接。

ss -s 命令查看连接状态

如果很高(占用了可用 local 端口范围的大部分),就说明瓶颈在这里了。

通过 tcp.flags.syn==1 and tcp.dstport == 80 and tcp.srcport == 65531 这个条件来过滤,我们可以查看同一个 local port 建立连接的历史。

连接建立的历史

打开 Delta Time,可以看到这个端口每次复用的时间在 60s 之后了,和 Linux timewait 默认的等待时间一致,也可以判断出来是这种问题。

==计算机网络实用技术 目录==

这篇文章是计算机网络实用技术系列文章中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网路分析。

如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!

没有链接的目录还没有写完,敬请期待……

  1. 序章
  2. 抓包技术以及技巧
  3. 理解网络的分层模型
  4. 数据是如何路由的
  5. 网络问题排查的思路和技巧
  6. 不可以用路由器?
  7. 网工闯了什么祸?
  8. 网络中的环路和防环技术
  9. 延迟增加了多少?
  10. TCP 延迟分析
  11. 压测的时候 QPS 为什么上不去?
  12. 压测的时候 QPS 为什么上不去?答案和解析
  13. 重新认识 TCP 的握手和挥手
  14. 重新认识 TCP 的握手和挥手:答案和解析
  15. TCP 下载速度为什么这么慢?
  16. TCP 长肥管道性能分析
  17. 请求为什么超时了?
  18. 请求为什么超时了?答案和解析
  19. 后记:学习网络的一点经验分享
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。

Spegel 镜像分发介绍

作者 laixintao
2025年3月16日 17:00

网络系列文章许久不更新了,因为最近的工作比较忙,有很多其他的问题要解决。上个周花了很多时间研究 P2P,(和 web3 区块链无关的),(和民间 P2P 借贷也无关),(和下载盗版电影也无关,好吧,算是有点关系)。而且着实被「云原生」坑了一把,所以这篇文章介绍一下我们要解决什么问题,Spegel 这个项目是什么,怎么解决问题的。最重要的是…… 我会写一下如何从 0 启动并运行这个项目。读者可能会问,这不是按照项目的文档就能跑起来的吗?不是!这项目是云原生的,它居然没有文档,只有一个 Helm Charts1,命令行的参数看的云里雾里,如果不在 K8S 中运行这个程序,就只能结合它的 Charts 以及源代码来弄懂参数的含义。好在我功力深厚,已经完全掌握命令行的启动方式了,接下来就把它传授给读者。

问题

在部署容器的时候,node 要从 image registry 来 pull image。如果要部署的规模非常大,比如 2000 个 container,那么就要 pull 2000 次,这样对 image registry 造成的压力就非常大。

在规模比较大的软件分发问题中,自然而然会想到 P2P 的方式来分发。如果要下载 1000 次,可以先让 10 个节点下载完成,然后后续 100 个节点从这 10 个节点下载,每一个并发是 10,然后其余节点从这 110 个节点下载。这样,每一个节点的最大并发都不会超过 10,就解决了中心点的性能瓶颈问题。

我们尝试过的 P2P 方案是 Dragonfly2, 这个工具以透明代理的方式工作3,透明代理会首先通过 P2P 查找资源,如果找到,优先从 P2P 网络进行下载。下载完成后,会另外保存一份「缓存」,在后续一段时间内(可配置,默认 6 小时,时间越长,文件生效的时间越长,但是占用的磁盘空间越大),会给其他请求下载的节点来 serve 文件。如果整个 P2P 网络都没有目标文件,那么就会回源下载。而且文件支持分段下载,比如一个文件 1G,就可以并行地从 10 个节点分别下载 100M。

透明代理的设计非常好,不光是镜像文件,像 Python 的 pip 下载 tar 包,apt 下载 deb 文件,部署其他的 binary 文件,都可以通过这个 P2P 网络来加速下载。

但是这个设计也有一些弊端,尤其是在镜像下载的场景。

比如进行故障切换的时候,要对目标 IDC 的服务进行扩容,需要同时扩容 100 个服务,那么这 100 个服务都需要请求到镜像中心,因为这 100 个服务都是不同的镜像,P2P 网络中现在没有缓存。或者对一组服务进行紧急扩容的时候,也会因为缓存中没有镜像而全部请求到镜像中心。

在多个数据中心部署的时候,由于无法事先得知一个 IDC 需要部署哪些服务,就得把所有的镜像都同步到这个 IDC,极其浪费带宽。如果不同步的话,那么每次部署都要跨 IDC 来拉取镜像, 延迟比较大。今天部署新的服务,跨 IDC 拉取镜像部署完成。明天一个机器挂了,容器要部署到另一个机器上去,但这时候 P2P 网络中的缓存已经删除了,所以又需要跨 IDC 拉取镜像部署。

以上的问题,按照 dragonfly 的缓存设计,不太好解决。

可能的方案

这个问题的本质,其实是镜像太大了,假设一个服务的镜像只有 10M4,算了,算 100M 吧,那么这些问题都不是问题,1000 个节点全部去镜像中心拉取,也才 10G,跨 IDC 也都能接受。

但是很多人打的 image 什么都往里放,golang 编译器也放进去,gcc 也放进去。甚至做基础镜像的团队也把很多不用的东西都放进去了。导致 image 最终就是 1G 的正常大小,甚至还有 10G 的5

Image lazy loading

一个很新奇的想法是,既然 image 很多的文件都用不到,那么就只下载用得到的文件,其他的文件等读到的时候再去下载。比如 Nydus6,把 image 进行文件级别的索引和分析,启动的时候只下载必要文件,其他的文件等 access 的时候再通过 P2P 网络下载7

从 containerd 来 serve

既然 image 在其他部署的机器上已经存在了,那能不能直接从其他的机器来下载呢?

通过 containerd 把已经存在的 image 读出来是可能的8。于是我们就想尝试写一个程序:

  • 可以把本地 containerd 的 image 暴露出来;
  • 需要下载 image 的时候,优先从其他的机器上直接下载;
  • 需要做一个服务发现服务让不同的节点知道 image 的 blob 都存在于哪些机器上。

然后就发现了 Spegel 这个项目9,简直和我们想做的事情一模一样!

Spegel 做了上面的三件事:

  • 暴露一个 HTTP 服务可以提供下载本机的 image;
  • 对 containerd 做 image mirror,containerd 想要 pull image 的时候,会被 spegel 代理到去其他机器下载;
  • 如何发现谁有什么镜像呢?服务发现是用的 P2P 网络,但是并没有用 P2P 来存储数据,本质上,只是用 P2P 来做了服务发现。Spegel 会定期把本地的 image 广播到 P2P 网络中,需要 image 的话,也会从 P2P 网络中寻找 Provider。

运行 Spegel

环境准备

首先需要准备一个 containerd 的运行环境。也可以直接安装 docker。

for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done
# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
sudo docker run hello-world

然后需要安装 golang 用来编译项目的 binary,直接安装 golang 的官方文档进行编译即可。

wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
cat - >> ~/.bashrc << EOF
export PATH=$PATH:/usr/local/go/bin
export GOPATH=~/go
EOF
source ~/.bashrc
go version

下载项目进行编译。

git clone https://github.com/spegel-org/spegel.git
cd spegel
git checkout v0.0.28
go mod download
go build main.go -o spegel

这里编译的是版本是 v0.0.28,因为最新的版本 v0.0.30 我没成功运行起来,会遇到错误:failed to negotiate security protocol: remote error: tls: bad certificate。有一个 issue 说是因为 IPv4 和 IPv6 dual stack 的问题10,但是尝试关闭 IPv6 也于事无补。

软件的 binary 准备好了,就可以准备运行起来了。

Containerd mirror 配置

Spegel 启动的时候会检查连接的 containerd 是否已经配置自己为 mirror,如果没有配置,会拒绝启动。Containerd registry config path needs to be set for mirror configuration to take effect

首先配置 CRI 的 config path。

containerd config default > /etc/containerd/config.toml
vim /etc/containerd/config.toml
# 修改如下内容
[plugins."io.containerd.grpc.v1.cri".registry]
   config_path = "/etc/containerd/certs.d"

然后用 Spegel 提供的配置命令来创建 registry 的 mirror 配置。这个命令只是修改配置文件,你也可以自己修改。

./spegel configuration \
--log-level="INFO" \
--containerd-registry-config-path="/etc/containerd/certs.d" \
--registries https://registry-1.docker.io \
--mirror-registries=http://127.0.0.1:5000 \
--resolve-tags=true

其中:

--containerd-registry-config-path:是要写入配置的目录,因为上面配置中 containerd 也是使用了这个目录,所以在这里 Spegel 就要修改这个目录。

--registries:是对什么域名配置 mirror。

--mirror-registries:Spegel 提供 image mirror 服务的地址。这里写什么地址,接下来启动 Spegel 的时候就使用什么地址。

--resolve-tags=true:标记 Spegel 的地址有 resolve tag 的能力。

这个命令执行过后,会看到多了一个文件:/etc/containerd/certs.d/registry-1.docker.io/hosts.toml,内容是:

server = 'https://registry-1.docker.io'

[host.'http://127.0.0.1:5000']

启动项目

下一步就可以启动项目了。

启动的命令是:

./spegel registry  \
 --log-level="DEBUG"  \
 --mirror-resolve-retries=3  \
 --mirror-resolve-timeout="2000ms"  \
 --registry-addr=:5000  \
 --router-addr=172.16.42.21:5001  \
 --metrics-addr=:9091  \
 --containerd-sock=/run/containerd/containerd.sock  \
 --containerd-registry-config-path="/etc/containerd/certs.d"  \
 --bootstrap-kind=http  \
 --http-bootstrap-addr=172.16.42.21:5002  \
 --http-bootstrap-peer=http://172.16.42.22:5002/id \
 --resolve-latest-tag=true  \
 --registries=http://registry-1.docker.io \
 --local-addr=172.16.42.22:30021

参数的含义如下:

  • registry 表示启动 registry 服务。spegel binary 只支持两个子命令,另一个就是上面用到的 configuration
  • --mirror-resolve-retries 表示 Spegel 在解析 image 的时候最多重试几次;
  • --mirror-resolve-timeout 表示解析 image 的时候多久会超时;
  • --registry-addr 指定 Spegel 在本地提供 image 下载服务的地址,containerd 会从这个地址来下载镜像,如果失败,就 fallback 到镜像中心;
  • --router-addr Spegel 会 listen 这个地址来接收来自 P2P 网络的请求;
  • --metrics-addr 这个地址可以访问 /metrics 以及 golang 的 pprof 文件;
  • --containerd-sock containerd 的客户端通过这个 socket 文件访问 containerd,本地已经存在的 image 也是通过这种方式访问到的;
  • --containerd-registry-config-path 和上面一样,但是这个值在代码中并没有实际用到;
  • --bootstrap-kind 加入一个 P2P 网络,至少需要认识一个已经存在 P2P 网络中的节点,通过已经存在于 P2P 的节点来加入网络。这里是指定发现节点的方式,是 HTTP;
  • --http-bootstrap-addr Spegel 启动之后会 listen 这个地址,提供 HTTP 服务。只有一个 path /id,访问这个 path 会返回自己的 multiaddr11。即,其他节点可以把本 node 的 --http-bootstrap-addr 来当作 bootstrap http 地址,这个配置是让本节点对其他节点提供服务;
  • --http-bootstrap-peer 指定其他节点的 --http-bootstrap-addr,来加入 P2P 网络;
  • --resolve-latest-tag 是否解析 latest tag,因为 Spegel 本质上是 image 缓存分发,如果 latest 修改了,那么 Spegel 解析到的 latest tag 可能是过时的;
  • --registries 对什么镜像地址进行 mirror;
  • --local-addr 本机的地址,实际上没有 listen,在代码中只是在获取到其他节点地址的时候,用这个配置项来比较是不是自己,过滤掉自己的地址,(用于有 NAT 等的复杂环境);

启动一个节点之后,再去另一个节点修改一下地址相关的参数,启动,就可以得到一个 2 节点的 Spegel 网络了。

使用 crictl 来测试

好像使用 docker 不会走到 contianerd 的 image 下载逻辑,所以我们下载一个 crictl 直接来操作 containerd。

安装 crictl:

VERSION="v1.30.0" # check latest version in /releases page
curl -L https://github.com/kubernetes-sigs/cri-tools/releases/download/$VERSION/crictl-${VERSION}-linux-amd64.tar.gz --output crictl-${VERSION}-linux-amd64.tar.gz
sudo tar zxvf crictl-$VERSION-linux-amd64.tar.gz -C /usr/local/bin
rm -f crictl-$VERSION-linux-amd64.tar.gz

在第一个节点下载:

time crictl -r /run/containerd/containerd.sock pull registry-1.docker.io/library/node:latest

耗时 3m 左右。

然后再去另一个节点执行同样的命令,会看到耗时 20s 左右,提升已经很明显了,而且会极大减少镜像中心的压力。

  1. Spegel 的 Charts:https://github.com/spegel-org/spegel/tree/main/charts/spegel ↩
  2. Dragonfly https://d7y.io/ ↩
  3. 架构图:https://d7y.io/docs/#architecture ↩
  4. 一个 Redis 都几 M 就够了 Build 一个最小的 Redis Docker Image ↩
  5. 这就是去年年终总结说过的问题,如果人人都是高级工程师,那么问题就不存在了 ↩
  6. 项目主页:https://nydus.dev/,也是 dragonfly 的一个项目 ↩
  7. AWS 等云厂商也有类似的技术 Under the hood: Lazy Loading Container Images with Seekable OCI and AWS Fargate | Containers ↩
  8. Containerd API:https://pkg.go.dev/github.com/containerd/containerd@v1.7.24#Client.ContentStore ↩
  9. 项目代码:https://github.com/spegel-org/spegel,项目主页:https://spegel.dev/ ↩
  10. “could not get peer id” and timeouts since 0.0.29 · Issue #709 · spegel-org/spegel ↩
  11. https://docs.libp2p.io/concepts/introduction/overview/ ↩

一个由 BGP Route Aggregation 引发的问题

作者 laixintao
2025年1月14日 08:36

上周遇到的一个问题很有意思,后来搜索相关的资料,找到的也比较少,感觉有必要记录一下。

问题的场景很简单:我们有两个路由设备同时发布了 10.81.0.0/16 的网段做 ECMP1,网络一切正常。拓扑如下图。现在,有一个新的 IP,只存在于 Router A 上,所以 Router A 宣告网段 10.81.100.100/32,而 Router B 不宣告。这样,由于在路由表中,/32 的 prefix 比 /16 要长,所以 Router X 在从路由表选路的时候,10.81.100.100 会优先选择去 Router A,而对于其他的 10.81.0.0/16 的网段,会负载均衡到 A 和 B 两台路由器上。

简化的拓扑图

理论上,一切看似合理并且正常。但是 /32 的网段一经宣告,10.81.0.0/16 的网络都挂了。

事后我们得知,在 Router X 上有一条路由聚合配置。但是这条合理的路由聚合,怎么会让整个网段挂掉呢?

BGP 路由聚合

为什么需要路由聚合呢?

Router A 每次宣告一个网段给 Router X,Router X 的 BGP 路由就会多一个。Router B 每次宣告一个网段,X 上也会多一个。可想而知,Router X 上的路由是它的下游的总和。同理,Router X 上游的路由器的路由将会更多。路由的条目越多,对路由器的性能要求就越高。所以,核心路由器要想处理所有的路由条目,就需要性能非常高。性能是有上限的,假设性能再搞也无法处理这么多路由,怎么办呢?我们可以优化另一个变量——路由条目2

如何减少路由条目呢? 考虑下面 3 个网段:

  • 10.81.2.0/24
  • 10.81.3.0/24
  • 10.83.4.4/26

其实都可以汇聚成一个网段:10.81.0.0/16。把这个网段宣告出去,收到的流量可以在 X 这里根据自己的路由表进行转发。

这里产生了一个问题:就是我们宣告了自己没有路由的网段出去,比如我们的路由中并不存在 10.81.5.0/24 这个段,但是被我们的 10.81.0.0/16 宣告了出去。

由此,会产生两个问题。第一个问题,假设其他路由器有到 10.81.5.0/24 的路由,那么会不会走到我们的 10.81.0.0/16 这里来呢?答案是不会的。因为路由表的匹配规则是最长前缀匹配/24 比我们的 /16 优先级更高。

第二个问题更加严重一些,路由的聚合可能导致环路3

考虑下面这个拓扑图,两个路由器都存在路由聚合的配置。

路由聚合导致环路产生的例子

这里的问题是,10.81.4.1 这个 IP 不存在于 A 也不存在于 B,但是由于路由聚合的配置,A 认为在 B 上,B 认为在 A 上,导致在转发的时候会出现环路。虽然 IP 层有 TTL 机制,会让这个包最终被丢弃,但是也会让两个路由器在某些网段的转发上浪费一些计算资源。

如何避免在转发「不存在的网段」的时候出现的环路呢?一个思路是我们精确的控制聚合的配置,不配置出来可能产生环路的聚合,但是这几乎是不可能的。(就像用静态路由配置替代动态路由一样不可能)。

另一个思路是,在 10.81.4.1 这种本地没有路由的包出现的时候,直接「黑洞」掉。方法很简单,就是在每次聚合的时候,创建一条路由,终点是 Null0,即直接丢弃。

具体来说,在上图的 Router A 中,聚合本地的三条路由到 /16,我们应该这么做:

  • 向外宣告路由 10.81.0.0/16,以达到减少路由条目的目的4
  • 在本地插入一条 Null0 的路由,使得本地的路由最终如下。
10.81.0.0/16 -> Null0
10.81.1.0/24->local
10.81.2.0/24->local
10.81.3.0/24->local

注意,路由表的顺序没有意义,因为用的是最长前缀匹配。转发包的时候,对于 10.81.1.0/24 这种本地存在的段,因为它们的前缀比 /16 长,所以正常转发;对于不存在的段,比如 10.81.4.1,会命中 10.81.0.0/16 -> Null0 的路由,直接在本地丢弃。这样,就可以阻止环路的产生。由聚合而自动产生的 /16 是一个防环的兜底路由,正常情况下,不应该使用这条路由,如果命中这条路由,说明无法转发的包到达了路由器,直接丢弃即可。

回到本文开头的问题上,为什么宣告一条 /32 会导致整个网段挂掉呢?Null0 不是说只是兜底而已吗?回答这个问题,还要补充一点知识。

BGP 和路由表

路由设备按照路由表(叫做 RIB, Routing Information Base)进行转发(实际上还有一层加速用的 FIB,但是 FIB 的 source of the truth 是 RIB,所以这里先忽略)。RIB 转发的逻辑是最长前缀匹配。

RIB 是怎么生成的呢?一种是静态配置,即静态路由。另一种是动态路由协议。路由协议之间交换路由信息,然后负责动态修改 RIB。在有多条可达路由的时候,怎么决定把哪一条路由写入到 RIB 呢?这就是不同的路由协议来决定的了。比如,BGP 有 13 条选路原则5;OSPF 和 IS-IS 这种协议也有自己的路径选择算法。

路由协议和 RIB 的关系6

这张图比较好,不同的路由协议可以同时运行,不同的路由协议可以根据自己的算法来操作路由表,决定转发路径。

路由的聚合也是路由协议的一部分。像 OSPF, EIGRP, BGP 这些协议,都有关于路由聚合的定义和支持。重申一下:路由聚合是路由协议的 feature,而不是路由表 RIB 的。

这也就是说,路由聚合中产生的 Null0 黑洞条目首先出现在 BGP 中,然后 BGP 根据自己的选路原则,放到路由表中。

回到本文最先开始讨论的问题,现在就可以用上面的知识来解释这个问题了。

首先,Router X 会收到 3 条路由。

到达 Router X,经过聚合之后,在 BGP 里面,会有 4 条路,多出来的一条是聚合产生的 Null0 黑洞路由。

到达 10.81.0.0/16 的路由有 3 个

BGP 会按照自己的选路原则,在 10.81.0.0/16 的 3 条路径中选择一条放到 RIB 中。这 3 条路径中,Null0 这条可是本地路由,Weight 是最高的。所以,Null0 由于其他两条真实存在的路由,进入了 RIB。

show ip route

可以看到路由表中,只有 10.81.100.100 明细路由和 10.81.0.0/16 到 Null0 的黑洞路由,其他两条路由被刷下去了。

到这里,真相就大白了。10.81.100.100 在没有发布的时候,10.81.0.0/16 工作正常。但是一旦发布,10.81.0.0/16 的正常路由就被路由聚合产生的 Null0 给刷下去了。

  1. 数据中心网络高可用技术:ECMP ↩
  2. Understand Route Aggregation in BGP – Cisco ↩
  3. 网络中的环路和防环技术 ↩
  4. 确认了下没有写错,这里的意思是 Tiao Mu De Mu Di,博大精深的中文! ↩
  5. Select BGP Best Path Algorithm – Cisco ↩
  6. 来源:FIB表与RIB表的区别与联系 – &Yhao – 博客园 ↩

用 LD_PRELOAD 写魔法程序

作者 laixintao
2025年1月8日 08:00

我和我的同事们排查网路问题非常喜欢 MTR,它是 traceroute 和 ping 的结合,可以快速告诉我们一个网络包的路径。是哪一跳丢包,或者延迟太高。

这些路径使用 IP 地址的形式表示的。没有人能记住这么多 IP 地址,所以我们需要有意义的名字。我在公司里写了一个平台,集成了其他的二十多个系统,给一个 IP,能查询出来这个 IP 对应的网络设备,容器,物理机,虚拟机等等。

复制 IP 到这个系统中查看结果,还是有些不方便,于是就想能不能让 MTR 直接展示设备的名字。

最终效果如图,可以展示 mtr 路径中所有的网络设备的名字。敏感信息已经隐藏。

MTR 支持 DNS PTR 反查,如果查到记录,会优先展示名字。这些名字在公网上通常没有什么意义。我们的内网 DNS 没有支持 PTR,所以这个 PTR 记录在默认的情况下也没有什么用。如果通过 DNS 系统来支持 PTR 记录的话,成本就有些大了,得对 DNS 做一些改造,DNS 又是一个比较重要的系统。那能不能有一个影响比较小的旁路系统来做到这个 feature 呢?

看了下 MTR 的代码,MTR 对 DNS PTR 的支持是通过 libc 的函数 getnameinfo(3) 来实现的。那么我就可以用 LD_PRELOAD 这个 hack,自己写一个 getnameinfo(3) 编译成 so,告诉 MTR 在寻找 getnameinfo(3) 的时候,先寻找我的 so 文件。这样,我就可以自己定义 getnameinfo(3) 的行为了。(就像魔法一样)

其实,proxychains1 程序也是用这种方式工作的,你只要在运行的命令前面添加 proxychains,proxychains 就会对后面运行的命令注入 LD_PRELOAD 环境变量,从而让程序调用的 socket API 是 proxychains 定义的,然后 proxychains 就会对 socket 做一些代理转发。

POC

可以写一个最简单的程序验证这样是否可行。

我们写一个最简单的函数,声明和 getnameinfo(3) 一模一样。

#include <stdio.h>
#include <netdb.h>
#include <string.h>
#include <sys/socket.h>

int getnameinfo(const struct sockaddr *__restrict addr, socklen_t addrlen,
                char *__restrict host, socklen_t hostlen,
                char *__restrict serv, socklen_t servlen, int flags) {

    strncpy(host, "kawabangga.com", hostlen);

    return 0;
}

不过,这个函数无论对于什么 ip,都会返回 kawabangga.com. 然后编译,运行 traceroute 程序。

编译命令:gcc -shared -fPIC -o libmylib.so mylib.c -ldl

$ LD_PRELOAD=./libmylib.so traceroute 1.1.1.1
traceroute to 1.1.1.1 (kawabangga.com), 30 hops max, 60 byte packets
 1  kawabangga.com (kawabangga.com)  0.248 ms  0.386 ms  0.360 ms
 2  kawabangga.com (kawabangga.com)  5.223 ms  5.412 ms  5.375 ms
 3  kawabangga.com (kawabangga.com)  8.764 ms  9.206 ms  10.454 ms
 4  kawabangga.com (kawabangga.com)  10.403 ms  10.001 ms  10.321 ms
 5  kawabangga.com (kawabangga.com)  11.578 ms  11.826 ms kawabangga.com (kawabangga.com)  13.214 ms
 6  kawabangga.com (kawabangga.com)  12.858 ms kawabangga.com (kawabangga.com)  14.859 ms kawabangga.com (kawabangga.com)  13.905 ms
 7  kawabangga.com (kawabangga.com)  13.204 ms  8.645 ms kawabangga.com (kawabangga.com)  10.036 ms
 8  kawabangga.com (kawabangga.com)  10.166 ms  8.282 ms  9.155 ms
 9  kawabangga.com (kawabangga.com)  7.035 ms kawabangga.com (kawabangga.com)  8.533 ms kawabangga.com (kawabangga.com)  21.939 ms
10  kawabangga.com (kawabangga.com)  8.789 ms  7.832 ms  8.259 ms

可以看到,traceroute 显示每一跳的名字都是 kawabangga.com 了。

用 Go 语言 POC

我比较倾向于用 Go 语言来实现逻辑,而不是用 C 语言。

Go 语言也是支持编译到 shared lib 的2。hello world 代码如下:

/*
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>

*/
import "C"
import (
        "unsafe"
)

//export getnameinfo
func getnameinfo(sa *C.struct_sockaddr, salen C.socklen_t, host *C.char, hostlen C.size_t, serv *C.char, servlen C.size_t, flags C.int) C.int {
        hostStr := "foobar"

        hostCString := C.CString(hostStr)
        defer C.free(unsafe.Pointer(hostCString))
        C.strncpy(host, hostCString, hostlen)

        servStr := "80"
        servCString := C.CString(servStr)
        defer C.free(unsafe.Pointer(servCString))
        C.strncpy(serv, servCString, servlen)

        return C.int(0)
}

func main() {}

编译命令是:go build -o libmylib.so -buildmode=c-shared mylib.go

程序运行的命令一样,也可以看到 getnameinfo(3) 被成功 hook 了。

剩下的只需要在程序里面写逻辑代码就可以了,应该很简单(实际发现不简单)。

遇到问题 1: log 不打印

因为 traceroute 和 mtr 这种程序都是往 stdout 打印的,我开发代码又需要用 print 来调试,所以为了不干扰正常输出,就把日志打印到一个文件,通过 ENV 来控制日志是否需要打印,以及打印的日志路径。

结果就遇到了问题:日志打印在 traceroute 中是正常的,但是在 mtr 中看不到日志。

因为程序是「寄生」在 mtr 的代码中的,而且在 traceroute 中没有问题,所以应该和 mtr 的代码有关。

去看了一下代码,发现 mtr 和 traceroute 不一样的地方是:mtr 是用了异步的方式来执行 getnameinfo 函数,因为这个函数可能使用 DNS PTR 记录,涉及到网络请求,耗时可能很长。所以在调用的时候,mtr 会 fork 一个进程,专门执行这个函数。fork 出来的进程使用 PIPE 和主进程通信,并且 fork 之后就把除了 stdin, stdout, stderr 和 PIPE 之外的 fd 都关闭了。跟着关闭的,也包括我们的日志文件 fd。

解决办法就是修改了一下程序,不写日志到文件了,而是写到 stderr。在 debug 的时候,就 mtr 2>/tmp/stderr.log 这样,就可以了。

问题2: Golang 程序卡住

之前的 POC 代码运行正常,我把它改成通过 HTTP 请求 IP 信息服务的时候,居然就出问题了。mtr 显示的是 IP 而不是名字。从现象看,是函数执行失败了。

但是失败在哪里呢?

经过一段时间的排查,发现了这么几个现象:

  1. 每次程序运行之后,都有一些 mtr 进程残留在系统中没有结束;
  2. traceroute 还是正常的,但是 mtr 每次都会出问题;
  3. 不断加 print 来 debug,发现程序的问题出现在发送 HTTP 请求的地方,但是把这个地方的代码改成直接返回固定字符串,程序就正常了;

使用 gdb 去 debug,backtrace 如下,也看不出什么信息。

Golang 程序的 backtrace

看起来这个线程好像没有什么事情可以做。

花了一天排查无果,问了朋友,最后发现这个问题:Goroutines cause deadlocks after fork() when run in shared library #155383,而且开发人员的回复是:This is to be expected. It’s almost impossible for multithreaded Go runtime to handle arbitrary forks.

而 mtr 正好执行了 fork,所以这算是一个 Golang 的 runtime 问题——如果以 shared-lib 的方式运行,那么主程序是不能 fork 的,如果 fork,Go runtime 中的 goroutine 管理与多线程模型,fork 后线程状态的不一致可能会导致无法正常恢复,从而触发死锁。

最后,我把程序的逻辑用 C 语言实现了一下,就没有问题了。把它打包成 deb 包发布到了内网中。打包推荐用 nfpm4,非常方便,传统的用 apt 工具链打包太复杂了。

  1. Proxychains 项目:https://github.com/rofl0r/proxychains-ng,我的博客:编译安装proxychains4 ↩
  2. Fun building shared libraries in Go, https://medium.com/@walkert/fun-building-shared-libraries-in-go-639500a6a669 ↩
  3. https://github.com/golang/go/issues/15538 ↩
  4. https://github.com/goreleaser/nfpm ↩

CPU 越多,延迟越高的问题排查

作者 laixintao
2024年12月29日 20:56

最近上线了一组规格比较高的 CPU 的机器。36 cores x 2 threads x 2 Sockets,在 htop 上可以看到 144 个 CPU。目的是用新机型来 POC。规格更高的硬件虽然更贵,但是总拥有成本 (TCO1) 会更低。机器运行成本只有一部分来自于购买硬件,还有 Rack 部署成本,电力等等,原来放 3 台机器,现在 1 台就够了。况且,在超售的情况下,10 个 CPU 可能可以当 12 个来用,100个 CPU 或许可以当成 150 个来用。超售的本质就是假设机器上的所有用户(容器)不会同时用满申请的 CPU capacity。那么更多的 CPU 就可以做更高的超售比例,因为资源池更大了。

htop

这是背景。然后有一天,用户报告这些机器上的网络延迟明显比较高。我使用 ping 确认了下,确实延迟已经高到可以让 ping 都感受到了。

现在已经排除用户程序的问题了,问题应该出在 kernel 的网络栈。

首先检查了 IRQ 队列相关的配置,都是正确的。就没有思路了。就去请教了另一位同事,原话引用如下:

什么情况下会引入延迟? 只有「多线程异步」操作能带来延迟,因为如果是单线程同步操作的话,那「延迟」几乎是恒定,因为就是纯代码执行而已了。

网络收包处理,在由网卡中断触发,Kernel 的中断处理分两部分:上半部分处理 hardirq,做的事情很少,只是处理中断信号然后 schedule softirq,即下半部分。softirq 会真正做协议栈的处理。

中断处理如果延迟了,就可能造成包的网络栈处理延迟。根据经验,问题发生在这里的概率较大。

同事推荐用 trace-irqoff2 这个工具来跟踪中断处理的延迟,这个工具能够统计中断被推迟处理的时间,以及导致中断处理推迟的栈。看了下代码,原理3应该是以一个 Kernel module 的方式运行,注册一个高精度计时器 hrtimer 定时执行硬中断,注册一个 timer 定时执行自己的代码,默认是 10ms 执行一次。每次执行的时候获取当前时间,和上次执行的时间对比。如果时间超过 10ms 太多,那就是因为某种原因导致 timer 没有定时执行了,同理,softirq 可能也被推迟了。timer 也是一种 interrupt,在 IRQ enable 的情况下会以抢占的方式运行,当我们的 timer 抢占进来的时候,可以打印出来抢占之前的 CPU stack,就可以知道在 timer 之前 CPU 在运行什么什么内容。

按照 trace-irqoff 的使用文档进行追踪,结果如下。

trace-irqoff 的结果

一个叫 estimation_timer 的函数夺人眼球。

查阅 Kernel 的源代码我们得知,这个是 IPVS 的统计函数。目的是遍历所有的 IPVS 相关的 rule4 (第一层循环),对于每一个 rule,读取每一个 CPU 的数据(第二层循环)。两层循环嵌套,就导致执行时间会比较长。这个函数也是以 timer 的形式注册,即每隔一段时间就会执行,执行期间 IRQ 是关闭的,即没有其他线程可以抢占 CPU,estimation_timer 函数会占据 CPU 直到执行结束。假设有 soft irq 调度到这个 CPU,那么延迟就增加了。

static void estimation_timer(struct timer_list *t)
{
	struct ip_vs_estimator *e;
	struct ip_vs_stats *s;
	u64 rate;
	struct netns_ipvs *ipvs = from_timer(ipvs, t, est_timer);

	spin_lock(&ipvs->est_lock);
	list_for_each_entry(e, &ipvs->est_list, list) { // 在这里遍历所有的 rule
		s = container_of(e, struct ip_vs_stats, est);

		spin_lock(&s->lock);
		ip_vs_read_cpu_stats(&s->kstats, s->cpustats); // 这里会遍历所有的 CPU
...
static void ip_vs_read_cpu_stats(struct ip_vs_kstats *sum,
				 struct ip_vs_cpu_stats __percpu *stats)
{
	int i;
	bool add = false;

	for_each_possible_cpu(i) { // 遍历所有的 CPU
...

由于新机型 CPU 数量比较多,那么在相同的 rule 数量下,遍历所需要的时间也就更多。所以延迟也更高。之前的机型延迟也受此影响,不过不是很严重罢了。

通过和用户讨论,发现我们没有使用 IPVS 提供的这些统计信息,所以这个函数可以关闭。目前已经可以通过 sysctl 参数关闭了5,不需要打 patch:sysctl -w net.ipv4.vs.run_estimation=0

趣头条的相同问题的排查记录,也很有趣,值得一读:https://www.ebpf.top/post/ebpf_network_kpatch_ipvs/ (不过这个网站的证书貌似过期了)。

  1. Total cost of own: https://en.wikipedia.org/wiki/Total_cost_of_ownership ↩
  2. 字节跳动开发的中断追踪工具 https://github.com/bytedance/trace-irqoff ↩
  3. 对原理的理解不是特别自信,有错误请读者指出。 ↩
  4. estimation_timer 函数的源代码https://elixir.bootlin.com/linux/v5.8/source/net/netfilter/ipvs/ip_vs_est.c#L191ip_vs_read_cpu_stats 遍历 CPU 的源代码:https://elixir.bootlin.com/linux/v5.8/source/net/netfilter/ipvs/ip_vs_est.c#L56 ↩
  5. 相关的讨论:https://lore.kernel.org/netdev/20210820045904.GC5594@linux.alibaba.com/T/ ↩

Keepalived 脑裂问题排查

作者 laixintao
2024年12月29日 17:08

用户报告问题,说在虚拟机里面启动的 Keepalived 有脑裂问题,一启动就开始脑裂了。

Keepalived1 是一个基于 LVS 的负载均衡和高可用框架。负载均衡主要是通过 VRRPv2 协议来实现的。VRRP 协议2在这个博客中介绍过,主要场景是两个路由器可以通过 VRRP 协议来协商出来一个 master,对外提供服务,当 master 挂的时候,slave 会成为 master 继续提供服务。

VRRP 的全称是 Virtual Router Redundancy Protocol,Keepalived 可不是 router,为什么用这个协议呢3?简单来说,Keepalived 要提供的是一个高可用的 VIP 服务,而 Virtual Router 本质上也是一个 VIP 来给其他的 Host 当作网关,这样一想,就很合理了。

Keepalived 实例之间无法达成一致,肯定是 VRRP 协商失败,而 VRRP 又是机器简单的协议,只有一种包的类型。出现 2 个 master 节点,那就肯定是 slave 的节点收不到 master 的 VRRP 协议包,认为 master 挂了,所以站出来当 master。

首先,用 tcpdump 检查了一下两个 keepalived,确实都在发送 VRRP 的包。而且VRRP 包的内容,如 auth,组 id,权重等,都正确。并且都只看得到自己发送出去的包,无法收到对方的包。

那接下来就来排查为什么这个包无法发送给 VRRP 的 slave。

虚拟机环境的网络简化如下。

虚拟机环境的网络架构

这个图看起来复杂,其实逻辑很简单。物理机 Host 使用 bonding 连接交换机,在 bond0 接口上配置 VLAN 封装,和交换机做 trunking,这一层对物理机内的网络使用几乎是透明的。然后虚拟机内网络使用 TAP4,所有 VM 内 eth0 发送的包会出现在 macvtap1 上,然后从 Host 的 bond0.1000 出去。 macvtap 其实就是 TAP 设备加上一个 bridge5,macvtap 基于 macvlan driver,使用一个 module 解决了原来 TAP + Bridge 的工作。

下一步就是定位 VRRP 包丢在了哪里,虽然涉及的网络 interface 很多,但是一个一个来排查就行了。首先给这个网络做了个简单的体检套餐,发现 ICMP 还是 TCP 都是一点问题没有,只有 VRRP 协议有问题。VRRP 协议是基于组播 Multicast 的,直觉上觉得可能是什么 ACL 把组播网络给 DROP 了。

从 Keepalived 发包的实例开始抓包,下一步直接抓发包的物理机接口 eth0 (二分查找定位么),确认 VRRP 正确发出去了,然后去收包的物理机上抓 eth0,也收到了,直接排除了交换机的问题。再抓 bond0.1000 接口,也抓得到,排除了 bonding 和 VLAN 问题。

下一步是 macvtap1@bond0.1000,也抓得到,然后去收包的 VM2 抓包 eth0,抓不到。那丢包就发生在 macvtap1 -> eth0 中。感觉已经接近真相了!

实际上并没有……

定位到这里之后,后面花了几个小时来研究为什么 macvtap1 的包没有转发到 eth0 中。

期间研究了 macvtap 设备的原理,libvirt 相关的文档,中间还有 chatGPT 这个半吊子瞎出主意,甚至把相关的 sysctl 参数都看了一遍,rp_filterxx_forwarding 之类的,仍然没有解决问题。这些没有用的排查就不记录了。

物理机的 macvtap 的 TAP 设备是由 qemu libvirt 接管的,负责转发到虚拟机中去。在研究 libvirt 和 multicast 流量的时候,发现了这个问题和回答6,虽然没有直接解决我的问题,但是感觉脑中一道闪电划过,有一个重要的本质问题被我忽略了——VRRP 是组播网络

单播是点对点发,广播是点对所有点群发,组播的特点是点对多点群发,「多点」指的是哪些点呢?怎么知道多点包括了哪些点呢?本质的原理是「订阅」,订阅了特定流量则会收到,如果不订阅,则收不到。

在网络上,多个设备之间订阅组播是通过 IGMP 协议7实现的。在同一个设备上,就是设备自己的实现了。

有了这个想法之后,我马上在 VM 中和 Host 中运行了 ip maddr show dev 命令进行验证。结果如下。

在 VM 中的运行结果
在 Host 中的运行结果

这条命令的含义是,列出来当前这个接口订阅的组播流量。通过结果可以看到,在 VM 中订阅了 VRRP 协议规定的组播地址,但是在物理机的 macvtap 接口上,就没有订阅这个地址。所以物理机的接口在收到发给 224.0.0.1 的 VRRP 流量之后,会认为当前这个接口没有订阅过这个组播,所以不需要这些流量,直接忽略。这是组播协议预期的工作方式,所以当我在排查接口的丢包参数的时候,都没有发现什么异常,因为这不算做是丢包吧,而算作是正常的处理方式。

那么为什么会出现这种情况呢?

按照组播协议的工作方式,当需要组播流量的时候,需要向操作系统通过 syscall 来发出「订阅」8,因为协议是由操作系统来处理的。但是我们这里存在 2 个操作系统,VM 是一个操作系统,Host 是一个操作系统。如上图所示,虽然 VM 知道订阅了这个组播地址,但是 Host 操作系统并不知情,两个系统是隔离的。所以当 Host 收到组播流量的时候,直接忽略了。

解决办法是,对接口设置 ip link set dev macvtap8 allmulticast on,意思是告诉接口,把所有的 multicast 都给收了,这样 VM 内的接口决定处理还是忽略,就正常了。(libvirt 也有 trustGuestRxFilters9 的配置选项)

VRRP 脑裂问题需要避免,在物理网络中,网关之间的 vrrp keepalive 会使用专用的 keepalive 线路,并且多条物理线路做 LACP 高可用。

  1. https://keepalived.readthedocs.io/en/latest/introduction.html ↩
  2. 数据中心网络高可用技术之从服务器到网关:首跳冗余协议 VRRP ↩
  3. Keepalived 的系统设计:https://keepalived.readthedocs.io/en/latest/software_design.html ↩
  4. TAP 设备在 VPN 和虚拟机网络中比较常见:https://en.wikipedia.org/wiki/TUN/TAP ↩
  5. https://virt.kernelnewbies.org/MacVTap ↩
  6. https://superuser.com/questions/944678/how-to-configure-macvtap-to-let-it-pass-multicast-packet-correctly ↩
  7. https://en.wikipedia.org/wiki/Internet_Group_Management_Protocol ↩
  8. 如何使用组播的教程 https://tldp.org/HOWTO/Multicast-HOWTO-6.html ↩
  9. https://libvirt.org/formatnetwork.html ↩

Linux interface Vlan 和 Bond 配置错误问题排查

作者 laixintao
2024年12月28日 17:25

昨天同事报告了一个 Linux 机器网络问题,现象是:一台服务器无法 ping 192.168.1.253,但是可以 ping 192.168.1.252 和 192.168.1.254. 这三个 IP 都是交换机的 IP,并且和和服务器的 IP 在同一个子网下。

服务器使用了 bond1 分别连接两台交换机2,两台交换机通过 VRRP 协议提供一个高可用的网关 IP3。其中,网段的最高位一般是 VRRP 的 VIP,即 192.168.1.254,而最高位 -1 和 -2 分别是两个交换机的物理 IP,即 192.168.1.253 和 192.168.1.252 分别是两台交换机。

于是,看到这个现象,自然而然地想到是其中一台交换机有问题,192.168.1.253 已经挂了,192.168.1.252 还存活,并且担任了 192.168.1.254 的 VIP 的责任。

先去这台服务器 ping 了一下,果然是 ping 不通的,ping 显示的错误信息是 Destination Host Unreachable。然后在服务器抓包,确认下 ICMP reply 确实没有发送回来。tcpdump -i bond0 icmp. 抓包确实没有看到 ICMP reply 包,但是奇怪的是,居然连 ICMP echo 也没有抓到

之后又去检查了交换机的配置,包括 channel-group,VLAN 配置,ACL 等等,也确认了下两台交换机之间的横连状态是正常的。这时候看起来不像是交换机的问题了。使用另一台服务器 ping 了一下这三个 IP,.252, .253, .254 都是通的。那应该是服务器的问题而不是交换机的问题。

其实这部分有些走弯路,因为 ping 明确显示 Destination Host Unreachable,说明这个包并没有发出去;而且 tcpdump 也没有抓到包,也可以印证。

接下来继续在服务器上定位问题。

ICMP 发包有问题,就先检查一下发包链路。之前遇到过类似错误,是 iptables 的 OUTPUT chain 把包 drop 了,于是先检查了 iptables,确认没有相关的 DROP。

ICMP 是基于 IP 层的协议,IP 层的协议依赖 ARP 协议来找到 MAC 地址,然后封装成二层 Frame,才能发出去,接下来去检查 ARP。(其实上一步直接检查 iptables 是不合理的,ARP 是第一步,有了 ARP 才可能构造出来完整的 Frame 开始发送,应该先从 ARP 开始排查)。

检查 arp -a | grep .253,发现 ARP 的 cache 结果是 <incomplete>. 然后用 arping 192.168.1.253 验证 ARP request 是否能得到正常的 reply,发现结果都是 Timeout。

到这里已经知道为什么 ping 会失败了,因为服务器得不到这个 IP 对应的 ARP 请求,所以 ping 无法将 ICMP request 的包发送出去,直接报错了。

接下来就定位为什么 ARP 会失败。

正常来说,ARP 应该从 bond0 接口发送出去一个 request,然后收到一个 reply,刷新服务器的 ARP cache entry。

服务器的 interface 配置如下,服务器所在的 VLAN 是 1000,和交换机做了 Trunking4,发送包的路由是走 bond0.1000@bond0 这个 interface,bond0.1000@bond0 是一个虚拟 interface,主要的功能是,发包的时候对包进行 802.1Q VLAN 封装,然后通过底层的 interface——在这里是 bond0——发送出去,收包的时候对 VLAN 进行解封装。

root@ubuntu-1:/$ ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: bond0: <BROADCAST,MULTICAST,MASTER,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether b6:db:e6:76:dd:8a brd ff:ff:ff:ff:ff:ff
3: bond0.1000@bond0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether b6:db:e6:76:dd:8a brd ff:ff:ff:ff:ff:ff
4: eth0.1000@eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether b6:db:e6:76:dd:8a brd ff:ff:ff:ff:ff:ff
143: eth0: <BROADCAST,MULTICAST,SLAVE,UP,LOWER_UP> mtu 1500 qdisc fq_codel master bond0 state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether b6:db:e6:76:dd:8a brd ff:ff:ff:ff:ff:ff
144: eth1: <BROADCAST,MULTICAST,SLAVE,UP,LOWER_UP> mtu 1500 qdisc fq_codel master bond0 state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether b6:db:e6:76:dd:8a brd ff:ff:ff:ff:ff:ff
接口的逻辑图

我首先在 bond0 抓包,确认 ARP 的发送和接收在协议上是正常的。

结果在这一步就发现问题了,bond0 抓包发现,只有发出去的包,没有收到的包。

为啥交换机不响应 ARP 了呢?

这时候又怀疑是交换机的问题,去检查了交换机的两个端口配置。没有发现问题。而且在其他机器上,ping 和 arping 都是没有问题的,交换机设备的问题可能性比较小。

也不会是服务器安全策略的问题,如果是的话,tcpdump 也会先抓到包的,在后面才会被 iptables 之类的 DROP 掉。

于是仔细想一想交换机和服务器之间经过了哪些组件,网卡收包,中断,网卡 driver,bond driver,协议栈处理。抓包都没抓到,说明问题出在协议栈之前,于是怀疑到 bond driver 头上去。

下一步,在物理 interface 上抓包,确认物理 interface 到底收到了 ARP reply 了没有。结果是,发现 eth0 这个 interface 收到了 ARP reply!

ARP reply 在 eth0 上收到了,但是 bond0 上没收到。这下感觉快要得到答案了。bond 有两个 slave,我把 eth0 shutdown 了,只留下 eth1,然后网路正常了。那要么是 bond driver 真的有问题,要么是我们的配置有问题。从经验上看,Linux driver 存在 bug 的概率要远远小于我们的配置错误。于是我去检查 bond 相关的配置。

检查 bond 状态 (/proc/net/bonding/bond0 文件), bond 配置,都没发现问题。可能是 eht0 这个接口有问题?

在重新看 interface 的时候(即上面的 ip link 命令和输出),我发现了可疑的一条 interface:

4: eth0.1000@eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether b6:db:e6:76:dd:8a brd ff:ff:ff:ff:ff:ff

这里多出来一个 VLAN interface。

所以,实际上的 interface 配置应该是如下这样。由于 eth0.1000 的存在,我怀疑 eth0 收到的 ARP reply 实际上是送给了 eth0.1000@eth0 而不是 bond0,然后在 ARP 协议处理的时候,Linux 认为我们没有从 eth0.1000 发送出去 ARP request,但是却收到了 ARP 响应,属于 Gratuitous ARP5. 而发送 ARP request 的 bond0,从来没有收到 ARP reply。ARP cache 是 per interface 的,所以 bond0 无法发送 ICMP 出去。

eth0.1000 的配置

证明这个猜测很简单,只要在 eth0.1000@eth0 抓包,看是否有 ARP reply 就好了。抓包发现果然有。

并且把这个接口的 arp_accept 打开,让其接受 Gratuitous ARP,发现 ARP cache 出现了如下记录:

proot@ubuntu-1:/$ arp -a
? (192.168.1.253) at c6:34:22:fc:78:b4 [ether] on eth0.1000

说明这个结论是正确的。到这里就发现,其实问题不仅仅是 ARP 的问题,因为 bond 的两个 slave 有一个不对,收包的时候可能是从 eth0 收,也可能是从 eth1 收,取决于交换机的 hash 策略6。如果从 eth0 进来,那么协议栈的 skb 的 device 就会是 eth0.1000@eth0,所有有连接的协议处理都匹配不上。

于是我 shutdown eth0.1000@eth0 这个接口,理论上机器的配置应该都是对的了。

结果不是,问题依然存在,有点让人怀疑人生。由于接口 down 了就无法抓包了,不太好确认包是不是还在往 eth0.1000@eth0 送了。此处又花了一些时间排查,因为怀疑自己的推论是错误的,是不是有别的地方导致这个问题?一通误打误撞,决定删除这个多余的接口,然后网路就完全恢复了。从结果看,只 shutdown 这个接口不能阻止包往这个 vlan 接口送,得删除才行。

事后我们得知,这台服务器在 infra 团队交付的时候存在问题,应该配置 bonding,但是没有配置,只是在一条线(eth0)上配置了 VLAN。我们的同事拿到机器之后修复了 bonding 问题,但是并没有删除 eth0.1000@eth0 这个 VLAN 虚拟接口,导致产生了非预期的行为。

后来看了下源代码,发现 VLAN 的处理确实优先级比较高,在 __netif_receive_skb_core7 这里就会执行 vlan_do_recieve8,然后会把 device 的 id 设置在 skb 上。这个逻辑比 bond driver 的逻辑靠前,导致后续协议栈的处理,会认为这个包是从 eth0.1000@eth0 收到的,而不是从 bond0 收到的。

  1. 数据中心网络高可用技术之从服务器到交换机:802.3 ad ↩
  2. 数据中心网络高可用技术之从交换机到交换机:MLAG, 堆叠技术 ↩
  3. 数据中心网络高可用技术之从服务器到网关:首跳冗余协议 VRRP ↩
  4. VLAN Trunking Protocol ↩
  5. 特殊的 ARP 用法:Gratuitous ARP, ARP Probe 和 ARP Announce ↩
  6. 数据中心网络高可用技术之从服务器到交换机:链路聚合 (balance-xor, balance-rr, broadcast) ↩
  7. https://elixir.bootlin.com/linux/v6.12.6/source/net/core/dev.c#L5457 ↩
  8. https://elixir.bootlin.com/linux/v6.12.6/source/net/8021q/vlan_core.c#L10 ↩

菠萝

作者 laixintao
2024年12月27日 13:51

体检的诊所送了我们两张 Toast Box 的券,Toast Box 和 BreadTalk (面包新语)是一家,是专卖吐司的新加坡经典早餐店。

新加坡注重效率,不像澳大利亚,买一杯咖啡都要和你寒暄一下今天怎么样,打算做什么。一般来说,早餐店的店员甚至都不会说出一个没有用的字。而且不管你是不是外国人,都只会用本地的语言要你点餐,也不会跟你解释。

但我可是来了新加坡 4 年1了,已经身经百战。

店员看了看券,说,”Coffee?”

“Kopi and Kopi C Kosong, Shao”,我已经把南洋咖啡的逻辑熟记于心,难不倒我,甚至还帮欣点好了。

南洋咖啡的逻辑

店员说,”Toast?”

我点了个最贵的,店员说这个不能用券。

我问能点啥。

“Kaya, Butter, Bo Luo.”

Kaya 齁甜,我点了个 Butter。但是 Bo Luo 是什么玩意?我想,哪有人在吐司上放菠萝的,难道美国人的夏威夷披萨启发了新加坡人吗?这也能吃?虽然很不可思议,但是麦当劳在新加坡推出了菠萝汉堡,如果菠萝汉堡能合法,那么菠萝吐司应该也不奇怪了吧。不过,真的会有点这种东西吗?

这样想着,欣说,”Bo Luo”.

我震惊,她是怎么想的。

在等餐的时候,我还在做着思想斗争,这菠萝会酸吗?菠萝是热过的还是鲜菠萝?早上吃这玩意胃能受的了吗?

漫长的两分钟过去了,Kaya 和 Bo Luo 好了。

我长舒了一口气——“太好了,是菠萝包。”

“你以为呢?” 欣说。

麦当劳的菠萝汉堡
  1. 十六个夏天(为什么会有人在这种文章用上脚注?) ↩

数据中心网络高可用技术:ECMP

作者 laixintao
2024年12月12日 19:33

在之前的文章中,已经介绍过很多次 ECMP1 了,它的原理非常简单:在路由协议中,如果下一跳有多个路径可以选择,并且多个路径的 cost metric 相等,那么路由器就会根据包的 header,计算一个 hash 值,然后根据这个 hash 值对这个 flow 选择一条固定的路径,作为下一跳。

这里的要点是:

  • 路径的 cost metric 要相等。需要注意,Administrative Distance 决定协议的优先级,而 ECMP 只适用于同一协议内部的路径选择,例如 OSPF 使用 Cost 值,BGP 使用 AS-Path 长度;
  • 如我们前面讨论过的 bonding 技术2,ECMP 选路的时候,也是基于 hash 的,这样可以对一个 flow 尽量保证包到达的顺序一致;
  • ECMP 只影响在某一跳选择下一跳,不影响全局。即,它在每一跳之间做均衡,而不是全局做均衡。

我们拿实际的问题来分析。

负载不均衡的 ECMP 部署

如果如图所示部署,一共有三台服务器,部署在两个 rack,这样会造成右边的服务器承担的流量是其他服务器的两倍,流量是不均衡的。因为第一层 ECMP 是发生在两个 Rack 之间,每一个 Rack 的交换机都是拿到 1/2 的流量。然后再进行服务器和 Rack Switch 之间的 ECMP,服务器之间均分 Rack 分得的流量。

所以 ECMP 对部署结构是有要求的,要保证每一个 Rack 的服务器数量大致相当

ECMP 环境排错要点:理解 flow hash

因为环境中有多条 path,所以在排查问题的时候,要考虑到这一点。

多条 path 之间是通过 flow 的 hash 结果来选路的。ICMP 包主要通过 Src IP, Dst IP, Type, Code, Identifier 来确定一个 flow,如果使用 mtr3,那么一次 mtr 的链路是固定的,因为 mtr 使用固定的 Identifier 来发送 ICMP,在 ECMP 链路中,总会 hash 到一条特定的路线。有的时候,网络存在问题,但是 ping 和 mtr 可能显示没有问题,原因就是恰好 ICMP 被 hash 到了好的线路上去。

如果使用 mtr --tcp --port 80,就可以看到链路中所有的线路。因为 mtr 使用 tcp 的时候,每次使用的 src port 是不固定的,这样就导致每次发出来的 TCP 包都 hash 到不同的线路上。

mtr 默认使用 ICMP,只能看到一条路线
如果使用 --tcp,会显示出来(第8跳)所有的路线

假设就是想测试和客户端一样的固定线路,可以使用 tcptraceroute4,这个工具可以指定 src port,这样 TCP 四元组就固定了。在 ECMP 环境中就会走一样的路线。

另外一点排错经验,如果是 25% 丢包,50% 丢包,通常和某条线路丢包有关。

连接池问题

我们常用的很多 SDK,比如 redis,和 db 的 SDK,都会老道的创建一个连接池来复用连接,减少 tcp 创建的 overhead。还有一个细节,就是这些 SDK 一般会给连接加上最大存活时间,如果超过之后,就会关闭这个连接并删除。

这样做是有好处的,我们自己写的代码中访问 HTTP 服务也会用连接池,但是很少会注意要重建连接。就导致一个连接建立起来,会存在数天。有一些交换机有 Sticky ECMP 的功能,假设多路网络中有一个设备下线,流量就会分散到剩余的设备中。当这个设备回来的时候, Sticky ECMP 会保证这期间创建的连接都依然走一样的路线(不会考虑重新加入的设备)。

这样就会造成连接的带宽使用不均衡。如果连接一直不关闭,就会一直不均衡。在总带宽利用远小于 100% 的时候,就会出现丢包的情况。

所以我们在写代码的时候,也要注意定时重建连接。

  1. 四层负载均衡漫谈 ↩
  2. 数据中心网络高可用技术之从服务器到交换机:802.3 ad ↩
  3. 使用 mtr 检查网络问题,以及注意事项 ↩
  4. https://linux.die.net/man/1/tcptraceroute ↩

数据中心网络高可用技术系列

  1. 数据中心网络高可用技术:序
  2. 数据中心网络高可用技术之从服务器到交换机:active-backup
  3. 数据中心网络高可用技术之从服务器到交换机:balance-tlb 和 balance-alb
  4. 数据中心网络高可用技术之从服务器到交换机:链路聚合 (balance-xor, balance-rr, broadcast)
  5. 数据中心网络高可用技术之从服务器到交换机:802.3 ad
  6. 数据中心网络高可用技术之从交换机到交换机:MLAG, 堆叠技术
  7. 数据中心网络高可用技术之从服务器到网关:VRRP
  8. 数据中心网络高可用技术:ECMP

iptables 拦截 bridge 包的问题排查

作者 laixintao
2024年12月6日 22:16

最近排查的一个网络问题,两个 IP 之间的网络不通,经过在 Linux 上一个一个 interface 上抓包,发现包丢在了本地的 bridge 上。

Bridge 就是一个简单的二层设备,虽然是虚拟的,但是应该逻辑也很简单,怎么会丢包呢?

经过一通乱查,发现 Bridge 的包跑到了 iptables 里面去,被 iptables 的 FORWARD chain DROP 了。

iptables dropped pakcet

说到这里跑个题,我有一个排查 iptables 是哪一条 rule 丢包的妙计,就是 watch -d "iptables -nvL | grep DROP,watch 会监控引号中的脚本,脚本会过滤出来所有会丢包的 rule,-d 参数很关键,它可以让 watch 每次对比和上一次命令的不通,然后高亮出来。一眼定位到问题。

话说回来,bridge 一个二层的设备怎么会跑到 iptables 里面去?iptables 可是 IP tables,这是三层呀。

在 Linux 中有一个机制,可以让 layer 2 的 bridge 代码调用 iptables, arptables, ip6tables 等。这样能做的事情就比 BROUTING chain (Bridge Routing1) 更多。可以在 bridge 上通过 iptables 做 dnat, stateful firewall, conntrack 等2

如果不需要 bridge 上的包跑到 iptables 上过一遍,可以通过 kernel 参数关闭:

sysctl -w net.bridge.bridge-nf-call-iptables=0

0 的意思是 bridge 的包不会去 iptables,1 就是会去 iptables,默认是 1. 也是执行完这行命令,网络果然就通了。

Bridge call iptables, 之前是在 kernel 实现的一个功能,但是显然这样会有性能问题。后来就独立出来作为一个独立的 kernel module 了 (br_netfilter3)。(如果使用 physdev 4就会自动启用这个 module)。

另外,nftablesiptables-nft 也会受到影响,layer violation 会有很多复杂的问题。新的 kernel module – nf_conntrack_bridge5 可以做到直接在 bridge layer 实现 connection track. nftables6 是下一代的 iptables。

前面说过这个功能是一个 kernel module,所以在关闭的时候有一个小小的问题。即我们关闭的时候,可能还没有这个 module load,所以会告诉我们无法设置这个参数:error: "net.bridge.bridge-nf-call-iptables" is an unknown key。如果后来创建一个 bridge,那么这个 module 会自动 load,那么包就又会跑到 iptables 里面去。

libvert 给出的解决方案7是,通过 udev 来创建一个事件,每次创建 bridge 就执行 sysctl 来 disable net.bridge.bridge-nf-call-iptables.

  1. ebtables https://linux.die.net/man/8/ebtables ↩
  2. ebtables/iptables interaction on a Linux-based bridge:https://ebtables.netfilter.org/br_fw_ia/br_fw_ia.html ↩
  3. https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?h=linux-3.18.y&id=34666d467cbf1e2e3c7bb15a63eccfb582cdd71f ↩
  4. physdev: https://manpages.debian.org/bookworm/iptables/iptables-extensions.8.en.html#physdev ↩
  5. nf_conntrack_bridge: https://www.kernelconfig.io/config_nf_conntrack_bridge ↩
  6. nftables wiki: https://wiki.nftables.org/wiki-nftables/index.php/Main_Page ↩
  7. https://wiki.libvirt.org/Net.bridge.bridge-nf-call_and_sysctl.conf.html ↩
❌
❌