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_net 和 proc_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 的对应结构。sctp、ct(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 对象,实现 init 和 exit 回调,然后调用:
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);
}
如何创建网络命名空间?
好了,理论讲够了。当你真的想造一个新世界时,你有三条路可以走:
- 硬核派:写个 C 程序,用
clone()或unshare()系统调用,带上CLONE_NEWNET标志位。这就像自己造房子。 - 工具派:用
iproute2包里的ip netns命令。这是最常用的方式,我们马上就会看到。 - 偷懒派:用 util-linux 提供的
unshare命令行工具,加个--net参数。
不管你选哪条路,终点都是一样的:一个新的 struct net 被分配出来,一个新的 lo 设备被创建,而你,就是这个新世界的上帝。