最近在做 Kubernetes Security 相关的工作。作为一个 Kubernetes 新手,刚看到 yaml 文件中的 namespace 时,很容易和 Linux 的 Namespace 搞混。加上在读 Kubernetes 文档的时候,总时不时看到 Linux Namespace 的字样就更加困惑起来。以此为契机,正好把 Linux Namespace 再好好梳理一下,解开心中的谜团:

  • Linux 的 Namespace 是如何实现资源的隔离
  • Linux 下哪些资源可以按照 Namespace 组织,如何组织
  • Container 哪些地方用到了 Linux Namespace

英雄诞生

传统的 Linux 及其衍生的 UNIX 变体中,许多资源是全局管理的。例如,系统中的所有进程按照惯例是通过 PID 标识的,这意味着内核必须管理一个全局的 PID 列表。而且,所有调用者通过 uname 系统调用返回的系统相关信息(包括系统名称和有关内核的一些信息)都是相同的。用户 ID 的管理方式类似,即各个用户是通过一个全局唯一的 UID 号标识。

全局 ID 给内核极大的选择权,可以通过 ID 来允许或拒绝某些权限。UID 为 0 的 root 用户可以做人和事,但其他 UID 会受限。例如 UID 为 n 的用户,不允许杀死属于用户 m 的进程(m ≠ n )。但这不能防止用户看到彼此,即用户 n 可以看到另一个用户 m 也在计算机上活动。这种效果在某些情况下是不可取的。

Namespace 提供了虚拟化的一种轻量级形式,它只使用一个内核在一台物理机上运行,就可以将上述的全局资源组织起来。这使得可以将一组进程放置到容器中,各个容器彼此隔离。隔离可以使容器的成员与其他容器毫无关系。但也可以通过允许容器进行一定的共享,来降低容器之间的分隔。例如,容器可以设置为使用自身的 PID 集合,但仍然与其他容器共享部分文件系统。

实现

Namespace 的实现需要两个部分:

  1. 每个子系统的 namespace 结构,将此前所有的全局组件包装到 namespace 中
  2. 将给定进程关联到所属各个 namespace 的机制

本文采用的源码是内核 5.4

Linux内核中进程用 task_struct 结构体表示,我们称为进程描述符。该结构体定义在 sched.h 中,记录了进程相关的所有信息,比如进程地址空间,进程状态,打开的文件等。

// sched.h

struct task_struct {
	/* Filesystem information: */
	struct fs_struct		*fs;

	/* Open file information: */
	struct files_struct		*files;

	/* Namespaces: */
	struct nsproxy			*nsproxy;

    // ...
}

这里我们重点关注 nsproxy 变量。nsproxy 结构体包含指向进程所属 Namespace 的所有指针,分别是:

Namespace系统调用参数隔离内容
UTSCLONE_NEWUTS主机名与域名
IPCCLONE_NEWIPC信号量、消息队列和共享内存
PIDCLONE_NEWPID进程编号
NetworkCLONE_NEWNET网络设备、网络栈、端口等等
MountCLONE_NEWNS挂载点(文件系统)
UserCLONE_NEWUSER用户和用户组

nsproxy 由 namespace 下的所有进程共享,namespace 再封装了各种资源,进程只能查看和操作绑定在 namespace 的资源。

// nsproxy.h

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 cgroup_namespace *cgroup_ns;
};

这里特别说明下:

  • 进程的 pid namespace 通过 task_active_pid_ns 获取。nsproxy 结构中的 pid_namespae 是给子进程使用的。
  • User namespace 没有直接出现在 nsproxy 中,但 **_namespace 都有包含 user namespace 的指针。
  • 实际上,内核从 4.6 开始引入了 cgroup namespace,本文就先掠过不表,留待以后探索。

Namespace 的 API

有两种方法创建 namespace:

  1. 在用 forkclone 系统调用创建新进程时,有特定的选项可以控制是与父进程共享 namespace,还是建立新的 namespace。
  2. unshare 系统调用将进程的某些部分从父进程分离,其中也包括 namespace。

这底下用到三个函数 clone()setns() 以及 unshare(),让我们逐个解析。

clone() 创建新 namespace

