徹底搞懂容器技術的基石: namespace (下)

張晉濤發表於2021-12-14

大家好,我是張晉濤。

目前我們所提到的容器技術、虛擬化技術(不論何種抽象層次下的虛擬化技術)都能做到資源層面上的隔離和限制。

對於容器技術而言,它實現資源層面上的限制和隔離,依賴於 Linux 核心所提供的 cgroup 和 namespace 技術。

我們先對這兩項技術的作用做個概括:

  • cgroup 的主要作用:管理資源的分配、限制;
  • namespace 的主要作用:封裝抽象,限制,隔離,使名稱空間內的程式看起來擁有他們自己的全域性資源;

這是一個系列文章,對此係列感興趣的小夥伴可以檢視:

本篇我們將繼續聊 namespace。

Namespace 型別

我們先來總覽一下 namespace 的型別,上篇中已經為大家介紹過 Cgroup , IPC, NetworkMount 等 4 種型別的 namespace。我們繼續聊剩餘的部分。

namespace名稱使用的標識 - Flag控制內容
CgroupCLONE_NEWCGROUPCgroup root directory cgroup 根目錄
IPCCLONE_NEWIPCSystem V IPC, POSIX message queues訊號量,訊息佇列
NetworkCLONE_NEWNETNetwork devices, stacks, ports, etc.網路裝置,協議棧,埠等等
MountCLONE_NEWNSMount points掛載點
PIDCLONE_NEWPIDProcess IDs程式號
TimeCLONE_NEWTIMEBoot and monotonic clocks啟動和單調時鐘
UserCLONE_NEWUSERUser and group IDs使用者和使用者組
UTSCLONE_NEWUTSHostname and NIS domain name主機名與 NIS 域名

PID namespaces

我們知道在 Linux 系統中,每個程式都會有自己的獨立的 PID,而 PID namespace 主要是用於隔離程式號。即,在不同的 PID namespace 中可以包含相同的程式號。

每個 PID namespace 中程式號都是從 1 開始的,在此 PID namespace 中可通過呼叫 fork(2), vfork(2)clone(2) 等系統呼叫來建立其他擁有獨立 PID 的程式。

要使用 PID namespace 需要核心支援 CONFIG_PID_NS 選項。如下:

(MoeLove) ➜ grep CONFIG_PID_NS /boot/config-$(uname -r)
CONFIG_PID_NS=y

init 程式

我們都知道在 Linux 系統中有一個程式比較特殊,所謂的 init 程式,也就是 PID 為 1 的程式。

前面我們已經說了每個 PID namespace 中程式號都是從 1 開始的,那麼它有什麼特點呢?

首先,PID namespace 中的 1 號程式是所有孤立程式的父程式。

其次,如果這個程式被終止,核心將呼叫 SIGKILL 發出終止此 namespace 中的所有程式的訊號。 這部分內容與 Kubernetes 中應用的優雅關閉/平滑升級等都有一定的聯絡。(對此部分感興趣的小夥伴可以留言交流,如果對這些內容感興趣的話,我可以專門寫一篇展開來聊)

最後,從 Linux v3.4 核心版本開始,如果在一個 PID namespace 中發生 reboot() 的系統呼叫,則 PID namespace 中的 init 程式會立即退出。這算是一個比較特殊的技巧,可用於處理高負載機器上容器退出的問題。

PID namespace 的層次結構

PID namespace 支援巢狀,除了初始的 PID namespace外,其餘的 PID namespace 都擁有其父節點的 PID namespace。

也就是說 PID namespace 也是樹形結構的,此結構內的所有 PID namespace 我們都可以追蹤到祖先 PID namespace。當然,這個深度也不是無限的,從 Linux v3.7 核心版本開始,樹的最大深度被限制成 32 。

如果達到此最大深度,將會丟擲 No space left on device的錯誤。(我之前嘗試巢狀容器的時候遇到過)

在同一個(且同級) PID namespace 中,程式間彼此可見。

但如果某個程式位於子 PID namespace 的話,那麼該程式是看不到上一層(即,父 PID namespace)中的程式的。

程式間是否可見,決定了程式間能否存在一定的關聯和呼叫關係,小夥伴們對這個應該比較熟悉,這裡我就不贅述了。

那麼,程式是否可以排程到不同層級的 PID namespace 呢?

