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.12 网络性能优化与容器网络

本章回答

面对「网络慢」,第一步该做什么?收包、发包、应用协作三个方向各有哪些旋钮,哪些能调、哪些是骗局?容器网络为什么比宿主机慢,veth 和 bridge 到底做了什么?VXLAN 的 50 字节开销从哪来?nftables 的最小可用规则集长什么样?

读完你会

按「先测量、再定位、后调参」的顺序做优化;用前十一章的观测手段判断瓶颈在收包、发包还是应用;用 netns + veth + bridge 手工搭一个容器网络;用 nft 写出端口映射与 SNAT 的最小规则集。

这一章是全篇的收口。前面十一章分别讲了协议、内核路径、容量规划,现在要把它们组合成一套可执行的优化顺序。之所以强调顺序,是因为网络优化的最大陷阱不是「不知道调什么」,而是「调错了地方」——把接收缓冲区调大,结果瓶颈在应用锁;把拥塞算法换成 BBR,结果瓶颈在 DNS。

优化有一条铁律:先测量,再假设,最后才动手。测量不是「跑个分」,而是把端到端延迟拆成几段,找到占比最大的那段。4.7.9 的 curl -w 是应用层的拆法,4.8/4.9 的 softnet_stat/qdisc 是内核层的拆法,这一章会把它们串起来。

后半章讲容器网络。它看起来是新东西,其实全是前面讲过的原语:netns 隔离协议栈,veth 当网线,bridge 当交换机,VXLAN 当隧道,nftables 当 NAT。看懂容器网络不需要新知识,只需要把旧知识组合起来。

4.12.1 先测量:把「慢」拆成四段

「网络慢」是一个没有信息量的描述。要优化,先把它拆成四段,每段有独立的观测手段:

段典型耗时观测手段优化方向
DNS0.1–100 mscurl -w '%{time_namelookup}'、dig缓存、改用 IP、DoH
TCP 握手0.1–200 mstime_connect - time_namelookup连接复用、TFO、就近接入
TLS 握手0.2–300 mstime_appconnect - time_connectTLS 1.3、会话复用、OCSP stapling
服务端处理 + 传输变化最大time_starttransfer - time_appconnect应用优化、缓存、并发
# 一条命令拿到四段耗时
curl -sS -o /dev/null -w \
  'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
  https://example.com/

拿到这四个数,先做减法,再看哪段最大。如果 ttfb - tls 占了 90%,说明是服务端慢,网络参数一个都不用调。这是最常见的误判:把应用问题当成网络问题,调了一圈内核参数,毫无效果。

内核层的测量用另一组工具:

# 软中断分布与丢包
grep 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]} squeeze={f[2]} rps={f[4]}")
PY
# qdisc 排队与丢包
tc -s qdisc show dev eth0 | head -12
# 重传与超时
nstat -az | grep -E 'TcpRetransSegs|TcpExtTCPTimeouts|TcpExtListenOverflows'

这三组数字分别回答三个问题:CPU 收得过来吗(softirqs/softnet_stat)、队列堵了吗(qdisc)、链路丢包吗(retrans)。三个都正常,问题就在应用。

4.12.2 请求优化:减少往返比减少字节更值钱

应用层的优化有一个优先级:减少 RTT 次数 > 减少传输字节 > 减少 CPU。因为在大多数场景里,RTT 是硬成本,字节数和 CPU 都可以靠硬件堆。

手段省掉什么效果代价
DNS 缓存 / 预解析一次 DNS 查询10–100 ms缓存一致性
连接池 / keep-alive每次 TCP + TLS 握手1–3 RTT连接管理复杂
HTTP/2 多路复用多次连接建立明显服务端支持
TLS 1.3 + session resumption1 RTT明显兼容性
内容压缩(gzip/br)传输字节带宽省 60–80%CPU
CDN / 边缘节点传播延迟最大成本

最容易被忽视的是 DNS。一次没有缓存的 DNS 查询可能要 20–100 ms,而它发生在所有事情之前。容器环境里 ndots:5 会让短域名先尝试多次补全,把一次查询变成四五次。在 Kubernetes 里把 ndots 调到 2,往往比调任何 TCP 参数都有效。

# 看一次解析花了多久
time getent hosts example.com
# 看解析器配置(ndots、超时、重试)
cat /etc/resolv.conf

4.12.3 收包优化:从网卡到软中断

收包路径的瓶颈按「从下到上」的顺序排查,因为下面的问题会伪装成上面的问题。

第一层:网卡 ring。 ethtool -S 的 rx_missed_errors 增长,说明 ring 太小或 CPU 取走得慢。

ethtool -g eth0                       # 看当前 ring 大小
sudo ethtool -G eth0 rx 4096          # 调大(注意内存)

第二层:中断与队列。 中断集中在一个 CPU 上,其他核闲着,这是最常见的浪费。

# 看每个队列的中断落在哪个 CPU
grep -E 'eth0|ens|enp' /proc/interrupts
# 看队列数
ethtool -l eth0
# 调整中断亲和性(把队列 0 绑到 CPU0-1)
sudo sh -c 'echo 3 > /proc/irq/$(grep eth0 /proc/interrupts | head -1 | cut -d: -f1)/smp_affinity'

第三层:软中断预算。 time_squeeze 增长说明一次软中断处理不完。

sysctl net.core.netdev_budget net.core.netdev_budget_usecs
sudo sysctl -w net.core.netdev_budget=600

第四层:协议栈处理。 GRO 合并、RPS 分流、减少 netfilter 规则。

ethtool -k eth0 | grep -E 'generic-receive-offload'
# RPS:单队列网卡把软中断分到多核
echo f | sudo tee /sys/class/net/eth0/queues/rx-0/rps_cpus

优化的顺序不能反。如果 ring 在丢包,调 netdev_budget 没有任何用;如果中断全在一个核,开 GRO 也只是让那个核更忙。先把包收进来,再谈处理得快。

收包优化的三个骗局
  1. 「把 ring 调到最大就好」。ring 占的是内核内存,4096 个描述符 × 2 KB 就是 8 MB/队列,16 队列就是 128 MB。而且 ring 大只吸收突发,不解决持续过载。
  2. 「RPS 一定提升性能」。队列数 ≥ CPU 数时,RPS 只增加 IPI 开销。它只对单队列网卡有意义。
  3. 「关掉 GRO 降低延迟」。只在极低延迟场景(高频交易)成立,普通服务关掉会让协议栈处理次数翻几十倍。

4.12.4 发包优化:qdisc 与卸载

发包路径的瓶颈通常是 qdisc 排队和单流限速。

先看 qdisc 统计:

tc -s qdisc show dev eth0
# 关注 backlog(当前排队字节)、dropped(队列满丢弃)、requeues(驱动忙退回)

backlog 持续很高说明发送速度超过链路,dropped 增长是正常的拥塞信号(对 TCP 有利),但如果发生在本地虚拟设备上,说明配置有问题。

换 qdisc。 默认的 fq_codel 已经不错,但在高带宽场景可以试 fq(配合 BBR 的 pacing):

sudo tc qdisc replace dev eth0 root fq
sudo tc qdisc show dev eth0

调 TSQ。 tcp_limit_output_bytes 限制单流在队列里的字节数,太小会限制单流吞吐,太大会推高延迟:

sysctl net.ipv4.tcp_limit_output_bytes
# 高带宽单流场景可以调大(默认约 1 MB)
sudo sysctl -w net.ipv4.tcp_limit_output_bytes=4194304

确认卸载开启。 TSO/GSO/校验和卸载能显著降低 CPU:

ethtool -k eth0 | grep -E 'tcp-segmentation|generic-segmentation|tx-checksum'

这四个开关默认都应该是 on。如果被抓包或兼容性问题临时关掉,记得改回来——关掉 TSO 会让 CPU 占用上升几倍。

4.12.5 协作优化:内核与应用之间的那段

即使协议栈很快,应用取数据的方式也可能拖垮整体。这一段有三个关键点:

第一,用 epoll,不要用 select。 4.10 已经解释了原因:select 每次都要拷贝位图并遍历所有 fd。连接数上千后,这个开销会超过网络本身。

第二,每个线程一个 epoll 实例。 多线程共享一个 epoll 会有锁竞争和「同一 fd 被两个线程拿到」的问题。用 EPOLLONESHOT 或每线程独立实例。

