跳到主要内容

14.3 网络命名空间的实现

上一节我们聊完了 UTS 命名空间——那个只管主机名的「软柿子」。它帮我们热了身,让我们明白内核是如何把「全局变量」这种坏习惯改造成「命名空间私有数据」的。

但那个骨架里装的东西实在是太少了。

如果我要把你扔进一个真正的隔离环境里,光改个主机名有什么用?你得有独立的网卡、独立的路由表、独立的 iptables 规则,甚至得有自己的一整套 TCP/IP 协议栈参数。这就是我们这一节要处理的硬骨头:网络命名空间(Network Namespace)

你可以把网络命名空间想象成一台完全独立的虚拟路由器或虚拟机。它不是简单的配置隔离,它是整个网络栈的深拷贝。


14.3 Network Namespaces Implementation

网络命名空间在逻辑上是网络栈的另一个完整副本。这意味着它拥有自己独立的一切:

  • 网络设备
  • 路由表
  • 邻居表(Neighbouring tables)
  • Netfilter 表(iptables 规则)
  • 网络套接字
  • /proc 下的网络相关文件
  • /sys 下的网络相关文件

这里有一个非常实用且容易让人产生「魔法错觉」的特性,我们先把它说清楚。

如果你创建了一个名为 ns1 的网络命名空间,内核会遵循一个特殊的查找逻辑:当 ns1 里的网络应用去读配置文件(比如 /etc/hosts)时,它会先去 /etc/netns/ns1/ 下面找;如果找不到,再去回退到全局的 /etc/

这就好像你在给每个命名空间准备专属的「抽屉」。

但「抽屉」这个比喻在这里有一个地方是错的:内核并不是真的修改了所有应用的文件读取逻辑。这个功能是通过 bind mount(绑定挂载)实现的,而且它仅限于使用 ip netns add 命令创建的命名空间。如果你直接用 unshare() 或者 clone() 系统调用硬造一个命名空间,这个配置文件魔法就不会自动生效——你得自己动手挂载。

The Network Namespace Object (struct net)

现在让我们撕开表象,直插心脏。

网络命名空间的核心数据结构是 struct net。你可以把它理解为那个「独立世界」的上帝对象,所有与网络相关的状态都挂在它下面。

代码有点长,但我们得一段一段啃完它。这是理解一切的基础:

struct net {
. . .
struct user_namespace *user_ns; /* Owning user namespace */
unsigned int proc_inum;
struct proc_dir_entry *proc_net;
struct proc_dir_entry *proc_net_stat;
. . .
struct list_head dev_base_head;
struct hlist_head *dev_name_head;
struct hlist_head *dev_index_head;
. . .
int ifindex;
. . .
struct net_device *loopback_dev; /* The loopback */
. . .
atomic_t count; /* To decided when the network
* namespace should be shut down.
*/
struct netns_ipv4 ipv4;
#if IS_ENABLED(CONFIG_IPV6)
struct netns_ipv6 ipv6;
#endif
#if defined(CONFIG_IP_SCTP) || defined(CONFIG_IP_SCTP_MODULE)
struct netns_sctp sctp;
#endif
. . .
#if defined(CONFIG_NF_CONNTRACK) || defined(CONFIG_NF_CONNTRACK_MODULE)
struct netns_ct ct;
#endif
#if IS_ENABLED(CONFIG_NF_DEFRAG_IPV6)
struct netns_nf_frag nf_frag;
#endif
. . .
struct net_generic __rcu *gen;
#ifdef CONFIG_XFRM
struct netns_xfrm xfrm;
#endif
. . .
};

(include/net/net_namespace.h)

别被这堆指针吓到了,我们来拆解几个关键字段,看看它们是怎么把「隔离」这件事落地的。

user_ns:这是网络命名空间的「所有者」。它是创建该网络命名空间的用户命名空间。对于初始的网络命名空间对象(init_net),它的所有者自然是初始的用户命名空间(init_user_ns)。这在 setup_net() 方法里被赋值。

proc_inum:还记得上一节 US 命名空间里那个用来唯一标识命名空间的 inode 号吗?这里也是一样。每个网络命名空间在 /proc 文件系统中都有一个唯一的 inode 编号。它由 proc_alloc_inum() 分配(在 net_ns_net_init() 调用时),并在 net_ns_net_exit() 清理时通过 proc_free_inum() 释放。

proc_netproc_net_stat:因为每个命名空间都要有自己独立的 /proc/net/proc/net/stat 视图,所以这里有两个指针分别指向这些 procfs 目录项。