clone() 函数的 manual 在 CLONE(2)

int clone(int (*fn)(void *), void *child_stack,
                 int flags, void *arg, ...
                 /* pid_t *ptid, void *newtls, pid_t *ctid */ );

clone() 可以看作是传统 UNIX fork 的通用版本,两者都创建一个新的进程,也创建了一个新的 namespace。不同之处在于,clone() 允许子进程共享父进程的部分执行上下文,比如虚拟地址空间、文件描述符等。通过 flags 参数,我们可以控制父子进程之间共享的内容。

clone() 的几个参数:

  • fn 传入子进程运行的主函数
  • child_stack 传入子进程使用的栈空间,因为子进程可能和父进程共享内存
  • flags 表示使用哪些 CLONE_* 标志位
  • arg 即传入 fn 函数的参数

setns() 加入已有 namespace

通过 setns() 可以加入一个已经存在的 namespace。

int setns(int fd, int nstype);

其中,

  • fd 是指向 /proc/[pid]/ns/ 目录的文件描述符
  • nstype 使用 CLONE_* 标志位约束了 namespace 的类型

unshare() 离开 namespace

int unshare(int flags);

unshare() 将进程(线程)从它被其他进程(线程)共享的执行上下文中分离出来。flags 参数是 CLONE_* 的位掩码。

/proc/[pid]/ns/ 目录

前文说到了 /proc/[pid]/ns/ 目录,每个进程在该目录下都有一个子目录,标识着所属的 namespace。

$ ls -l /proc/$$/ns
total 0
lrwxrwxrwx. 1 mtk mtk 0 Apr 28 12:46 cgroup -> cgroup:[4026531835]
lrwxrwxrwx. 1 mtk mtk 0 Apr 28 12:46 ipc -> ipc:[4026531839]
lrwxrwxrwx. 1 mtk mtk 0 Apr 28 12:46 mnt -> mnt:[4026531840]
lrwxrwxrwx. 1 mtk mtk 0 Apr 28 12:46 net -> net:[4026531969]
lrwxrwxrwx. 1 mtk mtk 0 Apr 28 12:46 pid -> pid:[4026531836]
lrwxrwxrwx. 1 mtk mtk 0 Apr 28 12:46 pid_for_children -> pid:[4026531834]
lrwxrwxrwx. 1 mtk mtk 0 Apr 28 12:46 user -> user:[4026531837]
rwxrwxrwx. 1 mtk mtk 0 Apr 28 12:46 uts -> uts:[4026531838]

下面,我们对 6 种 namespace 一一拆解。

PID Namespace

每个进程都有一个 PID,PID namespace 将 PID 隔离开来。这意味着在不同 PID namespace 下的进程可以有相同的 PID。Container 可以依靠 PID namespace 来 suspend 或 resume 一个 namespace 下的进程集合,而不影响其他 namespace 的进程。

forkclone 创建的进程,带有两个 PID,其中一个是 namespace 中唯一 PID,另一个是 host 系统上的 PID。PID namespace 的 PID 从 2 开始分配,1 这个位置留给 init 进程,它是所有孤儿进程的父进程。如果 init 进程终止了,那么内核会通过 SIGKILL 来终止该 namespace 下的所有进程。

PID namespace 可以组织为层次,也可以为非层次。内核为所有的 PID namespace 维护了一个树状结构,最顶层的是系统初始时创建的 root namespace。在层次组织的 namespace 中,父 namespace 能够看到子 namespace 的所有进程,因为后者进程的 PID 会映射到父 namespace 中。

我们来看下 pid_namespace 结构体:

struct pid_namespace {
	unsigned int pid_allocated;
	struct task_struct *child_reaper;
	struct pid_namespace *parent;
	struct user_namespace *user_ns;

	// ...
} __randomize_layout;

它包括:

  • PID
  • 子进程的指针
  • 父 PID namespace 的指针
  • 所属 User namespace 的指针

Mount Namespace

Mount namespace 隔离的是挂载点,也就是说不同 namesapce 下的进程看到不同的文件系统视图。因此,Mount namespace 用来实现容器中文件系统的隔离。引入 Mount namespace 之后,mount 和 umount 这两个系统调用就不会在 global 的 挂载点上执行了。