第三,用 SO_REUSEPORT 做多进程分流。 它让内核按四元组哈希把连接分给多个监听进程,避免 accept 锁:

int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
# 看是否多个进程监听同一端口
ss -lntp 'sport = :8080'

还有一个容易忽略的点:系统调用的次数。每次 read 都有一次用户态/内核态切换,读 1 字节和读 64 KB 的切换成本一样。所以应用应该「一次读尽可能多」,而不是「一次读一行」。

# 看系统调用的分布与耗时
strace -c -f -p <pid>
# 看每次 read 的平均字节数(太小说明读得太碎)
sudo bpftrace -e 'tracepoint:syscalls:sys_exit_read /args->ret > 0/ { @bytes = hist(args->ret); }'
io_uring 能带来什么

io_uring 把「提交请求」和「收割完成」做成两个环形队列,批量提交、批量收割,减少系统调用次数。对高 PPS 的网络应用,它能省掉大量 epoll_wait + read 的切换。代价是编程模型复杂,且需要较新的内核(5.1+,网络部分 5.6+ 才成熟)。普通 Web 服务用 epoll 就够了。

4.12.6 握手与挥手优化

短连接场景里,握手占的时间比例很高。可用的手段不多,但每个都很直接:

手段省掉前提
连接池 / keep-alive每次握手应用改造
TCP_FASTOPEN1 RTT(重复连接)内核与服务端支持
TCP_DEFER_ACCEPT一次唤醒客户端先发数据
TLS session resumption1 RTT会话票据
调大 somaxconn + backlog队列溢出应用 accept 够快

挥手方向的优化主要是避免 TIME_WAIT 堆积。与其调 tcp_tw_reuse,不如让连接活得久一点:

# 看 TIME_WAIT 数量与端口占用
ss -tan state time-wait | wc -l
ss -s | head -2
# 短连接客户端可以开 reuse(需要时间戳)
sysctl net.ipv4.tcp_tw_reuse

tcp_tw_reuse=1 只对主动发起的连接生效,且要求时间戳开启,所以它是相对安全的。但它治标不治本——真正的解法是连接池。

4.12.7 容器网络:把前面的原语组合起来

容器网络不是新协议,而是四个已有机制的叠加:

容器的三层隔离:namespace、cgroups、镜像 namespace 决定「看得见什么」,cgroups 决定「用得了多少」,镜像决定「根文件系统长什么样」。 容器 = namespace(看见什么)+ cgroups(能用多少)+ 镜像(根文件系统)HOSTCONTAINER · PID 1PID namespace容器内 PID 从 1 开始mount namespace独立挂载树 / rootfsnetwork namespace独立网卡 / 路由 / 端口UTS / IPC / user主机名 / IPC / uid 映射cgroups v2cpu.max / memory.max / pids.max / io.max进程视图ps 只看到自己 + 少量内核线程文件系统视图overlayfs:lowerdir + upperdir + merged资源视图CPU 配额 / 内存上限 / PID 上限 / IO 权重隔离挂载限额容器不是轻量虚拟机:没有独立内核,隔离靠内核提供的命名空间与资源控制器;共享内核也意味着共享漏洞面
读图:左列是内核机制,右列是容器内看到的效果。三者缺一不可:只有 namespace 没有 cgroups,一个容器可以吃光宿主;只有 cgroups 没有 namespace,进程仍能看见彼此。最下方的「共享内核」是容器与虚拟机的根本区别。

读这张图要抓住「隔离」和「限制」两条线。namespace 负责「看不见」——每个容器有自己的网络栈、路由表、iptables、端口空间。cgroup 负责「用不多」——带宽、连接数、优先级。两者正交:一个容器可以有很多网络命名空间(比如 Pod 里的多个容器共享一个),也可以被 cgroup 限制带宽。

一个容器要能上网,需要四样东西:

  1. netns:独立的协议栈。
  2. veth pair:一对虚拟网线,一头在容器里,一头在宿主机。
  3. bridge:宿主机上的虚拟交换机,把 veth 连起来。
  4. NAT 规则:让容器访问外网,让外网访问容器端口。

手工搭一个最小容器网络:

# 1. 建 netns 和 veth 对
sudo ip netns add c1
sudo ip link add veth-c1 type veth peer name eth0 netns c1

# 2. 宿主机侧接到网桥
sudo ip link add br0 type bridge
sudo ip link set veth-c1 master br0
sudo ip link set br0 up
sudo ip link set veth-c1 up

# 3. 容器侧配地址与默认路由
sudo ip -n c1 link set lo up
sudo ip -n c1 link set eth0 up
sudo ip -n c1 addr add 172.18.0.2/16 dev eth0
sudo ip -n c1 route add default via 172.18.0.1

# 4. 宿主机给网桥配网关地址
sudo ip addr add 172.18.0.1/16 dev br0

# 5. 验证
sudo ip netns exec c1 ping -c 2 172.18.0.1
sudo ip netns exec c1 ping -c 2 8.8.8.8      # 需要 NAT,见下一节

这里每一步都对应前文的知识:veth 是 4.2.5 讲的「虚拟网线」,bridge 是 4.2.5 的「自学习交换机」,netns 是 4.8 讲的「独立协议栈」,路由是 4.3 讲的最长前缀匹配。

# 看容器的网络命名空间与接口
sudo ip netns exec c1 ip -br addr
sudo ip netns exec c1 ip route
# 从宿主机看 veth 的另一头
ip -br link | grep veth
# 看网桥学到了哪些 MAC
sudo bridge fdb show br0 | grep -v permanent

veth 的收包路径是 4.8.15 讲过的 netif_rx → backlog → process_backlog。所以容器之间通信会走两遍 backlog:一次进 bridge,一次出 veth。这是容器网络比宿主机网络慢的第一层原因。

4.12.8 外部通信:NAT 与 overlay

容器访问外网需要 SNAT。用 nftables 写最小规则集:

# 建表和链
sudo nft add table ip nat
sudo nft add chain ip nat postrouting '{ type nat hook postrouting priority 100 ; }'
# 对 172.18.0.0/16 的流量做 masquerade(自动取出口地址)
sudo nft add rule ip nat postrouting ip saddr 172.18.0.0/16 masquerade
# 开转发
sudo sysctl -w net.ipv4.ip_forward=1

端口映射(外网访问容器)用 DNAT:

sudo nft add chain ip nat prerouting '{ type nat hook prerouting priority -100 ; }'
sudo nft add rule ip nat prerouting tcp dport 8080 dnat to 172.18.0.2:80

跨主机通信需要 overlay。VXLAN 把二层帧封装进 UDP,在 underlay 上建立一个虚拟的二层网络:

# 在两个宿主机上各建一个 VXLAN 接口,VNI 相同
sudo ip link add vxlan100 type vxlan id 100 dstport 4789 \
     remote 192.168.1.11 dev eth0
sudo ip link set vxlan100 up
sudo ip addr add 10.0.100.1/24 dev vxlan100

VXLAN 的 50 字节开销 = 外层 IP 20 + UDP 8 + VXLAN 8 + 内层以太网 14。所以 overlay 的 MTU 只能是 1450,这是容器网络里「大包不通」的头号原因。

方案隔离层开销适用
bridge + veth单机小单机容器
VXLAN overlay跨机二层50 B大二层、多租户
IP-in-IP跨机三层20 B简单隧道
路由方案(Calico BGP)跨机三层0大规模、无 overlay

Calico 的 BGP 模式之所以性能好,就是因为它不做封装:直接在每个宿主机上跑 BGP,把容器网段的路由通告出去,包按普通 IP 转发。代价是要求底层网络支持路由,且容器 IP 不能和宿主机网段冲突。

4.12.9 优化清单与反模式

把全篇的优化手段压缩成一张按顺序执行的清单:

网络优化的执行顺序
  1. 测量:curl -w 拆四段;ss -ti 看窗口与重传;softnet_stat 看丢包。
  2. 应用层:DNS 缓存、连接池、批量读写、减少 RTT。
  3. 协作层:epoll + 非阻塞 + 多线程/多进程、SO_REUSEPORT。
  4. 收包层:ring → 中断亲和 → RPS/RFS → GRO → budget。
  5. 发包层:qdisc → TSQ → TSO/GSO。
  6. 内核参数:缓冲区、somaxconn、tcp_tw_reuse。
  7. 架构:CDN、多机房、协议升级(HTTP/2/3)。

