分離焦慮:使用 Linux 命名空間隔離系統的教程
已發表: 2022-03-11隨著 Docker、Linux 容器等工具的出現,將 Linux 進程隔離到它們自己的小系統環境中變得非常容易。 這使得在一台真正的 Linux 機器上運行一系列應用程序成為可能,並確保它們中的任何一個都不會相互干擾,而不必求助於使用虛擬機。 這些工具對 PaaS 提供商來說是一個巨大的福音。 但是引擎蓋下到底發生了什麼?
這些工具依賴於 Linux 內核的許多特性和組件。 其中一些功能是最近才引入的,而其他功能仍然需要您修補內核本身。 但自 2008 年 2.6.24 版發布以來,使用 Linux 命名空間的關鍵組件之一一直是 Linux 的一項功能。
任何熟悉chroot
的人都已經對 Linux 命名空間可以做什麼以及如何使用命名空間有了基本的了解。 正如chroot
允許進程將任意目錄視為系統的根目錄(獨立於其餘進程),Linux 命名空間也允許獨立修改操作系統的其他方面。 這包括進程樹、網絡接口、掛載點、進程間通信資源等等。
為什麼使用命名空間進行進程隔離?
在單用戶計算機中,單個系統環境可能沒問題。 但是在您想要運行多個服務的服務器上,服務之間盡可能地相互隔離對於安全性和穩定性至關重要。 想像一個運行多個服務的服務器,其中一個被入侵者入侵。 在這種情況下,入侵者可能能夠利用該服務並以他的方式訪問其他服務,甚至可能危及整個服務器。 命名空間隔離可以提供一個安全的環境來消除這種風險。
例如,使用命名空間,可以在您的服務器上安全地執行任意或未知的程序。 最近,編程競賽和“黑客馬拉松”平台越來越多,例如 HackerRank、TopCoder、Codeforces 等等。 他們中的許多人利用自動化管道來運行和驗證參賽者提交的程序。 往往無法提前知道參賽者節目的真實性質,有的甚至可能含有惡意成分。 通過在與系統其餘部分完全隔離的情況下運行這些命名空間的程序,可以測試和驗證軟件,而不會使機器的其餘部分處於危險之中。 同樣,在線持續集成服務,例如 Drone.io,會自動獲取您的代碼存儲庫並在自己的服務器上執行測試腳本。 同樣,命名空間隔離使安全地提供這些服務成為可能。
像 Docker 這樣的命名空間工具還可以更好地控制進程對系統資源的使用,使得此類工具非常適合 PaaS 提供商使用。 Heroku 和 Google App Engine 等服務使用此類工具在同一真實硬件上隔離和運行多個 Web 服務器應用程序。 這些工具允許他們運行每個應用程序(可能已由許多不同用戶中的任何一個部署),而不必擔心其中一個使用過多的系統資源,或乾擾和/或與同一台機器上的其他部署服務發生衝突。 通過這樣的進程隔離,甚至可以為每個隔離環境擁有完全不同的依賴軟件(和版本)堆棧!
如果您使用過像 Docker 這樣的工具,那麼您已經知道這些工具能夠將進程隔離在小型“容器”中。 在 Docker 容器中運行進程就像在虛擬機中運行它們,只是這些容器比虛擬機輕得多。 虛擬機通常在操作系統之上模擬硬件層,然後在其之上運行另一個操作系統。 這允許您在虛擬機中運行進程,與您的真實操作系統完全隔離。 但是虛擬機很重! 另一方面,Docker 容器使用真實操作系統的一些關鍵特性,包括命名空間,並確保類似級別的隔離,但無需模擬硬件並在同一台機器上運行另一個操作系統。 這使它們非常輕巧。
進程命名空間
從歷史上看,Linux 內核一直維護一個單一的進程樹。 該樹包含對當前在父子層次結構中運行的每個進程的引用。 一個進程,如果它有足夠的權限並且滿足某些條件,它可以通過附加一個跟踪器來檢查另一個進程,甚至可以殺死它。
隨著 Linux 命名空間的引入,擁有多個“嵌套”進程樹成為可能。 每個進程樹都可以有一組完全隔離的進程。 這可以確保屬於一個進程樹的進程不能檢查或殺死 - 事實上甚至不知道存在 - 其他兄弟或父進程樹中的進程。
每次裝有 Linux 的計算機啟動時,它只會啟動一個進程,進程標識符 (PID) 為 1。此進程是進程樹的根,它通過執行適當的維護工作並啟動系統來啟動系統的其餘部分正確的守護進程/服務。 所有其他進程在樹中的該進程下方開始。 PID 命名空間允許使用自己的 PID 1 進程衍生出一棵新樹。 執行此操作的進程保留在原始樹中的父名稱空間中,但使子進程成為其自己進程樹的根。
使用 PID 命名空間隔離,子命名空間中的進程無法知道父進程的存在。 但是,父命名空間中的進程具有子命名空間中進程的完整視圖,就好像它們是父命名空間中的任何其他進程一樣。
可以創建一組嵌套的子命名空間:一個進程在新的 PID 命名空間中啟動一個子進程,該子進程在新的 PID 命名空間中生成另一個進程,依此類推。
隨著 PID 命名空間的引入,單個進程現在可以有多個與之關聯的 PID,一個對應於它所屬的每個命名空間。 在 Linux 源代碼中,我們可以看到一個名為pid
的結構,它過去只跟踪一個 PID,現在通過使用一個名為upid
的結構來跟踪多個 PID:
struct upid { int nr; // the PID value struct pid_namespace *ns; // namespace where this PID is relevant // ... }; struct pid { // ... int level; // number of upids struct upid numbers[0]; // array of upids };
要創建一個新的 PID 命名空間,必須使用特殊標誌CLONE_NEWPID
調用clone()
系統調用。 (C 提供了一個包裝器來公開這個系統調用,許多其他流行語言也是如此。)雖然下面討論的其他命名空間也可以使用unshare()
系統調用創建,但 PID 命名空間只能在一個新的進程是使用clone()
生成的。 一旦使用此標誌調用clone()
,新進程將立即在新進程樹下的新 PID 命名空間中啟動。 這可以用一個簡單的 C 程序來演示:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("PID: %ld\n", (long)getpid()); return 0; } int main() { pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); printf("clone() = %ld\n", (long)child_pid); waitpid(child_pid, NULL, 0); return 0; }
以 root 權限編譯並運行此程序,您會注意到類似以下的輸出:
clone() = 5304 PID: 1
從child_fn
中打印的 PID 將為1
。
儘管上面的這個命名空間教程代碼在某些語言中並不比“Hello, world”長多少,但在幕後發生了很多事情。 正如您所料, clone()
函數通過克隆當前進程創建了一個新進程,並在child_fn()
函數的開頭開始執行。 但是,在這樣做的同時,它將新進程從原始進程樹中分離出來,並為新進程創建了一個單獨的進程樹。
嘗試將static int child_fn()
函數替換為以下內容,以從隔離進程的角度打印父 PID:
static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }
這次運行程序會產生以下輸出:
clone() = 11449 Parent PID: 0
請注意,從隔離進程的角度來看,父 PID 為 0,表示沒有父進程。 再次嘗試運行相同的程序,但這一次,從clone()
函數調用中刪除CLONE_NEWPID
標誌:
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);
這一次,你會注意到父 PID 不再是 0:
clone() = 11561 Parent PID: 11560
然而,這只是我們教程的第一步。 這些進程仍然可以不受限制地訪問其他公共或共享資源。 例如,網絡接口:如果上面創建的子進程要監聽端口 80,它將阻止系統上的所有其他進程能夠監聽它。
Linux 網絡命名空間
這就是網絡命名空間變得有用的地方。 網絡命名空間允許這些進程中的每一個看到一組完全不同的網絡接口。 甚至每個網絡命名空間的環回接口都不同。
將一個進程隔離到它自己的網絡命名空間中涉及向clone()
函數調用引入另一個標誌: CLONE_NEWNET
;
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
輸出:
Original `net` Namespace: 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000 link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff New `net` Namespace: 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
這裡發生了什麼? 物理以太網設備enp4s0
屬於全局網絡命名空間,如從該命名空間運行的“ip”工具所示。 但是,物理接口在新的網絡命名空間中不可用。 此外,環回設備在原始網絡命名空間中處於活動狀態,但在子網絡命名空間中“關閉”。