Mount namespace 是内核 2.4.19 时引进的,代表常量是 CLONE_NEWNS,是没有考虑到之后的扩展性。

struct mnt_namespace {
	atomic_t		count;
	struct ns_common	ns;
	struct mount *	root;
	struct list_head	list;
	struct user_namespace	*user_ns;
	struct ucounts		*ucounts;
	u64			seq;	/* Sequence number to prevent loops */
	wait_queue_head_t poll;
	u64 event;
	unsigned int		mounts; /* # of mounts in the namespace */
	unsigned int		pending_mounts;
} __randomize_layout;

mnt_namespace 结构体中有:

  • User namespace 的指针
  • 所有 mount 的文件树的 root 指针

User Namespace

User namespace 隔离了 UID 和 GID。换句话说,进程的 UID 和 GID 在 namespace 内外可以不同。这意味着,一个进程可以在 user namespace 之外有一个 unprivileged UID,同时在一个 user namespace 内有为 0 的 privileged UID。

struct user_namespace {
	struct uid_gid_map	uid_map;
	struct uid_gid_map	gid_map;
	struct uid_gid_map	projid_map;
	atomic_t		count;
	struct user_namespace	*parent;
	int			level;
	kuid_t			owner;
	kgid_t			group;
	struct ns_common	ns;
	unsigned long		flags;

	struct ucounts		*ucounts;
	int ucount_max[UCOUNT_COUNTS];
} __randomize_layout;

user_namespace 中有:

  • 父 user namespace 的指针
  • UID 和 GID 的 map

Network Namespace

Network namespace 隔离了网络资源,包括网络设备、IP 地址、IP 路由表、/proc/net 目录、端口号等等。

对容器来说,每个容器就可以有自己的网络设备,containerized app 也可以绑定到自己的端口空间。Host 机器上的路由规则也可以把数据包传送给指定的容器(关联到某个网络设备)。

struct net {
	struct list_head	list;		/* list of network namespaces */
	struct user_namespace   *user_ns;	/* Owning user namespace */

	struct netns_core	core;
	struct netns_mib	mib;
	struct netns_packet	packet;
	struct netns_unix	unx;
	struct netns_nexthop	nexthop;
	struct netns_ipv4	ipv4;
}

net 结构中包括:

  • 各种 net_xxx 的网络设备
  • 所属的 user namespace
  • Network namespace 的列表

UTS Namespace

UTS 隔离 nodenamedomainname 系统标识符。通过 uname 调用可以看到这两个标识符的值。借助 UTS namespace,容器可以有自己的 hostname 和 NIS domain name。UTS 是 “UNIX Time-sharing System” 的缩写。

struct uts_namespace {
	struct kref kref;
	struct new_utsname name;
	struct user_namespace *user_ns;
	struct ucounts *ucounts;
	struct ns_common ns;
} __randomize_layout;

uts_namespace 结构体也是最简单的,只有所属的 user namespace。

IPC Namespace

IPC namespace 隔离了 System V IPC 和 POSIX 消息队列。这两个都是进程通信的资源。只有在一个 IPC namespace 下的进程才能够通信。所以在同一个 IPC namespace 下的进程彼此可见,而与其他的 IPC namespace 下的进程则互相不可见。

struct ipc_namespace {
	unsigned int	msg_ctlmax;
	unsigned int	msg_ctlmnb;
	unsigned int	msg_ctlmni;
	atomic_t	msg_bytes;
	atomic_t	msg_hdrs;

	size_t		shm_ctlmax;
	size_t		shm_ctlall;

	unsigned int    mq_queues_count;

	/* next fields are set through sysctl */
	unsigned int    mq_queues_max;   /* initialized to DFLT_QUEUESMAX */
	unsigned int    mq_msg_max;      /* initialized to DFLT_MSGMAX */
	unsigned int    mq_msgsize_max;  /* initialized to DFLT_MSGSIZEMAX */
	unsigned int    mq_msg_default;
	unsigned int    mq_msgsize_default;

	/* user_ns which owns the ipc ns */
	struct user_namespace *user_ns;
}

Reference