大家好,我是飛哥!
如果大家有過在容器中執(zhí)行 ps 命令的經(jīng)驗,都會知道在容器中的進程的 pid 一般是比較小的。例如下面我的這個例子。
# ps -ef
PID USER TIME COMMAND
1 root 0:00 ./demo-ie
13 root 0:00 /bin/bash
21 root 0:00 ps -ef
不知道大家是否和我一樣好奇容器進程中的 pid 是如何申請出來的?和宿主機中申請 pid 有什么不同?內(nèi)核又是如何顯示容器中的進程號的?
前面我們在《Linux進程是如何創(chuàng)建出來的?》中介紹了進程的創(chuàng)建過程。事實上進程的 pid 命名空間、pid 也都是在這個過程中申請的。我今天就來帶大家深入理解一下 docker 核心之一 pid 命名空間的工作原理。
一、Linux 的默認 pid 命名空間
前面的文章《Linux進程是如何創(chuàng)建出來的?》中我們提到了進程的命名空間成員 nsproxy。
//file:include/linux/sched.h
struct task_struct {
...
/* namespaces */
struct nsproxy *nsproxy;
}
Linux 在啟動的時候會有一套默認的命名空間,定義在 kernel/nsproxy.c 文件下。
//file:kernel/nsproxy.c
struct nsproxy init_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,
};
其中默認的 pid 命名空間是 init_pid_ns,它定義在 kernel/pid.c 下。
//file:kernel/pid.c
struct pid_namespace init_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 命名空間里我覺得最需要關(guān)注的是兩個字段。一個是 level 表示當前 pid 命名空間的層級。另一個是 pidmap,這是一個 bitmap,一個 bit 如果為 1,就表示當前序號的 pid 已經(jīng)分配出去了。
另外默認命名空間的 level 初始化是 0。這是一個表示樹的層次結(jié)構(gòu)的節(jié)點。如果有多個命名空間創(chuàng)建出來,它們之間會組成一棵樹。level 表示樹在第幾層。根節(jié)點的 level 是 0。

INIT_TASK 0號進程,也叫 idle 進程,它固定使用這個默認的 init_nsproxy。
//file:include/linux/init_task.h
#define INIT_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, \
......
}
所有進程都是一個派生一個的方式生成出來的。如果不指定命名空間,所有進程使用的都是使用缺省的命名空間。

