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 先测量:把「慢」拆成四段
「网络慢」是一个没有信息量的描述。要优化,先把它拆成四段,每段有独立的观测手段:
| 段 | 典型耗时 | 观测手段 | 优化方向 |
|---|---|---|---|
| DNS | 0.1–100 ms | curl -w '%{time_namelookup}'、dig | 缓存、改用 IP、DoH |
| TCP 握手 | 0.1–200 ms | time_connect - time_namelookup | 连接复用、TFO、就近接入 |
| TLS 握手 | 0.2–300 ms | time_appconnect - time_connect | TLS 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 resumption | 1 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 也只是让那个核更忙。先把包收进来,再谈处理得快。
- 「把 ring 调到最大就好」。ring 占的是内核内存,4096 个描述符 × 2 KB 就是 8 MB/队列,16 队列就是 128 MB。而且 ring 大只吸收突发,不解决持续过载。
- 「RPS 一定提升性能」。队列数 ≥ CPU 数时,RPS 只增加 IPI 开销。它只对单队列网卡有意义。
- 「关掉 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 把「提交请求」和「收割完成」做成两个环形队列,批量提交、批量收割,减少系统调用次数。对高 PPS 的网络应用,它能省掉大量 epoll_wait + read 的切换。代价是编程模型复杂,且需要较新的内核(5.1+,网络部分 5.6+ 才成熟)。普通 Web 服务用 epoll 就够了。
4.12.6 握手与挥手优化
短连接场景里,握手占的时间比例很高。可用的手段不多,但每个都很直接:
| 手段 | 省掉 | 前提 |
|---|---|---|
| 连接池 / keep-alive | 每次握手 | 应用改造 |
TCP_FASTOPEN | 1 RTT(重复连接) | 内核与服务端支持 |
TCP_DEFER_ACCEPT | 一次唤醒 | 客户端先发数据 |
| TLS session resumption | 1 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 负责「看不见」——每个容器有自己的网络栈、路由表、iptables、端口空间。cgroup 负责「用不多」——带宽、连接数、优先级。两者正交:一个容器可以有很多网络命名空间(比如 Pod 里的多个容器共享一个),也可以被 cgroup 限制带宽。
一个容器要能上网,需要四样东西:
- netns:独立的协议栈。
- veth pair:一对虚拟网线,一头在容器里,一头在宿主机。
- bridge:宿主机上的虚拟交换机,把 veth 连起来。
- 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 优化清单与反模式
把全篇的优化手段压缩成一张按顺序执行的清单:
- 测量:
curl -w拆四段;ss -ti看窗口与重传;softnet_stat看丢包。 - 应用层:DNS 缓存、连接池、批量读写、减少 RTT。
- 协作层:epoll + 非阻塞 + 多线程/多进程、
SO_REUSEPORT。 - 收包层:ring → 中断亲和 → RPS/RFS → GRO → budget。
- 发包层:qdisc → TSQ → TSO/GSO。
- 内核参数:缓冲区、
somaxconn、tcp_tw_reuse。 - 架构:CDN、多机房、协议升级(HTTP/2/3)。
对应地,有五条常见的反模式:
| 反模式 | 为什么错 | 正确做法 |
|---|---|---|
一上来就调 tcp_rmem/tcp_wmem | 瓶颈可能在应用或 DNS | 先测量,再定位 |
把 somaxconn 调到 65535 就以为能扛百万 | fd、内存、端口才是硬限制 | 按 4.11 的顺序做容量规划 |
| 关掉 TSO/GRO 追求「低延迟」 | 普通服务会 CPU 翻倍 | 只在极低延迟场景关 |
用 tcp_tw_recycle 解决 TIME_WAIT | 4.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。
| 阶段 | QPS | P99 | 关键改动 |
|---|---|---|---|
| 初始 | 3000 | 800 ms | — |
| 批量化读写 | 9000 | 120 ms | 应用层,收益最大 |
| RPS 分散中断 | 11000 | 100 ms | 内核层 |
SO_REUSEPORT + backlog | 12000 | 95 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 问题时,两边都抓才能看出「地址在哪一跳被改写」。
五步排障法:
- 接口与地址:
nsenter -n ip -br addr。有没有eth0?有没有 IP?UP和LOWER_UP是否都有? - 路由:
nsenter -n ip route。有没有默认路由?网关是否可达(ip neigh)? - DNS:
nsenter -n cat /etc/resolv.conf。ndots是多少?上游可达吗? - NAT/conntrack:
conntrack -L | grep <容器IP>。有没有映射?表满了吗(conntrack -C)? - 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 REDIRECT | 10–50 µs | 规则越多越慢 |
| 两次额外协议栈 | 20–60 µs | 本机回环,见 4.11.2 |
| mTLS 握手 | 0.5–3 ms | 新连接才有,连接复用后摊销 |
| sidecar 自身处理 | 50–500 µs | Envoy 的解析、路由、统计 |
这些数字加起来,单个请求的额外延迟通常在 1–3 ms。对延迟敏感的服务(P99 要求 < 10 ms),这是不可忽视的比例。
降低开销的三条路:
- 减少 iptables 规则。Istio 默认会给每个 pod 写几十条规则,规则越多,
nf_hook_slow的线性遍历越慢。用nftables或 eBPF 替代可以显著改善。 - 连接复用。sidecar 到 sidecar 用长连接池,避免每次请求都做 mTLS 握手。
- 换数据面。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 的复杂度和延迟开销通常不值。mTLS、重试、可观测性这三件事用库(比如 gRPC 的 interceptor)也能做,代价是「每个语言都要实现一遍」。Mesh 的价值在「多语言、多团队、统一策略」,不在「性能」。
4.12.13 四个协作原语的内核实现
4.12.5 提到「内核与应用之间的那段」最容易优化,这一节把四个常用原语落到字段与函数:SO_REUSEPORT、SO_BUSY_POLL、MSG_ZEROCOPY、SO_INCOMING_CPU。
| 原语 | socket 选项 | 内核字段 | 解决的问题 | 代价 |
|---|---|---|---|---|
| 多进程监听 | SO_REUSEPORT | sk_reuseport、reuseport_cb | accept 惊群、单锁竞争 | 哈希不均、迁移丢连接 |
| 忙等收包 | SO_BUSY_POLL | sk_ll_usec | 中断/唤醒延迟 | 空转烧 CPU |
| 零拷贝发送 | MSG_ZEROCOPY | sk_zckey、tx_flags | 用户态到内核的拷贝 | 完成通知复杂度 |
| 指定收包 CPU | SO_INCOMING_CPU | sk_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,不会在进程间乱跳。
三个边界:
- 只在
bind()之前设置有效,listen()之后再设无效。 - 所有 socket 必须同 uid、同协议、同地址,否则
bind()返回EADDRINUSE。 - 进程崩溃时,它持有的连接会 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,做到「哪个核收包,哪个核处理」。
默认哈希对短连接是均匀的,但对「源端口固定、只有少量五元组」的压测工具会严重倾斜。压测时看到 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_xmit | drivers/net/veth.c |
| 网桥入口 | br_handle_frame | net/bridge/br_input.c |
| FDB 查找 | br_fdb_find_rcu | net/bridge/br_fdb.c |
| 转发 | br_forward → dev_queue_xmit | net/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 |
| 外层 IPv4 | 20 |
| 外层 UDP | 8 |
| 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 + write | 1 次(到用户缓冲) | skb 拷贝/DMA | 2 次 | 无 | 通用,CPU 最高 |
sendfile | 0 次(页缓存直接进 skb) | DMA | 1 次 | 无 | 输入必须是普通文件 |
splice | 0 次(经 pipe) | DMA | 2 次 | 无 | 需要 pipe,语义复杂 |
MSG_ZEROCOPY | 0 次(用户页 pin 住) | DMA | 1 次 + 错误队列 | MSG_ERRQUEUE | 页对齐、协议/驱动支持 |
| AF_XDP | 0 次(UMEM 直接 DMA) | DMA | 环形队列 | 无 syscall/包 | 驱动、队列绑定 |
io_uring SEND_ZC | 0 次 | 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 还没切。
MSG_ZEROCOPY不读错误队列:发送队列被未完成项占满,内核回退拷贝;看SO_EE_CODE_ZEROCOPY_COPIED。- 用户缓冲提前复用:内核还在 DMA 读,数据被改写;必须等完成通知再释放/复用。
sendfile用在管道/普通文件输出:Linux 要求输出是 socket;文件到文件用copy_file_range。- 关掉 GRO 来「降低延迟」:小包/低延迟场景可能有效,但吞吐型服务会多走很多遍协议栈;先量再关。
- 把 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_POLL | socket | 省掉睡眠-唤醒延迟 | 空转烧 CPU | 吞吐型/CPU 紧张 |
SO_REUSEPORT | socket | 多进程共享端口,减少 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、按负载)。三个边界:
- 只在
bind()前设置有效,listen()后设置无效。 - 所有 socket 必须同 uid、同协议、同地址,否则
EADDRINUSE。 - 进程崩溃时它持有的连接 RST,内核不会自动迁移。
SO_INCOMING_CPU 可以把「哪个核收包」告诉应用,配合 SO_REUSEPORT 做 CPU 亲和;但它是「最近一次收包」的快照,连接迁移后会变。
- 多队列网卡再开 RPS:多一次 IPI,吞吐可能下降;先
ethtool -l。 rps_cpus写十进制:位掩码按十六进制解析,10是 CPU4。- 只开 RPS 不开 RFS:
rps_flow_cnt/rps_sock_flow_entries为 0 时 RFS 不生效。 SO_BUSY_POLL没有驱动支持:纯用户态空转,CPU 升高、延迟不变;先确认ndo_busy_poll。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_ssthresh | struct tcp_sock | 拥塞窗口与慢启动阈值(包) |
sk_pacing_rate | struct sock | pacing 速率(B/s),fq 据此排发送时间 |
delivered / app_limited | struct tcp_sock | 已交付包数、是否受应用限速 |
struct bbr | icsk_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/丢包/队列,再换算法。
- 只改
tcp_congestion_control不管 qdisc:没有fq时 pacing 精度差,突发更明显。 - 以为 BBR 一定更快:浅缓存/多流竞争下可能不如 CUBIC;用 netem 做对照。
tcp_congestion_control是 per-netns:容器里改宿主机的不生效;进 ns 改。- BBRv2/v3 不是所有内核都有:
tcp_available_congestion_control里没有就是没编/没模块。 - 把
delivery_rate当实时吞吐:它是估计值,受 app-limited 影响;看bytes_acked增量。
4.12.21 优化决策表:先证据,后旋钮
优化最容易犯的错是「先调参数,再看现象」。下面这张表把「症状 → 证据 → 首选动作 → 验证」串起来,避免把 somaxconn、rmem、busy_poll 全部调一遍。
| 症状 | 先看什么 | 可能原因 | 首选动作 | 验证 |
|---|---|---|---|---|
| RTT 高、吞吐上不去 | ss -ti 的 rtt/min_rtt | 物理 RTT/排队 | 就近部署、换机房、qdisc | ping/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/cwnd | ss -ti |
| 软中断打满 | mpstat %soft、softnet_stat | 单核收包 | RSS/RPS/多队列 | softnet_stat drop |
| ring 丢包 | ethtool -S rx_missed | ring 小/CPU 慢 | ethtool -G、加队列 | rx_missed 不再涨 |
| qdisc 丢包 | tc -s qdisc dropped | 出口带宽不足 | 限速/扩容/调 qdisc | backlog 下降 |
| conntrack 丢包 | conntrack -S drop | 表满/NAT | 调 max/buckets、减超时 | drop 不再涨 |
| accept 队列满 | ss -lnt Recv-Q | 应用 accept 慢 | 加快 accept/调 backlog | ListenOverflows |
| 唤醒延迟高 | 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 本章陷阱清单
SO_REUSEPORT在listen()之后设置:无效,必须在bind()前。- 压测客户端复用同一源端口:
SO_REUSEPORT哈希倾斜,误判为服务端不均。 - 认为
SO_REUSEPORT能迁移已有连接:进程崩溃时连接 RST,不会自动转移。 - 无脑开
SO_BUSY_POLL:没有驱动支持只是空转,满负载时反而降低吞吐。 MSG_ZEROCOPY不读错误队列:完成项耗尽后回退拷贝,性能不如不开。- 把
SO_INCOMING_CPU当「绑定收包核」:它只报告包落在哪,绑核要配合 RPS/IRQ 亲和。 - overlay 不配 MSS clamp:1500 字节包在 underlay 被分片或丢,表现为「小包通、大包卡」。
- VXLAN 的 50 字节开销算错:外以太 14 + IP 20 + UDP 8 + VXLAN 8 = 50,overlay MTU 应是 1450。
- 在 Pod 里看 conntrack 却在宿主机 netns:Service DNAT 的 conntrack 在宿主机 netns,容器内看不到。
- CNI 插件顺序写反:
portmap/bandwidth依赖bridge先建好设备,顺序错会静默失败。 - 认为 Service ClusterIP 可 ping:ClusterIP 是 DNAT 规则,不响应 ICMP;用
curl/nc测。 - 把 veth 的
dropped当链路丢包:对端 netns 关闭或队列满才会丢,先查对端。 MSG_ZEROCOPY不读完成队列:用户页在发送完成前不能复用;不读MSG_ERRQUEUE会让未完成项堆积并回退拷贝。SO_BUSY_POLL没驱动支持:ndo_busy_poll不存在时只是空转;先确认驱动与net.core.busy_poll非 0。- 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、NAT | 4.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 |
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.c | 4.12.18–4.12.20 | 完整:GSO/pacing/BBR |
下一站:第 4 篇到此结束。你已经能从协议、内核路径、容量、优化四个角度解释一条连接。第 5 篇会把这里的所有原语(namespace、cgroup、veth、overlay)组装成容器,从 5.1 容器原理 开始。