夜雨聆风学习资料网

ARTICLE · 1176977

容器网络是怎么在软件里「布线」的——namespace、veth 与 bridge

容器网络是怎么在软件里「布线」的——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 隔离的唯一手段,是给每个网络命名空间一份独立的 struct net。进程不直接访问全局的网卡/路由表,而是先拿到自己所属的那份 struct net,再从里面找。内核里所有「找网卡、查路由、找 socket」的动作,入口参数里都带着一个 struct net *。

容器网络全景:左侧两个 namespace 各自持有独立的协议栈,veth 对跨在边界上,bridge 在宿主 namespace 里做交换,tun/tap 直通用户态

看图指引:这是本文的地图。上下两个灰框是两个 namespace(房间),各自有独立的路由表、socket 表、网卡表;中间那对蓝线是 veth 对(网线),两头分属两个 namespace;veth 的上端都插在宿主 namespace 里的 bridge(橙色交换机)上;左下角的 tun/tap 是另一条路,直接通向用户态进程(VPN)。蓝箭头 = 数据流方向,橙框 = 控制/中枢设备。

1.1

PART

struct net:网络栈的「户口本」

先看这个贯穿全篇的主角。内核里每个网络命名空间,就是一份 struct net。它不是一个轻量对象,而是把整张网络栈的全局状态都内嵌了一份:

c

// 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

看图指引:中间橙色大框是一份 struct net,里面每个蓝格子是一个内嵌字段——网卡表、loopback_dev、netns_ipv4、netns_nf 各管一摊。下方 P1/P2 两个进程经 nsproxy->net_ns 指针指向同一份 struct net(同一 namespace);右上角那份灰色虚线的「另一份 struct net」则属于另一个 namespace,P1/P2 够不着它——隔离就是这么来的。

○ 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。它的骨架极简:

c

// 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 完成:

c

// 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:

c

// 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 上执行:

c

// 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(Virtual Ethernet)是一对成对出现的虚拟网卡:发给其中一头的包,会原封不动地从另一头「收」进来。它的收发路径里没有中断、没有 DMA、没有 qdisc 排队——ndo_start_xmit 里一句「投递给对端」就完成了。

veth 对转发:从 A 端 dev_queue_xmit 进入 veth_xmit,直接把 skb 灌进对端的收包队列,跨 namespace 时擦除 skb 上的旧 namespace 信息

看图指引:蓝箭头是数据流——包从 dev_queue_xmit 进来,经 veth_xmit 取出 priv->peer 对端,走 veth_forward_skb → __dev_forward_skb → __netif_rx 进入对端的收包路径。中间橙框 skb_scrub_packet 是关键动作:当两端分属不同 namespace 时,把 skb 上残留的旧 namespace 信息(入口设备、路由缓存、conntrack 状态)全部擦掉,防止跨 ns 信息泄漏。

2.1

PART

一根网线两个头:veth_newlink 造出对端

ip link add veth0 type veth peer name veth1 这条命令,经 netlink 进到 veth_newlink。它的思路值得注意——先造对端,再配对:

c

// 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):

c

// 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);

  }

  // ...见下半...

c

// 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) 完成真正的投递,它只有一行逻辑:

c

// 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:

c

// 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 是一台纯软件的「二层交换机」。它做的正是真交换机做的事:学习(记住哪个 MAC 从哪个口进来)、转发(查表定向发出)、泛洪(查不到就群发)、防环(生成树 STP)。区别只在,这些「口」不是物理网口,而是插进来的网卡(通常是 veth 的另一端)。

bridge 转发决策:rx_handler 拦截包后,先学习源 MAC,再按目的 MAC 查 FDB,命中定向转发、未命中泛洪

看图指引:蓝箭头是数据流。包从 veth 进入宿主 netns 后,被 br_handle_frame(rx_handler,橙色)拦截,进 br_handle_frame_finish:先 br_fdb_update 学习源 MAC(登记到 FDB),再 br_fdb_find_rcu 查目的 MAC——命中且非本地就 br_forward 定向发出,未命中就 br_flood 群发到所有其他端口。绿框是「本地投递」(目的 MAC 是桥自己,包上交给协议栈)。