对应地,有五条常见的反模式:

反模式为什么错正确做法
一上来就调 tcp_rmem/tcp_wmem瓶颈可能在应用或 DNS先测量,再定位
把 somaxconn 调到 65535 就以为能扛百万fd、内存、端口才是硬限制按 4.11 的顺序做容量规划
关掉 TSO/GRO 追求「低延迟」普通服务会 CPU 翻倍只在极低延迟场景关
用 tcp_tw_recycle 解决 TIME_WAIT4.12 内核已移除,NAT 下会丢连接用连接池,或 tcp_tw_reuse
所有流量都上 eBPF/XDP复杂度和维护成本高低于 10 万 PPS 用 netfilter

最后一条经验:网络优化的收益通常遵循「二八分布」——20% 的改动(连接复用、DNS 缓存、减少 RTT)带来 80% 的效果。剩下的内核参数调优,多数时候只是把曲线拉平一点。

4.12.10 一个完整的优化案例

抽象的顺序不如一次真实的推演。假设一个 Go 写的 HTTP 服务,压测结果是 3000 QPS 上不去,P99 延迟 800 ms,机器是 8 核、千兆网卡。

第一步:测四段。

curl -sS -o /dev/null -w \
  'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer}\n' \
  http://127.0.0.1:8080/api

结果 dns=0.0002 tcp=0.0003 tls=0.0 ttfb=0.78。DNS 和 TCP 都可以忽略,时间全在服务端处理。

第二步:看服务端在干什么。

# 看进程的 CPU 分布
perf top -p $(pgrep -f myserver) --stdio | head -20
# 看系统调用
strace -c -f -p $(pgrep -f myserver) -o /tmp/st.txt &
sleep 10; kill %1
head -20 /tmp/st.txt

perf top 显示大量时间在 runtime.lock2 和 syscall.Syscall,strace -c 显示 read 调用次数是 write 的几十倍——每次读 1 字节。这就是典型的「读得太碎」。

第三步:改应用。 用带缓冲的读(bufio.Reader)和批量写,read 次数下降两个数量级。QPS 从 3000 涨到 9000,P99 从 800 ms 降到 120 ms。

第四步:再看内核。

ss -s | head -2
grep NET_RX /proc/softirqs
ss -tin state established | head -4

发现 NET_RX 集中在 CPU0,其余 7 核几乎为 0;ss -ti 的 cwnd 正常、retrans 为 0。说明不是丢包,是中断没分散。

第五步:分散中断。

# 网卡如果有多个队列,确认队列数
ethtool -l eth0
# 单队列的话用 RPS 把软中断分到 8 个核(位掩码 ff)
echo ff | sudo tee /sys/class/net/eth0/queues/rx-0/rps_cpus

QPS 涨到 11000,CPU0 的软中断占比从 95% 降到 30%。

第六步:收尾。 打开 SO_REUSEPORT 跑多进程,把 accept 锁竞争消掉;把 somaxconn 从 4096 调到 8192,ListenOverflows 归零。最终稳定在 12000 QPS。

阶段QPSP99关键改动
初始3000800 ms—
批量化读写9000120 ms应用层,收益最大
RPS 分散中断11000100 ms内核层
SO_REUSEPORT + backlog1200095 ms协作层

这张表说明一件事:最大的收益来自应用层,不是内核参数。内核调优把 9000 推到 12000(+33%),而应用改造把 3000 推到 9000(+200%)。这不是说内核知识没用——没有它,你根本不知道第四步该看 softirqs。

4.12.11 容器网络的观测与排障

容器网络的排障难点在于「看不见」:容器有独立的 netns,宿主机的 ss、ip、tcpdump 默认看不到里面的接口。

进入容器的网络命名空间:

# 方法一:nsenter(需要知道 PID)
sudo nsenter -t $(pgrep -f mycontainer) -n ip -br addr
sudo nsenter -t $(pgrep -f mycontainer) -n ss -lntp
# 方法二:ip netns(如果容器是用 netns 名管理的)
sudo ip netns exec c1 ss -lntp

nsenter -n 只进入网络命名空间,不改变其他视图,比 docker exec 更轻,也不依赖容器里有这些工具。

抓容器内的包:

# 在宿主机上抓 veth 的另一头(看到的是容器发出的原始帧)
sudo tcpdump -i veth-c1 -nn -c 5
# 或者进入 netns 抓(看到的是容器视角,含 lo)
sudo nsenter -t <pid> -n tcpdump -i any -nn -c 5

两者看到的包不完全一样:宿主机侧看到的是「经过 veth 的帧」,容器侧看到的是「进入协议栈之前/之后的包」。排查 NAT 问题时,两边都抓才能看出「地址在哪一跳被改写」。

五步排障法:

容器网络排障五步
  1. 接口与地址:nsenter -n ip -br addr。有没有 eth0?有没有 IP?UP 和 LOWER_UP 是否都有?
  2. 路由:nsenter -n ip route。有没有默认路由?网关是否可达(ip neigh)?
  3. DNS:nsenter -n cat /etc/resolv.conf。ndots 是多少?上游可达吗?
  4. NAT/conntrack:conntrack -L | grep <容器IP>。有没有映射?表满了吗(conntrack -C)?
  5. MTU:nsenter -n ip link show eth0。是不是 1500 而 underlay 只有 1450?ping -M do -s 1472 能通吗?

这五步的顺序对应「从下到上」:先确认接口通,再确认路由对,再确认名字能解析,再确认地址被正确转换,最后才怀疑 MTU 这种「小包通、大包不通」的隐性问题。

conntrack 表满是容器环境的高频故障:

# 当前连接跟踪数 vs 上限
conntrack -C
sysctl net.netfilter.nf_conntrack_max
# 看是否有丢包
dmesg | grep -i conntrack | tail -3
nstat -az | grep -i conntrack

每个经过 NAT 的连接占一个 conntrack 表项,默认超时是 5 天(nf_conntrack_tcp_timeout_established,单位秒)。高并发短连接场景下,表项回收慢会导致表满,表现为「新连接随机失败」。处理办法是调小超时(比如 3600 秒)、调大上限、或者对不需要跟踪的流量用 NOTRACK。

4.12.12 Sidecar 与 Service Mesh 的额外开销

Service Mesh 把「重试、熔断、mTLS、遥测」从应用里挪到 sidecar 代理。代价是每个请求要多走两跳:

不用 mesh:
  app ──▶ 内核协议栈 ──▶ veth ──▶ bridge ──▶ ... ──▶ 目标 app
​
用 sidecar:
  app ──▶ iptables REDIRECT ──▶ sidecar(本机回环)
        ──▶ 内核协议栈 ──▶ veth ──▶ bridge ──▶ ... ──▶ 对端 sidecar
        ──▶ 对端 iptables REDIRECT ──▶ 对端 app

读这张图要抓住两个「多出来」:协议栈多走两遍(app → sidecar、sidecar → app),TLS 多一次(sidecar 之间要 mTLS)。所以一个请求的实际路径是「4 次进程上下文切换 + 2 次额外协议栈 + 1–2 次 TLS 握手」。

开销来源量级说明
两次 iptables REDIRECT10–50 µs规则越多越慢
两次额外协议栈20–60 µs本机回环,见 4.11.2
mTLS 握手0.5–3 ms新连接才有,连接复用后摊销
sidecar 自身处理50–500 µsEnvoy 的解析、路由、统计

这些数字加起来,单个请求的额外延迟通常在 1–3 ms。对延迟敏感的服务(P99 要求 < 10 ms),这是不可忽视的比例。

降低开销的三条路:

  1. 减少 iptables 规则。Istio 默认会给每个 pod 写几十条规则,规则越多,nf_hook_slow 的线性遍历越慢。用 nftables 或 eBPF 替代可以显著改善。
  2. 连接复用。sidecar 到 sidecar 用长连接池,避免每次请求都做 mTLS 握手。
  3. 换数据面。Istio 的 ambient 模式把 L4 处理放进节点级 ztunnel(每个节点一个,而不是每个 pod 一个),Cilium 直接用 eBPF 在 socket 层做转发(bpf_sock_ops),绕过大部分协议栈。