我們先來說結論,程式在 PID namespace 中的排程只能是單向排程(從高 -> 低)。即:

  • 程式只能從父 PID namespace 排程到 子 PID namespace 中;
  • 程式不能從子 PID namespace 排程到 父 PID namespace 中;

img

圖 1 ,通過 setns(2) 排程程式說明

PID namespace 的層級關係其實是由 ioctl_ns(2) 系統呼叫進行發現和維護的(NS_GET_PARENT),這裡先不展開。那麼,上述內容中的排程是如何實現的呢?

要解答這個問題,就必須先意識到在 PID namespace 建立之初,哪些程式具備該 namespace 的許可權就已經確定了。至於排程,我們可以簡單地將其理解成關係對映或者符號連結。

執行緒必須在同一個PID namespace 中,以便保證程式中的執行緒間可以彼此互傳訊號。這就導致了CLONE_NEWPID 不能與 CLONE_THREAD 同時使用。但如果分佈在不同 PID namespace 的多個程式互相有訊號傳遞的需求要怎麼辦呢? 用共享的訊號佇列即可解決。

此外,我們常接觸到的 /proc 目錄下有很多 /proc/${PID}的目錄,在其中可看到 PID namespace 中的程式情況。 同時此目錄也是可直接通過掛載方式進行操作的。比如:

(MoeLove) ➜ mount |grep proc 
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)

有沒有辦法知道當前最大的 PID 數呢?

這也是可以的,自從 Linux v3.3 版本的核心開始新增了一個 /proc/sys/kernel/ns_last_pid的檔案,用於記錄最後一個程式的 ID 。

當需要分配下一個程式 ID 的時候,核心會去搜尋最大的未使用 ID 進行分配,隨後會更新此檔案中 PID 的資訊。

Time namespaces

在聊 time namespace 之前,我們需要先聊下單調時間。首先,我們通常提到的系統時間,指的是 clock realtime,即,機器對當前時間的展示。它可以向前或者向後調整(結合 NTP 服務來理解)。而 clock monotonic 表示在某一時刻之後的時間記錄,它是單向向後的絕對時間,不受系統時間的變化所影響。

使用 time namespace 需要核心支援 CONFIG_TIME_NS 選項。如:

(MoeLove) ➜ grep CONFIG_TIME_NS /boot/config-$(uname -r)
CONFIG_TIME_NS=y

time namespace 不會虛擬化 CLOCK_REALTIME 時鐘。你可能會好奇,為什麼核心支援 time namespace 呢?主要是為了一些特殊的場景。

time namespace 中的所有程式共享由 time namespace 提供的以下兩個引數:

  • CLOCK_MONOTONIC - 單調時間,一個不可設定的時鐘;
  • CLOCK_BOOTTIME(可參考 CLOCK_BOOTTIME_ALARM 核心引數)- 不可設定的時鐘,包括系統暫停的時間。

time namespace 目前只能使用 CLONE_NEWTIME 標識,通過呼叫 unshare(2) 系統呼叫進行建立。建立 time namespace 的程式是獨立於新建的 time namespace 之外的,而該程式後續的子程式將會被放置到新建的 time namespace 之內。同一個 time namespace 中的程式們會共享 CLOCK_MONOTONIC 和 CLOCK_BOOTTIME。

當父程式建立子程式時,子程式的 time namespace 歸屬將在檔案 /proc/[pid]/ns/time_for_children 中顯示。

(MoeLove) ➜ ls -al /proc/self/ns/time_for_children 
lrwxrwxrwx. 1 tao tao 0 12月 14 02:06 /proc/self/ns/time_for_children -> 'time:[4026531834]'

檔案 /proc/PID/timens_offsets 定義了初始 time namespace 的單調時鐘和啟動時鐘,並記錄了偏移量。(如果一個新的 time namespace 還沒有程式入駐時,是可以進行修改的。這裡暫不展開,感興趣的小夥伴可討論區留言交流討論。)

需要注意的是:在初始的 time namespace 中,/proc/self/timens_offsets 顯示的偏移量都為 0。

(MoeLove) ➜ cat /proc/self/timens_offsets 
monotonic           0         0
boottime            0         0

其中第二列和第三列的含義如下:

  • <offset-secs> 可以為負值,單位 :秒(s)
  • <offset-nanosecs> 是個無符號值,單位 :納秒(ns)

以下的時鐘介面都與此 namespace 有所關聯:

  • clock_gettime(2)
  • clock_nanosleep(2)
  • nanosleep(2)
  • timer_settime(2)
  • timerfd_settime(2)