3.1

PART

rx_handler:桥是怎么「截胡」包的

真实交换机在物理层交换,而软件 bridge 在收包路径上截胡。当 br_add_if 把一个网卡(如 veth1)加进桥时,它会注册一个 rx_handler:

c

// 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:

c

// 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。它干两件事,先学后转:

c

// 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 还藏着「老化」和「移动」两个细节:

c

// 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:

c

// 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);

}

c

// 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)。端口的状态机只有五个状态:

c

// 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 定时器驱动后三个状态的迁移

看图指引:这是桥端口的状态机(橙框 = 状态,蓝箭头 = 迁移)。新端口从 DISABLED 进入 BLOCKING(只收 BPDU、不转发),br_make_forwarding 把它推入 LISTENING(只听、不学、不转),forward_delay 定时器到期后进 LEARNING(学 MAC、不转),再到期进 FORWARDING(正常收发)。整条链由 br_stp_timer_init 挂的 forward_delay_timer 驱动,默认 15 秒(BR_MIN_FORWARD_DELAY = 2*HZ 到 BR_MAX_FORWARD_DELAY = 30*HZ 可调)。

状态推进的核心在 br_make_forwarding——注意它只处理 BLOCKING 态,且是否「慢慢来」取决于 forward_delay:

c

// 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 把「网络设备」和「字符设备」焊在了一起:内核眼里它是块网卡,用户态眼里它是一个 /dev/net/tun 文件。往文件里 write,等于往内核注一个包;从文件里 read,等于从内核抽一个包。方向相反的两条路,各走一段代码。

tun/tap 双向数据流:内核往 tun 发包走 tun_net_xmit 进 tx_ring,用户态 read 取走;用户态 write 走 tun_get_user 构 skb 再 netif_rx 注回内核

看图指引:这是 tun/tap 的两条相反数据流。上半(蓝箭头)是「抽包」:协议栈 dev_queue_xmit → tun_net_xmit,把 skb 塞进 tfile->tx_ring,再 sk_data_ready 唤醒等 read() 的用户态进程;下半(橙箭头)是「注包」:用户态 write() 进 /dev/net/tun → tun_chr_write_iter → tun_get_user,把数据构造成 skb,设好协议、走 netif_rx 灌回内核收包路径。中间的 ptr_ring 是双向的闸门——满了就丢包(SKB_DROP_REASON_FULL_RING)。

4.1

PART

/dev/net/tun:一个字符设备,一块「可写的网卡」

用户态先 open("/dev/net/tun")。这个文件是一个 misc 字符设备,open 时内核做一件别致的事——借一个 socket 当载体:

c

// 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,设备不存在就现造一块:

c

// 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 包(或帧)交还给内核,让它从这块「网卡」上「收到」:

c

// 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:

c

// 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:一层之差,用途天壤之别

TUN(三层)
TAP(二层)
收发单位
裸 IP 包(L3)
完整以太网帧(L2)
典型用途
VPN 隧道、用户态协议栈
虚拟机网卡、二层 VPN、软 AP
头处理
自己判 IP 版本(skb->data[0] >> 4)
eth_type_trans 拆帧头
类比
一根只送「信件内容」的管
一根连「邮车」都送的管

这一层的区别决定了选型:VPN 要的是 IP 层隧道——加密的是 IP 包本身,所以用 tun;虚拟机要的是完整网卡——虚拟机的网卡驱动要看到以太网帧做 ARP、做二层,所以 QEMU 用 tap。

这也解释了为什么 docker0 上跑的是 veth 而不是 tap:容器要的是「一根连到交换机的网线」,veth 成对、可直接挂桥;tap 的另一端是 /dev/net/tun 文件,得有个用户态进程在另一头接着。