# 看 pod 里的 iptables 重定向规则数量
nsenter -t <pid> -n iptables-save | grep -c REDIRECT
# 看 sidecar 的连接数与端口
nsenter -t <pid> -n ss -tnp | head
# 抓 pod 内的包,看实际走的是哪个端口(15001 通常是 Envoy 的 inbound)
nsenter -t <pid> -n tcpdump -i lo -nn -c 5

排障时最容易迷路的地方是「请求到底被重定向到哪个端口」。Envoy 默认用 15001(outbound)和 15006(inbound),iptables-save 里的 REDIRECT --to-ports 能直接看出来。

什么时候不该上 mesh

如果服务只有十几个、语言统一、团队小,引入 mesh 的复杂度和延迟开销通常不值。mTLS、重试、可观测性这三件事用库(比如 gRPC 的 interceptor)也能做,代价是「每个语言都要实现一遍」。Mesh 的价值在「多语言、多团队、统一策略」,不在「性能」。

4.12.13 四个协作原语的内核实现

4.12.5 提到「内核与应用之间的那段」最容易优化,这一节把四个常用原语落到字段与函数:SO_REUSEPORT、SO_BUSY_POLL、MSG_ZEROCOPY、SO_INCOMING_CPU。

原语socket 选项内核字段解决的问题代价
多进程监听SO_REUSEPORTsk_reuseport、reuseport_cbaccept 惊群、单锁竞争哈希不均、迁移丢连接
忙等收包SO_BUSY_POLLsk_ll_usec中断/唤醒延迟空转烧 CPU
零拷贝发送MSG_ZEROCOPYsk_zckey、tx_flags用户态到内核的拷贝完成通知复杂度
指定收包 CPUSO_INCOMING_CPUsk_incoming_cpu缓存局部性绑错核反而更慢

SO_REUSEPORT 让多个 socket 绑同一端口。内核在 inet_csk_get_port() 里把同组 socket 挂到 reuseport_cb,收到 SYN 时按四元组哈希选一个:

/* net/ipv4/inet_hashtables.c,简化 */
static struct sock *inet_lhash2_lookup(...)
{
    ...
    if (sk->sk_reuseport) {
        ...
        result = reuseport_select_sock(sk, hash, skb, doff);
        if (result)
            return result;
    }
    ...
}

reuseport_select_sock() 默认用 sk_reuseport_hash()(四元组 + 随机种子)选 socket;如果挂了 BPF_PROG_TYPE_SK_REUSEPORT 程序,就由 eBPF 决定(bpf_sk_select_reuseport)。哈希是「连接级」的:同一条连接的所有包落到同一个 socket,不会在进程间乱跳。

三个边界:

  1. 只在 bind() 之前设置有效,listen() 之后再设无效。
  2. 所有 socket 必须同 uid、同协议、同地址,否则 bind() 返回 EADDRINUSE。
  3. 进程崩溃时,它持有的连接会 RST;内核不会把连接迁移到同组其他 socket(除非用 SO_REUSEPORT + TCP_REPAIR 或 eBPF 迁移)。

SO_BUSY_POLL 让 recvmsg() 在没数据时先自旋一段时间,而不是立刻睡眠:

/* net/core/sock.c,简化 */
int sock_setsockopt(...)
{
    case SO_BUSY_POLL:
        if (val < 0)
            return -EINVAL;
        WRITE_ONCE(sk->sk_ll_usec, val);
        return 0;
}

自旋的上限还受全局 net.core.busy_poll(微秒)限制,且需要驱动实现 ndo_busy_poll(busy_poll 队列)。没有驱动支持时,SO_BUSY_POLL 只是让进程空转,不会更快。它适合「延迟敏感、流量不高」的场景(交易、游戏服务器),不适合满负载的吞吐型服务。

MSG_ZEROCOPY 把用户缓冲区直接交给网卡 DMA,省掉 copy_from_user。发送时内核在 skb 上打 SKBTX_ZEROCOPY_FRAG,用一个 32 位 sk_zckey 标识这次发送;发送完成后通过错误队列回一个 SO_EE_ORIGIN_ZEROCOPY 通知:

/* net/ipv4/tcp.c,简化 */
if (zc) {
    if (skb) {
        skb_shinfo(skb)->tx_flags |= SKBTX_ZEROCOPY_FRAG;
        ...
    }
    ...
    skb_shinfo(skb)->destructor_arg = (void *)(long)uarg->ctx;
}

应用必须 recvmsg(MSG_ERRQUEUE) 读完成通知,否则发送队列会被未回收的 zerocopy 项占满,内核回退到普通拷贝(SO_EE_CODE_ZEROCOPY_COPIED)。这是「开了 zerocopy 反而更慢」的最常见原因。

SO_INCOMING_CPU 让应用知道「这个 socket 的包落在哪个 CPU」,从而把处理线程绑到同一个核,复用 L1/L2 缓存。内核在收包时把 skb->cpu 写进 sk->sk_incoming_cpu(sock_set_flag + set_skb_incoming_cpu)。它常与 SO_REUSEPORT 的 eBPF 选择配合:按 CPU 选 socket,做到「哪个核收包,哪个核处理」。

`SO_REUSEPORT` 的哈希不均

默认哈希对短连接是均匀的,但对「源端口固定、只有少量五元组」的压测工具会严重倾斜。压测时看到 8 个 worker 里只有 1 个忙,先确认客户端是否复用了同一个源端口;用 SO_REUSEPORT 的 eBPF 程序可以按 bpf_get_smp_processor_id() 重分配,但生产环境要谨慎,迁移连接会带来 RST。

4.12.14 CNI、veth、bridge 与 overlay 的转发路径

容器网络不是一套新协议,而是把 4.12.7–4.12.8 的原语组合起来。这一节按「同节点」「跨节点」两条路径,把每个包的转发过程走一遍。

同节点 Pod → Pod:

Pod A (eth0) → veth pair → 宿主机 bridge (cni0/docker0)
  → br_handle_frame → br_fdb_lookup(查目的 MAC)
  → br_forward → 对端 veth → Pod B (eth0)

内核函数:

阶段函数文件
veth 发送veth_xmitdrivers/net/veth.c
网桥入口br_handle_framenet/bridge/br_input.c
FDB 查找br_fdb_find_rcunet/bridge/br_fdb.c
转发br_forward → dev_queue_xmitnet/bridge/br_forward.c

同节点的开销主要是两次 veth 穿越 + 一次 bridge 查找,没有 NAT(Pod 在同一子网)。ping RTT 通常比回环高 30–80 微秒。

跨节点 Pod → Pod(overlay):

Pod A → veth → bridge → 宿主机路由 → vxlan0
  → vxlan_xmit → vxlan_build_skb(加 VXLAN 头)
  → udp_tunnel_xmit_skb(加外层 UDP/IP)
  → 物理网卡 → 对端宿主机 → vxlan0 → bridge → veth → Pod B

封装开销:

层字节
外层以太网14
外层 IPv420
外层 UDP8
VXLAN 头8
合计50

所以 underlay MTU 1500 时,overlay 的可用 MTU 是 1450;再套一层 IPsec/ WireGuard 就只剩 1400 左右。没有配 MSS clamp 时,Pod 发出的 1500 字节包会在 underlay 被分片或丢弃,表现为「小包通、大包卡」。常见修复:

# 在 veth 或 vxlan 上把 MSS 钳到 PMTU
sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
     -j TCPMSS --clamp-mss-to-pmtu