為了在子命名空間中提供可用的網絡接口,有必要設置跨越多個命名空間的附加“虛擬”網絡接口。 一旦完成,就可以創建以太網橋,甚至在命名空間之間路由數據包。 最後,為了使整個工作正常進行,必須在全局網絡命名空間中運行“路由進程”以接收來自物理接口的流量,並通過適當的虛擬接口將其路由到正確的子網絡命名空間。 也許您可以看到為什麼像 Docker 這樣為您完成所有這些繁重工作的工具如此受歡迎!
為此,您可以通過從父命名空間運行單個命令,在父命名空間和子命名空間之間創建一對虛擬以太網連接:
ip link add name veth0 type veth peer name veth1 netns <pid>
此處, <pid>
應替換為父級觀察到的子命名空間中進程的進程 ID。 運行此命令會在這兩個命名空間之間建立類似管道的連接。 父命名空間保留veth0
設備,並將veth1
設備傳遞給子命名空間。 任何進入一端的東西,都會從另一端出來,就像您對兩個真實節點之間的真實以太網連接所期望的一樣。 因此,必須為該虛擬以太網連接的兩端分配 IP 地址。
掛載命名空間
Linux 還為系統的所有掛載點維護了一個數據結構。 它包括諸如掛載哪些磁盤分區、掛載位置、是否為只讀等信息。 使用 Linux 命名空間,可以克隆這種數據結構,以便不同命名空間下的進程可以更改掛載點,而不會相互影響。
創建單獨的掛載命名空間具有類似於執行chroot()
的效果。 chroot()
很好,但它不提供完全隔離,而且它的作用僅限於根掛載點。 創建單獨的掛載命名空間允許這些隔離進程中的每一個對整個系統的掛載點結構具有與原始視圖完全不同的視圖。 這允許您為每個隔離進程以及特定於這些進程的其他掛載點擁有不同的根。 根據本教程小心使用,您可以避免暴露有關底層系統的任何信息。
實現此目的所需的clone()
標誌是CLONE_NEWNS
:
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
最初,子進程看到與其父進程完全相同的掛載點。 但是,在新的掛載命名空間下,子進程可以掛載或卸載它想要的任何端點,並且更改不會影響其父命名空間,也不會影響整個系統中的任何其他掛載命名空間。 例如,如果父進程在根目錄掛載了一個特定的磁盤分區,則隔離進程將在開始時看到完全相同的磁盤分區掛載在根目錄。 但是,當隔離進程嘗試將根分區更改為其他內容時,隔離掛載命名空間的好處是顯而易見的,因為更改只會影響隔離的掛載命名空間。
有趣的是,這實際上使得直接使用CLONE_NEWNS
標誌生成目標子進程不是一個好主意。 更好的方法是使用CLONE_NEWNS
標誌啟動一個特殊的“init”進程,讓該“init”進程根據需要更改“/”、“/proc”、“/dev”或其他掛載點,然後啟動目標進程. 這將在本命名空間教程的結尾部分更詳細地討論。
其他命名空間
這些進程還可以被隔離到其他命名空間中,即用戶、IPC 和 UTS。 用戶命名空間允許進程在命名空間內擁有 root 權限,而不會授予它訪問命名空間之外的進程的權限。 通過 IPC 命名空間隔離進程為其提供了自己的進程間通信資源,例如 System V IPC 和 POSIX 消息。 UTS 命名空間隔離了系統的兩個特定標識符: nodename
和domainname
。
一個簡單的例子展示瞭如何隔離 UTS 命名空間:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/utsname.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static void print_nodename() { struct utsname utsname; uname(&utsname); printf("%s\n", utsname.nodename); } static int child_fn() { printf("New UTS namespace nodename: "); print_nodename(); printf("Changing nodename inside new UTS namespace\n"); sethostname("GLaDOS", 6); printf("New UTS namespace nodename: "); print_nodename(); return 0; } int main() { printf("Original UTS namespace nodename: "); print_nodename(); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL); sleep(1); printf("Original UTS namespace nodename: "); print_nodename(); waitpid(child_pid, NULL, 0); return 0; }
該程序產生以下輸出:
Original UTS namespace nodename: XT New UTS namespace nodename: XT Changing nodename inside new UTS namespace New UTS namespace nodename: GLaDOS Original UTS namespace nodename: XT
在這裡, child_fn()
打印nodename
,將其更改為其他內容,然後再次打印。 自然,更改只發生在新的 UTS 命名空間內。
有關所有命名空間提供和隔離的更多信息,請參見此處的教程
跨命名空間通信
通常有必要在父命名空間和子命名空間之間建立某種通信。 這可能是為了在一個孤立的環境中進行配置工作,也可能只是為了保留從外部窺視該環境狀況的能力。 一種方法是讓 SSH 守護程序在該環境中運行。 您可以在每個網絡命名空間內擁有一個單獨的 SSH 守護程序。 但是,運行多個 SSH 守護程序會佔用大量寶貴的資源,例如內存。 這就是再次證明有一個特殊的“init”過程是一個好主意的地方。
“init”進程可以在父命名空間和子命名空間之間建立通信通道。 該通道可以基於 UNIX 套接字,甚至可以使用 TCP。 要創建跨越兩個不同掛載命名空間的 UNIX 套接字,您需要首先創建子進程,然後創建 UNIX 套接字,然後將子進程隔離到單獨的掛載命名空間中。 但是我們怎樣才能先創建流程,然後再隔離呢? Linux 提供unshare()
。 這個特殊的系統調用允許進程將自己與原始命名空間隔離,而不是讓父進程首先隔離子進程。 例如,以下代碼與之前在網絡命名空間部分中提到的代碼具有完全相同的效果:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { // calling unshare() from inside the init process lets you create a new namespace after a new process has been spawned unshare(CLONE_NEWNET); printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
由於“init”進程是你設計的,你可以讓它先完成所有必要的工作,然後在執行目標子進程之前將它自己與系統的其餘部分隔離開來。
結論
本教程只是對如何在 Linux 中使用命名空間的概述。 它應該讓您了解 Linux 開發人員如何開始實施系統隔離,這是 Docker 或 Linux 容器等工具架構的一個組成部分。 在大多數情況下,最好只使用這些現有工具中的一種,這些工具已經眾所周知並經過測試。 但在某些情況下,擁有自己的自定義進程隔離機制可能是有意義的,在這種情況下,本命名空間教程將極大地幫助您。
幕後發生的事情比我在本文中介紹的要多得多,並且您可能希望通過更多方式限制目標進程以增加安全性和隔離性。 但是,希望這對於有興趣了解更多關於 Linux 命名空間隔離如何真正起作用的人來說是一個有用的起點。