二、Linux 新 pid 命名空間創(chuàng)建
在這里,我們假設(shè)我們創(chuàng)建進程時指定了 CLONE_NEWPID 要創(chuàng)建一個獨立的 pid 命名空間出來(Docker 容器就是這么干的)。
在 《Linux進程是如何創(chuàng)建出來的?》一文中我們已經(jīng)了解了進程的創(chuàng)建過程。整個創(chuàng)建過程的核心是在于 copy_process 函數(shù)。
在這個函數(shù)中會申請和拷貝進程的地址空間、打開文件列表、文件目錄等關(guān)鍵信息,另外就是pid 命名空間的創(chuàng)建也是在這里完成的。
//file:kernel/fork.c
static struct task_struct *copy_process(...)
{
...
//2.1 拷貝進程的命名空間 nsproxy
retval = copy_namespaces(clone_flags, p);
//2.2 申請 pid
pid = alloc_pid(p->nsproxy->pid_ns);
//2.3 記錄 pid
p->pid = pid_nr(pid);
p->tgid = p->pid;
attach_pid(p, PIDTYPE_PID, pid);
...
}
2.1 創(chuàng)建進程時構(gòu)造新命名空間
在上面的 copy_process 代碼中我們看到對 copy_namespaces 函數(shù)的調(diào)用。命名空間就是在這個函數(shù)中操作的。
//file:kernel/nsproxy.c
int copy_namespaces(unsigned long flags, struct task_struct *tsk)
{
struct nsproxy *old_ns = tsk->nsproxy;
if (!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
CLONE_NEWPID | CLONE_NEWNET)))
return 0;
new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);
tsk->nsproxy = new_ns;
...
}
如果在創(chuàng)建進程時候沒有傳入 CLONE_NEWNS 等幾個 flag,還是會復(fù)用之前的默認命名空間。這幾個 flag 的含義如下。
- CLONE_NEWPID: 是否創(chuàng)建新的進程編號命名空間,以便與宿主機的進程 PID 進行隔離
- CLONE_NEWNS: 是否創(chuàng)建新的掛載點(文件系統(tǒng))命名空間,以便隔離文件系統(tǒng)和掛載點
- CLONE_NEWNET: 是否創(chuàng)建新的網(wǎng)絡(luò)命名空間,以便隔離網(wǎng)卡、IP、端口、路由表等網(wǎng)絡(luò)資源
- CLONE_NEWUTS: 是否創(chuàng)建新的主機名與域名命名空間,以便在網(wǎng)絡(luò)中獨立標識自己
- CLONE_NEWIPC: 是否創(chuàng)建新的 IPC 命名空間,以便隔離信號量、消息隊列和共享內(nèi)存
- CLONE_NEWUSER: 用來隔離用戶和用戶組的。
因為我們本節(jié)開頭假設(shè)傳入了 CLONE_NEWPID 標記。所以會進入到 create_new_namespaces 中來申請新的命名空間。
//file:kernel/nsproxy.c
static struct nsproxy *create_new_namespaces(unsigned long flags,
struct task_struct *tsk, struct user_namespace *user_ns,
struct fs_struct *new_fs)
{
//申請新的 nsproxy
struct nsproxy *new_nsp;
new_nsp = create_nsproxy();
......
//拷貝或創(chuàng)建 PID 命名空間
new_nsp->pid_ns = copy_pid_ns(flags, user_ns, tsk->nsproxy->pid_ns);
}
create_new_namespaces 中會調(diào)用 copy_pid_ns 來完成實際的創(chuàng)建,真正的創(chuàng)建過程是在 create_pid_namespace 中完成的。
//file:kernel/pid_namespace.c
static struct pid_namespace *create_pid_namespace(...)
{
struct pid_namespace *ns;
//新 pid namespace level + 1
unsigned int level = parent_pid_ns->level + 1;
//申請內(nèi)存
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);
//設(shè)置新命名空間 level
ns->level = level;
//新命名空間和舊命名空間組成一棵樹
ns->parent = get_pid_ns(parent_pid_ns);
//初始化 pidmap
set_bit(0, ns->pidmap[0].page);
atomic_set(&ns->pidmap[0].nr_free, BITS_PER_PAGE - 1);
for (i = 1; i < PIDMAP_ENTRIES; i++)
atomic_set(&ns->pidmap[i].nr_free, BITS_PER_PAGE);
return ns;
}
在 create_pid_namespace 真正申請了新的 pid 命名空間,為它的 pidmap 申請了內(nèi)存(在 create_pid_cachep 中申請的),也進行了初始化。
另外還有一點比較重要的是新命名空間和舊命名空間通過 parent、level 等字段組成了一棵樹。其中 parent 指向了上一級命名空間,自己的 level 用來表示層次,設(shè)置成了上一級 level + 1。
其最終的效果就是新進程擁有了新的 pid namespace,并且這個新 pid namespace 和父 pidnamespace 串聯(lián)了起來,效果如下圖。

如果 pid 有多層的話,會組成更直觀的樹形結(jié)構(gòu)。
2.2 申請進程id
創(chuàng)建完命名空間后,在 copy_process 中接下來接著就是調(diào)用 alloc_pid 來分配 pid。
//file:kernel/fork.c
static struct task_struct *copy_process(...)
{
...
//2.1 拷貝進程的命名空間 nsproxy
retval = copy_namespaces(clone_flags, p);
...
//2.2 申請 pid
pid = alloc_pid(p->nsproxy->pid_ns);
...
}
注意傳入的參數(shù)是 p->nsproxy->pid_ns。前面進程創(chuàng)建了新的 pid namespace,這個時候該命名空間就是 level 為 1 的新 pid_ns。我們繼續(xù)來看 alloc_pid 具體 pid 的過程。
//file:kernel/pid.c
struct pid *alloc_pid(struct pid_namespace *ns)
{
//申請 pid 內(nèi)核對象
pid = kmem_cache_alloc(ns->pid_cachep, GFP_KERNEL);
//調(diào)用到alloc_pidmap來分配一個空閑的pid
tmp = ns;
pid->level = ns->level;
for (i = ns->level; i >= 0; i--) {
nr = alloc_pidmap(tmp);
if (nr < 0)
goto out_free;
pid->numbers[i].nr = nr;
pid->numbers[i].ns = tmp;
tmp = tmp->parent;
}
...
return pid;
}
在上面的代碼中要注意兩個細節(jié)。
我們平時說的 pid 在內(nèi)核中并不是一個簡單的整數(shù)類型,而是一個小結(jié)構(gòu)體來表示的(struct pid)。
申請 pid 并不是申請了一個,而是使用了一個 for 循環(huán)申請多個出來
之所以要申請多個,是因為對于容器里的進程來說,并不是在自己當前的命名空間申請就完事了,還要到其父命名空間中也申請一個。我們把 for 循環(huán)的工作工程用下圖表示一下。