CNI 的角色是「把上面这些命令写成插件」:/etc/cni/net.d/*.conf 描述网络,kubelet 调用 CNI 插件(bridge、calico、cilium),插件负责创建 veth、分配 IP、配置路由与 NAT。CNI 只负责「建网」,不负责「转发」;转发由内核的 bridge/路由/netfilter/eBPF 完成。

Kubernetes Service 又多一层:

Pod → Service ClusterIP → kube-proxy 规则(iptables/IPVS/nftables)
  → DNAT 到某个 Pod IP → conntrack 记录映射 → 返回时反向 DNAT

这一层的坑集中在 conntrack:Service 的 DNAT 依赖 conntrack 表,表满时新连接被丢(4.8.18);hairpin(Pod 访问自己的 Service)需要 masquerade 或 promiscuous-bridge 配置,否则回包源地址不对。

模式数据面开销可观测性
bridge + iptables内核 bridge + netfilter中conntrack、iptables -t nat -L
IPVS内核 IPVS低ipvsadm -Ln
eBPF(Cilium)TC/XDP + eBPF map低bpftool、Hubble
Service Mesh用户态代理高(两次用户态)代理日志、trace

4.12.15 程序一:SO_REUSEPORT 的分配是否均匀

这段程序起 N 个子进程,各自 bind 同一端口(SO_REUSEPORT),父进程建 M 条连接并统计每个子进程接了多少。它验证「四元组哈希」的分布,也能复现「压测客户端复用源端口导致倾斜」。

/* reuseport_lb.c:验证 SO_REUSEPORT 的连接分配 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>

#define NWORKER 4
#define NCONN   64

static int worker_loop(int idx, int lfd, int wpipe)
{
    int served = 0;
    for (;;) {
        int c = accept(lfd, NULL, NULL);
        if (c < 0) {
            if (errno == EINTR)
                continue;
            break;
        }
        char msg[32];
        int n = snprintf(msg, sizeof msg, "w%d\n", idx);
        if (write(c, msg, (size_t)n) != n)
            perror("write");
        close(c);
        served++;
        if (write(wpipe, &served, sizeof served) != sizeof served)
            break;
    }
    return served;
}

int main(void)
{
    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_REUSEPORT, &one, sizeof one);
    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, 128) < 0 || getsockname(lfd, (struct sockaddr *)&a, &alen) < 0) {
        perror("bind/listen");
        return 1;
    }
    close(lfd);                       /* 父进程不监听,只用来选端口 */

    int pipes[NWORKER][2];
    pid_t pids[NWORKER];
    for (int i = 0; i < NWORKER; i++) {
        if (pipe(pipes[i]) < 0)
            return 1;
        pid_t pid = fork();
        if (pid == 0) {
            close(pipes[i][0]);
            int fd = socket(AF_INET, SOCK_STREAM, 0);
            setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
            setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
            if (bind(fd, (struct sockaddr *)&a, sizeof a) < 0 || listen(fd, 128) < 0) {
                perror("worker bind");
                _exit(1);
            }
            worker_loop(i, fd, pipes[i][1]);
            _exit(0);
        }
        pids[i] = pid;
        close(pipes[i][1]);
    }

    int hits[NWORKER] = {0};
    for (int i = 0; i < NCONN; i++) {
        int c = socket(AF_INET, SOCK_STREAM, 0);
        if (c < 0 || connect(c, (struct sockaddr *)&a, sizeof a) < 0) {
            perror("connect");
            break;
        }
        char buf[32] = {0};
        if (read(c, buf, sizeof buf - 1) > 0)
            hits[atoi(buf + 1)]++;
        close(c);
    }

    for (int i = 0; i < NWORKER; i++) {
        printf("worker %d: %d 条连接\n", i, hits[i]);
        kill(pids[i], SIGTERM);
        waitpid(pids[i], NULL, 0);
    }
    return 0;
}

编译运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 reuseport_lb.c -o reuseport_lb
./reuseport_lb

典型输出:

worker 0: 17 条连接
worker 1: 15 条连接
worker 2: 16 条连接
worker 3: 16 条连接

分布大致均匀。把 connect() 改成「所有连接绑定同一个源端口」(需要 SO_REUSEADDR + 固定源端口,且只能串行),分布会严重倾斜——这就是压测时「只有一个 worker 忙」的原因。生产环境不要用固定源端口压测 SO_REUSEPORT 服务。

4.12.16 程序二:veth 与回环的 RTT 对比

这段脚本建一对 netns,用 veth 连接,测 ping RTT,再和宿主机回环对比。它给出「容器网络比本机慢多少」的第一手数字。

#!/usr/bin/env bash
# netns_rtt.sh:对比 lo 与 veth 的 RTT
set -euo pipefail

NS1="lab1"
NS2="lab2"
cleanup() {
    ip netns del "$NS1" 2>/dev/null || true
    ip netns del "$NS2" 2>/dev/null || true
}
trap cleanup EXIT
cleanup

ip netns add "$NS1"
ip netns add "$NS2"
ip link add veth1 type veth peer name veth2
ip link set veth1 netns "$NS1"
ip link set veth2 netns "$NS2"
ip -n "$NS1" addr add 10.10.0.1/24 dev veth1
ip -n "$NS2" addr add 10.10.0.2/24 dev veth2
ip -n "$NS1" link set lo up
ip -n "$NS2" link set lo up
ip -n "$NS1" link set veth1 up
ip -n "$NS2" link set veth2 up

echo "== 宿主机 lo =="
ping -c 20 -q 127.0.0.1 | tail -2
echo "== netns 内 lo =="
ip netns exec "$NS1" ping -c 20 -q 127.0.0.1 | tail -2
echo "== veth 跨 netns =="
ip netns exec "$NS1" ping -c 20 -q 10.10.0.2 | tail -2

echo "== veth 统计 =="
ip -n "$NS1" -s link show veth1 | sed -n '1,6p'

运行:

chmod +x netns_rtt.sh
sudo ./netns_rtt.sh

典型输出(Linux,同一台机器):

== 宿主机 lo ==
rtt min/avg/max/mdev = 0.021/0.031/0.045/0.006 ms
== netns 内 lo ==
rtt min/avg/max/mdev = 0.024/0.035/0.052/0.007 ms
== veth 跨 netns ==
rtt min/avg/max/mdev = 0.045/0.062/0.089/0.011 ms

三个结论:

  • netns 内的 lo 与宿主机 lo 几乎一样,netns 本身不增加协议栈成本。
  • veth 比 lo 高约 30 微秒,来自两次 veth 穿越 + 一次网桥/路由查找 + 一次软中断调度。
  • 跨节点 overlay 还要再加 VXLAN 封装与物理网络 RTT(通常 0.1–1 ms),所以「容器网络慢」的大头通常在 underlay,不在 veth。

ip -s link 的 dropped/errors 若增长,说明 veth 队列或 netns 配置有问题;正常情况下 veth 的丢包只应来自对端关闭。

4.12.17 实验:busy_poll、zerocopy 与 overlay 的观测

实验一看 SO_BUSY_POLL 是否真的生效,实验二用 bpftrace 看 overlay 的封装路径。

实验一:busy_poll 的开关与效果。

# 全局上限(微秒);0 表示关闭
sysctl net.core.busy_poll net.core.busy_read
# 看网卡是否支持 ndo_busy_poll(不同驱动字段名不同)
ethtool -S eth0 | grep -i busy
# 应用侧:设置 50 微秒忙等
# setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &usec, sizeof usec);
# 用 perf 看自旋占比
sudo perf top -p <pid> | head

如果 busy_poll 生效,perf top 里会出现驱动的 *_busy_poll 函数(如 ixgbe_busy_poll),同时进程的 %CPU 上升、epoll_wait/recvmsg 的延迟下降。没有驱动支持时看不到这些函数,只有 cpu_relax 的空转。

实验二:overlay 封装路径。

sudo bpftrace -e '
kprobe:vxlan_xmit { @vxlan[comm] = count(); }
kprobe:udp_tunnel_xmit_skb { @udp_tun[comm] = count(); }
kprobe:br_forward { @bridge[comm] = count(); }
kprobe:dev_queue_xmit { @tx[comm] = count(); }
interval:s:5 { print(@vxlan); print(@udp_tun); print(@bridge); print(@tx); clear(@vxlan); clear(@udp_tun); clear(@bridge); clear(@tx); exit(); }'
  • @vxlan 与 @udp_tun 接近:每个 overlay 包都走了一次封装。
  • @bridge 很大而 @vxlan 很小:大部分流量是同节点 Pod 互访。
  • @tx 远大于 @vxlan:还有大量非 overlay 流量(宿主机会话、监控)。

配合 ss -tinm 看 overlay 连接的 mss:如果显示 1450 而不是 1460,说明 VXLAN 的 50 字节开销已被正确扣除;如果仍是 1460,检查 MSS clamp 是否生效。

4.12.18 零拷贝与分段卸载:省的是哪一次拷贝

「零拷贝」经常被当成一个开关,其实它是一组不同的系统调用,各自省掉不同的拷贝,也有不同的完成语义。先把账算清:

方式用户→内核拷贝内核→网卡系统调用完成通知限制
read + write1 次(到用户缓冲)skb 拷贝/DMA2 次无通用,CPU 最高
sendfile0 次(页缓存直接进 skb)DMA1 次无输入必须是普通文件
splice0 次(经 pipe)DMA2 次无需要 pipe,语义复杂
MSG_ZEROCOPY0 次(用户页 pin 住)DMA1 次 + 错误队列MSG_ERRQUEUE页对齐、协议/驱动支持
AF_XDP0 次(UMEM 直接 DMA)DMA环形队列无 syscall/包驱动、队列绑定
io_uring SEND_ZC0 次DMA提交/完成队列CQE内核 6.0+

sendfile 的路径:

do_sendfile
  → splice_direct_to_actor
    → do_splice_direct
      → file->f_op->splice_read(页缓存)
      → socket 的 sendpage/sendmsg
        → tcp_sendmsg_locked

它省掉的是「页缓存 → 用户缓冲 → socket 缓冲」中的第一段;页缓存页仍然要被 skb 引用/描述,所以不是「完全没有拷贝」,而是没有 CPU 参与的整块内存拷贝。

MSG_ZEROCOPY 的路径:

send(fd, buf, len, MSG_ZEROCOPY)
  → tcp_sendmsg_locked
    → skb_zerocopy_iter_stream     # 把用户页 pin 住,挂到 skb 的 frags
      → skb_shinfo(skb)->destructor_arg = uarg
      → tx_flags |= SKBTX_ZEROCOPY_FRAG
  → 驱动 DMA 直接读用户页
  → 发送完成中断
    → skb_zcopy_clear / sock_zerocopy_callback
      → 把完成通知挂到 sk_error_queue
应用 recvmsg(fd, ..., MSG_ERRQUEUE) 读取 SO_EE_ORIGIN_ZEROCOPY

关键点:用户缓冲区在发送完成前不能被复用/释放。完成通知通过错误队列返回,通知里的 ee_info 是这次发送的 sk_zckey;如果内核回退成普通拷贝,会带 SO_EE_CODE_ZEROCOPY_COPIED。

/* zc_send.c:MSG_ZEROCOPY 的最小骨架(Linux) */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <linux/errqueue.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

int main(void)
{
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    int one = 1;
    setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof one);

    struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(9000) };
    inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);
    if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) return 1;

    size_t len = 1 << 20;
    char *buf = NULL;
    if (posix_memalign((void **)&buf, 4096, len) != 0) return 1;
    memset(buf, 'x', len);

    if (send(fd, buf, len, MSG_ZEROCOPY) < 0) return 1;

    /* 读完成通知;实际程序应循环到所有 zckey 都完成 */
    char cbuf[256];
    struct msghdr msg = { .msg_control = cbuf, .msg_controllen = sizeof cbuf };
    if (recvmsg(fd, &msg, MSG_ERRQUEUE) < 0) return 1;
    for (struct cmsghdr *c = CMSG_FIRSTHDR(&msg); c; c = CMSG_NXTHDR(&msg, c)) {
        if (c->cmsg_level == SOL_IP && c->cmsg_type == IP_RECVERR) {
            struct sock_extended_err *ee = (void *)CMSG_DATA(c);
            if (ee->ee_origin == SO_EE_ORIGIN_ZEROCOPY)
                printf("zerocopy zckey=%u code=%u\n", ee->ee_info, ee->ee_code);
        }
    }
    free(buf);
    close(fd);
    return 0;
}

编译:cc -O2 -Wall -Wextra zc_send.c -o zc_send;对端要持续读,否则发送很快被窗口卡住。不读错误队列的后果:未完成的 zerocopy 项占满发送队列,内核回退到普通拷贝,吞吐反而下降。

GSO/GRO 是「减少协议栈遍历次数」,不是零拷贝。发送方向,tcp_write_xmit 把最多 64 KB 放进一个 skb,gso_size 记录每段大小;网卡支持 TSO 就由硬件切,否则 validate_xmit_skb() 调 skb_gso_segment() 在软件里切。接收方向,napi_gro_receive() 把同流连续段合并,再交给 ip_rcv。

offload方向谁做关闭后的现象
TSO发送网卡skb_gso_segment 调用增加,包数变多
GSO发送内核(延迟切分)同 TSO,CPU 升高
GRO接收内核协议栈处理包数增加
LRO接收网卡可能破坏转发/时间戳,现代少用
checksum双向网卡tcpdump 看到 bad checksum,CPU 升高
ethtool -k eth0 | grep -E 'tcp-segmentation|generic-segmentation|generic-receive|large-receive'
ethtool -S eth0 | grep -iE 'tso|gro|gso'
nstat -az | grep -E 'TcpOutSegs|TcpExtTCPTSQ'
sudo bpftrace -e 'kprobe:skb_gso_segment { @[comm] = count(); }'

veth/bridge 通常透传 GSO,所以容器之间抓包会看到 64 KB 的「超级包」;到物理网卡才切。在 veth 上看到大于 MTU 的包不是链路故障,是 GSO 还没切。

零拷贝/卸载的五个坑
  1. MSG_ZEROCOPY 不读错误队列:发送队列被未完成项占满,内核回退拷贝;看 SO_EE_CODE_ZEROCOPY_COPIED。
  2. 用户缓冲提前复用:内核还在 DMA 读,数据被改写;必须等完成通知再释放/复用。
  3. sendfile 用在管道/普通文件输出:Linux 要求输出是 socket;文件到文件用 copy_file_range。
  4. 关掉 GRO 来「降低延迟」:小包/低延迟场景可能有效,但吞吐型服务会多走很多遍协议栈;先量再关。
  5. 把 veth 上的大包当 MTU 错误:GSO 透传的包在抓包点就是 64 KB;看物理网卡一侧才是最终帧。

4.12.19 多队列、RPS/RFS、busy_poll 与 SO_REUSEPORT 的取舍

这四个机制经常被一起打开,但它们解决的是不同问题,叠加不一定更快:

机制层次解决什么代价什么时候别用
RSS网卡硬件多队列分流,硬件哈希队列/中断多,NUMA 跨节点队列数已≥CPU 数
RPS软件单队列把软中断分到多核一次 IPI、缓存不局部多队列网卡
RFS软件收包 CPU 跟随读 socket 的 CPU流表内存、哈希冲突应用不绑核/流很多
XPS软件发送方向选 CPU/队列配置复杂单队列
SO_BUSY_POLLsocket省掉睡眠-唤醒延迟空转烧 CPU吞吐型/CPU 紧张
SO_REUSEPORTsocket多进程共享端口,减少 accept 锁哈希不均、进程崩溃丢连接连接需要迁移/共享状态

RSS 是基础:ethtool -l eth0 看队列数,ethtool -L eth0 combined 8 调整;每个队列一个中断,/proc/interrupts 看落点,irqbalance 或手工 smp_affinity 绑核。队列数超过 CPU 数没有意义,多出来的队列只会争 CPU。

RPS/RFS 是单队列网卡的补丁:

# 把 rx-0 的软中断分到 CPU0-3(十六进制位掩码)
echo f | sudo tee /sys/class/net/eth0/queues/rx-0/rps_cpus
# RFS:全局流表 + 每队列流表,两个都非 0 才生效
sudo sysctl -w net.core.rps_sock_flow_entries=32768
echo 4096 | sudo tee /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
# 验证:received_rps/flow_limit 是否增长
cat /proc/net/softnet_stat

busy_poll 把「等中断/等唤醒」换成「主动轮询」:

sysctl net.core.busy_poll net.core.busy_read   # 0 表示关闭
# 应用侧
setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &usec, sizeof usec);

它需要驱动实现 ndo_busy_poll;没有驱动支持时,SO_BUSY_POLL 只是让进程空转,不会更快。适合延迟敏感、CPU 有富余的场景(交易、游戏),不适合满负载 Web 服务。epoll_wait 也会走 sk_busy_loop,但同样受全局 busy_poll 限制。

