认识Namespace
namespace 是 Linux 内核用来隔离内核资源的方式。通过 namespace 可以让一些进程只能看到与自己相关的一部分资源,而另外一些进程也只能看到与它们自己相关的资源,这两拨进程根本就感觉不到对方的存在。linux 内核提供的 namespace 技术为 docker 等容器技术的出现和发展提供了基础条件。
Linux 现有的namespace 有7种:
namespace | 宏 | 隔离内容 |
---|---|---|
Cgroup | CLONE_NEWCGROUP | Cgroup root directory |
IPC | CLONE_NEWIPC | System V IPC, POSIX消息队列 |
Network | CLONE_NEWNET | 网络设备、栈、端口等 |
Mount | CLONE_NEWNS | 挂载点 |
PID | CLONE_NEWPID | 进程ID |
User | CLONE_NEWUSER | 用户和组ID |
UTS | CLONE_NEWUTS | 主机名和NIS域名 |
其中Cgroup namespace在4.6的内核中才实现。本文只介绍pid namespace。文章写的比较繁琐,还请大家耐心看到后面( ^_^ ) ~~~~
Namespace操作函数
和namespace相关的函数只有三个,如下所示:
一、clone: 创建一个新的进程并把他放到新的namespace中。
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
其中:flags
用于指定一个或者多个上面的CLONE_NEW*宏定义(当然也可以包含跟namespace无关的flags,多个flags 用|进行分隔),这样就会创建一个或多个新的不同类型的namespace,并把新创建的子进程加入新创建的这些namespace中。
二、setns: 将当前进程加入到已有的namespace中
int setns(int fd, int nstype);
其中:
- fd:指向/proc/[pid]/ns/目录里相应namespace对应的文件,表示要加入哪个namespace
- nstype:指定namespace的类型(上面的任意一个CLONE_NEW*),具体分为两种情况:1. 如果当前进程不能根据fd得到它的类型,如fd由其他进程创建,并通过UNIX domain socket传给当前进程,那么就需要通过nstype来指定fd指向的namespace的类型。2. 如果进程能根据fd得到namespace类型,比如这个fd是由当前进程打开的,那么nstype设置为0即可。
三、unshare: 使当前进程退出指定类型的namespace,并加入到新创建的namespace(相当于创建并加入新的namespace)。
int unshare(int flags);
其中:flags
用于指定一个或者多个上面的CLONE_NEW*宏定义(当然也可以包含跟namespace无关的flags,多个flags 用|进行分隔),这样就会创建一个或多个新的不同类型的namespace,并把新创建的子进程加入新创建的这些namespace中。 clone和unshare的区别
clone和unshare的功能都是创建并加入新的namespace, 他们的区别是:
- unshare是使当前进程加入新的namespace。
- clone是创建一个新的子进程,然后让子进程加入新的namespace,而当前进程保持不变。
pid namespace有什么用?
PID Namespace对进程PID重新标号,即不同的Namespace下的进程可以有同一个PID。
内核为所有的PID Namespace维护了一个树状结构,最顶层的是系统初始化创建的,被称为Root Namespace,由它创建的新的PID Namespace成为它的Child namespace,原先的PID Namespace成为新创建的Parent Namespace,这种情况下不同的PID Namespace形成一个等级体系:父节点可以看到子节点中的进程,可以通过信号对子节点的进程产生影响,反过来子节点无法看到父节点PID Namespace里面的进程。下面用一个图描述容器、进程pid、pid namespace关系:
PID namesapce 对容器类应用特别重要, 可以实现容器内进程的暂停/恢复等功能,还可以支持容器在跨主机的迁移前后保持内部进程的 PID 不发生变化。
pid namespace 特性
1、进程所属的 PID namespace 在它创建的时候就确定了,不能更改,所以调用 unshare 和 nsenter 等命令后,原进程还是属于老的 PID namespace,新 fork 出来的进程才属于新的 PID namespace;
2、PID namespace 可以嵌套;
3、PID namespace 中的 init 进程。当一个进程的父进程退出后,该进程就变成了孤儿进程。孤儿进程会被当前 PID namespace 中 PID 为 1 的进程接管,而不是被最外层的系统级别的 init 进程接管。
下面从kernel源码中了解下pid namespace的原理和用法。所有kernel源码均来自linux 4.18.0。
核心数据结构
1、内核使用struct pid_namespace 结构体描述进程号命名空间:
//include/linux/pid_namespace.h struct pid_namespace { struct kref kref; struct idr idr; struct rcu_head rcu; unsigned int pid_allocated; struct task_struct *child_reaper; struct kmem_cache *pid_cachep; unsigned int level; struct pid_namespace *parent; #ifdef CONFIG_PROC_FS struct vfsmount *proc_mnt; struct dentry *proc_self; struct dentry *proc_thread_self; #endif #ifdef CONFIG_BSD_PROCESS_ACCT struct fs_pin *bacct; #endif struct user_namespace *user_ns; struct ucounts *ucounts; struct work_struct proc_work; kgid_t pid_gid; int hide_pid; int reboot; /* group exit code if this pidns was rebooted */ struct ns_common ns; } __randomize_layout;
2、内核将所有的namespace封装成struct nsproxy ,struct pid_namespace就在该结构体中:
//include/linux/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; };
3、命名空间是进程资源隔离技术,自然是要放在进程描述符中,下面截取struct task_struct部分:
struct task_struct { ... ... struct fs_struct *fs; struct files_struct *files; struct nsproxy *nsproxy; ... ... };
至此内核中 pid namespace组织形式有了初步认识,下面介绍下pid namespace的初始化和相关内核API。
调用过程分析
【1】系统 init进程的pid namespace
每一个进程都有自己的namespace(struct nsproxy),可以看做是这个进程自己的“地盘”。进程默认都是共享init进程的namespace,即系统“默认”的根命名空间(目录树、pid等)。pid_namespace按层次组织成一棵树,init进程pid namespace是树的根,对应全局变量 init_pid_ns:
注意,创建进程时,从进程所属的pid_namespace到init_pid_ns 都会分配进程号!
init进程的pid_namespace是在内核初始化阶段创建的。在x86体系结构上,kernel将init进程namespace数据放在了“.data”段上,下面是截取的部分源码:
- 上面提到的 init_pid_ns 统一放在init进程全局变量init_nsproxy中:
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, //init进程的pid_namespace #ifdef CONFIG_NET .net_ns = &init_net, #endif #ifdef CONFIG_CGROUPS .cgroup_ns = &init_cgroup_ns, #endif };
init进程的进程描述符是静态定义的,init_nsproxy就放在其中:
struct task_struct … … .fs = &init_fs, .files = &init_files, .signal = &init_signals, .sighand = &init_sighand, .nsproxy = &init_nsproxy, //命名空间代理 … … }
通过链接脚本,可以看到init进程静态定义的进程描述符放在内核数据段:
//include/asm-generic/vmlinux.lds.h #define INIT_TASK_DATA(align) . = ALIGN(align); __start_init_task = .; init_thread_union = .; init_stack = .; KEEP(*(.data..init_task)) KEEP(*(.data..init_thread_info)) . = __start_init_task + THREAD_SIZE; __end_init_task = .; //arch/x86/kernel/vmlinux.lds.S SECTIONS { … … /* init_task */ INIT_TASK_DATA(THREAD_SIZE) … … }
【2】子进程的pid_namespace
进程创建时函数调用栈如下(包含pid namespace创建):
sys_fork / sys_vfork / sys_clone / kernel_thread
└→ _do_fork
└→ copy_process
└→ copy_namespaces
└→ create_new_namespaces
└→ copy_pid_ns
在内核中进程创建核心函数是copy_process(),新建pid namespace的核心函数是copy_pid_ns()。下面依次分析。
- 首先在进程创建时,会判断是否需要新建pid namespace。copy_process()函数调用copy_namespaces() 函数处理子进程namespace相关,下面截取该函数部分:
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; if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWCGROUP)))) { get_nsproxy(old_ns); //如果flags中没有CONE_NEW相关新建namespace标记,则继承父进程的namespace return 0; } ... ... new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs); //否则创建新的namespace ... ... }
这里“get_nsproxy(old_ns) ”比较简单,增加父进程namespace引用计数,大部分子进程都是继承init进程的namespace。下面重点看下如何创建新的namespace。create_new_namespaces() 函数中调用copy_pid_ns() 来新建pid namespace,他的核心是create_pid_namespace() ,源码节选:
static struct pid_namespace *create_pid_namespace(struct user_namespace *user_ns, struct pid_namespace *parent_pid_ns) { struct pid_namespace *ns; unsigned int level = parent_pid_ns->level + 1; //新创建的pid_namespace在树中新一层 … … ns = kmem_cache_zalloc(pid_ns_cachep, GFP_KERNEL); if (ns == NULL) goto out_dec; … … ns->pid_cachep = create_pid_cachep(level); //struct pid结构体高速缓存 ns->ns.ops = &pidns_operations; kref_init(&ns->kref); ns->level = level; ns->parent = get_pid_ns(parent_pid_ns); //父pid_namespace ns->user_ns = get_user_ns(user_ns); ns->ucounts = ucounts; ns->pid_allocated = PIDNS_ADDING; //初始化新pid_namespace pid计数 INIT_WORK(&ns->proc_work, proc_cleanup_work); … … }
【3】创建子进程pid
如何用标识一个进程呢?对于进程id,虽然用户空间使用一个正整数来表示各种IDs,但是对于内核,我们需要使用“pid namespace,ID number”这样的二元组来表示。因为同样的进程在不同的 PID namespace 中拥有不同的 PID。linux内核使用struct pid结构体来标识进程:
struct pid { atomic_t count; unsigned int level; //该进程所属的pid_ns的level,也就表示了这个pid对象在多少个pid namespace中可见。 /* lists of tasks that use this pid */ struct hlist_head tasks[PIDTYPE_MAX]; //使用该pid结构体的进程描述符集合 struct rcu_head rcu; struct upid numbers[1]; //存储每层的pid信息的变成数组,长度就是上面的level }; struct upid { int nr; //该层pid ns 的PID值 struct pid_namespace *ns; //该层pid ns结构体地址 };
关于标识进程的各个ID详解(pid/tid/tgid/sid等),感兴趣的可以参考《Linux系统如何标识进程?》一文。
在进程创建核心函数copy_process中,通过alloc_pid 函数为子进程申请一个“struct pid”:
//copy_process() if (pid != &init_struct_pid) { //pid指针是函数入参,普通进程创建时调用的__do_fork中该参数为NULL pid = alloc_pid(p->nsproxy->pid_ns_for_children); if (IS_ERR(pid)) { retval = PTR_ERR(pid); goto bad_fork_cleanup_thread; } }
pid指针是函数入参,普通进程创建时调用的__do_fork函数中,pid参数为NULL,因此一般进程都会调用“alloc_pid”创建新的struct pid:
struct pid *alloc_pid(struct pid_namespace *ns) //参数是新进程pid ns,返回值是申请的pid结构体 { struct pid *pid; enum pid_type type; int i, nr; struct pid_namespace *tmp; struct upid *upid; int retval = -ENOMEM; pid = kmem_cache_alloc(ns->pid_cachep, GFP_KERNEL); //pid_ns 初始化时赋值了pid缓存 if (!pid) return ERR_PTR(retval); tmp = ns; pid->level = ns->level; //pid成员level记录新进程pid_ns的level,即进程ns的编号 for (i = ns->level; i >= 0; i--) { //遍历所有父pid_ns,pid结构体内需要记录每层pid_ns分配的pid值 int pid_min = 1; //如果进程的pid_ns是新创建的,则pid值从1开始 … … nr = idr_alloc_cyclic(&tmp->idr, NULL, pid_min, pid_max, GFP_ATOMIC); spin_unlock_irq(&pidmap_lock); idr_preload_end(); … … pid->numbers[i].nr = nr; //在pid结构体中记录每层pid_ns分配的pid值 pid->numbers[i].ns = tmp; //记录每层pid_ns分配同时,记录pid值对应进程的pid_ns地址 tmp = tmp->parent; } … … upid = pid->numbers + ns->level; //更新每层pid_ns的pid已使用值:pid_allocated … … for ( ; upid >= pid->numbers; --upid) { /* Make the PID visible to find_pid_ns. */ idr_replace(&upid->ns->idr, pid, upid->nr); upid->ns->pid_allocated++; //更新这层pid_ns的pid已使用值,即直接加一即可 } spin_unlock_irq(&pidmap_lock); return pid; … … }
【4】保存进程ID信息
在解析完子进程pid创建后,下面看看如何保存到进程描述符(task_struct)中。再回到进程创建copy_process函数中,有两处和保存进程ID信息相关:
//in copy_process func: p->pid = pid_nr(pid); //获取global pid_ns中的pid,即第0层 … … init_task_pid(p, PIDTYPE_PID, pid); //将struct pid 结构地址保存到进程描述符中
- 首先task_struct->pid中存的是global pid namespace中的PID value,因为对于内核来说,它只需要看全局的pid namespace即可(init进程),里面包含系统全局进程的global PID。
- 第二个函数init_task_pid 和相关结构体如下:
static inline void init_task_pid(struct task_struct *task, enum pid_type type, struct pid *pid) { task->pids[type].pid = pid; } struct task_struct { … … struct pid_link pids[PIDTYPE_MAX]; … … } enum pid_type { PIDTYPE_PID, PIDTYPE_PGID, PIDTYPE_SID, PIDTYPE_MAX, /* only valid to __task_pid_nr_ns() */ __PIDTYPE_TGID }; struct pid_link { struct hlist_node node; struct pid *pid; };
我们知道在linux中并不区分进程和线程,都是用task_struct来抽象,只不过支持多线程的进程是由一组task_struct来抽象。struct pid 结构可能被多个进程共享(比如表示pgid时),为了既能方便从task struct快速找到对应的struct pid,又能方便从struct pid能够遍历所有使用该pid的task,内核设计了 struct pid_link 来保存各个ID对应的struct pid 结构地址。
【5】获取进程PID value
linux内核提供三个标准API,用于获取进程PID value:
- pid_nr(): 获取全局pid_ns pid value,即第0 level,来自init namespace
static inline pid_t pid_nr(struct pid *pid) { pid_t nr = 0; if (pid) nr = pid->numbers[0].nr; return nr; }
2. pid_vnr() :获取当前pid_ns pid value,即进程当前pid namespace
pid_t pid_vnr(struct pid *pid) { return pid_nr_ns(pid, task_active_pid_ns(current)); } EXPORT_SYMBOL_GPL(pid_vnr);
3. pid_nr_ns() :获取指定ns 中的pid value
pid_t pid_nr_ns(struct pid *pid, struct pid_namespace *ns) { struct upid *upid; pid_t nr = 0; if (pid && ns->level <= pid->level) { upid = &pid->numbers[ns->level]; if (upid->ns == ns) nr = upid->nr; } return nr; } EXPORT_SYMBOL_GPL(pid_nr_ns);
- 内核空间系统管理只需要关注“默认”的根命名空间中的PID value即可,因此调用pid_nr在task_struct->pid中缓存的PID value ,称为global PID;
- 用户空间运用namespace进程资源隔离,因此用户空间获取进程PID 的系统调用getpid需要关注pid namespace。相关系统调用源码如下:
SYSCALL_DEFINE0(getpid) { return task_tgid_vnr(current); } static inline pid_t task_tgid_vnr(struct task_struct *tsk) { return __task_pid_nr_ns(tsk, __PIDTYPE_TGID, NULL); } pid_t __task_pid_nr_ns(struct task_struct *task, enum pid_type type, struct pid_namespace *ns) { if (!ns) ns = task_active_pid_ns(current); … … nr = pid_nr_ns(rcu_dereference(task->pids[type].pid), ns); } rcu_read_unlock(); return nr; }
因此用户空间获取的是进程当前pid namespace里的pid value,称为virtual PID。
【6】根据pid获取pid_namespace
函数ns_of_pid 用于根据pid获取pid_namespace:
static inline struct pid_namespace *ns_of_pid(struct pid *pid) { struct pid_namespace *ns = NULL; if (pid) ns = pid->numbers[pid->level].ns; return ns; }
docker namespace原理:https://blog.csdn.net/zhonglinzhang/article/details/64441263