14.4 管理 Network Namespace:从上帝视角到手动操作
上一节我们站在内核的角度,把 struct net 的前世今生走了一遍。现在,让我们回到用户空间。
既然理论已经有了,接下来就是实战。你不需要写 C 代码来调用 unshare(),虽然那确实很酷。在 99% 的场景下,你会用 iproute2 包提供的 ip netns 命令。它是操纵网络命名空间的瑞士军刀——好用、直观,但偶尔也会藏点雷。
这一节,我们就在命令行里把这些雷踩一遍。
4.4.1 创建与销毁:不仅是加个名字这么简单
创建一个名为 ns1 的网络命名空间,命令简单到令人发指:
ip netns add ns1
但这行命令背后发生的事情,比看起来要多得多。你可以把它想象成「注册户籍」的过程。
首先,系统会在 /var/run/netns/ 下创建一个名为 ns1 的文件。
紧接着,ip netns 通过 unshare() 系统调用(带上 CLONE_NEWNET 标志)让内核创建了一个新的网络命名空间。
最后,也是最关键的一步,它把 /proc/self/ns/net(当前进程的网络命名空间句柄)bind mount(绑定挂载)到了刚才创建的那个 /var/run/netns/ns1 文件上。
为什么要这么做? 这里有一个很有用的类比:
[类比引入]:你可以把
/var/run/netns/下的文件理解成「传送门的锚点」。内核里的网络命名空间是漂浮在内存里的数据结构,如果没有文件系统里的一个文件引用它,一旦创建它的进程退出,这个命名空间就会像幽灵一样消失。[揭示距离]:但「传送门」这个比喻有个局限性——它暗示文件本身就是命名空间。其实不是,文件只是指向内核对象的一个句柄(bind mount)。如果你直接删除
/var/run/netns/ns1文件(用rm而不是ip netns del),只要还有进程在这个命名空间里活着,命名空间本身就不会被销毁,只是你再也找不到门回去而已。
有了这个「锚点」,即使创建它的进程退出了,我们依然可以通过这个文件「回到」那个隔离的网络环境里。
当然,网络命名空间是可以嵌套的。你可以在 ns1 里再创建一个 ns2,就像俄罗斯套娃一样,每一层都有自己的 lo 设备和路由表。
销毁命名空间
删掉它也很直观:
ip netns del ns1
但这里有个坑。
如果在这个命名空间里还有活着的进程(比如你开了一个 bash 没退),这个命令会拒绝执行。为什么?因为内核不允许杀掉一个还有「住户」的房子。
只有在确认没有进程附着的情况下,ip netns del 才会删除 /var/run/netns/ns1 文件。随着引用计数归零,内核会清理这个命名空间。
这时候发生了一件很有趣的事:
当命名空间销毁时,它里面的所有网络设备会被强制「搬家」——搬回到初始的默认命名空间 init_net 里去。
例外:对于那些打上了 NETIF_F_NETNS_LOCAL 标记的设备(比如 lo 设备、VXLAN、bridge 等),它们是「本地户籍」,不允许搬家,只能跟着命名空间一起销毁。我们在后面讲 ip link set 的时候会细说这个标记。
4.4.2 查看、监控与 PID 游戏戏法
看看都有哪些世界
列出所有通过 ip netns add 创建的命名空间:
ip netns list
这背后的实现非常朴素:它只是简单地列出 /var/run/netns/ 目录下的文件名。
这意味着什么?意味着如果你是用 unshare --net bash 这种方式创建的命名空间,它不会出现在这个列表里——因为这种方式没有在 /var/run/netns/ 下留「锚点」。它像个隐形的空间,只有当前进程能感知。
实时监控
如果你想在另一个终端里盯着命名空间的创建和销毁,可以用这个:
ip netns monitor
这是通过 inotify 机制监控 /var/run/netns/ 目录实现的。
当你运行 ip netns add ns2 时,监控终端会打印 add ns2。
当你运行 ip netns del ns2 时,它会打印 delete ns2。
⚠️ 注意:如果你在还没有创建任何命名空间之前就运行监控,你会报错:
inotify_add_watch failed: No such file or directory
原因很简单:/var/run/netns/ 目录本身还不存在,inotify 没地方挂载钩子。
同样,只有通过 ip netns 命令操作才会触发监控。如果你是手动写代码 unshare() 的,监控这里是看不到的——因为没动那个目录。
进程与命名空间的捉迷藏
iproute2 还给了我们两个很有用的命令,用来处理进程和命名空间的关系。
1. 给 PID 找名字 你看到一个进程(比如 PID 1234),你想知道它现在在哪个网络命名空间里?
ip netns identify 1234
这背后发生的事情是:读取 /proc/1234/ns/net,然后遍历 /var/run/netns/ 下的文件,用 stat() 系统调用比对 inode 号。一旦匹配上,就告诉你名字。如果没匹配上(比如它是 unshare 出来的隐形空间),命令就沉默不语。
2. 给名字找 PID
你想知道谁正躲在 ns1 里?
ip netns pids ns1
这就像点名。它读取 /var/run/netns/ns1 的 inode,然后遍历 /proc/<pid>/ns/net,一个个比对 inode 号。凡是匹配的 PID,都列出来。
4.4.3 进入命名空间:Exec 的魔法
创建只是第一步,真正的目的是进去玩。
最经典的操作是在 ns1 里开一个 shell:
ip netns exec ns1 bash
这个 exec 命令非常强大。它本质上做了三件事:
- 打开
/var/run/netns/ns1这个文件(拿到了 bind mount 的 fd)。 - 通过
setns()系统调用,把当前进程关联到这个 fd 指向的命名空间。 fork()+execve()你指定的命令(这里是bash)。
一旦那个 bash 启动起来,你就置身于 ns1 里了。
这时候如果你运行 ifconfig -a,你会看到一个非常精简的世界:
ip netns exec ns1 ifconfig -a
lo Link encap:Local Loopback
LOOPBACK MTU:65536 Metric:1
(通常只有孤零零的 lo 设备,而且是 DOWN 状态)。
4.4.4 搬运设备:谁也不能带走所有的行李
现在你的 ns1 是空的,除了 lo 啥也没有。这太无聊了。我们通常需要把物理网卡或者虚拟网卡塞进去。
比如,把 eth0 移动到 ns1:
ip link set eth0 netns ns1
这一行命令敲下去,你的主 shell 就会立刻失去对 eth0 的控制权——它从你的视野里消失了。你必须 ip netns exec ns1 进去看看,才能发现它在那边。
但这里有个硬性限制。
还记得我们在上一节提到的 net_device 结构体吗?它有一个特性标志位叫 NETIF_F_NETNS_LOCAL。
如果这个位被置位了,这个设备就是「本地户籍」,它是拒绝被移动的。
你可以用 ethtool 看看这个标志:
ethtool -k eth0 | grep netns-local
netns-local: off [fixed]
如果是 on(比如 lo 设备、vxlan、pppoe 等),你尝试移动它时,内核会直接返回 -EINVAL(无效参数)。
让我们看看内核源码是怎么怼回来的:
/* net/core/dev.c */
int dev_change_net_namespace(struct net_device *dev, struct net *net, const char *pat)
{
int err;
ASSERT_RTNL();
/* Don't allow namespace local devices to be moved. */
err = -EINVAL;
if (dev->features & NETIF_F_NETNS_LOCAL)
goto out;
/* ... 真正的切换逻辑 ... */
dev_net_set(dev, net);
/* ... */
out:
return err;
}
这就是规则。你想带它走?门儿都没有。
另外一种移动方式: 有时候你可能不知道命名空间的名字,只知道有个进程(PID 666)跑在那个空间里。你也可以直接把网卡扔给那个进程:
ip link set eth1 netns 666
内核会通过 get_net_ns_by_pid() 找到 PID 666 所在的命名空间,然后把网卡塞进去。这和 get_net_ns_by_fd()(通过名字找)是殊途同归的。
无线网卡的特例:
如果你想把无线网卡(wlan0)移动到另一个命名空间,ip link 可能不够用。你需要用 iw 命令,因为无线设备的配置比普通以太网复杂得多(涉及到 phy 等)。
iw phy phy0 set netns <pidNumber>
4.4.5 穿越虚空:让两个世界对话
把设备分开了,我们还需要让它们通信。不然搞隔离有什么意义? 两个隔离的命名空间通信,通常有两种路子:
- Unix Sockets:进程间通信的老大哥,无视网络隔离。
- VETH Pair:这更像是一根「跨世界网线」。
VETH(Virtual Ethernet)总是成对出现的。这对设备就像一根管道的两头,从这头进去的数据,立刻从那头出来。
我们可以把一头留在 init_net(主世界),另一头扔到 ns1 里。
假设我们有两个空荡荡的命名空间 ns1 和 ns2:
# 创建空间
ip netns add ns1
ip netns add ns2
现在我们在当前(主)空间创建一对 VETH:
ip link add name if_one type veth peer name if_one_peer
现在主空间多了两个设备:if_one 和 if_one_peer。
接下来,我们把 if_one_peer 丢给 ns1:
ip link set dev if_one_peer netns ns1
现在,主空间只有 if_one,而 ns1 里有 if_one_peer。
如果你把 if_one 再丢给 ns2,或者把 if_one 和 ns1 里的 if_one_peer 分别配置 IP 并启动,你就在两个命名空间之间架起了一根直连网线。
你可以用 ifconfig 或 ip addr 给它们配置 IP,然后互相 ping。这就是构建容器网络的基础。
4.4.6 结语:但这只是开始
我们在这一章里拆解了网络命名空间的实现:从内核里的 nsproxy,到用户空间的 ip netns。
我们也看到了为了实现这一切,内核做了多少妥协和改动:新的 CLONE_NEW* 标志、新的系统调用、pernet_operations 回调机制……
这些东西不是魔法,是工程师们为了解决「隔离」这个古老问题,一层层堆出来的抽象。
但这还不是全部。命名空间解决了「视线隔离」的问题,但还没解决「资源抢夺」的问题。 如果某个容器里的进程开始疯狂占满 CPU,宿主机还是会卡死。 解决这个问题的,是下一章的主角:Cgroups。 在那之后,我们还会看到几个具体的网络模块是如何依附在 Cgroups 这个大框架下的。
准备好了吗?我们要进入资源管理的深水区了。