Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 层,不进 IP5–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 流 socket5–15 µs两次拷贝 + 调度
TCP loopback20–60 µs两遍 TCP/IP + backlog
同机房 TCP100–500 µs网卡、交换机、协议栈
跨洲 TCP50–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_NOFILEulimit -n1024(soft)/ 1048576(hard)
系统级fs.nr_opensysctl fs.nr_open1048576
全局fs.file-maxsysctl fs.file-max依内存,通常很大
当前使用/proc/sys/fs/file-nrcat三个数:已分配/未使用/上限

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_sock1.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 万条连接。要突破,只有三条路:

  1. 增加目的地址:连不同的后端(多机房、多 IP)。
  2. 增加源地址:本机绑多个 IP(127.0.0.2、127.0.0.3……),每个源 IP 各有独立的端口空间。
  3. 复用连接:用连接池,一条连接跑多个请求。
# 看端口范围
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 就是当前建立的连接数。如果它停在某个数字不再增长,往下看是哪个计数先到顶。

百万连接的瓶颈顺序
  1. fd:ulimit -n / fs.file-max。先撞这个,报 EMFILE/ENFILE。
  2. 端口:客户端单源 IP 最多 2.8 万,需要多源地址。
  3. 内存:/proc/net/sockstat 的 mem,超过 tcp_mem 第二档开始收缩。
  4. 队列:somaxconn / backlog,ListenOverflows 增长。
  5. CPU:软中断与 ss/tcpdump 这类观测工具本身也会成为瓶颈。

4.11.10 ss -s 与容量观测

ss -s 是容量观测的入口,它一次性给出全局视图。几个字段要会读:

字段含义异常时的解读
estab已建立连接数突然下跌说明服务重启或大量断开
closed已关闭但未回收大量说明回收慢或短连接过多
orphan无主连接(socket 已关,内核还在收尾)增长说明应用关得太快或 FIN 收不到
synrecv半连接数增长说明 SYN flood 或握手慢
timewaitTIME_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主动关闭方等待 2MSLtcp_max_tw_buckets超出直接销毁并打印告警
orphan应用已 close,内核还在收尾tcp_max_orphans超出回 RST 强制关闭
FIN_WAIT_2发了 FIN 等对方 FINtcp_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 里,分两步:

  1. 在 ip_local_port_range 范围内,从一个随机偏移开始扫描。
  2. 对每个候选端口,检查五元组是否冲突:同一个「源 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 万连接耗时说明
ssnetlink(sock_diag)几十毫秒内核直接返回,最快
netstat遍历 /proc/net/tcp几秒已废弃,大表下极慢
lsof遍历 /proc/*/fd几十秒还会打开每个 fd,影响业务

netstat 慢是因为它要读 /proc/net/tcp 的文本、逐行解析,而且 /proc 的生成本身要遍历内核的哈希表。在 10 万连接以上,netstat 可能卡住几十秒,期间 CPU 被占满——这就是「排查工具把服务拖垮」的经典场景。用 ss。

即使都用 ss,三个口径也要分清:

  1. ss -s 的 estab 是内核哈希表里的已建立连接数,包含所有 netns 吗?不包含——它只统计当前 netns。容器里看到的和宿主机上看到的不一样。
  2. /proc/net/sockstat 的 inuse 是「已分配的 TCP socket 数」,包含 TIME_WAIT 和 orphan,口径比 estab 大。
  3. 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 与 eBPF 数据面

要绕过虚拟交换机的开销,两条路:SR-IOV 把物理网卡的虚拟功能(VF)直通给虚拟机,绕过宿主机的 vSwitch;eBPF 数据面(Cilium 的 tc/XDP 程序)在内核里直接做转发和 NAT,跳过 iptables 的线性匹配。前者依赖硬件和云厂商支持,后者依赖内核版本(4.19+ 更成熟),两者都能把「云上比物理机慢」的差距缩小一半以上。

4.11.15 回环的内核路径:为什么它不需要 ARP 和校验和

4.11.2 说回环走「捷径」,这一节把捷径拆成函数。回环不是「特殊协议」,而是一块没有物理介质、没有队列、没有 ARP 的虚拟网卡(lo),它的发送路径比物理网卡短得多。

阶段函数文件与物理网卡的区别
路由ip_route_output_flownet/ipv4/route.c目的地址在本机,直接选 lo
输出ip_output → ip_finish_outputnet/ipv4/ip_output.c不需要分片(MTU 65536)
排队dev_queue_xmitnet/core/dev.clo 是 noqueue,没有 qdisc
驱动loopback_xmitdrivers/net/loopback.c不发网线,直接 netif_rx
回灌netif_rx → backlognet/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 微秒,其中相当一部分是两次软中断与进程唤醒的调度成本。

回环的 MTU 为什么是 65536

lo 的 MTU 是 65536,接近 IPv4 报文的最大长度(65535)。它让本机大块传输几乎不分片,减少了分片/重组开销。但这也带来一个陷阱:在回环上测出的 MSS 是 65483 左右,不能拿来推断以太网的 1460。把回环的吞吐数字直接套到物理网卡上,会高估 5–10 倍。

4.11.16 一条连接的内存构成:从 struct sock 到缓冲区

4.11.7 给了「一条连接吃多少内存」的量级,这一节把它拆成可核算的项。一条空闲 TCP 连接的内存分四块:

组成典型大小何时分配记账位置
struct socket + struct file~500 Baccept 返回新 fd 时进程 fd 表
struct sock + struct tcp_sock1.5–2.5 KB第三次 ACK 创建子 socketTCP slab
接收缓冲(sk_rcvbuf)0 到数 MB收到数据时按需sk_rmem_alloc
发送缓冲(sk_sndbuf)0 到数 MB应用写入时按需sk_wmem_queued
request_sock(握手期)~256 BSYN 到达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 泄漏)。

例 4.11.1 · 100 万连接的内存预算

按每条连接 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 的五个坑
  1. 新 netns 的 lo 是 down 的:ping 127.0.0.1 直接失败;先 ip link set lo up。
  2. ip netns add 后没清理:netns 挂载点残留,ip netns list 越来越多;删除用 ip netns del。
  3. 在多线程进程里 setns:只有调用线程切换,其他线程还在旧 ns;在 fork 后的单线程子进程里做。
  4. 以为 net.* sysctl 全局生效:大部分是 per-netns,改宿主机的不影响容器;要进 ns 改。
  5. 把 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_droppedip -s link队列/内存不足,或对端 netns 已删除
rx_nohandlerveth/bridge没有协议处理者,通常是 bridge 端口状态不对
br0 的 rx_bytesbridge经过二层转发的总量
veth/bridge 的五个坑
  1. veth 两端不在同一个 ns:创建时用 peer name eth0 netns ns1,之后看不到对端;排查要进对应 ns。
  2. bridge 没 up:master br0 配好了但 br0 down,帧不会转发;ip link set br0 up。
  3. veth-only 环境被 rp_filter 丢包:两端在同一台机器、非对称路由时,宽松模式 rp_filter=2 或关掉再试。
  4. 把 bridge 当三层设备:bridge 默认只做二层;跨网段要配 IP/路由或 NAT,不会自动路由。
  5. 容器删除后 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、记录已用地址路由/转发
calicoBGP 路由或 IPIP/VXLAN应用层策略(由 Felix 下)
ciliumeBPF 数据面、Service 负载均衡传统 iptables 规则

CNI 只负责「建网」,不负责转发;转发由内核的 bridge/路由/netfilter/eBPF 完成。排查 CNI 问题时,先看 /etc/cni/net.d/*.conf,再 nsenter 进容器看 ip addr/ip route,最后在宿主机看 bridge/FDB。

VXLAN/overlay 的五个坑
  1. VNI 不一致:两端 id 不同,vxlan_rcv 直接丢;ip -d link show 核对。
  2. FDB 缺失:单播表没有对端 VTEP,包被 flood 或丢弃;bridge fdb show 看动态学习是否生效。
  3. MTU 没减 50:小包通、大包卡;ping -M do -s 1450 验证,配 MSS clamp。
  4. remote 写成对端 overlay IP:remote 是 underlay VTEP 地址,不是 10.42.0.x。
  5. 在 vxlan0 上抓不到外层:tcpdump -i vxlan0 只看内层;外层要在物理网卡上抓 UDP 4789。

4.11.23 Service、iptables 与 IPVS:ClusterIP 的数据面

Kubernetes Service 不是一台真实设备,而是一组「把 VIP:port 映射到 Pod IP:port」的规则。kube-proxy 负责下发规则,数据面有三种实现:

模式数据面负载均衡典型问题
iptablesnetfilter NAT 表-m statistic --probability 随机规则多时线性遍历、conntrack 压力
IPVS内核 IPVS(nf_hook 上)rr/lc/sh 等调度器需要 ip_vs 模块、conntrack 交互
nftablesnftables 规则类似 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 的五个坑
  1. Service 没有 Endpoint:规则存在但后端为空,表现为 REJECT 或连接拒绝;先 kubectl get endpoints。
  2. hairpin 没做 SNAT:Pod 访问自己的 Service,回包绕过 IPVS/iptables,连接超时。
  3. externalTrafficPolicy: Local 加上没有本地 Endpoint:节点直接丢包,客户端看到超时。
  4. iptables 与 IPVS 规则同时存在:切换模式后旧规则可能残留,iptables-save/ipvsadm -Ln 都要看。
  5. 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 本章陷阱清单

15 条高频错误
  1. 把 /proc/net/sockstat 的 inuse 当连接总数:它不含 TIME_WAIT,TIME_WAIT 看 tw 或 ss -s。
  2. 用回环 MSS 推断以太网 MSS:回环 MTU 65536,MSS 约 65483;以太网是 1460。
  3. 认为回环不经过协议栈:它仍走 ip_output → loopback_xmit → backlog → ip_rcv。
  4. 把 tcp_mem 当字节:单位是页(4 KB),且是全局所有 TCP socket 的总和。
  5. 只调 tcp_rmem 不调 tcp_wmem:接收窗口受 rmem 限制,发送队列受 wmem 限制,两边都可能成为瓶颈。
  6. 把 SO_RCVBUF 设成 0 想「不缓冲」:内核会设最小值,且窗口不会因此为 0。
  7. 认为端口范围就是最大连接数:服务端不受临时端口限制;客户端还受 TIME_WAIT 与 tcp_tw_reuse 影响。
  8. 忽略 fd 的 soft/hard 两重限制:ulimit -n 只改 soft,hard 要 root 或 systemd 配置。
  9. 把 TCP slab 的对象数当「当前连接数」:它包含 TIME_WAIT、orphan、正在销毁的 socket。
  10. 在容器里读宿主机的 /proc/net/sockstat:netns 会隔离部分统计,但 slab 是全局的,两者不能直接相减。
  11. 用 ss -s 的 mem 当进程 RSS:它是协议栈记账,不含页表、fd 表等。
  12. 百万连接实验忘了调 fs.file-max/nr_open:ulimit -n 调到百万也会在 nr_open 处失败。
  13. netns 里的 lo 默认 down:ping 127.0.0.1 失败;先 ip link set lo up。
  14. VXLAN 的 remote 写成 overlay IP:它要的是 underlay VTEP 地址;再加 MTU 没减 50,表现为小包通、大包卡。
  15. Service 没有 Endpoint 或 hairpin 没 SNAT:规则看起来正常,连接却拒绝/超时;先 kubectl get endpoints,再看 conntrack -L。

4.11.26 本章出口检查

检查项达标线没达标去哪
说清 AF_UNIX 与 loopback TCP 的差别知道一个走 IP、一个不走4.11.1
描述 loopback 的发送捷径知道 loopback_xmit 调 netif_rx4.11.2
算出一条连接的内存给出 3–8 KB 的构成4.11.7
说清 fd 的三层限制能区分 EMFILE 与 ENFILE4.11.6
解释客户端的端口墙知道多源地址能突破4.11.8
按顺序说出百万连接的瓶颈fd → 端口 → 内存 → 队列 → CPU4.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 |

即时自测 · 4.11

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 网络性能优化与容器网络,把前面所有观测手段组合成一套可执行的优化顺序。