SO_REUSEPORT 让多个进程 bind 同一端口,内核按四元组哈希选 socket;SO_REUSEPORT + eBPF BPF_PROG_TYPE_SK_REUSEPORT 可以自定义选择(按 CPU、按负载)。三个边界:

  1. 只在 bind() 前设置有效,listen() 后设置无效。
  2. 所有 socket 必须同 uid、同协议、同地址,否则 EADDRINUSE。
  3. 进程崩溃时它持有的连接 RST,内核不会自动迁移。

SO_INCOMING_CPU 可以把「哪个核收包」告诉应用,配合 SO_REUSEPORT 做 CPU 亲和;但它是「最近一次收包」的快照,连接迁移后会变。

多队列/轮询的五个坑
  1. 多队列网卡再开 RPS:多一次 IPI,吞吐可能下降;先 ethtool -l。
  2. rps_cpus 写十进制:位掩码按十六进制解析,10 是 CPU4。
  3. 只开 RPS 不开 RFS:rps_flow_cnt/rps_sock_flow_entries 为 0 时 RFS 不生效。
  4. SO_BUSY_POLL 没有驱动支持:纯用户态空转,CPU 升高、延迟不变;先确认 ndo_busy_poll。
  5. SO_REUSEPORT 哈希不均:压测客户端复用源端口时,连接全落到一个 worker;用多源端口/多 IP 或 eBPF 重分配。

4.12.20 BBR:从丢包驱动到带宽/RTT 驱动

CUBIC 把「丢包」当拥塞信号:丢包就减窗,没丢就继续加。这在有缓存的链路上会填满 buffer(bufferbloat),在无线/跨洋链路上又把随机丢包误判成拥塞。BBR 换成「测量瓶颈带宽和最小 RTT」,用 pacing 控制发送速率。

算法信号优点缺点
CUBIC丢包公平、成熟、默认高 BDP 下恢复慢,bufferbloat
BBRv1带宽 + min RTT高吞吐、低排队公平性/深队列下 aggressiveness
BBRv2带宽 + RTT + ECN/丢包更公平、更抗丢包需补丁/新内核,主线 6.8 默认没有
BBRv3改进的公平/ECN最新实验主线未合入,需自行打补丁

BBR 的四个状态:

STARTUP   → 指数增窗,快速探测带宽
DRAIN     → 排空 STARTUP 造成的队列
PROBE_BW  → 周期性地 probe up/down,维持带宽估计
PROBE_RTT → 定期把 inflight 降到 4 个包,重测 min RTT

pacing 需要 qdisc 配合:fq 按 skb->tstamp/EDT 发送,BBR 把 pacing rate 写进 sk->sk_pacing_rate;fq_codel 不是不能跑,但没有 fq 的 pacing 精度。没有 fq 时 BBR 仍能工作,但突发更明显。

BBR 的状态落在 struct tcp_sock、struct sock 和拥塞控制私有区:

字段/对象位置作用
snd_cwnd / snd_ssthreshstruct tcp_sock拥塞窗口与慢启动阈值(包)
sk_pacing_ratestruct sockpacing 速率(B/s),fq 据此排发送时间
delivered / app_limitedstruct tcp_sock已交付包数、是否受应用限速
struct bbricsk_ca_priv(inet_csk_ca(sk))mode、bw、min_rtt、pacing_gain、cwnd_gain、cycle_idx

内核路径:

tcp_ack
  → tcp_cong_control
    → icsk_ca_ops->cong_control = bbr_main
      → bbr_update_model
          → bbr_update_bw / bbr_update_min_rtt / bbr_update_cycle_phase / bbr_update_gains
      → bbr_set_pacing_rate
      → bbr_set_cwnd
tcp_write_xmit
  → tcp_pacing_check(按 sk_pacing_rate 判断现在能不能发)
# 1. 确认内核有 bbr
sysctl net.ipv4.tcp_available_congestion_control
modprobe tcp_bbr 2>/dev/null || true
# 2. 默认 qdisc 换成 fq,算法换成 bbr
sysctl -w net.core.default_qdisc=fq
sysctl -w net.ipv4.tcp_congestion_control=bbr
# 3. 对已存在的设备立即生效
tc qdisc replace dev eth0 root fq
# 4. 验证
sysctl net.ipv4.tcp_congestion_control
ss -ti | grep -E 'bbr|cubic'

ss -ti 的 BBR 行类似:

bbr:(bw:120Mbps,mrtt:18.5ms,pacing_gain:2.0,cwnd_gain:2.0)

对应 tcp_info 的 tcpi_delivery_rate、tcpi_min_rtt、tcpi_pacing_rate。如果 bw 长期上不去,先看 notsent_bytes(应用没数据)、snd_wnd(对端窗口)、rwnd_limited(对端读得慢),不要一上来就调 tcp_wmem。

对比实验(两台 Linux 或 netns + netem):

# 加 1% 丢包、20ms 延迟,比较 cubic 与 bbr
tc qdisc replace dev eth0 root netem loss 1% delay 20ms   # 实验后换回 fq
for cc in cubic bbr; do
  sysctl -w net.ipv4.tcp_congestion_control=$cc
  iperf3 -c 192.0.2.2 -t 20 -J > /tmp/$cc.json
  ss -ti | grep -E "$cc|bbr" | head
done

在「有随机丢包、带宽高」的链路上,BBR 通常明显优于 CUBIC;在「浅缓存、低延迟、多流竞争」的链路上,BBR 不一定赢,甚至可能更不公平。先量 RTT/丢包/队列,再换算法。

BBR 的五个坑
  1. 只改 tcp_congestion_control 不管 qdisc:没有 fq 时 pacing 精度差,突发更明显。
  2. 以为 BBR 一定更快:浅缓存/多流竞争下可能不如 CUBIC;用 netem 做对照。
  3. tcp_congestion_control 是 per-netns:容器里改宿主机的不生效;进 ns 改。
  4. BBRv2/v3 不是所有内核都有:tcp_available_congestion_control 里没有就是没编/没模块。
  5. 把 delivery_rate 当实时吞吐:它是估计值,受 app-limited 影响;看 bytes_acked 增量。

4.12.21 优化决策表:先证据,后旋钮

优化最容易犯的错是「先调参数,再看现象」。下面这张表把「症状 → 证据 → 首选动作 → 验证」串起来,避免把 somaxconn、rmem、busy_poll 全部调一遍。

症状先看什么可能原因首选动作验证
RTT 高、吞吐上不去ss -ti 的 rtt/min_rtt物理 RTT/排队就近部署、换机房、qdiscping/mtr 对照
重传高retrans/total_retrans链路丢包/窗口小查链路、调 BBR、查 tc 丢包nstat 的 TcpRetransSegs
cwnd 小cwnd/ssthresh丢包/BBR 状态先查丢包,再换算法ss -ti 前后对比
rwnd_limited 高tcpi_rwnd_limited对端读得慢调对端 rmem/应用读取对端 Recv-Q
sndbuf_limited 高tcpi_sndbuf_limited本端写得多/缓冲小调 wmem/应用限流ss -tm
notsent 大tcpi_notsent_bytes窗口/cwnd/TSQ 限制查 snd_wnd/cwndss -ti
软中断打满mpstat %soft、softnet_stat单核收包RSS/RPS/多队列softnet_stat drop
ring 丢包ethtool -S rx_missedring 小/CPU 慢ethtool -G、加队列rx_missed 不再涨
qdisc 丢包tc -s qdisc dropped出口带宽不足限速/扩容/调 qdiscbacklog 下降
conntrack 丢包conntrack -S drop表满/NAT调 max/buckets、减超时drop 不再涨
accept 队列满ss -lnt Recv-Q应用 accept 慢加快 accept/调 backlogListenOverflows
唤醒延迟高bpftrace sock_def_readable→epoll_wait调度/绑核SO_INCOMING_CPU/绑核P99 下降

一个 10 分钟的巡检脚本(只读,不改参数):

#!/usr/bin/env bash
set -euo pipefail
echo "== 队列/丢包 =="
ss -lnt; ss -s
nstat -az | grep -E 'ListenOverflows|ListenDrops|TcpRetransSegs|TCPReqQFull'
echo "== 网卡 =="
for d in $(ls /sys/class/net | grep -v lo); do
  echo "-- $d"; ethtool -g "$d" 2>/dev/null | tail -4
  ethtool -S "$d" 2>/dev/null | grep -Ei 'miss|drop|no_buffer' | head
