4.11 本机网络、连接队列与百万连接
本机两个进程通信,为什么要绕一圈协议栈,127.0.0.1 和 Unix domain socket 差在哪?一条 TCP 连接在内核里占多少内存,一百万条要多少?服务端的瓶颈到底是 somaxconn、文件描述符还是端口?客户端只有 6 万个临时端口,怎么发起百万连接?
说清 loopback 的发送捷径与接收路径;用 ss -s 和 /proc/slabinfo 估算连接数与内存;按「fd → 内存 → 队列 → 端口」的顺序做容量规划;在本机用多个源地址复现百万连接实验。
前三章讲的路径,默认包是从网卡进出的。但很多真实场景里,两端都在同一台机器上:Nginx 反代到本机的应用、sidecar 代理拦截本机流量、测试环境里的 client 和 server。这种情况下,内核会走一条「捷径」,成本比跨机低得多,但也不是免费的。
这一章的另一半是容量规划。面试里常问「一台机器最多能撑多少连接」,正确答案不是一个数字,而是一串约束:文件描述符、内核内存、连接队列、端口范围。每一条都能单独成为瓶颈,而且瓶颈会随着连接数增长而转移。理解这个转移过程,比记住「100 万」有用得多。
最后会用一节做一个可复现的实验:在本机用多个源 IP 发起大量连接,观察 ss -s 的计数和内存占用。实验本身不重要,重要的是你会看到「先撞到哪个限制」。
4.11.1 本机通信的两条路
同一台机器上的两个进程要通信,至少有三种方式,成本和语义差异很大:
| 方式 | 协议栈路径 | 典型延迟 | 适用 |
|---|---|---|---|
Unix domain socket(AF_UNIX) | 只走 socket 层,不进 IP | 5–15 µs | 本机服务间通信、容器 runtime |
TCP over loopback(127.0.0.1) | 完整 IP + TCP,跳过网卡 | 20–60 µs | 需要 TCP 语义、端口、可跨机迁移 |
| 共享内存 / 管道 | 不经过网络栈 | < 5 µs | 高频、大数据量、同机进程 |
AF_UNIX 快,是因为它没有 IP 层和 TCP 层:数据从发送方的 socket 直接挂到接收方的 socket 队列,中间只做权限检查和拷贝。它保留的只是「socket 接口」的形状。
TCP over loopback 慢一些,但保留了完整的 TCP 语义:端口、连接状态、拥塞控制、可观测性(ss、tcpdump 都能看)。这就是为什么很多本机代理仍然用 TCP——可观测性和可迁移性比几微秒更重要。
# 看本机监听里有多少是 AF_UNIX
ss -lx | head
ss -lnt | head
# 比较两种方式的连接数统计
ss -s
ss -lx 列出 Unix domain socket,-lnt 列出 TCP 监听。一个典型的容器宿主机上,/run/containerd/containerd.sock、/var/run/docker.sock 都是 AF_UNIX。
4.11.2 本机发送:loopback 的捷径
当目的地址是本机地址(127.0.0.0/8 或本机网卡 IP)时,路由表的 local 表会命中,出接口是 lo。发送路径变成:
ip_local_out
→ dst_output
→ ip_output → ip_finish_output
→ ip_finish_output2
→ neigh_output(回环没有 ARP,直接构造伪首部)
→ dev_queue_xmit
→ loopback_xmit # drivers/net/loopback.c
→ __netif_rx(skb) # 注意:不是真的发到网卡
→ netif_rx → backlog → net_rx_action
关键在 loopback_xmit:它不碰硬件,直接把 skb 交给接收路径。所以回环的「发送」和「接收」在同一个 CPU 上下文里完成(准确说,接收被推迟到软中断,但仍在同一台机器、通常同一个 CPU)。
这带来两个后果。第一,本机 RTT 极低(几十微秒),因为省掉了网卡 DMA、中断、PCIe。第二,本机流量也会消耗软中断 CPU,top 里的 si 会升高。如果本机代理的 QPS 很高,软中断会成为瓶颈。
跨机路径(两个协议栈,中间隔一块网卡)
进程A ─▶ TCP/IP ─▶ qdisc ─▶ 驱动 ─▶ 网卡A ══网线══▶ 网卡B ─▶ 中断/NAPI
─▶ TCP/IP ─▶ socket 队列 ─▶ 进程B (两次协议栈 + 两次 DMA)
本机路径(两个协议栈,没有网卡)
进程A ─▶ TCP/IP ─▶ qdisc(noqueue) ─▶ loopback_xmit
─▶ netif_rx ─▶ backlog ─▶ 软中断 ─▶ TCP/IP ─▶ socket 队列 ─▶ 进程B
(同样两次协议栈,但没有 DMA、没有硬件中断、通常同一个 CPU)
AF_UNIX 路径(一个协议栈都没有)
进程A ─▶ sendmsg ─▶ 接收方 socket 队列 ─▶ 进程B
(两次拷贝,没有 IP、没有 TCP、没有路由)
读这张图要抓住「协议栈走几遍」这一列。跨机和本机都是两遍,所以本机 TCP 不比跨机 TCP 少做协议处理,只少做了硬件相关的部分;AF_UNIX 是零遍,所以它快一个数量级。选择哪一种,取决于你要「TCP 语义与可观测性」还是「最低延迟」。
# 看回环的统计:包数、字节数、错误、丢包
ip -s link show lo
# 看回环的 qdisc(应该是 noqueue)
tc qdisc show dev lo
# 抓回环的包(能看到完整的 IP/TCP 首部,但没有真实 MAC)
sudo tcpdump -i lo -nn -c 3 'tcp port 8080'
回环的 MTU 是 65536,所以本机通信几乎不会触发分片。这也意味着本机测试发现不了 MTU 问题——这是 4.1.7 强调过的坑。
4.11.3 本机接收:backlog 与软中断
loopback_xmit 调用的 netif_rx 会把 skb 挂到当前 CPU 的 softnet_data.input_pkt_queue,然后触发 NET_RX_SOFTIRQ。后续路径和真实网卡一样:net_rx_action → process_backlog → netif_receive_skb → ip_rcv → tcp_v4_rcv。
所以本机通信的完整成本是:
系统调用 + 拷贝(用户→内核)
+ TCP 处理(发送方向:分段、首部、校验和)
+ IP 处理(路由、首部)
+ backlog 排队
+ 软中断调度
+ TCP 处理(接收方向:查找 socket、入队、唤醒)
+ 拷贝(内核→用户)
注意这里没有「网卡」和「DMA」,但有两个方向的完整协议处理。这就是为什么本机 TCP 比 AF_UNIX 慢 2–4 倍:多的就是这两遍 TCP/IP。
# 看 backlog 是否在丢包(本机高并发时会)
grep -E 'NET_RX' /proc/softirqs
python3 - <<'PY'
for cpu, line in enumerate(open('/proc/net/softnet_stat')):
f = [int(x, 16) for x in line.split()]
print(f"cpu{cpu}: processed={f[0]} dropped={f[1]} time_squeeze={f[2]}")
PY
如果本机代理场景下 dropped 增长,调大 net.core.netdev_max_backlog 通常有效,因为回环走的就是 backlog 队列。
4.11.4 跨机与本机的成本对比
把两种路径放在一起量一次,比任何理论都有说服力。下面用一个简单的回声服务测 RTT:
# 起一个 TCP 回声服务(socat 或 nc 都可以)
socat TCP-LISTEN:9001,reuseaddr,fork EXEC:'cat' &
# 用 python 测 1000 次往返
python3 - <<'PY'
import socket, time
s = socket.create_connection(("127.0.0.1", 9001))
t0 = time.perf_counter()
for _ in range(1000):
s.sendall(b"x")
s.recv(1)
dt = (time.perf_counter() - t0) / 1000
print(f"loopback RTT = {dt*1e6:.1f} us")
PY
同一台机器上,这个数字通常在 20–60 µs。把地址换成局域网内的另一台机器,会变成 100–500 µs(取决于网络)。跨洲则是几十毫秒。
| 路径 | 典型 RTT | 主要成本 |
|---|---|---|
AF_UNIX 流 socket | 5–15 µs | 两次拷贝 + 调度 |
| TCP loopback | 20–60 µs | 两遍 TCP/IP + backlog |
| 同机房 TCP | 100–500 µs | 网卡、交换机、协议栈 |
| 跨洲 TCP | 50–200 ms | 光速 + 排队 |
这张表的用法是建立数量级直觉。看到「本机调用要 10 ms」,就知道不正常——那通常是应用层的问题(锁、GC、序列化),不是网络。
4.11.5 连接队列的容量账
4.5 讲了两个队列的机制,这里算它们的容量。
全连接队列的上限是 min(backlog, somaxconn)。somaxconn 默认 4096(较新内核),老内核是 128。假设 somaxconn = 4096、backlog = 511,那么队列最多 511。
半连接队列的上限更微妙:它由 tcp_max_syn_backlog 限制,但实际值还受「backlog 与 somaxconn 的较小值」影响。内核里 inet_csk_reqsk_queue_is_full 的判断是:
/* 简化:syn 队列是否满 */
if (reqsk_queue_len(&icsk->icsk_accept_queue) > sk->sk_max_ack_backlog)
return 1;
也就是说,SYN 队列的实际上限和 accept 队列共享同一个 sk_max_ack_backlog,而 tcp_max_syn_backlog 只是另一道上限。这解释了一个常见困惑:「我把 tcp_max_syn_backlog 调到 65535,为什么 SYN 还是被丢?」因为 accept 队列的 backlog 太小,先把路堵住了。
# 三个相关参数
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog
# 当前监听队列:Recv-Q 当前长度,Send-Q 上限
ss -lnt
# 溢出计数
nstat -az | grep -E 'ListenOverflows|ListenDrops'
一个实用的估算:如果 ss -lnt 的 Recv-Q 经常接近 Send-Q,说明队列不够或者应用 accept 太慢。先把 somaxconn 和 backlog 一起调大,再看 ListenOverflows 是否停止增长。
4.11.6 文件描述符:第一道硬限制
每条连接至少占一个 fd。fd 有三层限制,任何一层到顶都会失败:
| 层级 | 参数/文件 | 查看方式 | 典型默认 |
|---|---|---|---|
| 进程级 | RLIMIT_NOFILE | ulimit -n | 1024(soft)/ 1048576(hard) |
| 系统级 | fs.nr_open | sysctl fs.nr_open | 1048576 |
| 全局 | fs.file-max | sysctl fs.file-max | 依内存,通常很大 |
| 当前使用 | /proc/sys/fs/file-nr | cat | 三个数:已分配/未使用/上限 |
ulimit -n 是 soft limit,进程可以自己调到 hard limit(setrlimit)。systemd 服务要改 LimitNOFILE,光改 /etc/security/limits.conf 对 systemd 启动的服务无效——这是容器和云主机上最常见的「明明改了 ulimit 却不生效」的原因。
# 看当前进程与系统的 fd 使用
ulimit -n
cat /proc/sys/fs/file-nr
ls /proc/<pid>/fd | wc -l
# 看哪个进程占的 fd 最多
sudo lsof -nP 2>/dev/null | awk '{print $1}' | sort | uniq -c | sort -rn | head
file-nr 的三个数字是「已分配 / 空闲 / 上限」。第一个接近第三个时,open() 会返回 ENFILE(系统级)而不是 EMFILE(进程级)。这两个错误码要分清:EMFILE 调 ulimit,ENFILE 调 fs.file-max。
4.11.7 一条 TCP 连接吃多少内存
这是容量规划的核心数字。一条空闲的 TCP 连接,内核里至少有这些东西:
| 对象 | 大小(量级) | 说明 |
|---|---|---|
struct sock / tcp_sock | 1.5–2 KB | 协议控制块,含 TCP 专有字段 |
struct file + dentry | 数百字节 | fd 对应的文件对象 |
| 接收缓冲区(最小) | 2–4 KB | 由 tcp_rmem 的最小值决定 |
| 发送缓冲区(最小) | 2–4 KB | 由 tcp_wmem 的最小值决定 |
| skb 开销 | 每个 256–512 B | 队列里有数据时才有 |
所以一条空闲连接的常驻内存大约是 3–8 KB,取决于缓冲区最小值。100 万条就是 3–8 GB,还没算应用层的缓冲区和 epoll 的 epitem(每个约 160 B)。
# 看 TCP 相关的 slab 占用
sudo slabtop -s c -o | grep -Ei 'TCP|sock' | head
# 或者直接读 /proc/slabinfo
grep -E '^TCP|^tw_sock|^request_sock' /proc/slabinfo
# 看内核协议栈总内存
grep -E 'TCP|UDP' /proc/net/sockstat
/proc/net/sockstat 的 TCP: inuse 12 orphan 0 tw 3 alloc 15 mem 2 里,mem 是「协议栈占用的页数」,乘以 4 KB 就是字节数。这是最直接的「连接占了多少内存」的证据。
调小缓冲区能显著降低单连接开销,代价是吞吐下降:
# 把最小/默认缓冲区调小(百万连接场景常用)
sysctl -w net.ipv4.tcp_rmem='4096 16384 4194304'
sysctl -w net.ipv4.tcp_wmem='4096 16384 4194304'
sysctl -w net.ipv4.tcp_mem='786432 1048576 1572864' # 单位是页
tcp_mem 是全局的协议栈内存阈值(单位页),超过第一个值进入「内存压力」模式,TCP 会主动收缩窗口;超过第三个值,新连接可能被拒绝。它是百万连接实验里最容易忽略的一个参数。
4.11.8 客户端的端口墙
服务端用固定端口,靠四元组区分连接,所以连接数不受端口限制。客户端的限制不同:每条连接要占一个本地临时端口,范围由 ip_local_port_range 决定,默认约 2.8 万个。
如果客户端只用一个源 IP,对同一个「目的 IP:端口」,最多只能建 2.8 万条连接。要突破,只有三条路:
- 增加目的地址:连不同的后端(多机房、多 IP)。
- 增加源地址:本机绑多个 IP(
127.0.0.2、127.0.0.3……),每个源 IP 各有独立的端口空间。 - 复用连接:用连接池,一条连接跑多个请求。
# 看端口范围
sysctl net.ipv4.ip_local_port_range
# 看当前 TIME_WAIT 占了多少端口
ss -tan state time-wait | wc -l
# 临时扩大范围(注意不要和知名端口冲突)
sysctl -w net.ipv4.ip_local_port_range='1024 65535'
修改范围时要留出知名端口(0–1023),否则可能和应用抢端口。生产环境常用 10240 65000,既扩大可用量,又避开常用服务端口。
4.11.9 百万连接实验
这个实验的目标不是「真的达到 100 万」(那需要 4–8 GB 内存和一台足够大的机器),而是看到瓶颈按什么顺序出现。下面给出可复现的步骤,规模可以按机器配置缩小到 10 万。
第一步:调高限制。 这些参数需要 root,且部分要写 /etc/sysctl.d/ 才能持久。
# fd 限制
sudo sysctl -w fs.file-max=2097152
sudo sysctl -w fs.nr_open=2097152
ulimit -n 1048576
# 端口与队列
sudo sysctl -w net.ipv4.ip_local_port_range='10240 65000'
sudo sysctl -w net.core.somaxconn=65535
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=65535
# 缓冲区调小,降低单连接内存
sudo sysctl -w net.ipv4.tcp_rmem='4096 16384 4194304'
sudo sysctl -w net.ipv4.tcp_wmem='4096 16384 4194304'
sudo sysctl -w net.core.rmem_max=4194304
sudo sysctl -w net.core.wmem_max=4194304
第二步:起一个只 accept 不读的服务端。 用 Python 写一个最小版本,重点是「不读数据」,这样连接会一直挂在 ESTAB 状态:
# server.py:接受连接但不读,用于观察队列与内存
import socket, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 9000))
s.listen(65535)
conns = []
while True:
c, _ = s.accept()
conns.append(c) # 只保存,不读
第三步:用多个源地址发起连接。 客户端要显式绑定源地址,才能突破端口限制:
# client.py:用 127.0.0.2–127.0.0.21 作为源地址,各建一批连接
import socket
N_PER_IP = 5000
for i in range(2, 22):
src = f"127.0.0.{i}"
for _ in range(N_PER_IP):
c = socket.socket()
c.bind((src, 0))
try:
c.connect(("127.0.0.1", 9000))
except OSError:
c.close()
break
20 个源地址 × 每个约 5 万端口,理论上限是 100 万。实际能到多少,取决于内存和 fd。
第四步:观察瓶颈。
# 连接总数与状态分布
ss -s
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c
# 内核协议栈内存
grep TCP /proc/net/sockstat
# fd 使用
cat /proc/sys/fs/file-nr
# 队列溢出
nstat -az | grep -E 'ListenOverflows|ListenDrops|TcpExtTCPBacklogDrop'
ss -s 的输出形如:
Total: 312
TCP: 1000123 (estab 1000010, closed 80, orphan 0, synrecv 0, timewait 33/0)
Transport Total IP IPv6
RAW 1 0 1
UDP 12 9 3
TCP 1000021 1000013 8
这里的 estab 就是当前建立的连接数。如果它停在某个数字不再增长,往下看是哪个计数先到顶。
- fd:
ulimit -n/fs.file-max。先撞这个,报EMFILE/ENFILE。 - 端口:客户端单源 IP 最多 2.8 万,需要多源地址。
- 内存:
/proc/net/sockstat的mem,超过tcp_mem第二档开始收缩。 - 队列:
somaxconn/ backlog,ListenOverflows增长。 - CPU:软中断与
ss/tcpdump这类观测工具本身也会成为瓶颈。
4.11.10 ss -s 与容量观测
ss -s 是容量观测的入口,它一次性给出全局视图。几个字段要会读:
| 字段 | 含义 | 异常时的解读 |
|---|---|---|
estab | 已建立连接数 | 突然下跌说明服务重启或大量断开 |
closed | 已关闭但未回收 | 大量说明回收慢或短连接过多 |
orphan | 无主连接(socket 已关,内核还在收尾) | 增长说明应用关得太快或 FIN 收不到 |
synrecv | 半连接数 | 增长说明 SYN flood 或握手慢 |
timewait | TIME_WAIT 数 | 增长说明短连接多 |
orphan 是一个容易被忽略的指标。它表示「应用已经 close,但内核还在等 FIN 或者还在重传」。大量 orphan 通常意味着对端不响应,或者本机在强制关闭连接。
# 每秒采样,看趋势而不是快照
while true; do
date +%T
ss -s | head -2
sleep 1
done
容量规划的经验值是:单机 10 万连接需要约 1 GB 协议栈内存 + 10 万 fd + 足够的软中断 CPU。到 100 万时,内存和中断分布都会成为硬约束,通常需要多队列网卡 + RPS + 调小缓冲区 + 拆分到多台机器。
4.11.11 连接回收:TIME_WAIT、orphan 与 abort
连接数不只取决于「建了多少」,还取决于「回收得多快」。三个回收路径各有各的队列和上限:
| 路径 | 触发 | 上限参数 | 超限后果 |
|---|---|---|---|
| TIME_WAIT | 主动关闭方等待 2MSL | tcp_max_tw_buckets | 超出直接销毁并打印告警 |
| orphan | 应用已 close,内核还在收尾 | tcp_max_orphans | 超出回 RST 强制关闭 |
| FIN_WAIT_2 | 发了 FIN 等对方 FIN | tcp_fin_timeout | 超时回收 |
tcp_max_tw_buckets 默认会按内存自动计算,超出时内核打印 TCP: time wait bucket table overflow,并且直接销毁 TIME_WAIT 状态。这看起来「解决了问题」,实际破坏了 2MSL 保护——旧连接的延迟包可能被新连接误收。调大它不是优化,是掩盖。
tcp_max_orphans 管的是「无主连接」。应用 close 后,如果对端还有数据要发或者 FIN 没走完,socket 会进入 orphan 状态,由内核代为收尾。超出上限时,内核回 RST 强制关闭,应用看到的就是「连接被重置」。
# 看三个上限与当前值
sysctl net.ipv4.tcp_max_tw_buckets net.ipv4.tcp_max_orphans net.ipv4.tcp_fin_timeout
# 看 orphan 与 tw 的实时计数
awk '/^TCP:/ {print}' /proc/net/sockstat
# 看是否发生过 tw 溢出
dmesg | grep -i 'time wait bucket' | tail -3
短连接服务(比如 HTTP/1.0 的反向代理)最容易撞到这两个限制。根本解法是长连接和连接池,而不是把上限调到天上去。
4.11.12 端口是怎么选出来的
客户端 connect 时如果不 bind,内核要自动选一个源端口。这个过程在 inet_csk_get_port 里,分两步:
- 在
ip_local_port_range范围内,从一个随机偏移开始扫描。 - 对每个候选端口,检查五元组是否冲突:同一个「源 IP、源端口、目的 IP、目的端口」不能重复。
随机偏移是 4.x 之后引入的,目的是避免「端口可预测」带来的攻击(比如盲猜序列号)。扫描是线性的,但通常第一个或第二个就能命中。
服务端 bind 时的行为不同:它要检查「端口是否已被占用」,规则由 SO_REUSEADDR 和 SO_REUSEPORT 决定。
| 选项 | 作用 | 典型用途 |
|---|---|---|
| 默认 | 端口被占用则 EADDRINUSE | 普通服务 |
SO_REUSEADDR | 允许绑定 TIME_WAIT 状态的端口 | 服务重启不用等 60 秒 |
SO_REUSEPORT | 允许多个 socket 绑同一端口,内核分流 | 多进程/多线程服务器 |
SO_EXCLUSIVEADDRUSE | 禁止其他 socket 复用(Windows 概念) | Linux 无对应 |
SO_REUSEADDR 和 SO_REUSEPORT 经常被混为一谈。前者解决「重启时 TIME_WAIT 占用端口」,只影响绑定检查;后者解决「多个进程共享一个监听端口」,涉及内核的分流逻辑。Nginx 的多 worker、Envoy 的多进程都用 SO_REUSEPORT,因为它避免了 accept 锁竞争。
# 看端口占用与监听者
ss -lntp | grep 8080
# 看某个端口的复用情况(多个进程监听同一端口)
ss -lntp 'sport = :8080'
ss 输出里同一个端口出现多行、每行不同的进程,就是 SO_REUSEPORT 生效的证据。
4.11.13 观测本身的开销与三个统计口径
在百万连接场景里,「怎么数」本身就是一个问题。三个工具的开销差了几个数量级:
| 工具 | 数据来源 | 10 万连接耗时 | 说明 |
|---|---|---|---|
ss | netlink(sock_diag) | 几十毫秒 | 内核直接返回,最快 |
netstat | 遍历 /proc/net/tcp | 几秒 | 已废弃,大表下极慢 |
lsof | 遍历 /proc/*/fd | 几十秒 | 还会打开每个 fd,影响业务 |
netstat 慢是因为它要读 /proc/net/tcp 的文本、逐行解析,而且 /proc 的生成本身要遍历内核的哈希表。在 10 万连接以上,netstat 可能卡住几十秒,期间 CPU 被占满——这就是「排查工具把服务拖垮」的经典场景。用 ss。
即使都用 ss,三个口径也要分清:
ss -s的estab是内核哈希表里的已建立连接数,包含所有 netns 吗?不包含——它只统计当前 netns。容器里看到的和宿主机上看到的不一样。/proc/net/sockstat的inuse是「已分配的 TCP socket 数」,包含 TIME_WAIT 和 orphan,口径比estab大。ss -tan | wc -l是「列出的行数」,受ss默认过滤影响(比如不显示 TIME_WAIT 之外的某些状态时会有差异)。
# 三个口径对照,数字不一致是正常的
ss -s | head -2
awk '/^TCP:/ {print "sockstat:", $0}' /proc/net/sockstat
ss -tan | wc -l
做容量测试时,固定用一个口径,否则「连接数到 80 万就不涨了」可能只是统计方式变了,不是真的到顶。
还有一个隐藏的观测成本:tcpdump 或 bpftrace 挂在收包路径上,会让 PPS 下降、softnet_stat 的 dropped 上升。压测时不要同时开抓包,否则测的是「抓包后的性能」。
4.11.14 云主机与虚拟化:多出来的四道限制
在物理机上算好的容量,搬到云上往往会打折。原因不是「云更慢」,而是多出了四道限制:
| 限制 | 位置 | 表现 | 观测 |
|---|---|---|---|
| 虚拟网卡队列 | virtio-net | 队列少,中断集中 | ethtool -l eth0 |
| 安全组 / 云防火墙 | 宿主机或网关 | 连接跟踪表满 | conntrack -C、丢包计数 |
| NAT 网关端口 | 云厂商共享 | 访问外网时端口耗尽 | 客户端 Cannot assign requested address |
| 带宽/包速限制 | 宿主机 cgroup 或 tc | 超过即丢包 | tc -s、云监控 |
第一道最容易验证:云主机的 virtio 网卡默认可能只有 2–4 个队列,ethtool -l 会告诉你上限。如果 CPU 有 16 核而队列只有 4 个,剩下的核只能靠 RPS 分担软中断。
第二道是「安全组」的隐性成本。安全组通常在有状态防火墙上实现,每个连接占一个 conntrack 表项,方向是双向的。所以一个 10 万并发连接的服务,在安全组层面可能是 20 万个表项,超过配额就会静默丢包。
第三道是共享 NAT 网关。云厂商的 NAT 网关通常给每个公网 IP 分配一个端口池,多台机器共享。高峰期会出现「端口不够」,表现为间歇性的 connect: Cannot assign requested address——这不是本机端口耗尽(本机还有),是网关侧没有可用映射。
第四道是带宽与包速的双重限制。云主机的「带宽」通常按 Mbps 计,但小包场景真正的瓶颈是 PPS:1 Gbps 的线跑 64 字节小包,理论上限约 148 万 PPS,而虚拟交换机的 PPS 能力往往更低。压测时如果带宽没跑满但吞吐上不去,看 PPS 而不是 Mbps。
# 云主机上先看这四项
ethtool -l eth0 # 队列数
ethtool -S eth0 | grep -E 'drop|miss'
conntrack -C; sysctl net.netfilter.nf_conntrack_max
tc -s qdisc show dev eth0 | head -6
要绕过虚拟交换机的开销,两条路:SR-IOV 把物理网卡的虚拟功能(VF)直通给虚拟机,绕过宿主机的 vSwitch;eBPF 数据面(Cilium 的 tc/XDP 程序)在内核里直接做转发和 NAT,跳过 iptables 的线性匹配。前者依赖硬件和云厂商支持,后者依赖内核版本(4.19+ 更成熟),两者都能把「云上比物理机慢」的差距缩小一半以上。
4.11.15 回环的内核路径:为什么它不需要 ARP 和校验和
4.11.2 说回环走「捷径」,这一节把捷径拆成函数。回环不是「特殊协议」,而是一块没有物理介质、没有队列、没有 ARP 的虚拟网卡(lo),它的发送路径比物理网卡短得多。
| 阶段 | 函数 | 文件 | 与物理网卡的区别 |
|---|---|---|---|
| 路由 | ip_route_output_flow | net/ipv4/route.c | 目的地址在本机,直接选 lo |
| 输出 | ip_output → ip_finish_output | net/ipv4/ip_output.c | 不需要分片(MTU 65536) |
| 排队 | dev_queue_xmit | net/core/dev.c | lo 是 noqueue,没有 qdisc |
| 驱动 | loopback_xmit | drivers/net/loopback.c | 不发网线,直接 netif_rx |
| 回灌 | netif_rx → backlog | net/core/dev.c | 进 per-CPU backlog,等软中断 |
| 收包 | net_rx_action → ip_rcv | 同上 | 与物理网卡收包共用后半段 |
loopback_xmit() 的核心只有几行:
/* drivers/net/loopback.c,简化 */
static netdev_tx_t loopback_xmit(struct sk_buff *skb, struct net_device *dev)
{
...
skb_orphan(skb); /* 断开 socket 归属,避免内存记账串味 */
skb->protocol = eth_type_trans(skb, dev);
...
__netif_rx(skb); /* 直接进 backlog,不经过 qdisc/驱动 */
return NETDEV_TX_OK;
}
skb_orphan() 是关键:发送时 skb 还记着「我是哪个 socket 的」,如果不解除,接收端会把内存记到发送 socket 上,导致 sk_wmem_queued 虚高。回环上「发送方和接收方是同一个 socket」的场景(自己连自己)尤其容易踩这个坑。
回环为什么不需要 ARP?6.8 的 lo 并没有 IFF_NOARP:它有全 0 的 MAC(addr_len=6),但路由是 RTN_LOCAL,dst_neigh_lookup 不会触发解析,直接走本机路径。回环为什么不需要校验和?lo 的 features 含 NETIF_F_HW_CSUM/NETIF_F_RXCSUM,且数据没离开内存,校验和只在 skb->ip_summed 上标记为 CHECKSUM_UNNECESSARY;旧书里的 NETIF_F_NO_CSUM 在 6.8 已不存在。
回环的成本主要是两次协议栈遍历 + 一次 backlog 排队:
发送进程:write() → tcp_sendmsg → ip_output → loopback_xmit → __netif_rx
软中断:net_rx_action → ip_rcv → tcp_v4_rcv → sk_receive_queue
接收进程:read() → 拷贝到用户态
所以「本机通信比跨机快」不是因为没有协议栈,而是因为没有网线、没有交换机、没有排队,且 MTU 大到几乎不分片。一次回环 RTT 通常在 20–60 微秒,其中相当一部分是两次软中断与进程唤醒的调度成本。
lo 的 MTU 是 65536,接近 IPv4 报文的最大长度(65535)。它让本机大块传输几乎不分片,减少了分片/重组开销。但这也带来一个陷阱:在回环上测出的 MSS 是 65483 左右,不能拿来推断以太网的 1460。把回环的吞吐数字直接套到物理网卡上,会高估 5–10 倍。
4.11.16 一条连接的内存构成:从 struct sock 到缓冲区
4.11.7 给了「一条连接吃多少内存」的量级,这一节把它拆成可核算的项。一条空闲 TCP 连接的内存分四块:
| 组成 | 典型大小 | 何时分配 | 记账位置 |
|---|---|---|---|
struct socket + struct file | ~500 B | accept 返回新 fd 时 | 进程 fd 表 |
struct sock + struct tcp_sock | 1.5–2.5 KB | 第三次 ACK 创建子 socket | TCP slab |
接收缓冲(sk_rcvbuf) | 0 到数 MB | 收到数据时按需 | sk_rmem_alloc |
发送缓冲(sk_sndbuf) | 0 到数 MB | 应用写入时按需 | sk_wmem_queued |
request_sock(握手期) | ~256 B | SYN 到达 | request_sock_TCP slab |
所以「百万空闲连接」的内存下限约为 1M × 2.5 KB ≈ 2.5 GB,再加上 fd 表、epoll 的 epitem(~160 B)、页表等,实际 3–4 GB。如果每条连接都预分配 128 KB 接收缓冲,百万连接就是 128 GB——这就是为什么高并发服务要把 tcp_rmem 的默认值调小。
内核用三个 sysctl 控制 TCP 内存:
# 单位是页(4 KB):min / pressure / max
cat /proc/sys/net/ipv4/tcp_mem
# 每个 socket 的接收缓冲:min / default / max(字节)
cat /proc/sys/net/ipv4/tcp_rmem
# 发送缓冲
cat /proc/sys/net/ipv4/tcp_wmem
tcp_mem[1](pressure)是「开始回收」的水位,tcp_mem[2](max)是「拒绝分配」的水位。超过 max 后,新连接的数据会被丢,TCPAbortOnMemory 增长。它统计的是所有 TCP socket 的 sk_rmem_alloc + sk_wmem_queued 总和,不是 RSS。
socket 内存还有一层 cgroup 记账:sk_memcg 指向 socket 所属的 memory cgroup,sk_mem_charge()/sk_mem_uncharge() 在分配/释放 skb 时更新 memory.current。容器里看到的 memory.current 包含 socket 缓冲,这解释了「容器没跑什么却占了几百 MB」——可能是 TIME_WAIT 或接收队列。
struct sock 的 slab 可以从 /proc/slabinfo 直接读:
# TCP 对象数、对象大小、每 slab 页数
grep -E '^(TCP|TCPv6|request_sock_TCP|sock_inode_cache)' /proc/slabinfo
# 按内存排序看谁占得多
slabtop -o | head -15
TCP 行的第一列是活跃对象数,第二列是总对象数,第四列是 objsize。active_objs × objsize 就是协议栈结构体的近似内存。如果 TCP 对象数远大于 ss -s 的连接数,说明有大量 socket 没释放(CLOSE_WAIT、orphan、或 fd 泄漏)。
按每条连接 2.5 KB 结构体 + 160 B epitem + 8 KB 平均缓冲(收发各 4 KB):
结构体: 1,000,000 × 2.5 KB = 2.5 GB
epitem: 1,000,000 × 160 B = 0.16 GB
缓冲: 1,000,000 × 8 KB = 8 GB
页表/其他: ≈ 1 GB
合计: ≈ 11.7 GB
如果把 tcp_rmem 默认值从 128 KB 降到 16 KB,缓冲从 8 GB 降到 1 GB,总内存降到约 4.7 GB。代价是单连接吞吐下降,适合「连接多、每连接流量小」的场景(IM、推送、长轮询)。
4.11.17 程序一:用 /proc 量出每条连接的真实开销
这段程序建立 N 条本地 TCP 连接(不读不写),在建立前后读 /proc/net/sockstat 与 /proc/slabinfo,算出「每条连接增加了多少 socket、多少 slab 内存」。
/* conn_mem.c:量 N 条空闲连接带来的 sockstat/slab 变化(Linux) */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define MAXCONN 2000
static long read_sockstat_tcp_inuse(void)
{
#ifdef __linux__
FILE *f = fopen("/proc/net/sockstat", "r");
char line[256];
long v = -1;
if (f == NULL)
return -1;
while (fgets(line, sizeof line, f) != NULL) {
if (sscanf(line, "TCP: inuse %ld", &v) == 1)
break;
}
fclose(f);
return v;
#else
return -1;
#endif
}
static long read_tcp_slab_kb(void)
{
#ifdef __linux__
FILE *f = fopen("/proc/slabinfo", "r");
char line[512];
long kb = -1;
if (f == NULL)
return -1;
while (fgets(line, sizeof line, f) != NULL) {
char name[64];
long active, total, objsize;
if (sscanf(line, "%63s %ld %ld %ld", name, &active, &total, &objsize) == 4 &&
strcmp(name, "TCP") == 0) {
kb = active * objsize / 1024;
break;
}
}
fclose(f);
return kb;
#else
return -1;
#endif
}
int main(int argc, char **argv)
{
int n = argc > 1 ? atoi(argv[1]) : 500;
if (n < 1 || n > MAXCONN) {
fprintf(stderr, "用法: %s [1-%d]\n", argv[0], MAXCONN);
return 2;
}
int lfd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = 0 };
socklen_t alen = sizeof a;
if (lfd < 0)
return 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(lfd, (struct sockaddr *)&a, sizeof a) < 0 ||
listen(lfd, n) < 0 || getsockname(lfd, (struct sockaddr *)&a, &alen) < 0) {
perror("listen");
return 1;
}
long inuse0 = read_sockstat_tcp_inuse();
long slab0 = read_tcp_slab_kb();
int cfd[MAXCONN];
for (int i = 0; i < n; i++) {
cfd[i] = socket(AF_INET, SOCK_STREAM, 0);
if (cfd[i] < 0 || connect(cfd[i], (struct sockaddr *)&a, sizeof a) < 0) {
fprintf(stderr, "connect #%d: %s\n", i, strerror(errno));
n = i;
break;
}
int afd = accept(lfd, NULL, NULL);
if (afd < 0) {
perror("accept");
n = i;
break;
}
close(afd); /* 只保留客户端 fd,服务端立刻关,连接进 TIME_WAIT */
}
long inuse1 = read_sockstat_tcp_inuse();
long slab1 = read_tcp_slab_kb();
printf("连接数 %d\n", n);
printf("sockstat TCP inuse: %ld -> %ld(差 %ld)\n", inuse0, inuse1, inuse1 - inuse0);
printf("TCP slab: %ld KB -> %ld KB(差 %ld KB,每条 %.0f B)\n",
slab0, slab1, slab1 - slab0,
n > 0 ? (double)(slab1 - slab0) * 1024 / n : 0.0);
for (int i = 0; i < n; i++)
close(cfd[i]);
close(lfd);
return 0;
}
运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 conn_mem.c -o conn_mem
./conn_mem 1000
# 同时看 slab 与 sockstat
watch -n0.5 "grep '^TCP' /proc/net/sockstat; grep '^TCP ' /proc/slabinfo"
典型输出:
连接数 1000
sockstat TCP inuse: 12 -> 1012(差 1000)
TCP slab: 96 KB -> 2656 KB(差 2560 KB,每条 2621 B)
每条约 2.6 KB,与 sizeof(struct tcp_sock) + struct sock + SLUB 对齐后的量级一致。注意 inuse 差正好是 1000:客户端 socket 加服务端 socket 都应该在,但服务端 close(afd) 后进入 TIME_WAIT,sockstat 的 TCP inuse 统计的是「已分配的 TCP socket」,TIME_WAIT 用的是 inet_timewait_sock,仍在 TCP slab 里——所以差值与连接数一一对应。
把 n 加到 5000,观察 TCP slab 是否线性增长;如果增长斜率突然变大,可能是 sk_rmem_alloc 或 sk_wmem_queued 被计入(本程序不读不写,理论上只有结构体)。
4.11.18 程序二:容量核算脚本
一台机器能撑多少连接,不是「端口数」一个数字决定的。这个脚本把 fd、端口、内存、队列四个约束一起算出来,给出理论上限与短板。
#!/usr/bin/env python3
"""capacity.py:按 fd/端口/内存/队列四项估算最大连接数(Linux)"""
import os
import re
import resource
import subprocess
def read(path, default=0):
try:
with open(path, encoding="ascii") as f:
return int(f.read().strip())
except (OSError, ValueError):
return default
def main():
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
nr_open = read("/proc/sys/fs/nr_open", 1048576)
file_max = read("/proc/sys/fs/file-max", 0)
port_lo, port_hi = 32768, 60999
try:
with open("/proc/sys/net/ipv4/ip_local_port_range", encoding="ascii") as f:
port_lo, port_hi = map(int, f.read().split())
except OSError:
pass
mem_kb = 0
try:
with open("/proc/meminfo", encoding="ascii") as f:
for line in f:
if line.startswith("MemTotal:"):
mem_kb = int(line.split()[1])
break
except OSError:
pass
# 每条连接按 3 KB 结构体 + 8 KB 缓冲估算;可按业务调整
per_conn_kb = 3 + 8
mem_limit = mem_kb * 0.6 / per_conn_kb if mem_kb else 0
print(f"RLIMIT_NOFILE soft={soft} hard={hard}")
print(f"fs.nr_open={nr_open} fs.file-max={file_max}")
print(f"临时端口范围={port_lo}-{port_hi}({port_hi - port_lo + 1} 个)")
print(f"MemTotal={mem_kb / 1024 / 1024:.1f} GiB,按 {per_conn_kb} KB/连接 估算内存上限≈{mem_limit:,.0f}")
limits = {
"fd(soft)": soft,
"fd(nr_open)": nr_open,
"端口(客户端)": port_hi - port_lo + 1,
"内存": int(mem_limit),
}
for k, v in sorted(limits.items(), key=lambda kv: kv[1]):
print(f" {k}: {v:,}")
print("短板:", min(limits, key=limits.get))
if __name__ == "__main__":
main()
运行:
chmod +x capacity.py
./capacity.py
输出示例:
RLIMIT_NOFILE soft=1024 hard=1048576
fs.nr_open=1048576 fs.file-max=2097152
临时端口范围=32768-60999(28232 个)
MemTotal=62.7 GiB,按 11 KB/连接 估算内存上限≈3,650,000
fd(soft): 1,024
端口(客户端): 28,232
内存: 3,650,000
fd(nr_open): 1,048,576
短板: fd(soft)
读法:
- 服务端的瓶颈通常是 fd 与内存,不是端口(服务端只用一个监听端口)。
- 客户端的瓶颈通常是临时端口(约 2.8 万)加 TIME_WAIT;
tcp_tw_reuse=1能缓解主动关闭方的端口占用。 ulimit -n的 soft 值经常是 1024,是容器里最常见的「第一道墙」;改它需要LimitNOFILE(systemd)或--ulimit(docker)。
4.11.19 实验:连接、内存与队列的对账
实验一把四个统计口径对齐,实验二用 bpftrace 看连接的创建/销毁速率。
实验一:四个口径。
# 1) 连接状态分布
ss -s
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
# 2) 协议栈内存与 socket 数
cat /proc/net/sockstat
grep -E '^(TCP|TCPv6|request_sock_TCP)' /proc/slabinfo
# 3) 全局 fd 使用
cat /proc/sys/fs/file-nr
# 4) 端口占用
ss -tan | awk 'NR>1 {split($4,a,":"); print a[2]}' | sort | uniq -c | sort -rn | head
四个口径不一致是正常的:ss -s 的 total 包含 TIME_WAIT,sockstat 的 inuse 不含 timewait,slabinfo 的 TCP 包含正在释放的对象。排障时要固定一个口径看趋势,不要在不同命令之间对绝对值。
实验二:连接 churn 的 bpftrace。
sudo bpftrace -e '
kprobe:tcp_v4_connect { @connect[comm] = count(); }
kprobe:inet_csk_accept { @accept[comm] = count(); }
kprobe:tcp_close { @close[comm] = count(); }
kprobe:loopback_xmit { @lo[comm] = count(); }
interval:s:5 { print(@connect); print(@accept); print(@close); print(@lo); clear(@connect); clear(@accept); clear(@close); clear(@lo); exit(); }'
@connect远大于@accept:客户端在重试,或服务端 accept 跟不上。@close远小于@connect:连接泄漏(CLOSE_WAIT 堆积)。@lo很大:本机通信占比高,检查是否有不必要的回环调用(sidecar、代理)。
4.11.20 network namespace:一台机器里的多套协议栈
容器网络的第一块积木不是 veth,而是 network namespace(netns)。它把「网络设备、路由表、邻居表、netfilter 规则、conntrack、socket、/proc/net、net.* sysctl」整套隔离,让每个容器以为自己有一台独立的机器。没有 netns,veth 只是两根虚拟网线;有了 netns,才有一台台虚拟主机。
| 对象 | 是否 per-netns | 说明 |
|---|---|---|
网络设备(struct net_device) | 是(可移动) | 物理网卡也能 ip link set eth0 netns <ns> |
| 路由表 / 策略路由 | 是 | 每个 ns 有独立的 fib |
| ARP/NDP 邻居表 | 是 | ip neigh 只看当前 ns |
| netfilter 表 / conntrack | 是 | 每个 ns 独立计数、独立 nf_conntrack_max |
| socket | 是 | 同一端口在不同 ns 可同时监听 |
/proc/net | 是 | 挂载后看到的是当前 ns 的视图 |
net.* sysctl | 是 | net.ipv4.ip_forward 可按 ns 设置 |
创建与进入:
# ip netns 会在 /var/run/netns/ 下创建一个 bind mount,名字就是 ns 名
sudo ip netns add ns1
sudo ip netns list
# 默认只有 lo,且是 down 的;一定要先拉起
sudo ip netns exec ns1 ip link set lo up
sudo ip netns exec ns1 ip addr
# 进入一个已存在进程的 netns
sudo nsenter -t <pid> -n ip addr
# 在 ns 里执行命令
sudo ip netns exec ns1 ss -lnt
ip netns add 不是魔法:它创建一个新的 netns,并在 /var/run/netns/ns1 上 bind mount /proc/self/ns/net,这样名字在 shell 退出后仍然存在。没有这个挂载点,netns 会随最后一个引用消失。
系统调用层面:
/* 新线程/进程进入新的 netns;需要 CAP_SYS_ADMIN */
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <unistd.h>
int main(void)
{
if (unshare(CLONE_NEWNET) < 0) {
perror("unshare");
return 1;
}
/* 这里已经是新 netns:只有 lo,且 down;生产代码应通过 netlink 拉起 lo */
execlp("ip", "ip", "link", "set", "lo", "up", NULL);
perror("execlp");
return 1;
}
setns(fd, CLONE_NEWNET) 可以加入一个已有的 netns,fd 通常来自 /proc/<pid>/ns/net。两个限制:
- 多线程进程不能
setns到新的 netns:setns只改变调用线程,但 netns 是 per-thread 的nsproxy,glibc 的线程库可能在其他线程继续用旧 ns;容器运行时通常在fork后的单线程子进程里做。 - 需要
CAP_SYS_ADMIN:普通用户即使拥有 userns,也要先映射权限。
容器运行时(runc)的流程是:clone(CLONE_NEWNET) 创建新 netns → 把 netns fd 交给 CNI 插件 → 插件在 netns 里创建 veth、配 IP、加路由 → 容器进程在 netns 里启动。CNI 插件拿到的就是那个 fd,所以它能用 nsenter/setns 精确操作容器网络,而不影响宿主机。
- 新 netns 的 lo 是 down 的:
ping 127.0.0.1直接失败;先ip link set lo up。 ip netns add后没清理:netns 挂载点残留,ip netns list越来越多;删除用ip netns del。- 在多线程进程里
setns:只有调用线程切换,其他线程还在旧 ns;在fork后的单线程子进程里做。 - 以为
net.*sysctl 全局生效:大部分是 per-netns,改宿主机的不影响容器;要进 ns 改。 - 把 netns 当安全边界:它只隔离网络视图,不隔离文件系统/权限;安全边界要靠 user namespace、capabilities、seccomp。
4.11.21 veth 与 bridge:容器之间的二层路径
veth 是一对「背靠背的网卡」:从一端 send,另一端 recv。它不封装、不路由,只把 skb 从一端交给另一端。bridge 则是一个软件交换机,把多对 veth 的宿主机端接在一起,按 MAC 转发。
veth 的内核路径:
应用 sendto
→ 协议栈 → dev_queue_xmit → veth_xmit
→ dev_forward_skb(peer, skb) # 把 skb->dev 改成对端
→ netif_rx(skb) # 挂到 per-CPU backlog
→ ____napi_schedule(sd->backlog)
→ net_rx_action → process_backlog
→ netif_receive_skb → 对端协议栈
关键点:veth 的接收走 backlog,不走真实网卡的 NAPI。所以 net.core.netdev_max_backlog 和 softnet_stat 的 dropped 对容器网络同样重要;veth_xmit 里的 dev_forward_skb() 还会做 eth_type_trans(),把 skb->protocol 重新解析一遍。
bridge 的内核路径:
veth 宿主机端收到帧
→ __netif_receive_skb_core
→ rx_handler = br_handle_frame
→ br_handle_frame_finish
→ br_fdb_update(学习源 MAC)
→ br_fdb_get(查目的 MAC)
├─ 命中:br_forward → dev_queue_xmit → 对端 veth
└─ 未命中:br_flood → 所有端口(除入端口)
bridge 是二层设备:它不需要 IP 也能转发;给 bridge 配 IP 只是为了宿主机自己能访问这个二层域。br_handle_frame 注册在 veth 的 rx_handler 上,所以帧在进入协议栈之前就被 bridge 接管。
搭一个最小二层网络:
sudo ip netns add ns1; sudo ip netns add ns2
sudo ip link add br0 type bridge
sudo ip link set br0 up
for ns in ns1 ns2; do
sudo ip link add "veth-$ns" type veth peer name eth0 netns "$ns"
sudo ip link set "veth-$ns" master br0
sudo ip link set "veth-$ns" up
sudo ip netns exec "$ns" ip link set lo up
sudo ip netns exec "$ns" ip addr add "10.77.0.$([ "$ns" = ns1 ] && echo 1 || echo 2)/24" dev eth0
sudo ip netns exec "$ns" ip link set eth0 up
done
sudo ip netns exec ns1 ping -c 2 10.77.0.2
sudo bridge link show
sudo bridge fdb show br br0
bridge fdb show 里应该能看到两个容器 eth0 的 MAC 与对应端口;bridge link show 显示 veth 的 master br0 状态。第一次 ping 会先 ARP,FDB 里出现源 MAC 后才开始转发。
观测:
# 在 bridge 上抓,能看到两端 MAC;在 veth 上抓,只能看到一侧
sudo tcpdump -i br0 -e -nn
# 内核函数计数
sudo bpftrace -e 'kprobe:veth_xmit { @veth[comm] = count(); }
kprobe:br_handle_frame { @br = count(); }'
# 统计
ip -s link show br0
ethtool -S veth-ns1 2>/dev/null | head
| 计数 | 位置 | 增长说明 |
|---|---|---|
rx_dropped / tx_dropped | ip -s link | 队列/内存不足,或对端 netns 已删除 |
rx_nohandler | veth/bridge | 没有协议处理者,通常是 bridge 端口状态不对 |
br0 的 rx_bytes | bridge | 经过二层转发的总量 |
- veth 两端不在同一个 ns:创建时用
peer name eth0 netns ns1,之后看不到对端;排查要进对应 ns。 - bridge 没 up:
master br0配好了但br0down,帧不会转发;ip link set br0 up。 - veth-only 环境被
rp_filter丢包:两端在同一台机器、非对称路由时,宽松模式rp_filter=2或关掉再试。 - 把 bridge 当三层设备:bridge 默认只做二层;跨网段要配 IP/路由或 NAT,不会自动路由。
- 容器删除后 veth 残留:
ip link里出现vethXXX但 netns 已删;用ip -d link找 peer,手动清理。
4.11.22 VXLAN 与 overlay:跨主机的二层扩展
veth/bridge 只能在同一台主机内组网。跨主机时,要么走路由(三层),要么把二层帧封装进 UDP 里传(overlay)。VXLAN 是后者:外层 IP + UDP + VXLAN 头 + 原始以太网帧,用 VNI(24 位)标识不同的虚拟网络。
┌─────────────────────────────────────────────┐
│ 外层以太网 │ 外层 IP │ UDP(4789) │ VXLAN │ 内层以太网 │ 内层 IP │ ... │
└─────────────────────────────────────────────┘
20 B 8 B 8 B 14 B
合计新增 50 B(不含外层以太网)
发送路径:
Pod → veth → bridge → 宿主机路由 → vxlan0
→ vxlan_xmit
→ vxlan_xmit_one
→ vxlan_build_skb # 加 VXLAN 头(VNI + flags)
→ udp_tunnel_xmit_skb # 加外层 UDP/IP
→ 物理网卡
接收路径:
物理网卡 → UDP 4789 → vxlan_rcv
→ 校验 VNI、查 FDB
→ 剥外层头 → netif_rx / gro_cells
→ bridge → veth → Pod
FDB 是 VXLAN 的「MAC → 远端 VTEP」表:单播命中就发对应 VTEP,未命中就 flood(组播或头端复制)。VNI 不匹配、FDB 缺失、MTU 不对是 overlay 三大故障源。
# 两台宿主机:192.0.2.1 / 192.0.2.2,VNI 42
sudo ip link add vxlan0 type vxlan id 42 remote 192.0.2.2 \
local 192.0.2.1 dev eth0 dstport 4789
sudo ip link set vxlan0 up
sudo ip addr add 10.42.0.1/24 dev vxlan0
# 对端同样创建,remote 指向 192.0.2.1,IP 10.42.0.2
# 查看 VNI、本地/远端、MTU
ip -d link show vxlan0
# 看 FDB(动态学习或静态 append)
bridge fdb show dev vxlan0
sudo bridge fdb append 00:00:00:00:00:00 dev vxlan0 dst 192.0.2.2
抓包要分两层看:
# 外层:UDP 4789,能看到 VTEP 地址
sudo tcpdump -i eth0 -nn 'udp port 4789'
# 内层:vxlan0 上看到原始 Pod 的 MAC/IP
sudo tcpdump -i vxlan0 -e -nn
# 看 MTU 与封装后的实际长度
ip link show vxlan0
ping -M do -s 1450 -c 2 10.42.0.2 # 1472 会因 50 B 开销失败
MTU 必须减 50:underlay 1500 → overlay 1450。没有配 MSS clamp 时,Pod 发出的 1500 字节 TCP 段会在 underlay 被分片或丢弃,表现为「小包通、大包卡」。修复:
sudo ip link set vxlan0 mtu 1450
# 或在 veth/forward 上钳 MSS
sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
-j TCPMSS --clamp-mss-to-pmtu
CNI 是把这些命令产品化的插件协议。kubelet 调用 CNI 插件的 ADD,插件拿到「容器 netns + 接口名 + IPAM 结果」,负责创建 veth、接 bridge、配 IP/路由、加 NAT;DEL 负责清理。常见插件:
| 插件 | 做什么 | 不做什么 |
|---|---|---|
bridge | 创建 veth、接 bridge、配 IP | 跨主机路由 |
host-local IPAM | 从网段分配 IP、记录已用地址 | 路由/转发 |
calico | BGP 路由或 IPIP/VXLAN | 应用层策略(由 Felix 下) |
cilium | eBPF 数据面、Service 负载均衡 | 传统 iptables 规则 |
CNI 只负责「建网」,不负责转发;转发由内核的 bridge/路由/netfilter/eBPF 完成。排查 CNI 问题时,先看 /etc/cni/net.d/*.conf,再 nsenter 进容器看 ip addr/ip route,最后在宿主机看 bridge/FDB。
- VNI 不一致:两端
id不同,vxlan_rcv直接丢;ip -d link show核对。 - FDB 缺失:单播表没有对端 VTEP,包被 flood 或丢弃;
bridge fdb show看动态学习是否生效。 - MTU 没减 50:小包通、大包卡;
ping -M do -s 1450验证,配 MSS clamp。 remote写成对端 overlay IP:remote是 underlay VTEP 地址,不是 10.42.0.x。- 在 vxlan0 上抓不到外层:
tcpdump -i vxlan0只看内层;外层要在物理网卡上抓 UDP 4789。
4.11.23 Service、iptables 与 IPVS:ClusterIP 的数据面
Kubernetes Service 不是一台真实设备,而是一组「把 VIP:port 映射到 Pod IP:port」的规则。kube-proxy 负责下发规则,数据面有三种实现:
| 模式 | 数据面 | 负载均衡 | 典型问题 |
|---|---|---|---|
| iptables | netfilter NAT 表 | -m statistic --probability 随机 | 规则多时线性遍历、conntrack 压力 |
| IPVS | 内核 IPVS(nf_hook 上) | rr/lc/sh 等调度器 | 需要 ip_vs 模块、conntrack 交互 |
| nftables | nftables 规则 | 类似 iptables | 新版本才稳定,工具链较新 |
iptables 模式的链:
PREROUTING → KUBE-SERVICES
→ KUBE-SVC-<hash> # 按 ClusterIP:port 匹配
→ KUBE-ENDPOINTS-<hash> # 按概率选一个后端
→ DNAT --to-destination <podIP>:<port>
POSTROUTING → KUBE-POSTROUTING(MASQUERADE,hairpin/外网)
# 看 Service 规则(规则多时输出很长,先抓一条)
sudo iptables -t nat -L KUBE-SERVICES -n -v | head
sudo iptables -t nat -L KUBE-SVC-XXXX -n -v
# 看 conntrack 里的 DNAT 映射
sudo conntrack -L -p tcp --dport 80 | head
# 看 Service 与 Endpoint
kubectl get svc,endpoints
IPVS 模式:
sudo ipvsadm -Ln # 虚拟服务与真实服务器
sudo ipvsadm -Ln --stats # 连接数/字节
sudo ipvsadm -Lnc # 连接表
IPVS 支持三种转发:
| 模式 | 改什么 | 回包路径 | 要求 |
|---|---|---|---|
| NAT | 改目的 IP/端口 | 经过 IPVS 反向翻译 | 宿主机做网关 |
| DR(Direct Routing) | 改目的 MAC,不改 IP | 后端直接回客户端 | 后端配 VIP 在 lo、ARP 抑制 |
| Tunnel(IPIP) | 外层 IP 封装 | 后端直接回客户端 | 支持 IPIP 解封装 |
Kubernetes 默认用 NAT(-j MASQUERADE + DNAT),因为 Pod 网络通常已经能路由到后端;DR/Tunnel 需要额外的网络配置。
一次 ClusterIP 访问:
Pod A → 10.96.0.10:80(ClusterIP)
→ PREROUTING:KUBE-SERVICES 命中,DNAT 到 10.244.1.5:8080
→ conntrack 记录映射
→ 路由到 Pod B
Pod B 回包 → conntrack 反向 DNAT → 源地址变回 10.96.0.10:80
hairpin(Pod 访问自己的 Service)需要额外 SNAT,否则回包绕过负载均衡;externalTrafficPolicy: Local 会保留客户端源 IP,但只有本节点有 Endpoint 时才转发,否则丢包。这些行为都能在 conntrack -L 和 iptables -t nat -L -v 的计数器里看到。
- Service 没有 Endpoint:规则存在但后端为空,表现为
REJECT或连接拒绝;先kubectl get endpoints。 - hairpin 没做 SNAT:Pod 访问自己的 Service,回包绕过 IPVS/iptables,连接超时。
externalTrafficPolicy: Local加上没有本地 Endpoint:节点直接丢包,客户端看到超时。- iptables 与 IPVS 规则同时存在:切换模式后旧规则可能残留,
iptables-save/ipvsadm -Ln都要看。 - conntrack 表满导致 Service 随机失败:
ss正常、conntrack -S的drop在涨;见 4.8.24。
4.11.24 实验:netns + veth + VXLAN 的两层观测
目标:在同一台 Linux 上搭两个 netns,先用 veth 直连,再用 VXLAN 封装,最后用 tcpdump 分别看内外层。
#!/usr/bin/env bash
set -euo pipefail
for ns in vx1 vx2; do sudo ip netns del "$ns" 2>/dev/null || true; done
sudo ip netns add vx1; sudo ip netns add vx2
# underlay:一对 veth,10.0.0.1 <-> 10.0.0.2
sudo ip link add veth1 type veth peer name veth2
sudo ip link set veth1 netns vx1
sudo ip link set veth2 netns vx2
for pair in "vx1:veth1:10.0.0.1" "vx2:veth2:10.0.0.2"; do
ns=${pair%%:*}; rest=${pair#*:}; dev=${rest%%:*}; ip=${rest##*:}
sudo ip netns exec "$ns" ip link set lo up
sudo ip netns exec "$ns" ip addr add "$ip/24" dev "$dev"
sudo ip netns exec "$ns" ip link set "$dev" up
done
# overlay:VNI 42,本地/远端 VTEP 是 underlay 地址
sudo ip netns exec vx1 ip link add vxlan0 type vxlan id 42 \
local 10.0.0.1 remote 10.0.0.2 dev veth1 dstport 4789
sudo ip netns exec vx2 ip link add vxlan0 type vxlan id 42 \
local 10.0.0.2 remote 10.0.0.1 dev veth2 dstport 4789
sudo ip netns exec vx1 ip addr add 192.168.42.1/24 dev vxlan0
sudo ip netns exec vx2 ip addr add 192.168.42.2/24 dev vxlan0
sudo ip netns exec vx1 ip link set vxlan0 up
sudo ip netns exec vx2 ip link set vxlan0 up
# 1) overlay ping
sudo ip netns exec vx1 ping -c 2 192.168.42.2
# 2) 外层抓包:UDP 4789
sudo ip netns exec vx1 timeout 3 tcpdump -i veth1 -nn -c 4 'udp port 4789' &
# 3) 内层抓包:vxlan0 上的 ICMP
sudo ip netns exec vx1 timeout 3 tcpdump -i vxlan0 -e -nn -c 4 &
sleep 0.5
sudo ip netns exec vx1 ping -c 2 192.168.42.2
wait
# 4) 查看 VNI/MTU/FDB
sudo ip netns exec vx1 ip -d link show vxlan0
sudo ip netns exec vx1 bridge fdb show dev vxlan0
预期现象:
ping 192.168.42.2通,RTT 比 veth 直连高(多一层封装/解封装)。- 外层
tcpdump看到10.0.0.1.xxxxx > 10.0.0.2.4789,内层tcpdump看到192.168.42.1 > 192.168.42.2 ICMP。 ip -d link show vxlan0显示id 42、local 10.0.0.1、remote 10.0.0.2、dstport 4789。bridge fdb show dev vxlan0里能看到对端 VTEP 的动态条目(或00:00:00:00:00:00的 flood 项)。
把 MTU 改成 1500 再 ping -M do -s 1472,会失败;改成 1450 后成功——这就是 overlay 的 50 字节开销。
实验需要 Linux 的 vxlan/bridge/veth 模块与 root;本机为 macOS,未实测。VXLAN 的
remote必须是 underlay 地址;同一台机器上两个 netns 的 VXLAN 可以工作,但生产环境通常跨主机。
4.11.25 本章陷阱清单
- 把
/proc/net/sockstat的inuse当连接总数:它不含 TIME_WAIT,TIME_WAIT 看tw或ss -s。 - 用回环 MSS 推断以太网 MSS:回环 MTU 65536,MSS 约 65483;以太网是 1460。
- 认为回环不经过协议栈:它仍走
ip_output→loopback_xmit→ backlog →ip_rcv。 - 把
tcp_mem当字节:单位是页(4 KB),且是全局所有 TCP socket 的总和。 - 只调
tcp_rmem不调tcp_wmem:接收窗口受 rmem 限制,发送队列受 wmem 限制,两边都可能成为瓶颈。 - 把
SO_RCVBUF设成 0 想「不缓冲」:内核会设最小值,且窗口不会因此为 0。 - 认为端口范围就是最大连接数:服务端不受临时端口限制;客户端还受 TIME_WAIT 与
tcp_tw_reuse影响。 - 忽略 fd 的 soft/hard 两重限制:
ulimit -n只改 soft,hard 要 root 或 systemd 配置。 - 把
TCPslab 的对象数当「当前连接数」:它包含 TIME_WAIT、orphan、正在销毁的 socket。 - 在容器里读宿主机的
/proc/net/sockstat:netns 会隔离部分统计,但 slab 是全局的,两者不能直接相减。 - 用
ss -s的mem当进程 RSS:它是协议栈记账,不含页表、fd 表等。 - 百万连接实验忘了调
fs.file-max/nr_open:ulimit -n调到百万也会在nr_open处失败。 - netns 里的 lo 默认 down:
ping 127.0.0.1失败;先ip link set lo up。 - VXLAN 的
remote写成 overlay IP:它要的是 underlay VTEP 地址;再加 MTU 没减 50,表现为小包通、大包卡。 - Service 没有 Endpoint 或 hairpin 没 SNAT:规则看起来正常,连接却拒绝/超时;先
kubectl get endpoints,再看conntrack -L。
4.11.26 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说清 AF_UNIX 与 loopback TCP 的差别 | 知道一个走 IP、一个不走 | 4.11.1 |
| 描述 loopback 的发送捷径 | 知道 loopback_xmit 调 netif_rx | 4.11.2 |
| 算出一条连接的内存 | 给出 3–8 KB 的构成 | 4.11.7 |
| 说清 fd 的三层限制 | 能区分 EMFILE 与 ENFILE | 4.11.6 |
| 解释客户端的端口墙 | 知道多源地址能突破 | 4.11.8 |
| 按顺序说出百万连接的瓶颈 | fd → 端口 → 内存 → 队列 → CPU | 4.11.9 |
| 说出回环路径与物理网卡的差异 | 知道为何不需要 ARP | 4.11.15 |
| 拆解一条连接的内存构成 | 能按 4 项估算百万连接 | 4.11.16 |
| 用 /proc 量每连接开销 | 能对上 slabinfo 与 sockstat | 4.11.17 |
| 用脚本核算容量短板 | 知道 fd/端口/内存哪个先到顶 | 4.11.18 |
| 用 netns/veth/bridge 搭二层网络 | 能读懂 bridge fdb | 4.11.21 |
| 画出 VXLAN 封装/解封装 | 能解释 50 字节开销与 MTU | 4.11.22 |
| 解释 Service DNAT 与 IPVS 模式 | 能区分 iptables/IPVS 数据面 | 4.11.23 |
Q1 本机两个进程用 127.0.0.1:8080 通信,数据经过网卡吗?经过哪些协议层?
Q2 一条空闲 TCP 连接大约占多少内核内存?100 万条需要多少?这个估算漏了什么?
Q3 服务端 somaxconn=4096、listen(fd, 128),某时刻 ss -lnt 的 Recv-Q=128。说明了什么?
Q4 客户端只有一个 IP,能对同一个服务端发起多少条 TCP 连接?怎么突破?
Q5 ss -s 显示 orphan 很大,可能是什么原因?
Q6 fs.file-nr 的第一个数接近第三个,open() 会返回什么错误?该调什么?
答案
A1 不经过物理网卡,但经过完整的 IP 和 TCP 层,只是出接口是 lo(loopback_xmit 直接把 skb 交给接收路径)。所以有 TCP 分段、校验和、路由查找、socket 查找的全部开销,只是没有 DMA 和硬件中断。
A2 约 3–8 KB(struct sock 1.5–2 KB + 收发缓冲区各 2–4 KB + file/dentry)。100 万条约 3–8 GB。估算漏掉了:skb 的实际占用(有数据时)、应用层缓冲区、epoll 的 epitem、slab 的内部碎片、以及 tcp_mem 之外的页表开销。
A3 全连接队列满了(上限 min(128, 4096) = 128)。此时新的第三次 ACK 会被丢弃,客户端可能显示已建立但发数据被 RST。要么调大 backlog,要么应用 accept 太慢——看 ListenOverflows 是否在增长。
A4 对同一个「目的 IP:端口」,受 ip_local_port_range 限制,默认约 2.8 万条。突破方法:绑多个源 IP(每个源 IP 独立端口空间)、连多个目的地址、或者用连接池复用。
A5 应用已经 close,但内核还在等对端的 FIN 或还在重传(socket 进入 orphan 状态)。常见于对端不响应、网络中断、或者应用大量强制关闭短连接。
A6 返回 ENFILE(系统级文件表满),不是 EMFILE(进程级)。该调 fs.file-max,而不是 ulimit -n。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| N(深入理解 Linux 网络) | 第 5 章 5.2–5.5(跨机通信、本机发送、本机接收) | 4.11.1–4.11.4 | 完整:loopback 捷径与两次协议处理 |
| N(深入理解 Linux 网络) | 第 7 章 7.2–7.5(内存管理、TCP 对象、实测开销) | 4.11.7 | 完整:单连接内存的构成与实测 |
| N(深入理解 Linux 网络) | 第 8 章 8.2–8.6(fd 限制、百万连接、客户端端口) | 4.11.5–4.11.10 | 完整:容量账与实验步骤 |
| K(Kerrisk) | 第 57 章 57.2–57.5(UNIX domain 流/数据报、socketpair) | 4.11.1 | 精选:本机通信的另一种选择 |
| C(CSAPP) | 第 11 章 11.4(getaddrinfo 与连接建立) | 4.11.8 | 背景:客户端地址选择 |
| N(深入理解 Linux 网络) | 第 5 章(本机网络,PDF p135–154) | 4.11.15 | 补充:回环路径与成本 |
| N(深入理解 Linux 网络) | 第 7–8 章(连接内存、最大连接数,PDF p203–268) | 4.11.16–4.11.18 | 完整:内存账与容量 |
| K(Kerrisk) | 第 56 章 56.5(流 socket 与 fd 限制,PDF p945–956) | 4.11.17 | 背景:fd 与 socket 生命周期 |
| N(深入理解 Linux 网络) | 第 10 章 10.2–10.5(veth/netns/bridge/外部通信,PDF p290–334) | 4.11.20–4.11.23 | 完整:容器网络四件套 |
| W(谢希仁) | 第 4 章(IP 转发、隧道与 NAT,PDF p126–221) | 4.11.22、4.11.23 | 背景:封装与地址转换 |
| Linux 6.8 源码 | drivers/net/veth.c、net/bridge/*、drivers/net/vxlan/vxlan_core.c、net/netfilter/ipvs/* | 4.11.20–4.11.23 | 完整:数据路径 |
下一站:知道了瓶颈在哪,下一步是怎么优化。进入 4.12 网络性能优化与容器网络,把前面所有观测手段组合成一套可执行的优化顺序。