土法炼钢兴趣小组的算法知识备份

【Linux 网络子系统深度拆解】网络命名空间:内核级网络隔离的实现

文章导航

分类入口
linuxnetworking
标签入口
#netns#network-namespace#struct-net#container-networking#veth#nsproxy#pernet-operations#isolation#bpftrace

目录

每个容器都有独立的 IP 地址、独立的路由表、独立的 iptables 规则——这不是虚拟机,而是同一个内核里运行的进程。实现这一切的基础设施,是网络命名空间(network namespace)。

ip netns add test 一条命令就能创建一个全新的网络栈,但内核为此做了什么?一个 struct net 有多少字段?路由表、netfilter 钩子、socket 哈希表如何做到 per-namespace 隔离?veth 是怎么把一个包从容器命名空间”传送”到宿主机命名空间的?

本文从内核源码回答这些问题。

网络命名空间架构:struct net 与跨命名空间通信

一、struct net:网络命名空间的内核表示

网络命名空间在内核中对应 struct net,定义在 include/net/net_namespace.h。这个结构体是整个网络栈的”根节点”——路由表、netfilter 钩子、接口列表、socket 哈希表、sysctl 参数,全部挂在它下面。

关键字段分组:

// include/net/net_namespace.h
struct net {
    /* 生命周期管理 */
    refcount_t          passive;        // 销毁引用计数
    struct ns_common    ns;             // ns.count = 活跃引用计数
    struct list_head    list;           // 链入全局 net_namespace_list
    struct llist_node   cleanup_list;   // "死亡队列"——等待销毁

    /* 设备管理 */
    struct list_head    dev_base_head;  // 该命名空间所有网络设备链表
    struct hlist_head   *dev_name_head; // 按名称哈希查找设备
    struct hlist_head   *dev_index_head;// 按 ifindex 哈希查找设备
    struct xarray       dev_by_index;   // XArray 快速索引查找
    u32                 ifindex;        // 下一个待分配的接口编号
    struct net_device   *loopback_dev;  // 每个命名空间都有自己的 lo

    /* 协议栈状态 */
    struct netns_ipv4   ipv4;           // 路由表、sysctl、frag 队列
    struct netns_ipv6   ipv6;           // IPv6 路由与配置
    struct netns_nf     nf;             // netfilter 钩子数组
    struct netns_ct     ct;             // conntrack 哈希表
    struct netns_bpf    bpf;            // BPF 程序绑定

    /* 归属与标识 */
    struct user_namespace *user_ns;     // 所属用户命名空间
    struct idr          netns_ids;      // 对端命名空间 ID 映射
    u64                 net_cookie;     // 唯一标识符(写一次)
    u32                 hash_mix;       // 哈希混淆因子

    /* 策略路由 */
    struct list_head    rules_ops;      // fib_rules 操作链
    spinlock_t          rules_mod_lock; // 路由规则修改锁

    /* ... 更多子系统:XFRM、IPVS、MPLS 等 */
};

几个设计要点:

双重引用计数ns.count 跟踪活跃用户(进程、socket、设备),当它归零时触发销毁流程;passive 跟踪销毁过程中的异步清理任务,当它也归零时才释放内存。这是因为网络子系统的销毁涉及大量异步回调,不能在 ns.count 归零时立即 kfree()

per-netns loopback。每个命名空间创建时都会自动生成一个 lo 设备。loopback_dev 字段直接指向它,协议栈中大量路径需要快速访问 loopback——比如发给本机的包需要重定向到 lo。

hash_mix 缓存行对齐hash_mix 字段被标记为 ____cacheline_aligned_in_smp,因为它在每次路由查找、socket 查找中都会被读取,对齐到缓存行避免 false sharing。

二、possible_net_t 与 RCU 访问模式

内核中大量对象需要知道自己”属于哪个命名空间”:net_device 需要知道、sock 需要知道、sk_buff 的处理路径需要知道。问题是:如果内核编译时关闭了 CONFIG_NET_NS,这些字段就是浪费空间。