到这里,四块地基都讲完了。但它们从来不是孤立的——容器网络是它们摞在一起的结果。 下面把 容器 A ping 容器 B 完整走一遍。

5 一个 ping,从头到尾穿过了哪些虚拟设备?

把 namespace(房间)+ veth(网线)+ bridge(交换机)串起来,就是 Docker 默认 docker0 拓扑。一个 ICMP 包从容器 A 到容器 B,中途两次跨 namespace、两次过 veth、一次过 bridge。

容器 A ping 容器 B 全链路泳道图:去程 ICMP 请求、回程 ICMP 应答,四次经过 veth_xmit、一次经过 bridge 学习+转发

看图指引:这是完整时序。泳道从左到右是 容器A netns / vethA / bridge / vethB / 容器B netns。去程(上方蓝箭头):A 里 dev_queue_xmit → veth_xmit(vethA) 第一次跨网线 → 进 bridge 端口 br_handle_frame_finish 学习 A 的 MAC、查表定向转发 → dev_queue_xmit → veth_xmit(vethB) 第二次跨网线 → 进 B 的协议栈。回程(下方橙箭头)完全对称,只是 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,逐函数数一遍:

code

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

异常路径与闸门

把本文散落的失败点收拢,它们都是「虚拟化网络」里真实会咬人的地方:

场景
处理
位置
veth 对端被删 / 未配对
!rcv → kfree_skb + NET_XMIT_DROP
veth.c:355
veth 对端 MTU 装不下
__is_skb_forwardable 失败 → NET_RX_DROP
netdevice.h:4096
桥端口 BLOCKING / 坏源 MAC
kfree_skb 直接丢
br_input.c:334/427
桥端口 LEARNING 态收到数据
goto drop(只学不转)
br_input.c:158
FDB 条目 5 分钟没人用
老化清除(BR_DEFAULT_AGEING_TIME)
linux/if_bridge.h:65
tun 没人 attach
SKB_DROP_REASON_DEV_READY
tun.c:1085
tun 收发环满
SKB_DROP_REASON_FULL_RING
tun.c:1134
tun 注入的包校验和坏
rx_frame_errors 计数 + 丢
tun.c:1874
namespace 引用计数归零
挂 cleanup_list,工作队列异步拆
net_namespace.c:664

如果你在犹豫「这些丢包要不要紧」——要紧,而且它们的表现都是「静默丢包」:ip 计数里看不到明显异常,只能靠 dropwatch、ip -s link 的 dropped 计数、或 perf 追 kfree_skb 才能定位。

容器网络「偶尔丢包」「首包超时」的排查,十有八九要回到这张表。

6.4

PART

关键常量速查表

常量
值
定义位置
含义
BR_STATE_DISABLED / LISTENING / LEARNING / FORWARDING / BLOCKING
0 / 1 / 2 / 3 / 4
if_bridge.h:49-53
桥端口 STP 五态
BR_DEFAULT_AGEING_TIME
300 * HZ(5 分钟)
linux/if_bridge.h:65
FDB 条目老化时间
BR_MIN/MAX_FORWARD_DELAY
2*HZ / 30*HZ
br_private_stp.h:19-20
STP 转发延迟
BR_MIN/MAX_MAX_AGE
6*HZ / 40*HZ
br_private_stp.h:22-23
BPDU 对端失联判定
BR_MIN/MAX_HELLO_TIME
1*HZ / 10*HZ
br_private_stp.h:16-17
BPDU hello 发送间隔
BR_NO_STP / BR_KERNEL_STP / BR_USER_STP
0 / 1 / 2
br_private.h:528-530
STP 三种模式
NAPI_POLL_WEIGHT
64
netdevice.h:2667
软中断一次 poll 最多包数
MAX_TAP_QUEUES
256
tun.c:113
tap 多队列上限
GOODCOPY_LEN
128
tun.c:102
线性拷贝阈值
IFF_NO_QUEUE
1<<19
netdevice.h:1749
设备不挂 qdisc(veth 用它)

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

相关学习资料