本文以进程的创建过程为切入点,深入理解命名空间的工作原理。内核版本为6.12;
默认命名空间
在进程的结构体task_struct中有一个与命名空间相关的字段nsproxy。在这个成员中包含了进程和多种命名空间的关联关系。包括net、pid、mnt文件系统命名空间等。
struct task_struct {
/* Namespaces: */
struct nsproxy *nsproxy;
}
其结构体完整如下:
struct nsproxy {
atomic_t count; //引用计数,表示有多少个任务(进程)共享这个 nsproxy 结构体.
struct uts_namespace *uts_ns; //UTS 命名空间用于隔离主机名和域名。
struct ipc_namespace *ipc_ns; //进程间通信命名空间的
struct mnt_namespace *mnt_ns; //指向挂载命名空间的指针
struct pid_namespace *pid_ns_for_children; //指向子进程将要使用的 PID命名空间的指针
struct net *net_ns; //指向网络(Network)命名空间的指针
struct time_namespace *time_ns; //指向时间(Time)命名空间的指针,主要用于修改进程的时间行为
struct time_namespace *time_ns_for_children; //用于子进程继承的时间命名空间
struct cgroup_namespace *cgroup_ns; //指向控制组(cgroup)命名空间的指针
};
对于每一种命名空间,Linux在启动时会有一套默认值:
struct nsproxy init_nsproxy = {
.count = ATOMIC_INIT(1),
.uts_ns = &init_uts_ns,
#if defined(CONFIG_POSIX_MQUEUE) || defined(CONFIG_SYSVIPC)
.ipc_ns = &init_ipc_ns,
#endif
.mnt_ns = NULL,
.pid_ns_for_children = &init_pid_ns,
#ifdef CONFIG_NET
.net_ns = &init_net,
#endif
#ifdef CONFIG_CGROUPS
.cgroup_ns = &init_cgroup_ns,
#endif
#ifdef CONFIG_TIME_NS
.time_ns = &init_time_ns,
.time_ns_for_children = &init_time_ns,
#endif
};
在这里,默认的PID命名空间是init_pid_ns,在6.12的内核代码中:
struct pid_namespace init_pid_ns = {
.ns.count = REFCOUNT_INIT(2),
//记录id分配情况的基数树
.idr = IDR_INIT(init_pid_ns.idr),
.pid_allocated = PIDNS_ADDING,
.level = 0, // init_pid_ns处于最高层级(最外层命名空间)
.child_reaper = &init_task,
.user_ns = &init_user_ns,
.ns.inum = PROC_PID_INIT_INO,
#ifdef CONFIG_PID_NS
.ns.ops = &pidns_operations,
#endif
};
level表示当前的pid命名空间的层级。当前情况下被初始化为0,若有多个明明空间被创建出来,会组成一棵树,level则表示树在哪一层。
idr表示基数树,其中保存着分配出去的pid.
在Linux启动时有一个0号进程,也叫idle进程,它使用的就是默认的init_nsproxy.
struct task_struct init_task __aligned(L1_CACHE_BYTES) = {
#ifdef CONFIG_THREAD_INFO_IN_TASK
.thread_info = INIT_THREAD_INFO(init_task),
.stack_refcount = REFCOUNT_INIT(1),
#endif
.__state = 0,
.stack = init_stack,
.usage = REFCOUNT_INIT(2),
.flags = PF_KTHREAD,
.prio = MAX_PRIO - 20,
.static_prio = MAX_PRIO - 20,
.normal_prio = MAX_PRIO - 20,
.policy = SCHED_NORMAL,
.cpus_ptr = &init_task.cpus_mask,
.user_cpus_ptr = NULL,
......
.nsproxy = &init_nsproxy,
......
}
接下来操作系统中的所有进程都是以派生的方式生成的,若我们在创建进程时未曾指定创建新的命名空间,那么接下来的新进程都会使用这个默认的命名空间init_nsproxy,其示意图如下:
创建进程时的命名空间处理
kernel_clone:
当使用fork创建新进程时,会陷入系统调用,接着调用kernel_clone方法:
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}
kernel_clone的参数为:
struct kernel_clone_args {
u64 flags;
int __user *pidfd;
int __user *child_tid;
int __user *parent_tid;
const char *name;
.......
}
其中比较关键的是flag选项。该选项的值包括CLONE_VM,CLONE_FS和CLONE_FILES等,其定义如下:
/*
* 进程克隆(创建)标志:
*/
#define CSIGNAL 0x000000ff /* 退出时发送的信号掩码 */
#define CLONE_VM 0x00000100 /* 共享虚拟内存(VM) */
#define CLONE_FS 0x00000200 /* 共享文件系统(FS)信息 */
#define CLONE_FILES 0x00000400 /* 共享打开的文件描述符 */
#define CLONE_SIGHAND 0x00000800 /* 共享信号处理程序和屏蔽信号 */
#define CLONE_PIDFD 0x00001000 /* 在父进程中创建一个 pidfd(进程文件描述符) */
#define CLONE_PTRACE 0x00002000 /* 允许对子进程进行 ptrace 调试 */
#define CLONE_VFORK 0x00004000 /* 让子进程在 `mm_release` 时唤醒父进程 */
#define CLONE_PARENT 0x00008000 /* 让子进程与调用者拥有相同的父进程 */
#define CLONE_THREAD 0x00010000 /* 让子进程与调用者属于同一线程组 */
#define CLONE_NEWNS 0x00020000 /* 创建新的挂载(Mount)命名空间 */
#define CLONE_SYSVSEM 0x00040000 /* 共享 System V 信号量撤销(SEM_UNDO)语义 */
#define CLONE_SETTLS 0x00080000 /* 为子进程创建新的 TLS(线程本地存储) */
#define CLONE_PARENT_SETTID 0x00100000 /* 在父进程中设置子进程的 TID */
#define CLONE_CHILD_CLEARTID 0x00200000 /* 在子进程退出时清除 TID */
#define CLONE_DETACHED 0x00400000 /* 已弃用,忽略该标志 */
#define CLONE_UNTRACED 0x00800000 /* 如果设置,则追踪进程无法强制对子进程启用 CLONE_PTRACE */
#define CLONE_CHILD_SETTID 0x01000000 /* 在子进程中设置 TID */
#define CLONE_NEWCGROUP 0x02000000 /* 创建新的 cgroup 命名空间 */
#define CLONE_NEWUTS 0x04000000 /* 创建新的 UTS(主机名)命名空间 */
#define CLONE_NEWIPC 0x08000000 /* 创建新的 IPC(进程间通信)命名空间 */
#define CLONE_NEWUSER 0x10000000 /* 创建新的用户命名空间 */
#define CLONE_NEWPID 0x20000000 /* 创建新的 PID(进程 ID)命名空间 */
#define CLONE_NEWNET 0x40000000 /* 创建新的网络命名空间 */
#define CLONE_IO 0x80000000 /* 共享 I/O 上下文 */
但是可以看到在克隆时,只传了一个SIGCHID信号。
copy_process:
新建进程的核心函数为copy_process函数,其以复制父进程的方式来建立新的进程,代码如下。然后再调用wake_up_new_task将新进程添加到调度队列中等待调度。
/**
* copy_process - 复制当前进程,创建一个新的进程(或线程)
* @pid: 指向新进程的 PID 结构体
* @trace: 跟踪标志
* @node: NUMA 结点
* @args: 进程克隆参数
*
* 该函数用于 fork() 和 clone() 系统调用,负责复制当前进程的 task_struct,
* 并根据 clone_flags 选择性地共享或复制进程的各类资源,如文件、内存、信号等。
*
* 返回值:
* - 成功时,返回新创建的 task_struct 结构体指针。
* - 失败时,返回错误码。
*/
struct task_struct *copy_process(struct pid *pid, int trace, int node, struct kernel_clone_args *args)
{
struct task_struct *p; // 指向新创建进程的 task_struct
const u64 clone_flags = args->flags; // 进程克隆标志
struct nsproxy *nsp = current->nsproxy; // 继承当前进程的命名空间指针
......
// 复制当前进程的 task_struct 结构体,为新进程分配内存
p = dup_task_struct(current, node);
// 复制文件描述符表,根据 clone_flags 选择共享或复制
retval = copy_files(clone_flags, p, args->no_files);
if (retval)
goto bad_fork_cleanup_semundo;
// 复制文件系统信息(当前工作目录、root 目录等)
retval = copy_fs(clone_flags, p);
if (retval)
goto bad_fork_cleanup_files;
// 复制信号处理程序(包括信号屏蔽字、处理函数)
retval = copy_sighand(clone_flags, p);
if (retval)
goto bad_fork_cleanup_fs;
// 复制信号队列和信号状态
retval = copy_signal(clone_flags, p);
if (retval)
goto bad_fork_cleanup_sighand;
// 复制虚拟内存(地址空间),如果 CLONE_VM 设置,则共享地址空间
retval = copy_mm(clone_flags, p);
if (retval)
goto bad_fork_cleanup_signal;
// 复制命名空间(PID、网络、IPC、挂载等)
retval = copy_namespaces(clone_flags, p);
......
// 如果新进程不是 init 进程,则为其分配新的 PID
if (pid != &init_struct_pid) {
pid = alloc_pid(p->nsproxy->pid_ns_for_children, args->set_tid, args->set_tid_size);
if (IS_ERR(pid)) { // 检查分配 PID 是否成功
retval = PTR_ERR(pid);
goto bad_fork_cleanup_thread;
}
}
......
return p; // 返回新进程的 task_struct 指针
}
可见copy_process函数先调用dup_task_struct函数复制了一个新的task_struct,然后调用复制的函数对task_struct中的各种核心数据结构进行复制处理。
在我们创建进程或者是线程的时候,可以让内核为其创造一个独立的命名空间。在fork系统调用中,如果说没有指定命名空间相关的标记,则父子进程复用同样一套命名空间对象。
可以看到pid的申请也会涉及到命名空间,于是一起分析:
copy_namespaces:
如果flag指定CLONE_NEWPID,这就是告诉内核,该新进程需要一个新的独立的pid命名空间,展开copy_namespaces方法:
int copy_namespaces(unsigned long flags, struct task_struct *tsk)
{
struct nsproxy *old_ns = tsk->nsproxy; // 当前进程的命名空间
struct user_namespace *user_ns = task_cred_xxx(tsk, user_ns); // 进程的用户命名空间
struct nsproxy *new_ns;
/*
* 如果未设置任何需要新建命名空间的标志,并且时间命名空间未被修改,
* 则直接增加`nsproxy`的引用计数,并返回 0(共享命名空间)。
*/
if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
CLONE_NEWPID | CLONE_NEWNET |
CLONE_NEWCGROUP | CLONE_NEWTIME)))) {
if ((flags & CLONE_VM) || // 共享虚拟内存
likely(old_ns->time_ns_for_children == old_ns->time_ns)) { // 时间命名空间未改变
get_nsproxy(old_ns); // 增加 nsproxy 引用计数
return 0;
}
}
.......
// 创建新的命名空间(若 flags 指定)
new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);
if (IS_ERR(new_ns)) // 检查是否创建成功
return PTR_ERR(new_ns); // 失败时返回错误码
......
tsk->nsproxy = new_ns;
return 0; // 成功返回 0
}
create_new_namespaces:
若要新建一个命名空间,接下来会调用create_new_namespaces,该方法调用其他不同命名空间对应函数完成创建:
static struct nsproxy *create_new_namespaces(unsigned long flags, struct task_struct *tsk,struct user_namespace *user_ns,struct fs_struct *new_fs)
{
struct nsproxy *new_nsp;
int err;
// 分配新的 nsproxy 结构
new_nsp = create_nsproxy();
if (!new_nsp)
return ERR_PTR(-ENOMEM);
// 创建新的 PID 命名空间(如果 CLONE_NEWPID 被设置)
new_nsp->pid_ns_for_children =
copy_pid_ns(flags, user_ns, tsk->nsproxy->pid_ns_for_children);
if (IS_ERR(new_nsp->pid_ns_for_children)) {
err = PTR_ERR(new_nsp->pid_ns_for_children);
goto out_pid;
}......
return ns;
}
creat_pid_namespace:
对于pid命名空间,会依次调用copy_pid_ns、creat_pid_namespace来创建新的pid命名空间:
static struct pid_namespace *create_pid_namespace(struct user_namespace *user_ns,struct pid_namespace *parent_pid_ns)
{
struct pid_namespace *ns; // 新的 PID 命名空间
unsigned int level = parent_pid_ns->level + 1; // 计算新的命名空间层级
struct ucounts *ucounts;
int err;
...........
// 设定命名空间层级,子命名空间的层级比父命名空间高 1
ns->level = level;
// 记录父 PID 命名空间
ns->parent = get_pid_ns(parent_pid_ns);
// 记录所属用户命名空间
ns->user_ns = get_user_ns(user_ns);
// 记录用户命名空间的计数
ns->ucounts = ucounts;
// 标记 PID 命名空间正在初始化
ns->pid_allocated = PIDNS_ADDING;
...........
return ERR_PTR(err);
}
该方法使得新的命名空间和旧的命名空间通过parent和level字段组成了一棵树,parent指向上一级命名空间,level指向当前的层次,是上一级level+1.最后的效果如图所示:
在命名空间中申请pid
创建好pid_ns后,接下来调用alloc_pid分配pid,其参数为新进程所在的namespace:
pid = alloc_pid(p->nsproxy->pid_ns_for_children, args->set_tid, args->set_tid_size);
该函数:
- 在
pid_namespace
中为新进程分配唯一的 PID。 - 如果进程属于多个嵌套的 PID 命名空间,则为所有层级分配 PID。
- 初始化
struct pid
结构。
struct pid *alloc_pid(struct pid_namespace *ns, const struct pid_set *set_tid, size_t set_tid_size)
{
struct pid *pid;
struct pid_namespace *tmp;
int i, nr;
// 计算命名空间层级深度
int pid_alloc_depth = ns->level + 1;
pid = kmem_cache_alloc(pid_cachep, GFP_KERNEL);
pid->level = ns->level; // 记录 PID 命名空间层级
tmp = ns;
// 遍历 PID 命名空间层级,为所有层级分配 PID
for (i = ns->level; i >= 0; i--) {
nr = set_tid ? set_tid[i] : alloc_pidmap(tmp);
if (nr < 0)
goto out_free;
pid->numbers[i].nr = nr; // 记录 PID
pid->numbers[i].ns = tmp; // 记录所属命名空间
tmp = tmp->parent; // 继续向上查找父命名空间
}
return pid; // 成功返回 `struct pid *`
.......
return ERR_PTR(-ENOMEM);
}
该函数使用for循环在每个命名空间中都申请了,每一层都有一个独立的pid。这也就证明了为什么在容器中查看到的进程号和宿主机下查看到pid的不同了。