解决方案是 possible_net_t——一个条件编译的指针包装:

// include/net/net_namespace.h
typedef struct {
#ifdef CONFIG_NET_NS
    struct net __rcu *net;
#endif
} possible_net_t;

CONFIG_NET_NS 关闭时,possible_net_t 是空结构体,占零字节。对应的访问函数也变成返回全局 init_net 的常量:

static inline struct net *read_pnet(const possible_net_t *pnet)
{
#ifdef CONFIG_NET_NS
    return rcu_dereference_protected(pnet->net, true);
#else
    return &init_net;  // 无命名空间支持时,只有一个全局命名空间
#endif
}

static inline void write_pnet(possible_net_t *pnet, struct net *net)
{
#ifdef CONFIG_NET_NS
    rcu_assign_pointer(pnet->net, net);
#endif
}

read_pnet() 使用 rcu_dereference_protected(),要求调用者持有适当的锁或处于 RCU 读临界区。还有一个 read_pnet_rcu() 变体使用 rcu_dereference(),适用于纯 RCU 读路径。

基于此,内核为网络设备和 socket 提供了便捷访问器:

// include/linux/netdevice.h
static inline struct net *dev_net(const struct net_device *dev)
{
    return read_pnet(&dev->nd_net);
}

// include/net/sock.h
static inline struct net *sock_net(const struct sock *sk)
{
    return read_pnet(&sk->sk_net);
}

收包路径中,dev_net(skb->dev) 一次间接寻址就能拿到当前包所在的命名空间,然后查该命名空间的路由表、netfilter 钩子、socket 哈希表。

三、nsproxy:进程与命名空间的绑定

一个进程不只有网络命名空间,还有 PID、mount、UTS、IPC、cgroup、time 命名空间。内核用 struct nsproxy 把所有命名空间指针聚合在一起:

// include/linux/nsproxy.h
struct nsproxy {
    refcount_t count;
    struct uts_namespace  *uts_ns;
    struct ipc_namespace  *ipc_ns;
    struct mnt_namespace  *mnt_ns;
    struct pid_namespace  *pid_ns_for_children;
    struct net            *net_ns;      // 网络命名空间
    struct time_namespace *time_ns;
    struct time_namespace *time_ns_for_children;
    struct cgroup_namespace *cgroup_ns;
};

每个 task_struct 持有一个 nsproxy 指针。同一个 nsproxy 可以被多个进程共享(通过引用计数),这在 fork 时不指定 CLONE_NEW* 标志的情况下发生。

当进程调用 clone(CLONE_NEWNET, ...)unshare(CLONE_NEWNET) 时:

  1. 内核分配新的 nsproxy(或修改当前的)
  2. 调用 copy_net_ns() 创建新的 struct net
  3. nsproxy->net_ns 指向新创建的命名空间
  4. 进程后续的所有网络操作都在新命名空间中进行

四、命名空间创建:copy_net_ns() 与 pernet_operations

4.1 创建路径

clone(CLONE_NEWNET) 触发命名空间创建时,调用链为:

clone() / unshare()
  → create_new_namespaces()
    → copy_net_ns(flags, user_ns, old_net)
      → net_alloc()            // 分配 struct net
      → setup_net(net, user_ns)
        → 遍历 pernet_list,调用每个 ops->init(net)
      → 链入 net_namespace_list

setup_net() 是核心:它遍历所有通过 register_pernet_subsys() 注册的子系统,依次调用 init() 回调。每个子系统负责在新命名空间中分配自己的状态。

4.2 pernet_operations:子系统注册机制

每个需要 per-namespace 状态的子系统(路由、netfilter、conntrack、socket 等)都通过 pernet_operations 注册初始化和销毁回调:

// include/net/net_namespace.h
struct pernet_operations {
    struct list_head list;
    int  (*init)(struct net *net);          // 命名空间创建时调用
    void (*pre_exit)(struct net *net);      // 销毁前准备
    void (*exit)(struct net *net);          // 销毁时清理
    void (*exit_batch)(struct list_head *net_exit_list); // 批量销毁
    unsigned int *id;                       // 子系统 ID
    size_t size;                            // 自动分配的 per-ns 数据大小
};

