2025-06-08 23:57:48
一个 XDP 练习程序:作为 TCP 的 server 端,用 XDP 实现所有的 TCP 端口都接受 TCP 建立连接。(只是能够建立连接而已,无法支持后续的 TCP 数据传输,所以不具有实际意义,纯粹好玩。)
建立 TCP 连接需要实现 TCP 的三次握手,对于 server 端来说,要实现:
回复 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 返回的。
现在的重点在于如何修改这个 TCP SYN 包,并将其回送,使对方认为它是一个合法的 SYN-ACK 包。
我们可以从下往上一层一层看:
因为这个程序直接把收到的 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 连接,对于任意端口,可以观察到连接建立成功了:
也可以用 for 循环批量对端口建立连接,都可以连通。
for i in {5000..5010}; do nc -vz 172.16.199.22 ${i};done
XDP 的性能很高,客户端用 10000 个线程同时建立 TCP 连接,服务端的 XDP 程序使用了连 10% 都不到的 CPU。(Again,但是没有什么实际意义)
2025-06-01 21:53:26
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_id
和 student.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
表中的 id
。enrollments.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 (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'")
在声明 Model 的时候,使用 ForeignKey
要设置 db_constraint=False
2。这样在生成的 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 的。
2025-04-24 06:45:00
这篇博客是请求为什么超时了?这篇的答案和解析。
首先,通过这个抓包文件前几个包可以发现,服务器的网络是没有问题的,因为访问 DNS 或者 ubuntu apt 源都是通的。
这个问题有两个要点。第一点是,抓包文件并不是仅仅包含出问题的请求本身,还包含很多与问题无关的流量。不过现实的情况也往往如此,我们要在很多抓包和分析的时候过滤掉和问题无关的流量。
有一个非常好用的过滤方法,就是直接用 TCP 的 payload 内容进行过滤。比如,我们已经知道请求的目标是 example.com
了,那么 Host: example.com
必然会存在于 TCP 的 body 中,所以可以用以下的过滤条件:
tcp.payload contains "example.com"
(在之前的写的 网络抓包的技巧 中也介绍过,我们可以发送带有标记的请求,tcp.payload contains "xxx"
也是过滤出来这种标记请求的好方法)
用这个过滤条件可以得到以下的几个包,这就是我们要分析的请求了。
可以看到,我们发送给 exmaple.com 80 端口的包从来就没有得到过确认,于是一直在增大请求间隔并不断重试。
另一个奇怪的地方是,这个 TCP 请求没有 SYN 包被过滤出来,直接就开始发送 payload 了。这说明这个连接是在我们抓包之前就已经建立好的,所以我们没有看到连接建立的过程。
我们这个抓包文件的第一个包的时间是 37分52秒,而 HTTP 请求的第一个包时间是 39分28秒,间隔了 156 秒。这意味着这个 TCP 连接是至少在 156秒之前建立的,并且在建立之后,至少在 156秒 的时间内,没有发送过任何内容。
那么这个连接很可能因为 inactive 太久而被中间的网络设备丢弃了。如何定义 inactive?简单来说就是这个 TCP 连接上没有在一段时间内没有传输任何内容。
为什么网络设备会丢弃不活跃的 TCP 连接呢?因为机房的程序访问到公网要经过 NAT,防火墙等网络设备(其实和家用宽带是一样的,只不过家用路由器本质上是一个路由器+NAT+防火墙),而防火墙或 NAT 设备的内存只能保存有限的连接数,因为连接的保持需要内存,内存是有限的。它们普遍采用的策略是保留最近用到的连接,丢弃最旧没有有消息的连接。即使内存没有用完,一般在配置上也会设置一个连接最长的 inactive 时间,尤其是防火墙设备。
那么如何解决这个问题呢?首先如果不用长连接肯定就没问题了,每次需要发送 HTTP 请求的时候,都重新建立 TCP 连接。但这样成本就高了,TCP 连接不复用会浪费硬件资源,延迟也会升高。所以更好的方法是使用 Keepalive,即还是复用长连接,但是需要把长连接保持住。Keepalive 的原理,其实就是定时在 TCP 连接上发送 len=0 的包,即不包含 payload,类似于 duplicate ACK。发送空包不会对对端造成任何干扰,但是这些数据包会刷新中间的网络设备,避免连接失效。退一步讲,即使连接失效了,也可以通过 Keepalive 包来提前发现,避免用到的时候才通过超时发现问题。
这篇文章是计算机网络实用技术系列文章中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网路分析。
如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!
没有链接的目录还没有写完,敬请期待……
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。
2025-04-21 06:03:00
小明是一名网络工程师,有一天,同事报告问题说:自己的程序发送 HTTP 请求在测试环境好好的,但是在线上环境就总是超时,而且很容易复现,需要网络工程师的帮助。
这里的场景是,在线上运行环境,去用 HTTP 请求一个第三方(在这个例子中,是 example.com
提供的服务)。
首先,小明和同事一起复现了问题,确定超时确实存在,然后他们在请求发送方进行抓包,在抓包的同时又复现了一次超时的情况。拿到抓包文件,小明一看,立即就发现问题了所在了……
请下载这个文件并分析超时问题的根因。(如果没有头绪,可以打开这个提示)
这篇文章是计算机网络实用技术系列文章中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网路分析。
如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!
没有链接的目录还没有写完,敬请期待……
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。
2025-04-13 12:30:46
虽然这个系列的文章都是聚焦于如何通过分析网络抓包文件,结合网络知识,来解决实际的问题的,但是分析之前的步骤——抓包,也是同样重要!很显然,如果不会抓包,那么网络分析去分析什么呢?
抓得一手好包也是很厉害的!笔者遇到过很多次情况,虽然我们无法直接定位根因,但是同事能够精准地捕获到问题的现象,把问题描述给相关的网络专家,传给他们抓包文件,专家一看到准确的抓包文件,就可以很快解决问题了!
可惜的是,抓包的技巧无法像网络分析那样可以通过文章来出谜题,来让读者小试牛刀。所以,这篇文章就来写一下一些常用的抓包方式和技巧,希望能补齐这一块内容。
tcpdump
命令是我们最常用的抓包工具了1。
tcpdump -i eth0 icmp and host 1.1.1.1
这个命令就可以抓取到所有通过 eth0
去 ping 1.1.1.1
这个地址的包。
-i eth0
的意思是抓取指定的 interface,如果不指定,tcpdump 会默认选择一个。但是推荐每次都指定好这个参数,这样就没有不确定性了。如果使用 -i any
就可以抓取所有常规端口(文档的原文是 all regular network interfaces),但是什么属于「常规端口」就取决于操作系统的实现了。所以,建议也是如果要抓取多个 interface 来分析的话,就多开几个 tcpdump 进程,这样更加稳定一些。
这个参数非常有用,比如,在定位 ARP 问题的时候,我们需要确定每一个物理接口收发 ARP 的情况,就可以开多个进程分别 dump 每一个 interface 的网络;在定位 Linux 网络栈不通的情况时2,比如有 macvlan,vlan,veth 等复杂的 driver,可以用 tcpdump 对每一个接口 dump,看下包丢在哪里。
icmp and host 1.1.1.1
这个就是包过滤的表达式了,icmp
表示只抓取 icmp
协议,host 1.1.1.1
表示只抓取 src ip 或者 dst ip 是 1.1.1.1
的包。这种包过滤表达式其实是 pcap-filter(7)3 提供的,所以要想看语法是怎么定义的,看 pcap-filter 的文档就可以了。pcap-filter 支持的语法很灵活,能做的事情很多,基本上想抓什么样的包都可以写出来。但是我们没有必要把所有的语法都记住,因为常用的抓包都是比较简单的。可以找一个 tcpdump exmaple4 看一下,基本就够用了。其次,我们一般不会直接从 tcpdump 就分析出来问题原因,所以这个语法最重要的作用是把我们想要的包抓到,然后为了抓包性能更高,抓包文件更小,我们想要对抓包定义的更精确一些。其实,多抓一些包也没有什么问题,如果不确定怎么过滤出来 TCP SYN+ACK 的包,那不妨就把所有的 SYN 包全抓到,然后再用 Wireshark 这种工具来分析吧。最后,我们现在有 AI 了,用 AI 来写 pcap-filter 也是一个不错的方法,因为这种语法难写,但是很容易验证正确性。
Tcpdump 一些常用的其他参数如下:
-n
不解析主机名和端口号,保留原始的数字-v
, -vv
, -vvv
v 越多表示输出的信息越详细-c 5
表示抓到 5 个包之后就退出-e
显示二层的 link layer header,这样就可以看到 MAC 地址了-Q
可以指定抓包方向,可以选的有 in
, out
, inout
-A
可以展示包的内容,tcpdump 默认是只根据不同的协议展示 header 信息的。在线上排查问题的时候,我们往往需要通过特殊请求的关键字来定位到单个请求的情况进行排查,这样 -A 展示出来包的内容就格外有用。这里分享一个特殊的技巧,就是发标记请求来定位问题。比如 A 通过 B 代理发请求给 C,现在网络不通,我们要定位 B 收到了请求没有,才知道是 B 的问题还是 C 的问题。但是 B 本身就有很多线上流量,怎么知道 A 发送的请求到达 B 了没有呢?我们可以在 B 进行 tcpdump:tcpdump -i eth0 tcp | grep asdf123 -A 10
,然后我们从 A 发送一个请求:curl http://host-C.com/asdf123
。asdf123
就是我们在请求里面放上的标记,如果 B 能够正常转发,我们就可以 match 到这个请求。当然了,这种技巧只适用于 HTTP 这种明文协议。
有些问题很难直接在 tcpdump 的终端分析出来问题,比如涉及 sequence number 分析的,重传分析之类的,我们需要人工对比 seq number,真是一项费眼睛的工作!所以如上所说,我们也经常在机器上用 tcpudmp 抓包保存成 .pcap
文件,下载到本地用 Wireshark 分析。Wireshark 就可以自动根据 sequence number 告诉我们重传等信息了!
具体的操作方式是,用 tcpdump -i eth0 -w file.pcap icmp
来进行抓包,-w file.pcap
表示把抓包文件保存为 file.pcap
,抓包结束后,就可以把这个文件用 rsync 或者 scp 下载到本地,用 Wireshark 打开了。
.pcap
文件是一种标准的二进制抓包文件5,很多抓包分析工具都支持这种格式的解析,比如 tcpdump, wireshark, scapy 等等,如果想写代码进行更加定制化的分析,也可以用已有的库6解析,就如同用 json 库来解析 json 文件一样。
使用 -w
写入文件的时候有一个小问题,就是 tcpdump 原本的到终端的输出没有了。有两种方式可以解决,第一种是用 tcpdump 自带的 --print
功能:
tcpdump -i eth0 -w file.pcap --print
--print
会让 tcpdump 把内容输出到屏幕,即使当前使用了 -w
参数。
第二种就是用 tee
,在写入文件的同时,也写入到 stdout。
tcpdump -i eth0 -U -w - | tee test.pcap | tcpdump -r -
其中,第一个 tcpdump 把抓包文件写入到 stdout(-w stdout
,注意其中的 -U
表示按照 packet buffer,即来一个 packet 就输出一个到 stdout,而不是等 buffer 满了才进行输出),然后 tee
这里做了分流,把 stdin(tcpdump 的 stdout)同时输出到文件和 stdout。由于这里的 stdout 是 tcdpump 输出的二进制抓包内容,所以我们需要再用 tcpdump 解析这个二进制内容,-r -
表示从 stdin 读入。
还有一个技巧是 -s
参数,默认情况下 tcpdump 会保存所有抓到的内容,但是在分析某些问题的时候,尤其是 TCP 性能问题,我们其实不需要 TCP 传输的 payload 内容,只看 TCP 包的 header(序列号部分)就知道传输的速度了,所以可以用 -s 40
来只抓取前 40 个 bytes,有了 IP header 和 TCP header,就足够分析了。(如果担心有 TCP option 的存在,可以用 -s 54
)
知道包是从哪里抓到的,很重要。在排查问题的时候,拿到抓包文件,应该第一时间确认抓包的位置。否则,就可能连自己看到的问题是现象还是根因都分不清楚。建议在复杂的结构中画一个拓扑图来对照分析,在定位 Linux 网络栈的问题时,如果接口拓扑非常复杂,也建议画一个拓扑图来分析。
可以从网络的多端抓包对照分析。发送端的抓包不一定等于接受端,尤其分析 TCP 问题的时候。可以同时在发送端和接收端进行抓包,然后对照分析。
在使用 tcpdump 的时候,要尤其注意,我们抓到的包已经经过了网卡驱动的处理,网卡驱动经常会帮 CPU 做一些 offload 的工作,比如把可能因网卡的 GRO/LRO 等特性,导致多个小包在抓包时被合并为一个较大的数据包,或者网卡帮助卸载了 vlan tag 等,我们用 tcpdump 抓到的包不一定是真正在网络上传输的包7。要格外注意。
注意抓包不要抓重。比如有人很喜欢用 tcpdump -i any ...
抓全部的包回来慢慢分析。然后下载下来抓包文件就吓坏了——重传率高达 50%!
在 Linux 中的网卡配置有 slave 和 master 的时候很容易发生这种情况,比如有 bonding 配置8,-i any
会从 slave 抓包包,从 master 又抓到一次,然后在 Wireshark 看来,所有的包都被重传了。实际是同一个包先后经过 slave 和 master 而已。
抓包的时候最好把相关 host 的 ICMP 协议包也一起抓了。因为 ICMP 是重要的 control message,TCP 在传输的时候,不光有 TCP 协议,可能还会用 ICMP 协议来传递一些信息。比如 PMTUD9,以及之前遇到过的这个问题10,都是涉及到 ICMP 包。如果只按照 TCP 协议来抓包,那这个重要的信息就错过了。
除了我们熟悉的 Linux 抓包,其实网络设备上也可以抓包的。我们一般叫它「端口镜像」技术,故名思义,原理就是把网络设备的一个端口的流量全部复制到另一个端口,而另一个端口连接的就是我们的抓包程序。
这篇文章是计算机网络实用技术系列文章中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网路分析。
如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!
没有链接的目录还没有写完,敬请期待……
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。
2025-04-07 19:17:18
这个问题实际的原因是客户端的端口不够用了。
为什么端口会不够用呢?因为一个 TCP 连接的标志是四元组:
(src ip, src port, dst ip, dst port)
在这个场景中,代理服务器去连接 Real Server:
所以,能让 TCP 四元组不一样的字段,就只有 src port 了。
那么 Linux 服务器在连接远程服务器的 80 端口的时候,本地端口会用什么呢?答案是随机指定的。但是我们可以设置随机指定的范围。通过 sysctl -w net.ipv4.ip_local_port_range="32768 65535"
命令,可以让 client port 使用 32768 到 65535 之间的值。这样,低于 32768 的端口可以让其他服务 listen。
默认的端口就有 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 连接。
为什么要等呢?原因主要有二:
那么这种情况该如何解决呢?
首先可以调整参数,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 默认的等待时间一致,也可以判断出来是这种问题。
这篇文章是计算机网络实用技术系列文章中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网路分析。
如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!
没有链接的目录还没有写完,敬请期待……
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。