nsenter 套娃

在 Linux 系统里,nsenter 是一个命令行工具,用于进入到另一个 namespace。譬如,nsenter -n -t 1 bash 就是进入到 pid 为 1 的进程所在的网络 namespace 里。

如果不断的执行 nsenter 命令,就形成了一个 nsenter 套娃。

nsenter 套娃

而在 Linux 系统里,会不会形成一个 namespace 栈呢?以便在退出里层的 namespace 后,就回到外层的 namespace

答案:不会。

本篇博客后面的内容,以 Go 代码的 nsenter 的实现为例,分析一下在 nsenter 背后 Linux 系统的实现。

Go 代码 nsenter 示例

需要实现的需求是,在一个 Go 进程里去对接处于不同 namespaceiptables-nfqueue

使用 Go 对接 iptables-nfqueue,请参考:使用 Go 对接 iptables NFQUEUE 的例子

namespace 的一个误解

在找到 kata-containers/nsenter 之前,一直以为 namespace 是 Go 程序运行的外部环境,导致一个 Go 程序无法处理跨 namespace 的事情。

因为这个错误的理解,导致在某个项目中,将原本可以写成一个 Go 程序的方案、变成了两个 Go 程序,每个 Go 程序各自在一个 namespace 里,进而导致了复杂的方案带来的性能损耗。非常遗憾采用了比较复杂的方案。

nsenter 带来的希望

kata-containers/nsenter 解决了一个 Go 程序对接处于不同 namespaceiptables-nfqueue 的需求,使用的代码如下:

1
2
3
4
5
6
import knsenter "github.com/kata-containers/runtime/virtcontainers/pkg/nsenter"

func nsenter(pid int, fn func() error) error {
   netns := knsenter.Namespace{PID: pid, Type: knsenter.NSTypeNet}
   return knsenter.NsEnter([]knsenter.Namespace{netns}, fn)
}

对接指定 PID 的网络 namespaceiptables-nfqueue 时,即可使用如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import "github.com/subgraph/go-nfnetlink/nfqueue"

func openNfqueue(pid, nfqueueNum int) (ch <-chan *nfqueue.NFQPacket, err error) {
    err = nsenter(pid, func() error {
        nfq := nfqueue.NewNFQueue(nfqueueNum)
        ch, err = nfq.Open()
        return err
    })
    return
}

这段代码的作用是:

  1. 进入到指定 PID 的网络 namespace
  2. 监听指定序号的 NFQUEUE
  3. 退出网络 namespace
  4. 返回一个网络包 chan

调用 openNfqueue 函数后,从 chan 取到的网络包就是从指定 PID 的网络 namespace 里 NFQUEUE 发过来的网络包。

这里产生一个疑问:为什么退出网络 namespace 后,用户程序的 NFQUEUE 不会从网络 namespace 里退出呢?

nsenter 背后的原理

在阅读内核源代码之前,完全想不明白内核怎么实现 namespace 切换。

iptables-nfqueue 是基于 netlink 跟用户程序进行通信;则在用户程序中,会创建一个 netlinksocket fd 去接收 NFQUEUE 发送过来的网络包;则该 socket fd 是怎么跟网络 namespace 绑定到一起的?

内核是怎么看网络 namespace 的?

其实在内核中,一个网络 namespace 就是一个 struct net,并采用引用计数来管理 struct net 的生命周期。所以,在创建网络 namespace 后,需要有某个地方引用这个网络 namespace;譬如,使用命令 ip netns add nstest 后,会创建一个文件 /var/run/netns/nstest,并将这个文件的 fd 引用这个网络 namespace

在内核中,进程和线程都是使用 struct task_struct 来管理,并使用 struct nsproxy 去引用 namespace

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// https://github.com/torvalds/linux/blob/master/include/linux/nsproxy.h#L31
struct nsproxy {
	atomic_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;
};

nsenter 函数的实现原理

openNfqueue 函数的实现过程如下:

  1. 将当前线程的 nsproxy->net_ns 指向目标网络 namespace
  2. 创建 netlink socket,使得 socket fd 引用目标网络 namespace
  3. 将当前线程的 nsproxy->net_ns 指向原来的网络 namespace

Go nsenter procedure

nsenter 的源代码分析

openNfqueue Go 函数为例,翻开内核源代码看看 nsenter 涉及的源代码:

1
2
3
4
// https://github.com/kata-containers/runtime/blob/master/virtcontainers/pkg/nsenter/nsenter.go#L101
	if err := unix.Setns(int(nsFile.Fd()), nsFlag); err != nil {
		return fmt.Errorf("Error switching to ns %v: %v", nsFile.Name(), err)
	}