以 IPv4 路由为例,简化的注册流程:

// net/ipv4/fib_frontend.c(示意)
static struct pernet_operations fib_net_ops = {
    .init = fib_net_init,    // 创建 per-ns 路由表
    .exit = fib_net_exit,    // 销毁 per-ns 路由表
};

static int __init ip_fib_init(void)
{
    register_pernet_subsys(&fib_net_ops);
    ...
}

当新命名空间创建时,fib_net_init() 被调用,它会:

注册顺序即初始化顺序。子系统注册的先后决定了创建新命名空间时 init() 回调的调用顺序,也决定了销毁时 exit() 回调的逆序调用。这意味着依赖关系必须通过注册顺序隐式表达。

4.3 两种注册函数的区别

int register_pernet_subsys(struct pernet_operations *);   // 子系统级
int register_pernet_device(struct pernet_operations *);    // 设备级

register_pernet_subsys() 的回调在 register_pernet_device() 之前执行。这保证了底层子系统(路由表、netfilter)在设备初始化之前就绑定好。销毁顺序相反:先销毁设备,再销毁子系统。

五、Per-Namespace 资源隔离详解

5.1 路由表隔离

每个命名空间有独立的 FIB(Forwarding Information Base):

// include/net/netns/ipv4.h
struct netns_ipv4 {
    struct hlist_head       *fib_table_hash;   // 路由表哈希
    struct fib_table __rcu  *fib_main;         // main 表快速指针
    struct fib_table __rcu  *fib_default;      // default 表快速指针
    struct fib_rules_ops    *rules_ops;        // 策略路由规则引擎
    struct inet_peer_base   *peers;            // per-ns 对端缓存
    struct fqdir            *fqdir;            // per-ns IP 分片队列
    // ... 100+ sysctl 参数
};

路由查找的入口 fib_lookup() 通过 net->ipv4.fib_table_hash 定位到当前命名空间的路由表。这意味着:

三者互不干扰,因为它们查的是不同的 fib_table_hash

5.2 Netfilter 钩子隔离

每个命名空间有独立的 netfilter 钩子数组:

// include/net/netns/netfilter.h
struct netns_nf {
    struct nf_hook_entries __rcu *hooks_ipv4[NF_INET_NUMHOOKS];
    struct nf_hook_entries __rcu *hooks_ipv6[NF_INET_NUMHOOKS];
    struct nf_hook_entries __rcu *hooks_arp[NF_ARP_NUMHOOKS];
    struct nf_hook_entries __rcu *hooks_bridge[NF_INET_NUMHOOKS];
    // ...
};

五个钩子点(PREROUTING、INPUT、FORWARD、OUTPUT、POSTROUTING)每个协议族都是独立数组。在容器内执行 iptables -A INPUT -j DROP 只影响该容器的命名空间,宿主机的 INPUT 链完全不受影响。

nf_hook() 的第一件事就是通过 dev_net(skb->dev) 拿到命名空间,然后查该命名空间的钩子数组:

// 简化的钩子遍历路径
static inline int nf_hook(u_int8_t pf, unsigned int hook,
                          struct net *net, ...)
{
    struct nf_hook_entries *e = rcu_dereference(net->nf.hooks_ipv4[hook]);
    // 遍历 e->hooks[] 中注册的回调
}

5.3 Socket 隔离

socket 通过 sk->sk_net 绑定到命名空间。端口绑定(bind)、连接查找(socket lookup)都在命名空间范围内进行:

// include/net/sock.h
static inline struct net *sock_net(const struct sock *sk)
{
    return read_pnet(&sk->sk_net);
}

容器 A 和容器 B 可以同时监听 80 端口,因为它们的 socket 哈希表是不同的。收包路径中的 __inet_lookup()sock_net(sk) 匹配的前提下才会返回结果。

5.4 网络设备隔离

每个 net_device 通过 nd_net 字段绑定到一个命名空间:

// include/linux/netdevice.h
static inline struct net *dev_net(const struct net_device *dev)
{
    return read_pnet(&dev->nd_net);
}

static inline void dev_net_set(struct net_device *dev, struct net *net)
{
    write_pnet(&dev->nd_net, net);
}

设备可以跨命名空间迁移——ip link set eth0 netns container_ns 的内核实现就是调用 dev_change_net_namespace(),它会:

  1. 从旧命名空间的设备链表中摘除
  2. 调用 dev_net_set() 更新 nd_net
  3. 插入新命名空间的设备链表
  4. 重新注册 sysfs 和 netlink 通知

物理网卡也可以迁移到容器命名空间(SR-IOV VF 常这么做),但 loopback 设备不能迁移——它是命名空间的”内置设备”。

六、init_net:默认命名空间

系统启动时,所有网络资源都在 init_net 这个全局命名空间中:

// include/net/net_namespace.h
extern struct net init_net;

init_net 是所有命名空间链表(net_namespace_list)的起点。所有宿主机进程默认使用 init_net,物理网卡初始注册在 init_net 中。

内核中有大量代码通过 &init_net 直接引用默认命名空间。当 CONFIG_NET_NS 未启用时,所有 read_pnet() 调用返回 &init_net,整个命名空间机制退化为零开销。

全局命名空间链表的遍历:

// 需要持有 net_rwsem 或 rtnl_lock
#define for_each_net(VAR) \
    list_for_each_entry(VAR, &net_namespace_list, list)

// RCU 保护的遍历
#define for_each_net_rcu(VAR) \
    list_for_each_entry_rcu(VAR, &net_namespace_list, list)

ip netns list 命令就是通过 netlink 遍历这个链表实现的。/proc/[pid]/ns/net 符号链接指向进程的网络命名空间文件描述符。

七、veth pair:跨命名空间的数据通道

veth(virtual ethernet)是一对虚拟网卡,一端在容器命名空间,另一端在宿主机命名空间。它是容器网络最常用的跨命名空间通信机制。

7.1 veth 的数据结构

// drivers/net/veth.c
struct veth_priv {
    struct net_device __rcu *peer;   // 对端设备指针
    atomic64_t          dropped;
    struct bpf_prog     *_xdp_prog;  // XDP 程序
    struct veth_rq      *rq;         // 接收队列(XDP 模式)
    unsigned int        requested_headroom;
};

veth 的核心就是 peer 指针——它指向另一端的 net_device。两端设备可以在不同的命名空间中。

7.2 veth_xmit:命名空间切换的魔法

// drivers/net/veth.c(简化)
static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
    struct veth_priv *priv = netdev_priv(dev);
    struct net_device *rcv;

    rcu_read_lock();
    rcv = rcu_dereference(priv->peer);  // 获取对端设备
    if (unlikely(!rcv) || !pskb_may_pull(skb, ETH_HLEN)) {
        kfree_skb(skb);
        goto drop;
    }

    // 关键:skb->dev 从当前设备切换为对端设备
    // 对端设备在另一个命名空间 → 包进入另一个命名空间的协议栈
    if (likely(veth_forward_skb(rcv, skb, ...) == NET_RX_SUCCESS))
        ...

    rcu_read_unlock();
    return NETDEV_TX_OK;
}

veth_forward_skb() 内部调用 netif_rx()dev_forward_skb(),将 skb 注入对端设备的接收队列。由于 rcv->nd_net 指向对端命名空间,后续的协议栈处理(路由查找、netfilter、socket lookup)全部在对端命名空间中进行。

数据流示意:

容器命名空间                    宿主机命名空间
┌──────────────┐               ┌──────────────┐
│ 应用 send()  │               │              │
│     ↓        │               │              │
│ TCP/IP 协议栈│               │              │
│     ↓        │               │              │
│ eth0 (veth)  │──veth_xmit──→│ vethXXX      │
│ nd_net=容器ns│  skb->dev切换 │ nd_net=宿主ns│
└──────────────┘               │     ↓        │
                               │ netif_rx()   │
                               │     ↓        │
                               │ 宿主协议栈   │
                               └──────────────┘

7.3 性能特征

veth 的 veth_xmit() 本质是内存操作——没有 DMA、没有中断、没有 ring buffer。但它仍然需要经过完整的协议栈处理,包括 netfilter 钩子。

性能关键点:

八、命名空间的销毁与清理

命名空间的销毁比创建复杂得多——网络子系统中有大量异步资源(conntrack 条目、定时器、延迟工作队列),不能在最后一个引用释放时立即销毁。

8.1 销毁流程

最后一个 put_net(net) 使 ns.count → 0
  → __put_net(net)
    → 将 net 链入 cleanup_list(死亡队列)
    → 调度 net_cleanup_work(工作队列)
      → cleanup_net()
        → 遍历 pernet_list(逆序)
          → ops->pre_exit(net)     // 停止新请求
        → 遍历 pernet_list(逆序)
          → ops->exit(net)         // 释放资源
          → ops->exit_batch(list)  // 批量释放
        → 等待 passive 引用归零
        → net_free(net)            // 释放 struct net 内存

pre_exit 与 exit 分离pre_exit() 先标记”正在关闭”,阻止新的资源分配;exit() 再实际释放资源。这避免了在释放过程中有新的对象创建导致 use-after-free。

exit_batch 优化。当多个命名空间同时销毁时(比如一批容器被删除),exit_batch() 接收整个命名空间列表,可以批量处理,减少锁获取次数。

8.2 引用计数管理

static inline struct net *get_net(struct net *net)
{
    refcount_inc(&net->ns.count);
    return net;
}

static inline void put_net(struct net *net)
{
    if (refcount_dec_and_test(&net->ns.count))
        __put_net(net);  // 触发销毁
}

static inline struct net *maybe_get_net(struct net *net)
{
    // 仅当 refcount > 0 时才成功获取引用
    if (!refcount_inc_not_zero(&net->ns.count))
        return NULL;
    return net;
}

maybe_get_net() 是命名空间正在销毁时的安全获取方式——如果命名空间已经进入销毁流程(refcount = 0),返回 NULL 而不是递增一个已经归零的计数器。

谁持有引用:

九、命名空间标识与互操作

9.1 netns_ids:跨命名空间引用

内核需要在不同命名空间之间建立引用关系(比如 veth 的两端),使用 struct idr netns_ids 来管理:

// struct net 中的字段
struct idr netns_ids;   // 映射:本地 ID → 对端 struct net *

ip link set veth0 netns <ns> 时,内核在两个命名空间的 netns_ids 中互相注册对方的 ID。netlink 消息中使用这个 ID 来引用其他命名空间,避免传递内核指针到用户态。

9.2 net_cookie:全局唯一标识

u64 net_cookie;  // 在 struct net 中

net_cookie 是命名空间的全局唯一标识符,在命名空间创建时写入一次,永不改变。BPF 程序通过 bpf_get_netns_cookie() 助手函数获取当前命名空间的 cookie,用于策略匹配和统计。

十、可观测性

10.1 查看命名空间信息

# 列出所有命名命名空间
ip netns list

# 查看进程的命名空间
ls -la /proc/$PID/ns/net

# 在指定命名空间中执行命令
ip netns exec test ip addr show

# 查看命名空间中的路由表
ip netns exec test ip route show table all

# 查看命名空间中的 iptables 规则
ip netns exec test iptables -L -v -n

10.2 bpftrace 追踪命名空间操作

追踪命名空间创建与销毁:

# 追踪网络命名空间创建
bpftrace -e '
kprobe:copy_net_ns {
    printf("netns create: pid=%d comm=%s\n", pid, comm);
}
kprobe:cleanup_net {
    printf("netns destroy batch\n");
}'

追踪设备跨命名空间迁移:

# 追踪 dev_change_net_namespace
bpftrace -e '
kprobe:dev_change_net_namespace {
    $dev = (struct net_device *)arg0;
    printf("dev %s moving to new netns, pid=%d comm=%s\n",
           $dev->name, pid, comm);
}'