整體而言, time namespace 在一些特殊場景下還是很有用的。

User namespaces

User namespaces 顧名思義是隔離了使用者 id、組 id 等。

使用 user namespaces 需要核心支援 CONFIG_USER_NS 選項。如:

➜  local_time grep CONFIG_USER_NS /boot/config-$(uname -r)
CONFIG_USER_NS=y

程式的使用者 id 和組 id 在一個 user namespace 內和外有可能是不同的。

比如,一個程式在 user namespace 中的使用者和組可以是特權使用者(root),但在該 user namespace 之外,可能只是一個普通的非特權使用者。這就涉及到使用者、組對映(uid_map 、gid_map)等相關的內容了。

自 Linux v3.5 版本的核心開始,在 /proc/[pid]/uid_map/proc/[pid]/gid_map 檔案中,我們可以檢視到對映內容。

(MoeLove) ➜ cat /proc/self/uid_map 
         0          0 4294967295
(MoeLove) ➜ cat /proc/self/gid_map 
         0          0 4294967295

user namespace 也支援巢狀,使用 CLONE_NEWUSER 標識,使用 unshare(2) 或者 clone(2) 等系統呼叫來建立,最大的巢狀層級深度也是 32。

如果是通過 fork(2) 或者 clone(2) 建立的子程式沒帶有 CLONE_NEWUSER 標識,也是一樣的,子程式跟父程式同在一個 user namespace 中。樹狀的關聯關係同樣通過 ioctl(2) 系統呼叫介面維護。

一個單執行緒程式可以通過 setns(2) 系統呼叫來調整其歸屬的 user namespace。

此外, user namespace 還有個很重要的規則,那就是關於 Linux capability 的繼承關係。關於 Linux capability 我就不展開了,這裡簡單記錄一下:

  • 當程式所在的 user namespace 擁有 effective capability set 中的 capability 時,該程式具有該 capability。
  • 當程式在該 user namespace 中擁有 capability 時,該程式在此 user namespace 的所有子 user namespace 中都擁有該 capability。
  • 建立該 user namespace 的使用者會被核心記錄為 owner ,即,擁有該 user namespace 中的全部 capabilities。

對於 Docker 而言,它可以原生的支援此能力,進而達到對容器環境的一種保護。

UTS namespaces

UTS namespaces 隔離了主機名和 NIS 域名。

使用 UTS namespaces 需要核心支援 CONFIG_UTS_NS 選項。如:

(MoeLove) ➜ grep CONFIG_UTS_NS /boot/config-$(uname -r)
CONFIG_UTS_NS=y

在同一個 UTS namespace 中,通過 sethostname(2) 和 and setdomainname(2) 系統呼叫進行的設定和修改是所有程式共享檢視的,但是對於不同 UTS namespaces 而言,則彼此隔離不可見。

Namespaces 主要的 API

前面內容中提到了很多的系統呼叫,這裡我們來挑幾個重要的介紹一下。

clone(2)

系統呼叫 clone(2) 建立一個新的程式,它會根據引數中的 CLONE_NEW* 設定,逐個實現對應的配置功能。當然這個系統呼叫也實現了一些與 namespace 無關的功能。對低於 Linux 3.8 版本核心的系統而言,大多數情況下, 需要具備 CAP_SYS_ADMIN 的 capability。

unshare(2)

系統呼叫 unshare(2) 將程式分配至新的 namespace ,同樣,它也會根據引數中的 CLONE_NEW* 設定來調整實現對應的配置功能。對低於 Linux 3.8 的系統而言,大多數情況,需要具備 CAP_SYS_ADMIN 的 capability。

setns(2)

系統呼叫 setns(2) 將程式移動到某一已存在的 namespace,這會導致 /proc/[pid]/ns 對應的目錄中內容的變更。程式建立的子程式可以通過呼叫 unshare(2) 和 setns(2) 來調整所屬的 namespace。這通常是需要具備 CAP_SYS_ADMIN 的 capability 的。

一些關鍵目錄說明

/proc/[pid]/ns/ 目錄

每個程式都有一個 /proc/[pid]/ns/ 子目錄,目錄中的內容會受到 setns(2) 系統呼叫的影響。只要目錄中的檔案被開啟,對應的 namespace 就不能被銷燬。系統可以通過呼叫 setns(2) 來變更這些檔案內容。

  • Linux 3.7 及更早期的版本 - 檔案是以硬連結方式存在的;
  • Linux 3.8 開始 - 檔案以軟連線的方式存在;