设备管理三元组

  • dev_base_head:指向该命名空间内所有网络设备组成的链表头。
  • dev_name_head:这是一个哈希表,键是设备名称(比如 eth0),用于快速查找。
  • dev_index_head:这也是一个哈希表,键是设备索引(ifindex)。

ifindex:这是该命名空间里最后一次分配的设备索引。注意,索引也是虚拟化的。这意味着在命名空间 A 和命名空间 B 里,lo 设备的索引都可以是 1,而各自的 eth0 也可以拥有相同的索引号。它们互不干扰。

loopback_dev:回环设备。这是每个新创建的网络命名空间里唯一默认存在的网络设备。它在 loopback_net_init() 方法(位于 drivers/net/loopback.c)中被赋值。记住,你不能把 lo 设备从一个命名空间移动到另一个命名空间,它是钉死在那里的。

count:这是引用计数。网络命名空间创建时初始化为 1。get_net() 增加它,put_net() 减少它。如果在 put_net() 里减到了 0,就会触发 __put_net(),把这个命名空间挂到一个全局的待清理列表(cleanup_list)上,稍后彻底销毁。

协议栈私有数据: 接下来的几个字段是各大协议栈的「私人地盘」:

  • ipv4 (struct netns_ipv4):IPv4 的所有私有数据都在这里,比如路由表、sysctl 参数等。
  • ipv6 (struct netns_ipv6):IPv6 的对应结构。
  • sctpct (netfilter connection tracking)、xfrm (IPsec):以此类推。

gen (struct net_generic):这里有个工程上的权衡。如果每个子系统想往 struct net 里塞点私有数据,那这个结构体就会膨胀得没法看。为了不把 struct net 变成垃圾场,内核引入了 net_generic。它是一个通用的指针数组,允许那些可选的子系统(比如 sit 模块)在这里申请一个 ID,然后存自己的私有数据,而不直接修改 struct net 的定义。


为了让你更直观地感受「隔离」的深度,我们钻进 netns_ipv4 里看一看。这不仅仅是换个 IP 地址那么简单,它是整个 IPv4 世界观的克隆:

struct netns_ipv4 {
. . .
#ifdef CONFIG_IP_MULTIPLE_TABLES
struct fib_rules_ops *rules_ops;
bool fib_has_custom_rules;
struct fib_table *fib_local;
struct fib_table *fib_main;
struct fib_table *fib_default;
#endif
. . .
struct hlist_head *fib_table_hash;
struct sock *fibnl;
struct sock **icmp_sk;
. . .
#ifdef CONFIG_NETFILTER
struct xt_table *iptable_filter;
struct xt_table *iptable_mangle;
struct xt_table *iptable_raw;
struct xt_table *arptable_filter;
#ifdef CONFIG_SECURITY
struct xt_table *iptable_security;
#endif
struct xt_table *nat_table;
#endif
int sysctl_icmp_echo_ignore_all;
int sysctl_icmp_echo_ignore_broadcasts;
. . .
int sysctl_tcp_ecn;
. . .
long sysctl_tcp_mem[3];
. . .
#ifdef CONFIG_IP_MROUTE
#ifndef CONFIG_IP_MROUTE_MULTIPLE_TABLES
struct mr_table *mrt;
#else
struct list_head mr_tables;
. . .
#endif
#endif
};

(net/netns/ipv4.h)

看到了吗? 这里有 FIB 路由表fib_local, fib_main 等); 这里有 Netfilter 表iptable_filter, nat_table 等); 这里有 组播路由表mrt); 这里甚至还有 sysctl 参数(比如 sysctl_tcp_mem)。

回到刚才那个「虚拟路由器」的类比:你现在应该能看出来,netns_ipv4 里的这些字段,就是那台虚拟路由器里的路由规则配置面板、防火墙配置面板和 TCP 参数调节旋钮。

设备与套接字的归属

既然世界分成了那么多份,那网络设备和套接字怎么知道自己属于哪一份?

