PID namespace 用來隔離進程的 PID 空間,使得不同 PID namespace 里的進程 PID 可以重復且互不影響。PID namesapce 對容器類應用特別重要, 可以實現容器內進程的暫停/恢復等功能,還可以支持容器在跨主機的遷移前后保持內部進程的 PID 不發生變化。
說明:本文的演示環境為 ubuntu 16.04。
PID namesapce 與 /proc
Linux下的每個進程都有一個對應的 /proc/PID 目錄,該目錄包含了大量的有關當前進程的信息。 對一個 PID namespace 而言,/proc 目錄只包含當前 namespace 和它所有子孫后代 namespace 里的進程的信息。
創建一個新的 PID namespace 后,如果想讓子進程中的 top、ps 等依賴 /proc 文件系統的命令工作,還需要掛載 /proc 文件系統。下面的例子演示了掛載 /proc 文件系統的重要性。先輸出當前進程的 PID,然后查看其 PID namespace,接着通過 unshare 命令創建新的 PID namespace:
$ sudo unshare --pid --mount --fork /bin/bash
該命令會同時創建新的 PID 和 mount namespace,然后再查看此時的 PID namespace:
上圖中的結果似乎不是我們想要的,因為顯示的 PID namespace 並沒有變化。讓我們接着做實驗:
看樣子 ps 命令顯示的 PID 還是舊 namespace 中的編號,而 $$ 為 1 說明當前進程已經被認為是該 PID namespace 中的 1 號進程了。再看看 1 號進程的詳細信息:/sbin/init,這是系統的 init 進程,這一切看起來實在是太亂了。
造成混亂的原因是當前進程沒有正確的掛載 /proc 文件系統,由於我們新的 mount namespace 的掛載信息是從老的 namespace 拷貝過來的,所以這里看到的還是老 namespace 里面的進程號為 1 的信息。執行下面的命令掛載 /proc 文件系統:
$ mount -t proc proc /proc
然后再來檢查相關的信息:
這次就符合我們的預期了,顯示了新的 PID namespace,當前 PID namespace 中的 1 號進程也變成了 bash 進程。
其實 unshare 命令提供了一個專門的選項 --mount-proc 來配合 PID namespce 的創建:
$ sudo unshare --pid --mount-proc --fork /bin/bash
這樣在創建了 PID 和 Mount namespace 后,會自動掛載 /proc 文件系統,就不需要我們手動執行 mount -t proc proc /proc 命令了。
不能修改的進程 PID namespace
在前面的演示中我們為 unshare 命令添加了 --fork /bin/bash 參數:
$ sudo unshare --pid --mount-proc --fork /bin/bash
--fork 是為了讓 unshare 進程 fork 一個新的進程出來,然后再用 /bin/bash 替換掉新的進程中執行的命令。需要這么做是由於 PID namespace 本身的特點導致的。進程所屬的 PID namespace 在它創建的時候就確定了,不能更改,所以調用 unshare 和 nsenter 等命令后,原進程還是屬於老的 PID namespace,新 fork 出來的進程才屬於新的 PID namespace。
我們在一個 shell 中執行下面的命令:
$ echo $$ $ sudo unshare --pid --mount-proc --fork /bin/bash
然后新打開一個 shell 檢查進程所屬的 PID namespace:
查看進程樹中進程所屬的 PID namespace,只有被 unshare fork 出來的 bash 進程加入了新的 PID namespace。
PID namespace 的嵌套
PID namespace 可以嵌套,也就是說有父子關系,除了系統初始化時創建的根 PID namespace 之外,其它的 PID namespace 都有一個父 PID namespace。一個 PID namespace 的父是指:通過 clone 或 unshare 方法創建 PID namespace 的進程所在的 PID namespace。
在當前 namespace 里面創建的所有新的 namespace 都是當前 namespace 的子 namespace。父 namespace 里面可以看到所有子孫后代 namespace 里的進程信息,而子 namespace 里看不到祖先或者兄弟 namespace 里的進程信息。一個進程在 PID namespace 的嵌套結構中的每一個可以被看到的層中都有一個 PID。這里所謂的 "看到" 是指可以對這個進程執行操作,比如發送信號等。
目前 PID namespace 最多可以嵌套 32層,由內核中的宏 MAX_PID_NS_LEVEL 來定義。
在一個 PID namespace 里的進程,它的父進程可能不在當前 namespace 中,而是在外面的 namespace 里(外面的 namespace 指當前 namespace 的父 namespace),這類進程的 PPID 都是 0。比如新創建的 PID namespace 里面的第一個進程,他的父進程就在外面的 PID namespace 里。通過 setns 的方式將子進程加入到新 PID namespace 中的進程的父進程也在外面的 namespace 中。
我們可以把子進程加入到新的子 PID namespace 中,但是卻不能把子進程加入到任何祖先 PID namespace 中。
下面我們通過示例來獲得一些直觀的感受。
打開第一個 shell 窗口
先創建查看下當前進程的 PID,然后創建三個嵌套的 PID namespace:
打開第二個 shell 窗口
在另一個 shell 中查看 2616 進程的子進程:
bash(2616)───
sudo(2686)───unshare(2687)───bash(2688)───
sudo(2709)───unshare(2710)───bash(2711)───
sudo(2722)───unshare(2723)───bash(2724)
下面我們通過 PID 來查看上面進程屬於的 PID namespace:
這與我們創建 PID namespace 看到的結果是一樣的。然后我們通過 /proc/[pid]/status 看看 2724 號進程在不同 PID namespace 中的 PID:
$ grep pid /proc/2724/status
在我們創建的三個 PID namespace 中,PID 分別為 27, 24 和 1。
接下來我們使用 nsenter 命令進入到 2711(我們創建的第二個 PID namespace) 進程所在的 PID namespace:
$ sudo nsenter --mount --pid -t 2711 /bin/bash
查看進程樹,這里 bash(14) 就是最后一個 PID namespace 中 PID為 1 的進程。細心的讀者可能已經發現了,pstree 命令並沒有顯示我們通過 nsenter 添加進來的 bash 進程,讓我們來看看究竟:
$ ps -ef
有兩個 PPID 為 0 的進程,PID 為 38 的進程不屬於當前 PID namespace 中 init 進程的子進程,所以不會被 pstree 顯示。這也是我們創建的 PID namespace 根最外層的 PID namespace 不一樣的地方:可以有多個 PPID 為 0 的進程。
再看上圖中的 TTY 列,可以通過它看出命令是在哪個 shell 窗口中執行的。pts/17 代表的是我們打開的第一個 shell 窗口,pts/2 代表我們打開的第二個 shell 窗口。
打開第三個 shell 窗口
使用 nsenter 命令進入到 2688(我們創建的第一個 PID namespace) 進程所在的 PID namespace:
$ sudo nsenter --mount --pid -t 2688 /bin/bash
查看進程樹,這里 bash(27) 是最后一個 PID namespace 中 PID為 1 的進程。bash(14) 是第二個 PID namespace 中 PID為 1 的進程。用 ps 命令查看進程信息:
PID 為 51 和 66 的進程都是由 nsenter 命令添加的 bash 進程。到這里我們也可以看出,同樣的進程在不同的 PID namespace 中擁有不同的 PID。
最后我們嘗試給第二個 shell 窗口中的 bash 進程(51)發送一個信號:
$ kill 51
回到第二個 shell 窗口
此時 bash 進程已經被 kill 掉了,這說明從父 PID namespace 中可以給子 PID namespace 中的進程發送信號。
PID namespace 中的 init 進程
在一個新的 PID namespace 中創建的第一個進程的 PID 為 1,該進程被稱為這個 PID namespace 中的 init 進程。
在 Linux 系統中,進程的 PID 從 1 開始往后不斷增加,並且不能重復(當然進程退出后,PID 會被回收再利用),進程的 PID 為 1 的進程是內核啟動的第一個應用層進程,被稱為 init 進程(不同的 init 系統的進程名稱可能不太一樣)。這個進程具有特殊意義,當 init 進程退出時,系統也將退出。所以除了在 init 進程里指定了 handler 的信號外,內核會幫 init 進程屏蔽掉其他任何信號,這樣可以防止其他進程不小心 kill 掉 init 進程導致系統掛掉。
不過有了 PID namespace 后,可以通過在父 PID namespace 中發送 SIGKILL 或者 SIGSTOP 信號來終止子 PID namespace 中的 PID 為 1 的進程。由於 PID 為 1 的進程的特殊性,當這個進程停止后,內核將會給這個 PID namespace 里的所有其他進程發送 SIGKILL 信號,致使其他所有進程都停止,最終 PID namespace 被銷毀掉。
當一個進程的父進程退出后,該進程就變成了孤兒進程。孤兒進程會被當前 PID namespace 中 PID 為 1 的進程接管,而不是被最外層的系統級別的 init 進程接管。
下面我們通過示例來獲得一些直觀的感受。
繼續以上面三個 PID namespace 為例,第一步,先回到第一個 shell 窗口, 新啟動兩個 bash 進程:
首先,利用 unshare、nohup 和 sleep 命令組合,創建出父子進程。下面的命令 fork 出一個子進程並在后台 sleep 一小時:
$ unshare --fork nohup sleep 3600& $ pstree -p
然后我們 kill 掉進程 unshare(34):
$ kill 34 $ pstree -p
如同我們期望的一樣,進程 sleep(35) 被當前 PID namespace 中的 init 進程 bash(1) 收養了!
現在 kill 掉進程 sleep(35)並重新執行 unshare --fork nohup sleep 3600& 命令:
我們得到了和剛才相同的進程關系,只是進程的 PID 發生了一些變化。
第二步,回到第三個 shell 窗口
先檢查當前的進程樹:
$ pstree -p
bash(1)───
sudo(12)───unshare(13)───bash(14)───
sudo(25)───unshare(26)───bash(27)───bash(79)───bash(89)───unshare(105)───sleep(106)
我們先 kill 掉 sleep 進程的父進程 unshare(105):
$ kill 105 $ pstree -p
進程 sleep(106)被 bash(27) 收養了而不是 baus(1),這說明孤兒進程只會被自己 PID namespace 中的 init 進程收養。
接下來 kill 掉第二個 PID namespace 中的 init 進程,即這里的 bash(14):
$ kill -SIGKILL 14 $ pstree -p
此時第一個和第三個 shell 窗口都回到了我們創建的第一個 PID namespace 中。我們創建的第二個和第三個 PID namespace 中的進程都被系統清除掉了。
總結
PID namespace 具有比較顯著的點,比如可以嵌套,對 init 進程的特殊照顧,孤兒進程的收養等等。尤其是一旦進程的 PID namespace 確定后就不能改變的特點,與其它的 namespace 是完全不一樣的。
參考:
Linux Namespace PID
PID namespaces
PID namespaces2
pid namespace man page