首先到當前層次的命名空間申請一個 pid 出來,然后順著命名空間的父節(jié)點,每一層也都要申請一個,并都記錄到 pid->numbers 數(shù)組中。
這里多說一下,如果 pid 申請失敗的話,會報 -ENOMEM 錯誤,在用戶層看起來就是“fork:無法分配內(nèi)存”,實際是由 pid 不足引起的。這個問題我在《明明還有大量內(nèi)存,為啥報錯“無法分配內(nèi)存”?》 提到過。
2.3 設(shè)置整數(shù)格式 pid
當申請并構(gòu)造完 pid 后,將其設(shè)置在 task_struct 上,記錄起來。
//file:kernel/fork.c
static struct task_struct *copy_process(...)
{
...
//2.2 申請 pid
pid = alloc_pid(p->nsproxy->pid_ns);
//2.3 記錄 pid
p->pid = pid_nr(pid);
p->tgid = p->pid;
attach_pid(p, PIDTYPE_PID, pid);
...
}
其中 pid_nr 是獲取的根 pid 命名空間下的 pid 編號,參見 pid_nr 源碼。
//file:include/linux/pid.h
static inline pid_t pid_nr(struct pid *pid)
{
pid_t nr = 0;
if (pid)
nr = pid->numbers[0].nr;
return nr;
}
然后再調(diào)用 attach_pid 是把申請到的 pid 結(jié)構(gòu)掛到自己的 pids[PIDTYPE_PID] 鏈表里了。
//file:kernel/pid.c
void attach_pid(struct task_struct *task, enum pid_type type,
struct pid *pid)
{
...
link = &task->pids[type];
link->pid = pid;
hlist_add_head_rcu(&link->node, &pid->tasks[type]);
}
task->pids 是一組鏈表。
三、容器進程 pid 查看
pid 已經(jīng)申請好了,那在容器中是如何查看當前層次的進程號的呢?比如我們在容器中看到的 demo-ie 進程的 id 就是 1。
# ps -ef
PID USER TIME COMMAND
1 root 0:00 ./demo-ie
...
內(nèi)核提供了個函數(shù)用來查看進程在當前某個命名空間的命名號。
//file:kernel/pid.c
pid_t pid_vnr(struct pid *pid)
{
return pid_nr_ns(pid, task_active_pid_ns(current));
}
其中在容器中查看進程 pid 使用的是 pid_vnr,pid_vnr 調(diào)用 pid_nr_ns 來查看進程在特定命名空間里的進程號。
函數(shù) pid_nr_ns 接收連個參數(shù)
第一個參數(shù)是進程里記錄的 pid 對象(保存有在各個層次申請到的 pid 號)
第二個參數(shù)是指定的 pid 命名空間(通過 task_active_pid_ns(current)獲?。?。
當具備這兩個參數(shù)后,就可以根據(jù) pid 命名空間里記錄的層次 level 取得容器進程的當前 pid 了
//file:kernel/pid.c
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;
}
在 pid_nr_ns 中通過判斷 level 就把容器 pid 整數(shù)值查出來了。
四、總結(jié)
最后,舉個例子,假如有一個進程在 level 0 級別的 pid 命名空間里申請到的進程號是 1256,在 level 1 容器 pid 命名空間里申請到的進程號是 5。那么這個進程以及其 pid 在內(nèi)存中的形式是下圖這個樣子的。

那么容器在查看進程的 pid 號的時候,傳入容器的 pid 命名空間,就可以將該進程在容器中的 pid 號 5 給打印出來了??!