ARTICLE · 1176977
容器网络是怎么在软件里「布线」的——namespace、veth 与 bridge
QUOTE
你在宿主机上敲下 docker run,几秒钟后,一个「容器」就有了自己的 IP、自己的路由表、自己的防火墙。你在容器里 ping 172.17.0.2,包居然能从这台「虚拟机器」一路跑到另一台——可整台宿主机上,自始至终只有一块真实网卡,没有第二块硬件,没有实体网线。这些「网卡」「网线」「交换机」到底长在哪?
答案是:它们全是内核用数据结构 + 函数指针在软件里「演」出来的。容器网络的地基只有四样东西——struct net(把网络栈隔成一间间房)、veth(一对软件网线)、bridge(一台软件交换机)、tun/tap(一根直通用户态的管)。理解了这四样,你就理解了 Docker/K8s 那层 docker0 网桥、Flannel/Calico 那些虚拟网卡到底在干什么。
读完这一篇,你能做三件事。第一,说清 struct net 怎么让一个进程「看不见」别的 namespace 的网卡和路由表,以及它生老病死的完整生命周期。第二,把 veth 和 bridge 的收发代码从头追到尾——包不经过真实网卡,只靠 veth_xmit 一句「直接投递给对端」就跨过了 namespace。第三,看懂 tun/tap 怎么让一个用户态进程(VPN、用户态协议栈)从内核手里「抽包、注包」。
本章导读:一栋公寓楼里的布线工
把一台宿主机想成一栋公寓楼:容器是每户房间,namespace 是房间的隔断,veth 是网线,bridge 是楼道配线间里的交换机,tun/tap 是一根直通物业办公室的专线。
○ 房间 = namespace:每户有独立的门牌号(IP)、独立的电话本(路由表)、独立的插座(socket)。墙是共享的(同一个内核),但水电煤账本(网络栈数据)各管各的。
○ 网线 = veth 对:一根线两端各有一个水晶头,一头插房间插座,一头插配线间。数据从一头进,瞬间从另一头出,中间没有「网卡」。
○ 交换机 = bridge:所有网线都插在它上面。它记一本「哪根线连着哪户」的账(MAC 学习表),查到就定向投递,查不到就群发(泛洪)。
○ 专线 = tun/tap:不连到任何房间,而是直通物业办公室——物业(用户态进程)直接拆开原始包裹看里面是什么(IP 包或以太网帧),看完还能原样塞回来。
本文要回答的核心问题是:一个包在「虚拟」世界里,怎么从 A 户的房间出发,穿过网线、交换机,到达 B 户的房间。 文中所有行号/符号都锚定 QCOM kernel-msm(Linux 6.6.87)这一份内核。真实网卡的收发(NAPI、软中断、DMA ring)在《内核收包(上)》《内核发包(下)》已经讲过,这里不再展开;本文只讲「当网卡是软件演出来的」时,收发路径变成了什么样。
1 一个进程凭什么「看不见」别的容器的网络?
容器网络全景:左侧两个 namespace 各自持有独立的协议栈,veth 对跨在边界上,bridge 在宿主 namespace 里做交换,tun/tap 直通用户态
1.1
PART
struct net:网络栈的「户口本」
先看这个贯穿全篇的主角。内核里每个网络命名空间,就是一份 struct net。它不是一个轻量对象,而是把整张网络栈的全局状态都内嵌了一份:
// include/net/net_namespace.h:61
structnet{
refcount_t passive;/* 决定何时释放 namespace */
atomic_t dev_unreg_count;
u32 ifindex;/* 网卡序号分配器 */
structlist_head list;/* 挂在全局 net_namespace_list 上 */
structlist_head exit_list;/* 待清理名单 */
structllist_node cleanup_list;/* 死缓名单 */
structuser_namespace*user_ns;/* 所属的用户命名空间 */
structidr netns_ids;/* namespace 的 id 分配 */
structns_common ns;/* 挂进 /proc/self/ns/net */
structlist_head dev_base_head;/* 这个 namespace 的网卡链表 */
structhlist_head*dev_name_head;/* 按名字找网卡的哈希表 */
structhlist_head*dev_index_head;/* 按序号找网卡的哈希表 */
structnet_device*loopback_dev;/* 每个 namespace 自己的 lo */
u32 hash_mix;/* 哈希盐,防跨 ns 哈希碰撞攻击 */
structnetns_ipv4 ipv4;/* 自己的路由表、TCP/UDP socket 表 */
structnetns_ipv6 ipv6;/* IPv6 那一套 */
structnetns_nf nf;/* 自己的 netfilter/iptables */
structnetns_ct ct;/* 自己的 conntrack 表 */
structnet_generic __rcu *gen;/* 各子系统按需挂的私有数据 */
// ...省略其余 netns_xxx 子系统...
} __randomize_layout;
struct net 结构拆解:一份 struct net 内嵌网卡表、路由表、socket 表、netfilter 等全部子系统,两个进程通过 nsproxy->net_ns 指向同一份,另一份就是另一个 namespace
○ dev_base_head / dev_name_head / dev_index_head:这是「网卡按 namespace 隔离」的直接体现——每个 struct net 有自己的网卡表,ip link 看到的列表就是从这张表遍历出来的。一个 veth 接口只登记在它所属的那份 struct net 里,别的 namespace 根本「查不到」它。
○ netns_ipv4:里面是 IPv4 的私有状态——路由表(FIB)、TCP/UDP socket 的哈希表、netfilter 链。这就是「路由表、socket、iptables 各自独立」的来源。你在容器里 ip route 和宿主机 ip route 看到不同的表,因为它们在两份不同的 struct net->ipv4 里。
○ loopback_dev:每个 namespace 有自己的 lo。这是隔离最直观的证据——容器的 127.0.0.1 和宿主机的 127.0.0.1 是两块不同的回环设备,互不干扰。
○ hash_mix:一个随机盐,参与 socket 哈希的计算。没有它,攻击者可以在两个 namespace 之间构造哈希碰撞;每个 namespace 的盐不同,哈希分布就不一样。
○ gen:指向 struct net_generic——一张「按子系统编号」的指针数组。像 conntrack、netfilter 这种可以编译成模块的子系统,不能给 struct net 加死字段(那样每次增删模块都要改这个结构体),于是各自 register_pernet_subsys 申请一个 id,把私有数据挂在 gen 数组的对应槽位上。
如果你在想:是不是每个 socket、每个 sk_buff 都记着自己属于哪个 namespace? 答案是「不需要」。
socket 通过 sock_net(sk) 间接拿到 namespace,而绝大多数内核代码处理包时,namespace 是顺着「网卡」拿到的——dev_net(dev) 返回这个网卡所属的 struct net。一份 struct net 覆盖了整个网络栈,所以只要一个对象能定位到它的网卡或 socket,就能定位到 namespace,不需要到处冗余存指针。
1.2
PART
一个 namespace 的诞生:copy_net_ns
用户态 unshare -n、clone(CLONE_NEWNET)、容器 runtime 的 setns,最终都会走到 copy_net_ns。它的骨架极简:
// net/core/net_namespace.c:465
structnet*copy_net_ns(unsignedlong flags,
structuser_namespace*user_ns,structnet*old_net)
{
structucounts*ucounts;
structnet*net;
int rv;
if(!(flags & CLONE_NEWNET))
returnget_net(old_net);/* 没要求新 netns,复用父的 */
ucounts =inc_net_namespaces(user_ns);/* 记入用户配额 */
if(!ucounts)
returnERR_PTR(-ENOSPC);
net =net_alloc();/* 从 net_cachep 分配一份 struct net */
if(!net){
rv =-ENOMEM;
goto dec_ucounts;
}
preinit_net(net);
refcount_set(&net->passive,1);
net->ucounts = ucounts;
get_user_ns(user_ns);
rv =down_read_killable(&pernet_ops_rwsem);
if(rv <0)
goto put_userns;
rv =setup_net(net, user_ns);/* 跑所有子系统的 init */
up_read(&pernet_ops_rwsem);
// ...省略错误回滚...
return net;
}
○ if (!(flags & CLONE_NEWNET)) return get_net(old_net); 是第一个关键判断:没带 CLONE_NEWNET 标志,就不建新 namespace,直接把引用计数加一、复用父 namespace。这解释了为什么默认 fork 出来的子进程和父进程共享同一套网络——它们指向同一份 struct net。
○ net_alloc() 用专属的 slab 缓存 net_cachep 分配内存,再 net_alloc_generic() 分配那张 gen 数组,然后 rcu_assign_pointer(net->gen, ng) 挂上。
○ 真正的「初始化」不在 copy_net_ns 里,而在 setup_net 里——那才是重头戏。
1.3
PART
setup_net 与 pernet_operations:谁来给新房间摆家具?
setup_net 自己只做几件小事(置引用计数、生成随机 hash_mix、分配 net_cookie),真正的「按 namespace 初始化」靠一个回调链表 pernet_list 完成:
// net/core/net_namespace.c:320(节选)
static __net_init intsetup_net(structnet*net,structuser_namespace*user_ns)
{
conststructpernet_operations*ops,*saved_ops;
int error =0;
refcount_set(&net->ns.count,1);
refcount_set(&net->passive,1);
get_random_bytes(&net->hash_mix,sizeof(u32));
net->net_cookie =gen_cookie_next(&net_cookie);
net->dev_base_seq =1;
net->user_ns = user_ns;
idr_init(&net->netns_ids);
list_for_each_entry(ops,&pernet_list, list){
error =ops_init(ops, net);/* 逐个子系统初始化 */
if(error <0)
goto out_undo;
}
down_write(&net_rwsem);
list_add_tail_rcu(&net->list,&net_namespace_list);/* 挂上全局链表 */
up_write(&net_rwsem);
return error;
// ...省略 out_undo 回滚:反向调用 exit/free...
}
这些 ops 的类型是 pernet_operations,每个网络子系统(IPv4、conntrack、netfilter、bridge……)在模块加载时 register_pernet_subsys / register_pernet_device 把自己挂进 pernet_list:
// include/net/net_namespace.h:429
structpernet_operations{
structlist_head list;
int(*init)(structnet*net);/* 新 netns 建好后,给它摆家具 */
void(*pre_exit)(structnet*net);/* 拆除前的准备 */
void(*exit)(structnet*net);/* 拆除家具 */
void(*exit_batch)(structlist_head*net_exit_list);/* 批量拆除 */
unsignedint*id;/* 在 gen 数组里占的槽位号 */
size_t size;/* 私有数据大小 */
};
○ init 在每个 namespace 诞生时被调用一次——IPv4 的 init 会建好这张 namespace 专属的路由表,conntrack 的 init 会建好专属的连接跟踪表。这就是「新房间进来,各子系统进去摆自己的家具」。
○ register_pernet_device 和 register_pernet_subsys 的区别在于拆除顺序:device 先于 subsys 拆除。原因是网络设备的拆除必须先于依赖它的协议栈——否则网卡还在,socket 表先没了,会出乱子(历史上 IPv4 ICMP 曾因为注册错类型导致 namespace 清理时内核崩溃)。
如果你在想:这么一长串 init 回调,出错了一半怎么办?setup_net 的 out_undo 分支会从后往前反向调用那些已经 init 成功的子系统的 exit/free,把摆了一半的家具拆回去。这是 namespace 创建路径上的异常处理,和下面要讲的销毁是对称的。
1.4
PART
一个 namespace 的死亡:cleanup_net 走工作队列
namespace 销毁不能就地做——它可能正被某个 RCU 读端拿着,也可能正在软中断里。所以内核把「拆房间」推迟到一个独立工作队列 netns_wq 上执行:
// net/core/net_namespace.c:660
void__put_net(structnet*net)
{
ref_tracker_dir_exit(&net->refcnt_tracker);
/* Cleanup the network namespace in process context */
if(llist_add(&net->cleanup_list,&cleanup_list))
queue_work(netns_wq,&net_cleanup_work);
}
○ 引用计数 put_net() 减到零时,__put_net 把这个 namespace 挂上全局的 cleanup_list(死缓名单),然后 queue_work 排队一个 net_cleanup_work。
○ 真正干活的是 cleanup_net:它先 llist_del_all 把死缓名单一次性取走,把每个 namespace 从全局链表摘掉(list_del_rcu),然后反向遍历 pernet_list 调 pre_exit → exit → free,最后 rcu_barrier() 等所有还在路上的 RCU 读者走完,才真正 net_free 释放 struct net。
○ 关键点:device 类的 pernet ops 先拆,保证所有网卡(含 veth、bridge 端口)先从 namespace 里消失、不再有包进出,之后协议栈(socket 表、路由表)才跟着拆。这也是 register_pernet_device 与 register_pernet_subsys 必须区分的原因。
到这里,「隔间」讲完了。下一个问题很自然:房间隔开了,但房间之间总得通信吧? 一根网线也不能只有一头——内核给出的答案是 veth,一台「成对出生」的软件网卡。
2 两个 namespace 之间那根「网线」,包怎么过去?
veth 对转发:从 A 端 dev_queue_xmit 进入 veth_xmit,直接把 skb 灌进对端的收包队列,跨 namespace 时擦除 skb 上的旧 namespace 信息
2.1
PART
一根网线两个头:veth_newlink 造出对端
ip link add veth0 type veth peer name veth1 这条命令,经 netlink 进到 veth_newlink。它的思路值得注意——先造对端,再配对:
// drivers/net/veth.c:1809(节选)
staticintveth_newlink(structnet*src_net,structnet_device*dev,
structnlattr*tb[],structnlattr*data[],
structnetlink_ext_ack*extack)
{
structnet_device*peer;
structveth_priv*priv;
structnet*net;
// ...省略对端 name/attrs 的解析...
net =rtnl_link_get_net(src_net, tbp);/* 对端可以放进别的 netns */
peer =rtnl_create_link(net, ifname, name_assign_type,
&veth_link_ops, tbp, extack);/* 造对端设备 */
// ...省略 ifindex 分配、random MAC...
err =register_netdevice(peer);/* 先注册对端 */
// ...省略 dev 名字重分配...
err =register_netdevice(dev);/* 再注册本端 */
priv =netdev_priv(dev);
rcu_assign_pointer(priv->peer, peer);/* 本端 -> 对端 */
priv =netdev_priv(peer);
rcu_assign_pointer(priv->peer, dev);/* 对端 -> 本端 */
// ...省略队列初始化...
return0;
}
○ 注意 rtnl_link_get_net(src_net, tbp) 这步:ip link 的 netns 参数可以把对端直接放进另一个 namespace。这是「容器网线」的标准造法——宿主机执行 ip link add veth0 netns ns1 type veth peer name veth1 netns ns2,一根网线两端分属两个房间。
○ 配对靠 struct veth_priv 里的 peer 指针:本端存对端、对端存本端,用 rcu_assign_pointer 写、rcu_dereference 读(因为收发路径和删除路径可能并发,必须走 RCU)。
○ 造的时候 register_netdevice(peer)先、register_netdevice(dev)后,这是故意为之——如果先注册了本端而配对还没完成,本端在 veth_xmit 里 rcu_dereference(priv->peer) 会拿到空指针。顺序反过来后,两端的 peer 指针在同一把 rtnl 锁下写好,就不会有窗口期。
删除也是成对的。ip link del veth0 最终落到 veth_dellink(veth.c:1944):它先 rtnl_dereference 取出对端,RCU_INIT_POINTER 把两端的 peer 指针同时清空,再把两个设备都 unregister_netdevice_queue 摘牌。这正好解释了跨 namespace 的清理——一个 namespace 退出时,default_device_exit_batch 逐个注销它的网卡,veth 的 dellink 会把「住在隔壁 namespace 的对端」也一并注销,绝不留下悬空的半根网线。
2.2
PART
veth_xmit:不排队、不中断,直接投递
这是全篇最短也最关键的一段。当协议栈 dev_queue_xmit 决定把包发给 veth,最后调的 ndo_start_xmit 就是 veth_xmit(见 veth_netdev_ops:.ndo_start_xmit = veth_xmit):
// drivers/net/veth.c:343(上半:拿对端、选队列)
staticnetdev_tx_tveth_xmit(structsk_buff*skb,structnet_device*dev)
{
structveth_priv*rcv_priv,*priv =netdev_priv(dev);
structveth_rq*rq =NULL;
structnet_device*rcv;
bool use_napi = false;
int rxq;
rcu_read_lock();
rcv =rcu_dereference(priv->peer);/* 取出对端设备 */
if(unlikely(!rcv)||!pskb_may_pull(skb, ETH_HLEN)){
kfree_skb(skb);
goto drop;
}
rcv_priv =netdev_priv(rcv);
rxq =skb_get_queue_mapping(skb);
if(rxq < rcv->real_num_rx_queues){
rq =&rcv_priv->rq[rxq];
/* The napi pointer is available when an XDP program is
* attached or when GRO is enabled
* Don't bother with napi/GRO if the skb can't be aggregated
*/
use_napi =rcu_access_pointer(rq->napi)&&
veth_skb_is_eligible_for_gro(dev, rcv, skb);
}
// ...见下半...
// drivers/net/veth.c:373(下半:投递 + 计数)
skb_tx_timestamp(skb);
if(likely(veth_forward_skb(rcv, skb, rq, use_napi)== NET_RX_SUCCESS)){
if(!use_napi)
dev_sw_netstats_tx_add(dev,1, length);
else
__veth_xdp_flush(rq);
}else{
drop:
atomic64_inc(&priv->dropped);
ret = NET_XMIT_DROP;
}
rcu_read_unlock();
return ret;
}
○ 第一句 rcv = rcu_dereference(priv->peer) 就是全部玄机:veth 没有自己的「发送硬件」,它的「发送」就是拿包转发给对端。priv->peer 是建网线时配对好的那一端。
○ 紧接着 veth_forward_skb(rcv, skb, rq, use_napi) 完成真正的投递,它只有一行逻辑:
// drivers/net/veth.c:316
staticintveth_forward_skb(structnet_device*dev,structsk_buff*skb,
structveth_rq*rq, bool xdp)
{
return__dev_forward_skb(dev, skb)?: xdp ?
veth_xdp_rx(rq, skb):
__netif_rx(skb);
}
○ __dev_forward_skb 做「转发前准备」——MTU 检查、跨 namespace 擦除(下一小节展开);成功返回 0 后,?: 运算符落到 __netif_rx(skb),把包灌进对端的收包队列。从这一刻起,这个包就变成了对端设备「收到」的包,接下来走的就是《内核收包(上)》那条 netif_receive_skb → 协议栈 的老路了。
○ 全程没有中断、没有软中断、没有 DMA ring——因为根本没有硬件。use_napi 分支(挂 XDP 或开 GRO 时)才用 NAPI 队列批量收,默认路径 __netif_rx 直接把包投进 per-CPU 的 backlog 队列等软中断处理。
○ 为什么连排队都没有?veth_setup 给设备设了 IFF_NO_QUEUE(veth.c:1739),内核便给它挂 enqueue 为空的 noqueue qdisc;__dev_queue_xmit 在 if (q->enqueue)(dev.c:4389)这一关跳过入队,直接调到 ndo_start_xmit(即 veth_xmit)——「软件网线」零排队就来自这里。
两条路线是刻意的取舍。默认走 __netif_rx:一次函数调用把包塞进 per-CPU 的 backlog 队列,等 NET_RX_SOFTIRQ 软中断来取——全程不维护 NAPI 实例、不排队 xdp_ring,单包开销最小,但代价是这条路既不能跑 XDP,也不能做 GRO 聚合。
开 use_napi(挂 XDP 程序或对端开 GRO)后才换到 veth_xdp_rx:包先进 xdp_ring,再经 __veth_xdp_flush 里 napi_schedule 批量 poll,XDP 和 GRO offload 才吃得着。但 NAPI 队列和软中断调度都是要付的 CPU。所以内核用 veth_skb_is_eligible_for_gro 把关:没有聚合希望的包(发送端开着 TSO、又不是本地 UDP)直接走默认路省 CPU。
这样直接投递,会不会违反「隔离」?——不会,而且内核专门防了这件事,下一小节就是答案。
2.3
PART
跨 namespace 时的那道「消毒」:skb_scrub_packet
__dev_forward_skb 会落到内联函数 ____dev_forward_skb:
// include/linux/netdevice.h:4091
static __always_inline int____dev_forward_skb(structnet_device*dev,
structsk_buff*skb,
const bool check_mtu)
{
if(skb_orphan_frags(skb, GFP_ATOMIC)||
unlikely(!__is_skb_forwardable(dev, skb, check_mtu))){
dev_core_stats_rx_dropped_inc(dev);
kfree_skb(skb);
return NET_RX_DROP;
}
skb_scrub_packet(skb,!net_eq(dev_net(dev),dev_net(skb->dev)));
skb->priority =0;
return0;
}
○ __is_skb_forwardable 做 MTU 检查:对端装不下就丢(NET_RX_DROP)。这是 veth 路径上的一个失败分支。
○ skb_scrub_packet(skb, xnet) 的第二个参数是「是否跨 namespace」:!net_eq(dev_net(dev), dev_net(skb->dev)) 比较「目的设备所属 ns」和「skb 原设备所属 ns」。跨 ns 时,skb_scrub_packet 会把 skb 上所有可能泄漏旧 namespace 信息的东西清掉——skb_iif(入口设备序号)、dst(路由缓存)、conntrack 状态、IPVS 记录等(跨 ns 时再加 mark 与时间戳)。
○ 为什么必须擦?设想包从 ns A 的 veth 一端「转发」到 ns B 的另一端:skb 里若还带着 ns A 的路由缓存指针,ns B 的协议栈误用它,就会用 A 的路由决策 B 的包——隔离被穿透。skb_scrub_packet 保证包跨过 namespace 边界时,是「干干净净」地以「新收包」的身份进入对端。
这就解释了「网线」为什么安全:转发发生在内核里,但每次跨边界都是一次语义上的「重新收包」。一根网线讲完了,但真实容器场景里不止一根网线——宿主机上往往有几十个容器,几十根 veth 都要接起来,这就是 bridge 登场的时候。
3 十几个容器插同一台「交换机」,包怎么找对路?
bridge 转发决策:rx_handler 拦截包后,先学习源 MAC,再按目的 MAC 查 FDB,命中定向转发、未命中泛洪
3.1
PART
rx_handler:桥是怎么「截胡」包的
真实交换机在物理层交换,而软件 bridge 在收包路径上截胡。当 br_add_if 把一个网卡(如 veth1)加进桥时,它会注册一个 rx_handler:
// net/bridge/br_if.c:567(节选)
intbr_add_if(structnet_bridge*br,structnet_device*dev,
structnetlink_ext_ack*extack)
{
// ...省略校验:dev 不能是另一个桥、不能是 loopback 等...
err =netdev_rx_handler_register(dev,br_get_rx_handler(dev), p);
// ...省略 FDB 初始、STP 启停...
}
注册之后,这个网卡「收到」的任何包,不再直接上交协议栈,而是先交给桥的 rx_handler ——也就是 br_handle_frame。这对应收包主线(《内核收包(上)》)里 netif_receive_skb 的那句 rx_handler 调用:普通网卡这里是空,交给 __netif_receive_skb 上协议栈;而桥的端口设备这里是 br_handle_frame。桥就是这样「劫持」了端口的所有入站流量。
br_handle_frame 的第一件事是给 STP 和链路本地协议放行/拦截(01:80:C2:00:00:00 开头的保留地址),然后检查端口状态,能转发就交给 br_handle_frame_finish:
// net/bridge/br_input.c:325(节选)
staticrx_handler_result_tbr_handle_frame(structsk_buff**pskb)
{
structnet_bridge_port*p;
structsk_buff*skb =*pskb;
constunsignedchar*dest =eth_hdr(skb)->h_dest;
if(unlikely(skb->pkt_type == PACKET_LOOPBACK))
return RX_HANDLER_PASS;
if(!is_valid_ether_addr(eth_hdr(skb)->h_source))
goto drop;
skb =skb_share_check(skb, GFP_ATOMIC);/* 要转发就得有独享副本 */
// ...省略 STP/链路本地地址的处理...
switch(p->state){
case BR_STATE_FORWARDING:
case BR_STATE_LEARNING:
returnnf_hook_bridge_pre(skb, pskb);/* 放行,继续 */
default:
drop:
kfree_skb(skb);
}
return RX_HANDLER_CONSUMED;
}
○ p->state 是端口的 STP 状态。只有 FORWARDING 和 LEARNING 两个状态才放行数据帧——这就是 STP「防环」落到代码上的闸门:BLOCKING 端口的包在这里就被 kfree_skb 丢掉了。
○ skb_share_check:桥要转发可能要把包复制成多份(泛洪),所以先确保手里是独享的 skb,避免和别的消费者共享。
3.2
PART
学习与转发:br_handle_frame_finish 的两件事
过了闸门,就进到桥的核心 br_handle_frame_finish。它干两件事,先学后转:
// net/bridge/br_input.c:76(节选)
intbr_handle_frame_finish(structnet*net,structsock*sk,structsk_buff*skb)
{
structnet_bridge_port*p =br_port_get_rcu(skb->dev);
// ...省略变量声明、MST/状态检查...
if(p->flags & BR_LEARNING)
br_fdb_update(br, p,eth_hdr(skb)->h_source, vid,0);/* 学习 */
if(is_multicast_ether_addr(eth_hdr(skb)->h_dest)){
/* 广播也是组播的特例 */
if(is_broadcast_ether_addr(eth_hdr(skb)->h_dest))
pkt_type = BR_PKT_BROADCAST;
else
pkt_type = BR_PKT_MULTICAST;
}
if(state == BR_STATE_LEARNING)
goto drop;/* 学习状态只学不转 */
// ...省略 ARP/ND 抑制、组播处理...
switch(pkt_type){
case BR_PKT_UNICAST:
dst =br_fdb_find_rcu(br,eth_hdr(skb)->h_dest, vid);/* 查表 */
break;
// ...省略组播分支...
}
if(dst){
if(test_bit(BR_FDB_LOCAL,&dst->flags))
returnbr_pass_frame_up(skb, false);/* 发给桥自己 */
br_forward(dst->dst, skb, local_rcv, false);/* 定向转发 */
}else{
br_flood(br, skb, pkt_type, local_rcv, false, vid);/* 泛洪 */
}
// ...省略 local_rcv 本地投递...
}
○ 先学习:br_fdb_update(br, p, 源MAC, vid, 0) 把「源 MAC → 这个端口」写进转发数据库 FDB。FDB 就是桥的那本「哪根线连着哪户」的账本。
○ state == BR_STATE_LEARNING 直接丢:STP 的 learning 状态允许学习 MAC,但不允许转发——端口刚接入时先闷头记地址,记够了才转。
○ 再查表:目的 MAC 是单播就 br_fdb_find_rcu 查 FDB。查到且 BR_FDB_LOCAL(目的就是桥自己)→ 上交给本地协议栈;查到且是别的端口 → br_forward 定向发出;查不到 → br_flood 泛洪。
○ 广播和组播不需要查表,直接走泛洪(或组播组的定向分发)。
学习那步 br_fdb_update 还藏着「老化」和「移动」两个细节:
// net/bridge/br_fdb.c:853(节选)
voidbr_fdb_update(structnet_bridge*br,structnet_bridge_port*source,
constunsignedchar*addr, u16 vid,unsignedlong flags)
{
structnet_bridge_fdb_entry*fdb;
if(hold_time(br)==0)/* 关闭学习,直接返回 */
return;
fdb =fdb_find_rcu(&br->fdb_hash_tbl, addr, vid);
if(likely(fdb)){
unsignedlong now = jiffies;
fdb->updated = now;/* 刷新时间戳,防老化 */
if(unlikely(source !=READ_ONCE(fdb->dst)&&
!test_bit(BR_FDB_STICKY,&fdb->flags))){
WRITE_ONCE(fdb->dst, source);/* MAC 换口了,改指向 */
}
// ...省略 switchdev 通知...
}else{
spin_lock(&br->hash_lock);
fdb =fdb_create(br, source, addr, vid, flags);/* 新条目 */
// ...省略通知...
spin_unlock(&br->hash_lock);
}
}
○ fdb->updated = now 刷新时间戳:FDB 条目默认老化时间 BR_DEFAULT_AGEING_TIME = 300 * HZ(5 分钟),到期没人再发包就被清掉——这是桥的「遗忘」,防止表被塞满,也是失败/超时路径的一部分。
○ source != fdb->dst 时直接 WRITE_ONCE(fdb->dst, source):MAC 换口了,桥立即改账。这正是虚拟机热迁移时,同一个 MAC 从这台宿主机跑到那台,桥能自动跟上不丢包的原因。
○ spin_lock(&br->hash_lock) 保护哈希表的并发插入——多个 CPU 同时学习不同 MAC 时,这是 FDB 的一道锁闸门。
3.3
PART
定向转发与泛洪:包最终怎么出桥
无论定向还是泛洪,最后都要把包从某个端口「发出去」。定向转发走 br_forward → __br_forward → br_forward_finish → br_dev_queue_push_xmit:
// net/bridge/br_forward.c:73(节选)
staticvoid__br_forward(conststructnet_bridge_port*to,
structsk_buff*skb, bool local_orig)
{
// ...省略 VLAN 出方向处理...
indev = skb->dev;
skb->dev = to->dev;/* 换成出端口设备 */
if(!local_orig){
br_hook = NF_BR_FORWARD;/* 转发的包过 FORWARD 链 */
skb_forward_csum(skb);
net =dev_net(indev);
}else{
br_hook = NF_BR_LOCAL_OUT;/* 本地发出的包过 LOCAL_OUT 链 */
net =dev_net(skb->dev);
}
NF_HOOK(NFPROTO_BRIDGE, br_hook,
net,NULL, skb, indev, skb->dev, br_forward_finish);
}
// net/bridge/br_forward.c:33
intbr_dev_queue_push_xmit(structnet*net,structsock*sk,structsk_buff*skb)
{
skb_push(skb, ETH_HLEN);/* 重新压回以太网头 */
if(!is_skb_forwardable(skb->dev, skb))
goto drop;
br_drop_fake_rtable(skb);
// ...省略 VLAN/checksum 处理...
dev_queue_xmit(skb);/* 交给出端口设备的发送路径 */
return0;
drop:
kfree_skb(skb);
return0;
}
○ skb->dev = to->dev 是桥转发的灵魂:包进桥时 skb->dev 是入端口(如 veth1),转发时被换成出端口(如 veth3),然后 dev_queue_xmit 就会去调出端口的 ndo_start_xmit。
○ 而出端口如果又是 veth,dev_queue_xmit 就会调 veth_xmit——包从桥出去,进了下一根「网线」。这就是第 5 节要串起来的完整链条。
○ br_forward_finish 里 skb_clear_tstamp 清掉入方向的时间戳,NF_BR_FORWARD 链是 __br_forward 里的 NF_HOOK 挂的(转发路径),NF_BR_POST_ROUTING 是 br_forward_finish 里的 NF_HOOK 挂的(出桥方向)——这是「iptables 在 bridge 上也能过滤」的挂载点。
泛洪 br_flood 的逻辑类似,只是遍历除入端口外的所有转发端口,每个端口 skb_clone 一份副本再转发。这样同一个广播帧(比如 ARP)能被复制到桥上的每一个容器。
3.4
PART
STP 生成树:五态状态机怎么防环
桥最怕环路:两个交换机之间多接一根线,广播帧就会在环里无限循环、指数级增长,这就是「广播风暴」。真交换机用 STP(生成树协议)解决,Linux 桥也实现了一套(kernel STP)。端口的状态机只有五个状态:
// include/uapi/linux/if_bridge.h:49
#defineBR_STATE_DISABLED0
#defineBR_STATE_LISTENING1
#defineBR_STATE_LEARNING2
#defineBR_STATE_FORWARDING3
#defineBR_STATE_BLOCKING4
STP 端口五态状态机:从 DISABLED 起,经 BLOCKING → LISTENING → LEARNING → FORWARDING 逐步放行,forward_delay 定时器驱动后三个状态的迁移
状态推进的核心在 br_make_forwarding——注意它只处理 BLOCKING 态,且是否「慢慢来」取决于 forward_delay:
// net/bridge/br_stp.c:447
staticvoidbr_make_forwarding(structnet_bridge_port*p)
{
structnet_bridge*br = p->br;
if(p->state != BR_STATE_BLOCKING)
return;
if(br->stp_enabled == BR_NO_STP || br->forward_delay ==0){
br_set_state(p, BR_STATE_FORWARDING);/* 不开 STP 直接转发 */
br_topology_change_detection(br);
del_timer(&p->forward_delay_timer);
}elseif(br->stp_enabled == BR_KERNEL_STP)
br_set_state(p, BR_STATE_LISTENING);/* 先只听 */
else
br_set_state(p, BR_STATE_LEARNING);/* 用户态 STP,先学 */
br_ifinfo_notify(RTM_NEWLINK,NULL, p);
if(br->forward_delay !=0)
mod_timer(&p->forward_delay_timer, jiffies + br->forward_delay);
}
○ 不开 STP(BR_NO_STP)时,直接跳到 FORWARDING :这也是 Docker 默认的做法——docker0 桥默认 stp 关闭,端口一接就转发,省掉那 30 秒的等待。代价是宿主机上如果误接出环路,就没有防环保护了。
○ 开 STP 时走「慢热」路径:LISTENING(只听 BPDU)→ LEARNING(学 MAC)→ FORWARDING(转发),每步之间隔一个 forward_delay(默认 15 秒)。这个「先听再学最后转」的节奏,就是 STP 的核心思想——给全网一个收敛时间,让根桥先选出来、阻塞口先定下来,再放行数据,避免在拓扑还没稳定时形成临时环路。
○ forward_delay_timer 定时器(br_stp_timer_init 里 timer_setup 挂的)到期后回调,把端口从 LISTENING 推进到 LEARNING 再到 FORWARDING。这是本文主题覆盖清单里的「异步调度 + 超时」:状态迁移不靠函数调用当场完成,而靠定时器在未来某个时刻驱动。
此外 STP 还有一套 BPDU(桥协议数据单元)报文,靠 hello_timer(默认 2 秒)周期性发送、max_age(默认 20 秒)判定对端失联。这些定时器一起构成桥的「心跳」——值域都写死在 br_private_stp.h(BR_MIN_HELLO_TIME = 1*HZ 到 BR_MAX_MAX_AGE = 40*HZ),第 6 节速查表一并列出。
桥这台「交换机」讲完了。但它解决的是「包在二层怎么找路」,还有一个更刁钻的需求:有时用户态程序想要的不只是「转发」,而是亲手拿到原始包——VPN 要看到整份 IP 包、用户态协议栈要自己处理 TCP。这就是 tun/tap 的地盘。
4 用户态程序怎么从内核手里「抽」原始包?
tun/tap 双向数据流:内核往 tun 发包走 tun_net_xmit 进 tx_ring,用户态 read 取走;用户态 write 走 tun_get_user 构 skb 再 netif_rx 注回内核
4.1
PART
/dev/net/tun:一个字符设备,一块「可写的网卡」
用户态先 open("/dev/net/tun")。这个文件是一个 misc 字符设备,open 时内核做一件别致的事——借一个 socket 当载体:
// drivers/net/tun.c:3468
staticinttun_chr_open(structinode*inode,structfile* file)
{
structnet*net = current->nsproxy->net_ns;/* 记住当前进程的 netns */
structtun_file*tfile;
tfile =(structtun_file*)sk_alloc(net, AF_UNSPEC, GFP_KERNEL,
&tun_proto,0);
if(!tfile)
return-ENOMEM;
if(ptr_ring_init(&tfile->tx_ring,0, GFP_KERNEL)){/* 收发环 */
sk_free(&tfile->sk);
return-ENOMEM;
}
// ...省略 mutex/RCU 初始化...
tfile->socket.file = file;
tfile->socket.ops =&tun_socket_ops;/* 一个假 socket */
sock_init_data_uid(&tfile->socket,&tfile->sk,current_fsuid());
tfile->sk.sk_write_space = tun_sock_write_space;
tfile->sk.sk_sndbuf = INT_MAX;
file->private_data = tfile;
// ...省略 list 初始化...
return0;
}
○ current->nsproxy->net_ns:tun 文件记住了打开它的进程所属的 namespace。之后这个文件创建/关联的 tun 设备,也属于这个 namespace——这就是 ip tuntap add 在容器里执行时,设备不会跑到宿主机的原因。
○ sk_alloc 分配了一个 AF_UNSPEC 的「假 socket」:tun 借 socket 的等待队列、数据就绪回调(sk_data_ready)来实现「有包可读时唤醒用户态」,但它在网络上没有对端,纯粹是内核里现成的「等待 + 唤醒」机制复用。回扣公寓楼:tun/tap 是直通物业办公室的专线,这个假 socket 就是办公室门口的门铃(呼叫器)——内核把包裹放进收发室窗口(tx_ring)后按门铃(sk_data_ready),把用户态进程(物业)叫醒去取包。门铃不递包裹、不连任何一户,只是一套现成的通知机关。
○ tfile->tx_ring 是一个 ptr_ring(指针环),是 tun 的收发队列——内核塞包进环、用户态取包出环。它是 tun 吞吐的「闸门」:环满了,发包方就得丢包。
打开文件只是第一步,还要用 ioctl(TUNSETIFF) 把文件和一个具体的 tun/tap 设备绑起来。这个 ioctl 进到 tun_set_iff,设备不存在就现造一块:
// drivers/net/tun.c:2747(节选)
staticinttun_set_iff(structnet*net,structfile*file,structifreq*ifr)
{
structtun_struct*tun;
structtun_file*tfile = file->private_data;
structnet_device*dev;
dev =__dev_get_by_name(net, ifr->ifr_name);
if(dev){
// ...设备已存在:tun_attach 把这个 file 挂上去...
}else{
char*name;
unsignedlong flags =0;
if(!ns_capable(net->user_ns, CAP_NET_ADMIN))/* 要权限 */
return-EPERM;
if(ifr->ifr_flags & IFF_TUN){
flags |= IFF_TUN;
name ="tun%d";
}elseif(ifr->ifr_flags & IFF_TAP){
flags |= IFF_TAP;
name ="tap%d";
}else
return-EINVAL;
dev =alloc_netdev_mqs(sizeof(structtun_struct), name,
NET_NAME_UNKNOWN, tun_setup, queues, queues);
dev_net_set(dev, net);/* 归入当前 netns */
dev->rtnl_link_ops =&tun_link_ops;
// ...省略 attach、flag 设置...
}
// ...
}
○ IFF_TUN 造的是 tun(三层设备,收发 IP 包),IFF_TAP 造的是 tap(二层设备,收发以太网帧)。这是两者唯一的本质区别,后面 4.4 展开。
○ alloc_netdev_mqs 分配一块 struct net_device,tun_net_initialize 填充它的 netdev_ops(.ndo_start_xmit = tun_net_xmit),dev_net_set(dev, net) 把它登记进当前 namespace 的网卡表。
○ ns_capable(net->user_ns, CAP_NET_ADMIN):造设备要 CAP_NET_ADMIN 权限。普通用户想开 VPN,靠的是 VPN 客户端有 root 权限,或设备已被管理员预先建好。
○ 关闭路径:close /dev/net/tun 触发 tun_chr_close(tun.c:3507)逐个 detach tfile;最后一个队列摘掉且设备非 IFF_PERSIST 时,unregister_netdevice(tun.c:685)注销设备,netdev uninit 再经 tun_detach_all(tun.c:710)清光残余 tfile。
4.2
PART
注包:tun_get_user 把用户态数据塞回内核
用户态 write() 到 /dev/net/tun,最终到 tun_chr_write_iter → tun_get_user。这是「注包」方向——用户态(VPN 客户端、用户态协议栈)把一个 IP 包(或帧)交还给内核,让它从这块「网卡」上「收到」:
// drivers/net/tun.c:1750(节选:构包与设协议)
staticssize_ttun_get_user(structtun_struct*tun,structtun_file*tfile,
void*msg_control,structiov_iter*from,
int noblock, bool more)
{
structtun_pi pi ={0,cpu_to_be16(ETH_P_IP)};
structsk_buff*skb;
size_t total_len =iov_iter_count(from);
// ...省略 PI 头 / virtio-net 头解析...
switch(tun->flags & TUN_TYPE_MASK){
case IFF_TUN:
if(tun->flags & IFF_NO_PI){
u8 ip_version = skb->len ?(skb->data[0]>>4):0;
switch(ip_version){
case4: pi.proto =htons(ETH_P_IP);break;
case6: pi.proto =htons(ETH_P_IPV6);break;
default: err =-EINVAL;goto drop;
}
}
skb_reset_mac_header(skb);
skb->protocol = pi.proto;/* 告诉内核这是 IP 包 */
skb->dev = tun->dev;
break;
case IFF_TAP:
skb->protocol =eth_type_trans(skb, tun->dev);/* 以太网帧 */
break;
}
// ...省略 XDP、哈希、多队列处理...
if(frags){
// ...NAPI frags 路径...
}elseif(!IS_ENABLED(CONFIG_4KSTACKS)){
tun_rx_batched(tun, tfile, skb, more);/* 攒批注入 */
}else{
netif_rx(skb);/* 直接注入收包路径 */
}
// ...省略统计与返回...
}
○ 对 tun(三层)来说,用户态写进来的是裸 IP 包——没有以太网头。所以 tun_get_user 要自己判断 skb->data[0] >> 4(IP 头的版本字段高 4 位)是 4 还是 6,据此把 skb->protocol 设成 ETH_P_IP 或 ETH_P_IPV6,再 skb->dev = tun->dev 挂上设备。这样内核协议栈才知道「这是一块 tun 网卡收上来的 IP 包」。
○ 对 tap(二层)来说,写进来的是完整以太网帧,直接 eth_type_trans 从帧头里拆出协议即可。
○ 最后 netif_rx(skb)(或 tun_rx_batched 攒批)把 skb 灌进内核收包路径——从这一刻起,这个「用户态造出来的包」就和网卡真正收到的包一模一样了,走《内核收包(上)》的 netif_receive_skb → ip_rcv → tcp_v4_rcv 老路。
○ 注入前还会做 virtio_net_hdr_to_skb 校验(校验和、GSO 元数据),失败的包 goto free_skb 丢掉并计 rx_frame_errors——这是注包方向的失败路径。
4.3
PART
抽包:tun_net_xmit 把内核数据交给用户态
反方向——内核有个包要「发」给这块 tun 网卡时,ndo_start_xmit 是 tun_net_xmit:
// drivers/net/tun.c:1071(节选)
staticnetdev_tx_ttun_net_xmit(structsk_buff*skb,structnet_device*dev)
{
structtun_struct*tun =netdev_priv(dev);
structtun_file*tfile;
rcu_read_lock();
tfile =rcu_dereference(tun->tfiles[txq]);
/* Drop packet if interface is not attached */
if(!tfile){
drop_reason = SKB_DROP_REASON_DEV_READY;/* 没人 attach,丢 */
goto drop;
}
// ...省略过滤器、eBPF、checksum 处理...
skb_orphan(skb);/* 孤儿化:内核可能长期持有它 */
nf_reset_ct(skb);
if(ptr_ring_produce(&tfile->tx_ring, skb)){
drop_reason = SKB_DROP_REASON_FULL_RING;/* 环满,丢 */
goto drop;
}
/* Notify and wake up reader process */
if(tfile->flags & TUN_FASYNC)
kill_fasync(&tfile->fasync, SIGIO, POLL_IN);
tfile->socket.sk->sk_data_ready(tfile->socket.sk);/* 唤醒 read() 等待者 */
rcu_read_unlock();
return NETDEV_TX_OK;
// ...省略 drop 标签...
}
○ tfile = rcu_dereference(tun->tfiles[txq]):一个 tun 设备可以挂多个 tun_file(多队列),按 txq 取对应队列的 file。没有 attach(!tfile)时直接丢包——这是「网卡建好了但没人收」的失败路径,SKB_DROP_REASON_DEV_READY 这个 drop reason 说得明明白白。
○ skb_orphan(skb) 是必须的一步:这个包马上要离开内核、交给用户态,用户态可能攥着它很久才处理(甚至不处理)。orphan 掉之后,skb 不再回写原 socket 的发送记账,防止那个 socket 因为包「迟迟没发完」被卡住。
○ ptr_ring_produce(&tfile->tx_ring, skb) 把 skb 塞进指针环。环满(返回非 0)就丢——这是 tun 的吞吐闸门,SKB_DROP_REASON_FULL_RING 对应「用户态读得慢、内核发得快」时的背压点。回扣开篇那根「直通物业办公室的专线」:tx_ring 就是办公室门口的收发室窗口,skb 是内核递进去的包裹,用户态进程是窗口那头收件的物业;内核发得比物业收得快,窗口一满,新包裹直接退回,退回单上盖的章就是 SKB_DROP_REASON_FULL_RING。
○ sk_data_ready(tfile->socket.sk) 唤醒正阻塞在 read() 上的用户态进程。用户态醒来后 read() 走 tun_chr_read_iter → tun_do_read,从 tx_ring 取包、tun_put_user 拷贝回用户缓冲区,完成「抽包」。
用户态 read() 出来的是什么形态的包?tun 上是裸 IP 包(可选带一个 4 字节的 PI 头标记协议),tap 上是完整以太网帧。
VPN 客户端(如 OpenVPN)就是 read() 拿到 IP 包、加密后塞进一条真实 UDP 连接发出去;对端解密后又 write() 回 tun——内核以为自己有一块连着的网卡,其实下面是一段用户态加密隧道。
4.4
PART
TUN 与 TAP:一层之差,用途天壤之别
这一层的区别决定了选型:VPN 要的是 IP 层隧道——加密的是 IP 包本身,所以用 tun;虚拟机要的是完整网卡——虚拟机的网卡驱动要看到以太网帧做 ARP、做二层,所以 QEMU 用 tap。
这也解释了为什么 docker0 上跑的是 veth 而不是 tap:容器要的是「一根连到交换机的网线」,veth 成对、可直接挂桥;tap 的另一端是 /dev/net/tun 文件,得有个用户态进程在另一头接着。
到这里,四块地基都讲完了。但它们从来不是孤立的——容器网络是它们摞在一起的结果。 下面把 容器 A ping 容器 B 完整走一遍。
5 一个 ping,从头到尾穿过了哪些虚拟设备?
容器 A ping 容器 B 全链路泳道图:去程 ICMP 请求、回程 ICMP 应答,四次经过 veth_xmit、一次经过 bridge 学习+转发
5.1
PART
去程:ICMP 请求,A 到 B
假设 veth0 在容器 A 的 netns 里、veth1 是它的对端插在宿主 netns 的 docker0 桥上;veth2 是容器 B 的、veth3 插在桥上。A ping 172.17.0.2(B 的 IP):
1. A 的进程发 ICMP:ping 构造 ICMP Echo 请求,经 raw socket 进协议栈(raw_sendmsg),查 A 自己 netns 里的路由表(ip_route_output_flow),得出「下一跳 172.17.0.2,走 veth0」,最后 ip_push_pending_frames 把 IP 包推出去。路由表、socket 表都是 A 的 struct net 里的那份——这正是第 1 节的隔离在起作用。
2. 第一次跨网线:dev_queue_xmit(skb, veth0) 调 veth_xmit,rcu_dereference(priv->peer) 拿到 veth1,__dev_forward_skb 发现 dev_net(veth1) != dev_net(veth0)(跨 ns),skb_scrub_packet 把 A 的 namespace 信息擦干净,__netif_rx 把包灌进宿主 netns 的收包路径。
3. 进桥,学习 + 查表:veth1 是 docker0 的端口,它的 rx_handler 是 br_handle_frame。过了状态检查进 br_handle_frame_finish——如果这是第一个 ping,A 的 MAC 还没被学过,桥先 br_fdb_update 记下「A 的 MAC 在 veth1 口」,然后查「B 的 MAC 在哪个口」。B 的 MAC 也还没学过,于是走 br_flood 泛洪:把 ICMP 帧复制给 veth3(也可能复制给桥上其他容器口)。
4. 第二次跨网线:泛洪到 veth3 时,dev_queue_xmit(skb, veth3) 又调 veth_xmit,对端是 veth2,再次 skb_scrub_packet 跨 ns 擦除,__netif_rx 灌进容器 B 的 netns。
5. B 收到:B 的协议栈 ip_rcv 看到目的 IP 是自己,上交给 ICMP,icmp_echo 准备回一个 Echo Reply。
这个过程中有两点值得停下:第一,桥泛洪的是 ARP 请求和首个 ICMP——ARP 广播会先于 ICMP 发出,桥把它泛洪到所有端口,B 的 ARP 应答回来时桥顺便学到了 B 的 MAC。第二,全程没有任何一个包碰过真实网卡——两块「网卡」都是 veth,一台「交换机」是 bridge,全是软件在 CPU 上搬内存。
5.2
PART
回程:ICMP 应答,B 到 A
回程完全对称,只有一处不同:
1. B 的 icmp_echo 构造 Echo Reply,查 B 的路由表,dev_queue_xmit(skb, veth2) → veth_xmit → 对端 veth3 → 宿主 netns。
2. veth3 是桥端口,br_handle_frame → br_handle_frame_finish。这次 br_fdb_update 已经学过了 B 的 MAC(去程学到的),查「A 的 MAC 在哪个口」——去程时桥也学到了 A 的 MAC 在 veth1 ,所以这次 br_fdb_find_rcu命中,直接 br_forward 定向转发到 veth1,不再泛洪。
3. dev_queue_xmit(skb, veth1) → veth_xmit → veth0 → A 的 netns → ip_rcv → ICMP → ping 打印 64 bytes from 172.17.0.2。
回程比去程「聪明」的地方,就是桥的学习表生效了:去程靠泛洪铺路,回程靠查表直达。这也正是交换机和集线器的本质区别——集线器每次都群发,交换机第二次起就定向。
第一个 ping 的延迟是不是特别高? 对,首个包会经历一次 ARP 广播泛洪 + 一次 ICMP 泛洪,之后 FDB 建好就全是定向转发了。这也是为什么新起的容器第一次互 ping 有时会丢第一个包(ARP 还没回来)——不是 bug,是二层学习还没完成。
6 总结:虚拟化网络,本质是「数据结构 + 函数指针」
这一章从四样东西讲到一条完整链路。收个尾——把「虚拟」二字拆开看,内核没有造任何新硬件,只用了两个老工具:数据结构(struct net、veth_priv.peer、FDB 表、tx_ring)和函数指针(ndo_start_xmit、rx_handler)。所谓「软件交换机」,就是 rx_handler 劫持了收包、ndo_start_xmit 改写了发往端口的方向;所谓「软件网线」,就是 ndo_start_xmit 把包塞给了对端的收包队列。拆到最后,那栋公寓楼的墙和网线,其实都是 struct net 与 ndo_start_xmit 这样的数据结构与函数指针。
6.1
PART
全链路回顾
一条 容器 A ping 容器 B,逐函数数一遍:
A 进程 sendto() [netns A]
→ raw_sendmsg → ip_route_output_flow(查 A 的 FIB 路由表,下一跳走 veth0)
→ ip_push_pending_frames
→ dev_queue_xmit(veth0)
→ veth_xmit ── rcu_dereference(priv->peer) 拿到 veth1,第一次跨 ns
→ veth_forward_skb → __dev_forward_skb → skb_scrub_packet(擦除 A 的 ns 信息)
→ __netif_rx → netif_receive_skb [netns 宿主]
→ rx_handler = br_handle_frame(veth1 是 docker0 端口,被桥劫持)
→ br_handle_frame_finish
├─ br_fdb_update(学习 A 的 MAC → veth1 口)
└─ br_fdb_find_rcu(查 B 的 MAC)→ 未命中 → br_flood 泛洪
→ __br_forward → br_forward_finish → br_dev_queue_push_xmit
→ dev_queue_xmit(veth3)
→ veth_xmit ── peer 拿到 veth2,第二次跨 ns
→ __netif_rx → ip_rcv → icmp_echo [netns B] → 构造 Echo Reply
(回程对称,桥查表命中,直接 br_forward 定向转发到 veth1)
6.2
PART
三个设计亮点
○ 隔离靠「一份数据 + 一个指针」:不是把内核复制 N 份,而是把全局状态「内嵌」进 struct net,每个进程通过自己的 struct net * 看到自己的那份。省内存、免复制,代价是每一处内核代码都得记得带上 net 参数——所以 dev_net(dev)、sock_net(sk) 才无处不在。
○ 跨 ns 必消毒:____dev_forward_skb 里那句 skb_scrub_packet(skb, !net_eq(...)) 是全篇最「安全」的一行。虚拟设备让包在内核里「转发」,若不清掉旧 ns 的痕迹,隔离就是纸糊的。
○ 慢热防环:STP 的 LISTENING → LEARNING → FORWARDING 三步走,本质是用「先等 15 秒」换「拓扑收敛」。Docker 默认关 STP 图省事,代价是宿主机误接环时没人拦得住广播风暴。
6.3
PART
异常路径与闸门
把本文散落的失败点收拢,它们都是「虚拟化网络」里真实会咬人的地方:
如果你在犹豫「这些丢包要不要紧」——要紧,而且它们的表现都是「静默丢包」:ip 计数里看不到明显异常,只能靠 dropwatch、ip -s link 的 dropped 计数、或 perf 追 kfree_skb 才能定位。
容器网络「偶尔丢包」「首包超时」的排查,十有八九要回到这张表。
6.4
PART
关键常量速查表
6.5
PART
下一个谜题
这一章把「虚拟设备」讲透了——veth 靠 ndo_start_xmit 直投、bridge 靠 rx_handler 劫持、tun 靠字符设备桥接。但有个问题一直悬着:所有这些虚拟设备的收包,最后还是走 netif_receive_skb 那一条路。有没有办法在更早的地方——包刚进网卡驱动、协议栈还没碰它之前——就把包截下来自己处理?
有,而且它把性能推向了一个新台阶:XDP 让一段 eBPF 程序直接挂在网卡驱动里,收包的第一站不是 netif_receive_skb,而是你的 BPF 代码;sockmap 甚至能让包在 socket 之间直接「短路」,根本不进协议栈。下一章,我们追 eBPF 与 XDP 这条「快车道」,看内核怎么把网络性能的天花板又往上抬了一层。
相关规范(rfc792.txt、rfc826.txt):
○ Echo or Echo Reply Message(Type 0 = Echo Reply,Type 8 = Echo——ping 用到的 ICMP 消息类型,去程请求 / 回程应答)
○ Address Resolution Protocol(ARP 请求是广播帧——首次 ping 时桥把它泛洪到所有端口,借此学习两端 MAC)
源码仓库:
○ kernel-msm: https://github.com/MotorolaMobilityLLC/kernel-msm/tree/android-16-release-w1waa36.48-12-10-4[1]
参考资料
[1] https://github.com/MotorolaMobilityLLC/kernel-msm/tree/android-16-release-w1waa36.48-12-10-4















































