对于网络设备(struct net_device: 内核给它加了一个成员叫 nd_net,这是一个指向 struct net 的指针。

  • 设置归属用 dev_net_set()
  • 查询归属用 dev_net()

规则很简单:一个网络设备在任意时刻只能属于一个命名空间。当你注册设备,或者把设备从一个命名空间移动到另一个时,这个指针就会被更新。

我们看一个真实的例子——注册 VLAN 设备时的场景:

static int register_vlan_device(struct net_device *real_dev, u16 vlan_id)
{
struct net_device *new_dev;

假设我们要创建一个 VLAN 设备。这个新设备应该属于哪个命名空间?答案是:继承自底层物理设备。 这里的 real_dev 是物理网卡,我们先拿到它的命名空间:

struct net *net = dev_net(real_dev);
. . .
new_dev = alloc_netdev(sizeof(struct vlan_dev_priv), name, vlan_setup);

if (new_dev == NULL)
return -ENOBUFS;

设备分配好了,现在通过 dev_net_set() 把它的「户口」落在刚才查到的命名空间里:

dev_net_set(new_dev, net);
. . .
}

对于套接字(struct sock: 也是一样的逻辑。内核给 struct sock 加了 sk_net 指针。

  • 设置用 sock_net_set()
  • 查询用 sock_net()

同样的限制:一个套接字也只能属于一个命名空间。当你在某个命名空间里创建一个 Socket,它就绑死在那了,跨命名空间的通信必须经过某种「网关」机制,而不是直接访问。

命名空间的生与死:pernet_operations

系统启动时,内核会创建一个默认的网络命名空间:init_net。 刚开机那会儿,所有的物理网卡、所有的 Socket,甚至 lo 设备,都归它管。

但随着系统运行,我们会创建新的命名空间。这时候,内核面临一个问题:有些子系统(比如刚才说的 IPv4,或者某些驱动模块)需要在每个新命名空间创建时做点初始化工作(比如在新的 /proc/net 下建个文件),在命名空间销毁时做点清理工作。

为了解决这个需求,内核引入了 struct pernet_operations。这是一个回调机制:

struct pernet_operations {
. . .
int (*init)(struct net *net);
void (*exit)(struct net *net);
. . .
int *id;
size_t size;
};

(include/net/net_namespace.h)

如果你在写一个网络设备驱动或者子系统,你需要定义一个 pernet_operations 对象,实现 initexit 回调,然后调用:

  • register_pernet_device():用于设备。
  • register_pernet_subsys():用于子系统。

看个实战例子:PPPoE 模块。

PPPoE 模块需要导出会话信息到 /proc/net/pppoe。因为它要支持命名空间,所以每个命名空间创建时,它都得去那个命名空间里创建这个 proc 文件。

它定义了自己的 pernet_operations

static struct pernet_operations pppoe_net_ops = {
.init = pppoe_init_net,
.exit = pppoe_exit_net,
.id = &pppoe_net_id,
.size = sizeof(struct pppoe_net),
}

(net/ppp/pppoe.c)

init 回调 pppoe_init_net() 里,它干了一件事:在当前命名空间的 proc_net 下创建 pppoe 文件。

static __net_init int pppoe_init_net(struct net *net)
{
struct pppoe_net *pn = pppoe_pernet(net);
struct proc_dir_entry *pde;

rwlock_init(&pn->hash_lock);

pde = proc_create("pppoe", S_IRUGO, net->proc_net, &pppoe_seq_fops);
#ifdef CONFIG_PROC_FS
if (!pde)
return -ENOMEM;
#endif
return 0;
}

而在 exit 回调 pppoe_exit_net() 里,它负责把文件删掉:

static __net_exit void pppoe_exit_net(struct net *net)
{
remove_proc_entry("pppoe", net->proc_net);
}

网络命名空间自己是怎么注册的呢?

命名空间模块本身也是一个子系统。它在启动阶段注册了 net_ns_ops

static struct pernet_operations __net_initdata net_ns_ops = {
.init = net_ns_net_init,
.exit = net_ns_net_exit,
};

static int __init net_ns_init(void)
{
. . .
register_pernet_subsys(&net_ns_ops);
. . .
}

(net/core/net_namespace.c)

每当创建一个新命名空间,net_ns_net_init 就会被调用。它只做一件事:分配那个唯一的 proc inode 号(proc_inum):

static __net_init int net_ns_net_init(struct net *net)
{
return proc_alloc_inum(&net->proc_inum);
}

销毁时则调用 net_ns_net_exit 释放它:

static __net_exit void net_ns_net_exit(struct net *net)
{
proc_free_inum(net->proc_inum);
}

如何创建网络命名空间?

好了,理论讲够了。当你真的想造一个新世界时,你有三条路可以走:

  1. 硬核派:写个 C 程序,用 clone()unshare() 系统调用,带上 CLONE_NEWNET 标志位。这就像自己造房子。
  2. 工具派:用 iproute2 包里的 ip netns 命令。这是最常用的方式,我们马上就会看到。
  3. 偷懒派:用 util-linux 提供的 unshare 命令行工具,加个 --net 参数。

不管你选哪条路,终点都是一样的:一个新的 struct net 被分配出来,一个新的 lo 设备被创建,而你,就是这个新世界的上帝。