Linux资源隔离基础(二):namespace

本文以进程的创建过程为切入点,深入理解命名空间的工作原理。内核版本为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);

该函数:

  1. pid_namespace 中为新进程分配唯一的 PID
  2. 如果进程属于多个嵌套的 PID 命名空间,则为所有层级分配 PID
  3. 初始化 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的不同了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值