最终调用的是 setns 系统调用。

setns 系统调用

setns 系统调用的文档:setns(2)

接着,暴力翻看 setns 系统调用的源代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
SYSCALL_DEFINE2(setns, int, fd, int, flags) // kernel/nsproxy.c
|-->file = fget(fd);
|-->ns = get_proc_ns(file_inode(file)); // 根据 fd 获取对应的 namespace
|-->prepare_nsset(flags, &nsset);
|-->validate_ns(&nsset, ns);
|   |-->ns->ops->install(nsset, ns);
|    \
|     \
|      \
|       |-->netns_install(struct nsproxy *nsproxy, struct ns_common *ns) // net/core/net_namespace.c
|           |-->put_net(nsproxy->net_ns); // 减少一个网络 namespace 引用
|           |-->nsproxy->net_ns = get_net(net); // 增加一个网络 namespace 引用,并且变更 nsproxy->net_ns 为目标网络 namespace
|-->commit_nsset(&nsset);
    |-->switch_task_namespaces(me, nsset->nsproxy);
        |-->p->nsproxy = new; // 变更当前 task_struct->nsproxy 为新的 nsproxy,新的 nsproxy->net_ns 引用了目标网络 namespace

setns 实现的功能就是将当前线程引用的 namespace 引用目标 namespace

socket 系统调用

跟踪 nfqueue.NewNFQueue(nfqueueNum) 函数的实现,最终会调用 syscall.Socket 系统调用去创建一个 socket fd

1
2
3
4
5
6
7
8
// https://github.com/subgraph/go-nfnetlink/blob/master/nfnl_sock.go#L69
func NewNetlinkSocket(bus int) (*NetlinkSocket, error) {
	fd, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_RAW, bus)
	if err != nil {
		return nil, err
	}
    ...
}

socket 系统调用的文档:socket(2)

暴力翻看 socket 系统调用的源代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol) // net/socket.c
|-->__sys_socket(family, type, protocol);
    |-->sock_create(family, type, protocol, &sock);
    |   |-->__sock_create(current->nsproxy->net_ns, family, type, protocol, res, 0); // 拿到当前的 task_struct->nsproxy->net_ns 所引用的网络 namespace
    |       |-->pf = rcu_dereference(net_families[family]);
    |       |-->pf->create(net, sock, protocol, kern); // 以 netlink socket 为例,看下创建 socket 的时候是 socket 哪个属性引用网络 namespace 的
    |      /
    |     /
    |    /
    |   |-->netlink_create(struct net *net, struct socket *sock, int protocol, int kern) // net/netlink/af_netlink.c
    |       |-->__netlink_create(net, sock, cb_mutex, protocol, kern);
    |           |-->sk = sk_alloc(net, PF_NETLINK, GFP_KERNEL, &netlink_proto, kern); // net/core/sock.c
    |           |   |-->sock_net_set(sk, net); // include/net/sock.h
    |           |       |-->write_pnet(&sk->sk_net, net); // include/net/net_namespace.h
    |           |           |-->pnet->net = net;
    |           |-->sock_init_data(sock, sk);
    |               |-->sock->sk = sk; // net/core/sock.c
    |-->sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK)); // 保存 struct socket 跟 fd 的映射关系

最终,一个 socket fd 引用网络 namespace 的地方是 struct socket->struct sock->sk_net.net

nsenter 函数退出时

查看 NsEnter 函数

1
2
3
4
5
6
7
8
9
// https://github.com/kata-containers/runtime/blob/master/virtcontainers/pkg/nsenter/nsenter.go#L168
		for nsType, pair := range targetNSList {
			// Switch to targeted namespace.
			if err := setNS(pair.targetNS, nsType); err != nil {
				return fmt.Errorf("error switching to ns %v: %v", pair.targetNS.Name(), err)
			}
			// Switch back to initial namespace after closure return.
			defer setNS(pair.threadNS, nsType)
		}

从这段代码可知,在函数退出时(defer setNS),会将当前线程发生变更的 namespace 恢复回原来的 namespacesetNS 函数是对 setns 系统调用的封装。

小结

至此,nsenter 再无秘密。

揭开 nsenter 的神秘面纱后,发现 nsenter 在项目中的用武之地还真不少,就是因为它带给用户程序处理跨 namespace 的能力。

总结

经历了几次带着问题来查看源代码的过程,既能提升阅读源代码的能力,也扎实了计算机基础知识;特别是在阅读源代码解决了问题之后的那感觉,真美好。

阅读内核源代码的工具推荐: