当前位置: 首页 > 科技观察

如何申请Docker容器中进程的pid?

时间:2023-03-14 22:12:12 科技观察

大家好,我是飞哥!如果你有在容器中执行过ps命令的经验,就会知道容器中进程的pid一般都比较小。比如我下面的例子。#ps-efPIDUSERTIMECOMMAND1root0:00./demo-ie13root0:00/bin/bash21root0:00ps-ef我想知道你是否和我一样好奇pid在容器进程是从哪里申请的?和在宿主机申请pid有什么区别?内核如何显示容器中的进程号?前面我们在《Linux进程是如何创建出来的?》中介绍了进程的创建过程。实际上,进程的pid命名空间和pid也是在这个进程中申请的。今天就带大家深入了解docker的核心之一pid命名空间的工作原理。一、Linux默认的pid命名空间在上一篇《Linux进程是如何创建出来的?》中,我们提到了进程命名空间成员nsproxy。//file:include/linux/sched.hstructtask_struct{.../*namespaces*/structnsproxy*nsproxy;}Linux在启动时会有一组默认的命名空间,定义在kernel/nsproxy.c文件中。//file:kernel/nsproxy.cstructnsproxyinit_nsproxy={.count=ATOMIC_INIT(1),.uts_ns=&init_uts_ns,.ipc_ns=&init_ipc_ns,.mnt_ns=NULL,.pid_ns=&init_pid_ns,.net_ns=&init_net,};defaultpid命名空间为init_pid_ns,定义在kernel/pid.c下。//file:kernel/pid.cstructpid_namespaceinit_pid_ns={.kref={.refcount=ATOMIC_INIT(2),},.pidmap={[0...PIDMAP_ENTRIES-1]={ATOMIC_INIT(BITS_PER_PAGE),NULL}},.last_pid=0,.level=0,.child_reaper=&init_task,.user_ns=&init_user_ns,.proc_inum=PROC_PID_INIT_INO,};在pid命名空间中,我认为有两个字段是最需要关注的。一个是level,指示当前pid名称空间的级别。另一个是pidmap,也就是位图。如果某位为1,则表示当前序号的pid已经分配完毕。另外,默认命名空间的层级初始化为0,这是一个代表树层次结构的节点。如果创建了多个命名空间,它们之间会形成一棵树。level表示树在第几层,根节点的层级为0。INIT_TASK0号进程,也叫空闲进程,它使用默认的init_nsproxy。//file:include/linux/init_task.h#defineINIT_TASK(tsk)\{.state=0,.stack=&init_thread_info,.usage=ATOMIC_INIT(2),.flags=PF_KTHREAD,.prio=MAX_PRIO-20,\.static_prio=MAX_PRIO-20,\.normal_prio=MAX_PRIO-20,\....nsproxy=&init_nsproxy,\...}所有进程都是通过fork一个产生的。如果未指定命名空间,则所有进程都使用默认命名空间。2、创建一个新的Linuxpidnamespace这里,我们假设在创建进程时指定CLONE_NEWPID来创建一个独立的pidnamespace(Docker容器就是这样做的)。在《Linux进程是如何创建出来的?》一文中我们了解了创建进程的过程。整个创建过程的核心在于copy_process函数。在这个函数中,会申请并复制进程的地址空间、打开文件列表、文件目录等关键信息。另外,这里也完成了pid命名空间的创建。//file:kernel/fork.cstaticstructtask_struct*copy_process(...){...//2.1Copyprocessnamespacensproxyretval=copy_namespaces(clone_flags,p);//2.2申请pidpid=alloc_pid(p->nsproxy->pid_ns);//2.3记录pidp->pid=pid_nr(pid);p->tgid=p->pid;attach_pid(p,PIDTYPE_PID,pid);...}2.1创建进程时构造新命名空间在上面的copy_process代码中我们看到了对copy_namespaces函数的调用。在此函数中操作命名空间。//file:kernel/nsproxy.cintcopy_namespaces(unsignedlongflags,structtask_struct*tsk){structnsproxy*old_ns=tsk->nsproxy;如果(!(标志&(CLONE_NEWNS|CLONE_NEWUTS|CLONE_NEWIPC|CLONE_NEWPID|CLONE_NEWNET)))返回0;new_ns=create_new_namespaces(flags,tsk,user_ns,tsk->fs);tsk->nsproxy=new_ns;...}如果在创建进程的时候没有传入CLONE_NEWNS等几个flag,那么还是会复用之前的Defaultnamespace。这些标志的含义如下。CLONE_NEWPID:是否创建一个新的进程号命名空间来隔离宿主机的进程PIDCLONE_NEWNS:是否创建一个新的挂载点(文件系统)命名空间来隔离文件系统和挂载点CLONE_NEWNET:是否创建一个新的网络命名空间来隔离网卡、IP、端口、路由表等网络资源CLONE_NEWUTS:是否新建主机名和域名命名空间,在网络中独立标识自己CLONE_NEWIPC:是否新建IPC命名空间,隔离信号卷、消息队列和共享内存CLONE_NEWUSER:用于隔离用户和用户组。因为我们开始本节假设传入了CLONE_NEWPID标志。所以它会进入create_new_namespaces申请新的命名空间。//file:kernel/nsproxy.cstaticstructnsproxy*create_new_namespaces(unsignedlongflags,structtask_struct*tsk,structuser_namespace*user_ns,structfs_struct*new_fs){//申请一个新的nsproxystructnsproxy*new_nsp;new_nsp=create_nsproxy();......//CopyorcreatePIDnamespacenew_nsp->pid_ns=copy_pid_ns(flags,user_ns,tsk->nsproxy->pid_ns);}create_new_namespaces会调用copy_pid_ns来完成实际的创建,真正的创建过程就完成了在create_pid_namespace中。//file:kernel/pid_namespace.cstaticstructpid_namespace*create_pid_namespace(...){structpid_namespace*ns;//新的pid命名空间level+1unsignedintlevel=parent_pid_ns->level+1;//申请内存ns=kmem_cache_zalloc(pid_ns_cachep,GFP_KERNEL);ns->pidmap[0].page=kzalloc(PAGE_SIZE,GFP_KERNEL);ns->pid_cachep=create_pid_cachep(level+1);//设置新的命名空间级别ns->level=level;//新的命名空间和旧的命名空间形成一棵树ns->parent=get_pid_ns(parent_pid_ns);//初始化pidmapset_bit(0,ns->pidmap[0].page);atomic_set(&ns->pidmap[0].nr_free,BITS_PER_PAGE-1);对于(i=1;ipidmap[i].nr_free,BITS_PER_PAGE);returnns;}实际上是在create_pid_namespace中申请了一个新的pidnamespace,它已经为其pidmap申请了内存(在create_pid_cachep中申请),同时也进行了初始化。还有一点很重要,新的命名空间和旧的命名空间通过parent、level等字段形成了一棵树。其中parent指向上层命名空间,用自己的level来表示层级,设置为上层level+1。最终的效果是新进程有一个新的pid命名空间,并且这个新的pid命名空间与父pid命名空间串联,如下图所示。如果pid有多层,会形成更直观的树形结构。2.2申请进程id创建命名空间后,copy_process下一步就是调用alloc_pid分配pid。//file:kernel/fork.cstaticstructtask_struct*copy_process(...){...//2.1Copyprocessnamespacensproxyretval=copy_namespaces(clone_flags,p);...//2.2申请pidpid=alloc_pid(p->nsproxy->pid_ns);...}注意传入的参数是p->nsproxy->pid_ns。前面的过程创建了一个新的pid命名空间。此时的namespace就是新的level为1的pid_ns,我们继续看alloc_pid的具体pid过程。//file:kernel/pid.cstructpid*alloc_pid(structpid_namespace*ns){//申请pid内核对象pid=kmem_cache_alloc(ns->pid_cachep,GFP_KERNEL);//调用alloc_pidmap分配一个空闲的pidtmp=ns;pid->level=ns->level;对于(i=ns->level;i>=0;i--){nr=alloc_pidmap(tmp);如果(nr<0)转到out_free;pid->numbers[i].nr=nr;pid->numbers[i].ns=tmp;tmp=tmp->父级;}...返回pid;上面的代码有两个细节需要注意。我们平时说的pid在内核中并不是简单的整型,而是一个小结构体(structpid)。申请一个pid不是申请一个,而是用for循环申请多个。之所以需要申请多个,是因为对于容器中的进程,在你当前的namespace中没有完成申请。在其父命名空间中申请一个。让我们用下图来表示for循环的工作项目。首先在当前层级的命名空间中申请一个pid,然后跟随命名空间的父节点,为每个层级申请一个,记录在pid->numbers数组中。这里多说一下,如果pid申请失败,会报-ENOMEM错误,在用户层看似是“fork:unabletoallocatememory”,其实是pid不足导致的。我在《明明还有大量内存,为啥报错“无法分配内存”?》中提到了这个问题。2.3设置整型pid申请构造pid后,设置到task_struct上并记录。//file:kernel/fork.cstaticstructtask_struct*copy_process(...){...//2.2申请pidpid=alloc_pid(p->nsproxy->pid_ns);//2.3记录pidp->pid=pid_nr(pid);p->tgid=p->pid;attach_pid(p,PIDTYPE_PID,pid);...}其中pid_nr是获取到的根pid命名空间下的pid号,参见pid_nr源码。//file:include/linux/pid.hstatic内联pid_tpid_nr(structpid*pid){pid_tnr=0;如果(pid)nr=pid->numbers[0].nr;returnnr;}然后调用attach_pid就是把申请的pid结构挂到自己的pids[PIDTYPE_PID]链表中。//file:kernel/pid.cvoidattach_pid(structtask_struct*task,enumpid_typetype,structpid*pid){...link=&task->pids[type];链接->pid=pid;hlist_add_head_rcu(&link->node,&pid->tasks[type]);}task->pids是一个链表。3.查看容器进程的pid。pid已经申请了,那么如何查看容器中当前级别的进程号呢?比如我们在容器中看到的demo-ie进程的id是1#ps-efPIDUSERTIMECOMMAND1root0:00./demo-ie...内核提供了一个函数可以查看一个进程的名称当前命名空间中的进程。//file:kernel/pid.cpid_tpid_vnr(structpid*pid){returnpid_nr_ns(pid,task_active_pid_ns(current));}其中pid_vnr用于查看容器中的进程pid,pid_vnr调用pid_nr_ns查看该进程在特定名称空间中的进程号。函数pid_nr_ns接收两个参数。第一个参数是进程中记录的pid对象(保存每一级申请的pid号)。第二个参数是指定的pid命名空间(通过task_active_pid_ns(current)获取)。当有这两个参数时,可以根据pid命名空间中记录的级别获取容器进程的当前pid//file:kernel/pid.cpid_tpid_nr_ns(structpid*pid,structpid_namespace*ns){structupid*笨蛋;pid_tnr=0;if(pid&&ns->level<=pid->level){upid=&pid->numbers[ns->level];如果(upid->ns==ns)nr=upid->nr;}returnnr;}在pid_nr_ns中,通过判断级别,查看容器pid的整数值。4.小结最后,比如0级pid命名空间中有一个进程号为1256的进程,1级容器pid命名空间中的进程号为5,那么这个进程及其pid在内存中的形式为如下图所示。然后在容器查看进程pid号的时候,传入容器的pid命名空间,就可以打印出容器中进程的pid号5!!