你在容器里 ping 8.8.8.8 能通,但宿主机 ip addr 里看不到容器的 eth0;反过来容器 ip addr 也看不到宿主的网卡。两边明明"插着网线",却活在不同的世界里。把这两个世界接起来的,就是 veth pair。
很多人把 veth 当成"一张虚拟网卡",这是个误解。它其实是一对背靠背的虚拟以太网设备——像一根两头各焊了一个 RJ45 接头的网线,只不过这根线完全活在内存里,由内核代码实现。容器网络能成立,靠的不是什么黑魔法,而是 ip link add 一行命令背后那套精确的内核机制。
这篇文章把 veth pair 从创建、跨命名空间、数据包发送、接收上行、直到接上桥的全过程讲清楚,重点放在驱动层 drivers/net/veth.c。读完你应该能回答一个很实际的问题:一个 ICMP 包,是怎么"穿过" veth 从容器走到宿主协议栈的。
一、veth 在内核里到底是什么
ip link add veth0 type veth peer name veth1 这行命令,最终走进内核的 rtnl_link_ops veth_link_ops:
staticstructrtnl_link_opsveth_link_ops = { .kind = DRV_NAME, .priv_size = sizeof(struct veth_priv), .setup = veth_setup, .newlink = veth_newlink, .dellink = veth_dellink, .policy = veth_policy, .maxtype = VETH_INFO_MAX, .get_link_net = veth_get_link_net,};
关键在于 veth_newlink。它做的事很直白:用 rtnl_create_link 创建两个struct net_device(一个叫 dev,一个叫 peer),各自 register_netdevice,最后让它们互相记住对方:
/* veth_newlink 结尾的互指 */priv = netdev_priv(dev);rcu_assign_pointer(priv->peer, peer);priv = netdev_priv(peer);rcu_assign_pointer(priv->peer, dev);
记住 struct veth_priv 长什么样,它是 veth 的"灵魂":
structveth_priv {structnet_device __rcu *peer;/* 对端设备,xmit 时靠它找路 */atomic64_t dropped; /* 丢弃计数 */structbpf_prog __rcu *_xdp_prog;/* peer 上挂的 XDP 程序 */structveth_rq *rq;/* 接收队列 + NAPI */unsignedint requested_headroom;};
两个 net_device各自持有一份veth_priv,不是共享一份。常见误解是"veth pair 共享一个 priv",其实不然——每个设备独立,靠 peer 指针互指。后面你会看到,发包就是顺着这根指针摸到对端。
veth_setup 把一个设备初始化成以太网设备,并注册自己的操作集:
staticvoidveth_setup(struct net_device *dev){ ether_setup(dev); dev->priv_flags &= ~IFF_TX_SKB_SHARING; dev->priv_flags |= IFF_LIVE_ADDR_CHANGE; dev->priv_flags |= IFF_NO_QUEUE; /* veth 没有发送队列深度限制 */ dev->priv_flags |= IFF_PHONY_HEADROOM; dev->netdev_ops = &veth_netdev_ops; /* ndo_start_xmit = veth_xmit */ ...}
IFF_NO_QUEUE 值得停一下:普通网卡有发送队列(qdisc + tx ring),veth 没有——它的"发送"是即时的,不存在排队等硬件。这一点直接决定了 veth 的性能画像。
二、跨命名空间:一根线两头各接一个世界
刚创建时,两个 veth 设备都在当前命名空间(通常是 init_net)。把一端搬进容器,靠的是:
ip linkset veth1 netns container1
这行命令触发 dev_change_net_namespace(实现在 net/core/dev.c),核心就一句:
intdev_change_net_namespace(struct net_device *dev, struct net *net, ...){ ... dev_net_set(dev, net); /* 把 dev->nd_net 指向新的 net */ ...}
为什么 veth 能被搬走,而 lo、vxlan 不行?看 dev_change_net_namespace 开头:
if (dev->features & NETIF_F_NETNS_LOCAL)goto out; /* 返回 -EINVAL */
lo、VXLAN 等设备带 NETIF_F_NETNS_LOCAL 标志,意味着"它只属于创建它的那个 netns",内核拒绝移动。veth 不带这个标志——这正是由它"一根连线、两头各接一个世界"的本质决定的。
搬完之后:veth0 留在宿主 netns,veth1 进了容器 netns。ip link 里能看到 link-netnsid 字段标识对端在哪;ip netns exec container1 ip link 才能看到 veth1。此时一端发包,另一端就在对端 netns 里被"接收"——容器网络连通的原子,到此就齐了。
三、发送路径:veth_xmit 没有"硬件"
这是全文的核心。先看物理网卡怎么发:协议栈把 skb 交给驱动,驱动配置 DMA 描述符,真正的比特流由网卡硬件发出,发完硬件产生中断通知 CPU。
veth 没有硬件。它的 ndo_start_xmit 长这样:
staticnetdev_tx_tveth_xmit(struct sk_buff *skb, struct net_device *dev){structveth_priv *priv = netdev_priv(dev);structnet_device *rcv;int length = skb->len;bool rcv_xdp = false;structveth_rq *rq =NULL;int rxq; rcu_read_lock(); rcv = rcu_dereference(priv->peer); /* 1. 找到对端设备 */if (unlikely(!rcv)) { kfree_skb(skb);goto drop; } rxq = skb_get_queue_mapping(skb);if (rxq < rcv->real_num_rx_queues) { rq = &rcv_priv->rq[rxq]; rcv_xdp = rcu_access_pointer(rq->xdp_prog);if (rcv_xdp) skb_record_rx_queue(skb, rxq); }if (likely(veth_forward_skb(rcv, skb, rq, rcv_xdp) == NET_RX_SUCCESS)) {structpcpu_vstats *stats = this_cpu_ptr(dev->vstats); u64_stats_update_begin(&stats->syncp); stats->bytes += length; stats->packets++; u64_stats_update_end(&stats->syncp); } else {drop: atomic64_inc(&priv->dropped); } rcu_read_unlock();return NETDEV_TX_OK;}
几个要点:
- 不拷贝、不 DMA、不排队等中断。它直接把同一个
skb 顺着 peer 指针交给对端设备。 rcu_dereference(priv->peer) 用 RCU 保护:对端可能正在被移走或删除,RCU 保证这一刻拿到的指针有效。- 返回
NETDEV_TX_OK 只是告诉上层协议栈"我收下了",实际上包在这一刻就已经"送到"对端了。如果 peer 为 NULL(对端已被删),skb 被丢弃并计入 dropped。
真正的转发在 veth_forward_skb:
staticintveth_forward_skb(struct net_device *dev, struct sk_buff *skb,struct veth_rq *rq, bool xdp){return __dev_forward_skb(dev, skb) ?: xdp ? veth_xdp_rx(rq, skb) : netif_rx(skb);}
先调用 __dev_forward_skb,它把 skb 从"发送侧状态"清理成"接收侧状态"——清掉原 netns 残留的路由缓存、分片、tw 等信息,并把 skb->dev 设为对端设备(eth_type_trans)。然后分叉:
- 若对端挂了 XDP 程序(
xdp 为真)→ 走 veth_xdp_rx,入 NAPI 接收队列; - 否则 → 走
netif_rx,进入传统软中断收包路径。
四、接收与协议栈上行:软中断里的接力
netif_rx 这条经典路径:netif_rx_internal → enqueue_to_backlog,把 skb 塞进当前 CPU 的 softnet_data->input_pkt_queue,然后 __napi_schedule 触发 NET_RX_SOFTIRQ。
软中断上下文里:
net_rx_action → process_backlog → __netif_receive_skb → 按协议 deliver 到 ip_rcv
注意一个反直觉的点:veth 的"接收"实际上发生在发送端的 CPU 上。因为 veth_xmit 直接调用 netif_rx,而 netif_rx 用的是当前 CPU 的 softnet 队列。这解释了为什么 veth 的吞吐与 CPU 软中断亲和性高度相关——包的收发都在同一颗核上接力完成。
对比物理网卡:物理网卡有硬中断唤醒 NAPI poll,poll 里调 napi_gro_receive。veth 走的是 netif_rx 这条"软件入队"路径,全程没有硬中断,是纯软件转发。
五、NAPI 与 GRO:现代内核的批量优化与坑
每包一次 netif_rx + 一次软中断,在高频小包场景下软中断开销吃不消。现代内核给 veth 的接收队列(veth_rq)配了 napi_struct(即 xdp_napi)和 ptr_ring。当对端挂了 XDP 程序时,skb 走 veth_xdp_rx 入 ptr_ring,napi_schedule 唤醒 veth_poll,由它在 NAPI 上下文里批量取出 skb 并调 napi_gro_receive 交给协议栈。
GRO(Generic Receive Offload)就在 napi_gro_receive 这一层生效:把多个连续的 TCP 小段合并成一个大 skb,减少上层协议栈的处理次数。代价是合并后的包变大、延迟增加。实测数据里,关掉 GRO 能让跨容器通信延迟下降约三成,代价是吞吐掉约两成——延迟敏感型微服务常常这么调。
# 查看 veth 的 offload 状态ethtool -k veth0 | grep -E 'gro|gso|tso'# 关 GRO 换延迟(牺牲吞吐)ethtool -K veth0 gro off
一个经典诊断误区:用 ethtool -k veth0 查到的 offload 设置,显示的其实是 peer(对端 veth1) 的状态,因为 veth pair 的 offload 能力是对称的。在容器侧看 veth 的 GRO 开关,得去宿主侧对应的那根 peer 上查,否则会被"明明关了却还生效"骗到。
六、checksum 与 offload 的真相
veth 的 dev->features 里带 NETIF_F_HW_CSUM、NETIF_F_GSO_SOFTWARE 等,所以 GSO/GRO 在 veth 上是软件实现的。两个要点:
- veth 不支持 TSO(硬件分段),只支持 GSO(软件分段)。大包在 veth 上由 GSO 处理,而不是交给"网卡"去切。这就是为什么容器出口启用 TSO 时,包会在 veth 上被降级成 GSO,多一层软件分段开销。
- checksum:因为包根本没离开这台机器,veth 上的
skb 校验和可以在发送侧就标记为无需重算(CHECKSUM_PARTIAL / CHECKSUM_UNNECESSARY)。tcpdump 抓 veth 包时常看到 checksum: validation disabled,不是网卡坏了,而是这层根本不需要硬件校验。
还有一个高频踩坑点:MTU 必须两端对齐。若 veth0 是 1500 而 veth1 是 9000,GSO 产生的大包在接收端因 MTU 不匹配被丢弃,表现就是诡异的"大包不通、小包通"。ip link set veth0 mtu 1500 两端要一起改。
七、当它接上 bridge:rx_handler 的拐点
容器网络里,宿主侧那根 veth 通常被加进 Linux bridge(docker0 / cni0)。一旦加入,bridge 会通过 netdev_rx_handler_register 把自己的 br_handle_frame 注册成该设备的 rx_handler。
于是数据包到达宿主侧 veth(仍是从容器经 veth_xmit 转发来的)后,在 __netif_receive_skb_core 里不再直接进 IP 协议栈,而是先执行 rx_handler——也就是 br_handle_frame:查 FDB、学 MAC、决定洪泛 / 转发 / 上送。
这里要分清:veth 自身不走 rx_handler,它在发送侧就直接 forward 了;rx_handler 是装在"接收端设备"(被桥接的那根 veth)上的钩子。正是这个钩子,把"veth pair 点对点连线"升级成了"多端口二层交换"——Docker/K8s 默认网络能连通任意容器,根源在此。
八、性能画像与调优
veth 是纯软件转发:没有物理驱动、没有 DMA、没有硬中断,单包延迟通常低于物理网卡,但每个包都吃 CPU(软中断)。
瓶颈几乎总在宿主的软中断:cat /proc/net/softnet_stat 里 dropped 增长、top 里 %si 飙高,往往就是多容器高 PPS 把单核 softnet 队列打满。调优思路:
- 根本加速:eBPF 在 veth 上做
bpf_redirect / bpf_redirect_peer。Cilium 的做法是把包从容器 netns 通过 eBPF 直接 redirect 到宿主 netns 的对应设备,完全 bypass 掉"veth_xmit → netif_rx → 软中断 → 协议栈"这一整段来回。注意 eBPF 不是替代 veth,而是在 veth 这层"抄近路"——理解 veth 的原生路径,才知道 eBPF 省掉了什么。
九、收束:一个 ping 的完整旅程
把前面所有点串起来,容器里 ping 8.8.8.8 的包走过的路:
回到开头那个困惑:宿主机 ip addr 看不到容器的 eth0,因为它们根本不在同一个 netns 里——而 veth pair,正是跨过这道墙的那根"软件网线"。veth_xmit 不过几十行,却撑起了整个容器网络。理解它的内核路径,排障时就知道该往哪一层看:peer 指针断了?dropped 在涨?GRO/MTU 不对齐?软中断打满了?——问题出在哪一层,答案就在这套机制里。
动手验证
# 1. 建一对 veth 并跨 nsip netns add ns1ip link add veth0 type veth peer name veth1ip linkset veth1 netns ns1ip addr add 192.168.100.1/24 dev veth0ip netns exec ns1 ip addr add 192.168.100.2/24 dev veth1ip linkset veth0 upip netns exec ns1 ip linkset veth1 up# 2. 两端都能抓到同一个包(验证"一根线")ip netns exec ns1 tcpdump -n -i veth1 icmp &ping 192.168.100.2# 3. 看对端在哪、丢包计数ip link show veth0 # 关注 link-netnsidcat /sys/class/net/veth0/statistics/rx_dropped# 4. 看 offload 与软中断ethtool -k veth0 | grep -E 'gro|gso|tso'cat /proc/net/softnet_stat