done
echo "== 软中断 =="
grep NET_RX /proc/softirqs
cat /proc/net/softnet_stat
echo "== qdisc =="
tc -s qdisc show | grep -E 'qdisc|dropped|backlog' | head -20
echo "== conntrack =="
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max 2>/dev/null || true
conntrack -S 2>/dev/null | head -8 || true

先跑一遍留存基线,出问题再跑一遍对比增量;没有基线的绝对值没有意义。这也是 4.12.1「把慢拆成四段」的落地方式:每一段都有独立证据,才知道该动哪个旋钮。

本章新增的 MSG_ZEROCOPY/BBR/多队列命令需要在 Linux 上执行;本机为 macOS,未实测。参数名以 Linux 6.8 与 iproute2 6.x 为准。

4.12.22 本章陷阱清单

15 条高频错误
  1. SO_REUSEPORT 在 listen() 之后设置:无效,必须在 bind() 前。
  2. 压测客户端复用同一源端口:SO_REUSEPORT 哈希倾斜,误判为服务端不均。
  3. 认为 SO_REUSEPORT 能迁移已有连接:进程崩溃时连接 RST,不会自动转移。
  4. 无脑开 SO_BUSY_POLL:没有驱动支持只是空转,满负载时反而降低吞吐。
  5. MSG_ZEROCOPY 不读错误队列:完成项耗尽后回退拷贝,性能不如不开。
  6. 把 SO_INCOMING_CPU 当「绑定收包核」:它只报告包落在哪,绑核要配合 RPS/IRQ 亲和。
  7. overlay 不配 MSS clamp:1500 字节包在 underlay 被分片或丢,表现为「小包通、大包卡」。
  8. VXLAN 的 50 字节开销算错:外以太 14 + IP 20 + UDP 8 + VXLAN 8 = 50,overlay MTU 应是 1450。
  9. 在 Pod 里看 conntrack 却在宿主机 netns:Service DNAT 的 conntrack 在宿主机 netns,容器内看不到。
  10. CNI 插件顺序写反:portmap/bandwidth 依赖 bridge 先建好设备,顺序错会静默失败。
  11. 认为 Service ClusterIP 可 ping:ClusterIP 是 DNAT 规则,不响应 ICMP;用 curl/nc 测。
  12. 把 veth 的 dropped 当链路丢包:对端 netns 关闭或队列满才会丢,先查对端。
  13. MSG_ZEROCOPY 不读完成队列:用户页在发送完成前不能复用;不读 MSG_ERRQUEUE 会让未完成项堆积并回退拷贝。
  14. SO_BUSY_POLL 没驱动支持:ndo_busy_poll 不存在时只是空转;先确认驱动与 net.core.busy_poll 非 0。
  15. BBR 只改算法不配 fq:pacing 精度差、突发明显;tcp_congestion_control 还是 per-netns,容器里要单独设。

4.12.23 本章出口检查

检查项达标线没达标去哪
把「慢」拆成四段会用 curl -w 做减法4.12.1
说出收包优化的四层顺序ring → 中断 → budget → 协议栈4.12.3
说清 qdisc 与 TSQ 的作用知道一个管设备、一个管单流4.12.4
解释容器网络的四件套netns、veth、bridge、NAT4.12.7
算清 VXLAN 的 50 字节开销能说出四部分构成4.12.8
写出 nftables 的 SNAT/DNAT两条规则能跑通4.12.8

| 说出四个协作原语的字段 | 知道各自的代价 | 4.12.13 | | 画出同节点与跨节点 Pod 路径 | 能算 VXLAN 的 50 字节开销 | 4.12.14 | | 验证 SO_REUSEPORT 的分配 | 能解释哈希倾斜 | 4.12.15 | | 量 lo 与 veth 的 RTT 差 | 知道瓶颈不在 netns | 4.12.16 | | 算清零拷贝省了哪次拷贝 | 能说出完成通知的代价 | 4.12.18 | | 选择 RSS/RPS/busy_poll/REUSEPORT | 知道各自什么时候不该用 | 4.12.19 | | 用 BBR 与 fq 做对照实验 | 能读 ss -ti 的 bbr 行 | 4.12.20 |

即时自测 · 4.12

Q1 curl -w 显示 dns=0.001 tcp=0.002 tls=0.003 ttfb=0.004 total=0.9。瓶颈在哪?该查什么?

Q2 softnet_stat 的 dropped 增长、time_squeeze 不增长。先调哪个参数?

Q3 容器之间 ping 通,但传大文件卡住。最可能的原因是什么?

Q4 VXLAN 的 50 字节开销具体是哪几部分?为什么不是 54?

Q5 为什么 Calico 的 BGP 模式比 VXLAN 模式性能好?代价是什么?

Q6 一个服务 QPS 很高但每个请求很小,优化应该优先做哪三件事?

答案

A1 前三段加起来只有 6 ms,而 total - ttfb = 0.896 s,说明响应正文传输慢。可能原因:响应体很大、带宽不足、或者客户端读得慢。该看响应大小、ss -ti 的 cwnd/retrans、以及是否有压缩。

A2 先调 net.core.netdev_max_backlog(backlog 队列太小),因为 time_squeeze 不增长说明不是预算问题,而是包在排队阶段就被丢了。如果 backlog 已经很大,再查内存和 RPS 分流。

A3 MTU 不匹配。容器网桥的 MTU 是 1500,但 overlay(VXLAN)只有 1450,大包在 underlay 被丢弃或分片失败。把容器接口和 CNI 配置的 MTU 调成 1450,或者在网关上放行 ICMP Fragmentation Needed。

A4 外层 IP 20 + UDP 8 + VXLAN 8 + 内层以太网 14 = 50。不是 54,因为没有额外的 FCS(内层 FCS 不参与封装),也没有 VLAN 标签(除非用了 QinQ)。

A5 BGP 模式不做封装,包按普通 IP 转发,没有 50 字节开销、没有额外的封装/解封装 CPU。代价是要求底层网络支持路由(或宿主机之间跑 BGP),且容器 IP 段不能与宿主机冲突。

A6 第一,减少 RTT(连接复用、DNS 缓存、HTTP/2);第二,减少系统调用(批量读写、writev、TCP_NODELAY 的取舍);第三,提高并发度(epoll 多线程、SO_REUSEPORT)。小请求场景里,每次请求的固定开销(握手、系统调用、调度)比带宽重要得多。

母本对应

母本章节本书落点程度
N(深入理解 Linux 网络)第 9 章 9.1–9.5(请求、接收、发送、协作、握手优化)4.12.1–4.12.6完整:五类优化的顺序与手段
N(深入理解 Linux 网络)第 10 章 10.2–10.6(veth、netns、Bridge、外部通信)4.12.7、4.12.8完整:容器网络的四件套与 NAT
W(谢希仁)第 4–6 章(IP、运输层、应用层)4.12.7、4.12.8背景:NAT 与隧道的协议基础
G(鸟哥)第 17 章(网络配置与排障)4.12.1、4.12.9精选:命令与排障习惯
R(奔跑吧 Linux 内核)第 2–3 章(内存与进程,cgroup 资源限制)4.12.7背景:cgroup 与 namespace 的分工
N(深入理解 Linux 网络)第 9 章(性能优化建议,PDF p269–287)4.12.13补充:原语与代价
N(深入理解 Linux 网络)第 10 章(容器网络虚拟化,PDF p288–335)4.12.14–4.12.16完整:veth/bridge/overlay
W(谢希仁)第 4 章(IP 转发与隧道,PDF p126–221)4.12.14背景:封装与 MTU
N(深入理解 Linux 网络)第 9 章(接收/发送优化,PDF p269–287)4.12.18–4.12.20完整:卸载与多队列
W(谢希仁)第 5 章(TCP 拥塞控制,PDF p222–270)4.12.20背景:CUBIC/BBR 的协议基础
Linux 6.8 源码net/ipv4/tcp_output.c、net/core/dev.c、net/sched/sch_fq.c、net/ipv4/tcp_bbr.c4.12.18–4.12.20完整:GSO/pacing/BBR

下一站:第 4 篇到此结束。你已经能从协议、内核路径、容量、优化四个角度解释一条连接。第 5 篇会把这里的所有原语(namespace、cgroup、veth、overlay)组装成容器,从 5.1 容器原理 开始。