追踪 veth 跨命名空间转发:

# veth_xmit 追踪
bpftrace -e '
kprobe:veth_xmit {
    $skb = (struct sk_buff *)arg0;
    $dev = (struct net_device *)arg1;
    printf("veth_xmit: dev=%s len=%d\n", $dev->name, $skb->len);
}'

10.3 perf 分析命名空间开销

# 统计命名空间创建耗时
perf trace -e 'syscalls:sys_enter_unshare' -p $PID

# 火焰图分析命名空间创建路径
perf record -g -e cycles -p $PID -- sleep 10
perf script | stackcollapse-perf.pl | flamegraph.pl > netns-create.svg

十一、关键参数与调优

参数 默认值 说明
/proc/sys/net/netns/max 无限制 系统最大命名空间数量(部分内核版本)
/proc/sys/net/core/somaxconn 4096 per-netns socket 监听队列深度
/proc/sys/net/ipv4/ip_forward 0 per-netns IP 转发开关
/proc/sys/net/ipv4/conf/all/rp_filter 0 per-netns 反向路径过滤
/proc/sys/net/netfilter/nf_conntrack_max 262144 per-netns conntrack 表大小

几乎所有 /proc/sys/net/ 下的参数都是 per-namespace 的。这意味着容器内 sysctl -w net.ipv4.tcp_fin_timeout=10 只影响该容器的 TCP 栈,不影响宿主机。

但有一个陷阱:容器默认没有权限修改 sysctl。需要在容器运行时配置 --sysctl 选项,或者授予 CAP_NET_ADMIN 能力。在 Kubernetes 中,可以通过 Pod 的 securityContext.sysctls 配置安全的 sysctl 参数。

十二、容器网络架构中的命名空间

12.1 Docker 默认网络模型

宿主机命名空间 (init_net)
┌─────────────────────────────────────┐
│ eth0 (物理网卡)                     │
│    ↑                                │
│ 路由表 + iptables NAT               │
│    ↑                                │
│ docker0 (bridge)                    │
│    ↑          ↑                     │
│ vethAAA    vethBBB                  │
└────┼──────────┼─────────────────────┘
     │          │
     │veth pair │veth pair
     ↓          ↓
┌────────┐ ┌────────┐
│容器 A  │ │容器 B  │
│eth0    │ │eth0    │
│10.0.0.2│ │10.0.0.3│
└────────┘ └────────┘

每个容器运行在独立的网络命名空间中。docker0 网桥在宿主机命名空间中,连接所有容器的 veth 对端。容器到外网的流量通过 iptables MASQUERADE 做 SNAT。

12.2 Kubernetes CNI 模式

Kubernetes 通过 CNI(Container Network Interface)插件管理命名空间网络:

无论哪种 CNI,第一步都是 ip netns add + ip link add veth 创建隔离环境和跨命名空间通道。

12.3 性能考量

命名空间本身的开销很小——struct net 的分配和子系统初始化在微秒级完成。真正的开销在于跨命名空间通信:

Cilium 通过 BPF 直接在 veth 的 TC/XDP 钩子上做转发决策,绕过了宿主机侧的完整协议栈,将跨命名空间的延迟降低了约 30-50%。

十三、参考文献


上一篇Traffic Control 深度拆解:qdisc、class 与 filter

下一篇虚拟网络设备内核实现:veth、bridge 与 macvlan

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-25 · linux / networking

【Linux 网络子系统深度拆解】虚拟网络设备内核实现:veth、bridge 与 macvlan

容器网络不能没有虚拟设备。本文从 Linux 6.6 内核源码拆解四类核心虚拟网络设备的实现:veth pair 的 veth_xmit 零拷贝转发与 XDP native 模式、Linux bridge 的 br_handle_frame 转发路径与 FDB 学习/老化机制、macvlan 五种模式的内核实现差异、tun/tap 的内核态与用户态数据交换路径,以及各类设备的性能特征对比。


By .