(MoeLove) ➜ ls -l --time-style='+' /proc/$$/ns  
總用量 0
lrwxrwxrwx. 1 tao tao 0  cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx. 1 tao tao 0  ipc -> 'ipc:[4026531839]'
lrwxrwxrwx. 1 tao tao 0  mnt -> 'mnt:[4026531840]'
lrwxrwxrwx. 1 tao tao 0  net -> 'net:[4026532008]'
lrwxrwxrwx. 1 tao tao 0  pid -> 'pid:[4026531836]'
lrwxrwxrwx. 1 tao tao 0  pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx. 1 tao tao 0  time -> 'time:[4026531834]'
lrwxrwxrwx. 1 tao tao 0  time_for_children -> 'time:[4026531834]'
lrwxrwxrwx. 1 tao tao 0  user -> 'user:[4026531837]'
lrwxrwxrwx. 1 tao tao 0  uts -> 'uts:[4026531838]'

如果兩個程式的 namespace 相同,那麼它們這個目錄內的內容應該是一樣的。

以下是該目錄下檔案的詳細說明:

檔名稱起始版本描述
/proc/[pid]/ns/cgroupLinux 4.6程式的 cgroup namespace
/proc/[pid]/ns/ipcLinux 3.0程式的 IPC namespace
/proc/[pid]/ns/mntLinux 3.8程式的 mount namespace
/proc/[pid]/ns/netLinux 3.0程式的 network namespace
/proc/[pid]/ns/pidLinux 3.8程式的 PID namespace在程式的整個生命週期裡是不變的
/proc/[pid]/ns/pid_for_childrenLinux 4.12程式建立子程式的 PID namespace這個檔案與 /proc/[pid]/ns/pid 不一定一致。
/proc/[pid]/ns/timeLinux 5.6程式的 time namespace
/proc/[pid]/ns/time_for_childrenLinux 5.6程式建立子程式的 time namespace
/proc/[pid]/ns/userLinux 3.8程式的 user namespace
/proc/[pid]/ns/utsLinux 3.0程式的 UTS namespace

/proc/sys/user 目錄

/proc/sys/user 目錄下的檔案記錄了各 namespace 的相關限制。當達到限制,相關呼叫會報錯 error ENOSPC 。

檔名稱限制內容說明
max_cgroup_namespaces在 user namespace 中的每個使用者可以建立的最大 cgroup namespaces 數
max_ipc_namespaces在 user namespace 中的每個使用者可以建立的最大 ipc namespaces 數
max_mnt_namespaces在 user namespace 中的每個使用者可以建立的最大 mount namespaces 數
max_net_namespaces在 user namespace 中的每個使用者可以建立的最大 network namespaces 數
max_pid_namespaces在 user namespace 中的每個使用者可以建立的最大 PID namespaces 數
max_time_namespacesLinux 5.7在 user namespace 中的每個使用者可以建立的最大 time namespaces 數
max_user_namespaces在 user namespace 中的每個使用者可以建立的最大 user namespaces 數
max_uts_namespaces在 user namespace 中的每個使用者可以建立的最大 uts namespaces 數

Namespace 的生命週期

正常的 namespace 的生命週期與最後一個程式的終止和離開相關。

但有一些情況,即使最後一個程式已經退出了,namespace 仍不能被銷燬。這裡來稍微聊下這些特殊的情況:

  • /proc/[pid]/ns/* 中的檔案被開啟或者 mount ,即使最後一個程式退出,也不能被銷燬;
  • namespace 存在分層,子 namespace 仍存在 ,即使最後一個程式退出,也不能被銷燬;
  • 一個 user namespace 擁有一些非 user namespace (比如擁有 PID namespace 等其他的 namespace 存在),即使最後一個程式退出,也不能被銷燬;
  • 對於 PID namespace 而言,如果與 /proc/[pid]/ns/pid_for_children 存在關聯關係時,即使最後一個程式退出,也不能被銷燬;

當然還有一些其他的情況,有空再補充。

總結

通過之前的一篇,和本篇,主要為大家介紹了 Linux namespace 的發展歷程,基本型別,主要 API 以及一些使用場景和用途。

namespace 對於容器技術而言,是非常核心的部分。後續本系列中還將繼續為大家分享關於容器和 Kubernetes 等技術的內容,敬請期待。


歡迎訂閱我的文章公眾號【MoeLove】

相關文章