徹底搞懂容器技術的基石: cgroup

語言: CN / TW / HK

大家好,我是張晉濤。

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

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

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

  • cgroup 的主要作用:管理資源的分配、限制;

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

本篇,我們重點來聊 cgroup 。

為什麼要關注 cgroup & namespace

雲原生/容器技術的井噴式增長

自 1979年, Unix 版本7 在開發過程中引入 Chroot Jail 以及 Chroot 系統呼叫開始,直到 2013 年開源出的 Docker,2014 年開源出來的 Kubernetes,直到現在的雲原生生態的火熱。容器技術已經逐步成為主流的基礎技術之一。

在越來越多的公司、個人選擇了雲服務/容器技術後,資源的分配和隔離,以及安全性變成了人們關注及討論的熱點話題。

其實容器技術使用起來並不難,但要真正把它用好,大規模的在生產環境中使用, 那我們還是需要掌握其核心的。

以下是容器技術&雲原生生態的大致發展歷程:

img

圖 1 ,容器技術發展歷程

從圖中,我們可以看到容器技術、雲原生生態的發展軌跡。容器技術其實很早就出現了,但為何在 Docker 出現後才開始有了較為顯著的發展?早期的 chroot 、 Linux VServer 又有哪些問題呢?

Chroot 帶來的安全性問題

img

圖 2 ,chroot 示例

Chroot 可以將程序及其子程序與作業系統的其餘部分隔離開來。但是, 對於 root process ,卻 可以任意退出 chroot

package main

import (
"log"
"os"
"syscall"
)

func getWd() (path string) {
path, err := os.Getwd()
if err != nil {
log.Println(err)
}
log.Println(path)
return
}

func main() {
RealRoot, err := os.Open("/")
defer RealRoot.Close()
if err != nil {
log.Fatalf("[ Error ] - /: %v\n", err)
}
path := getWd()

err = syscall.Chroot(path)
if err != nil {
log.Fatalf("[ Error ] - chroot: %v\n", err)
}
getWd()

err = RealRoot.Chdir()
if err != nil {
log.Fatalf("[ Error ] - chdir(): %v", err)
}
getWd()

err = syscall.Chroot(".")
if err != nil {
log.Fatalf("[ Error ] - chroot back: %v", err)
}
getWd()
}

分別以普通使用者和 sudo 的方式執行:

➜  chroot go run main.go 
2021/11/18 00:46:21 /tmp/chroot
2021/11/18 00:46:21 [ Error ] - chroot: operation not permitted
exit status 1
➜ chroot sudo go run main.go
2021/11/18 00:46:25 /tmp/chroot
2021/11/18 00:46:25 /
2021/11/18 00:46:25 (unreachable)/
2021/11/18 00:46:25 /

可以看到如果是使用 sudo 來執行的時候,程式在當前目錄和系統原本的根目錄下進行了切換。而普通使用者則無許可權操作。

Linux VServer 的安全漏洞

Linux-VServer 是一種基於 Security Contexts 的軟分割槽技術,可以做到虛擬伺服器隔離,共享相同的硬體資源。主要問題是 VServer 應用程式針對 "chroot-again" 型別的攻擊沒有很好的進行安全保護,攻擊者可以利用這個漏洞脫離限制環境,訪問限制目錄之外的任意檔案。(自 2004年開始,國家資訊保安漏洞庫就登出了相關漏洞問題)

img

圖 3 ,國家資訊保安漏洞庫網站圖示

現代化容器技術帶來的優勢

  • 輕量級,基於 Linux 核心所提供的 cgroup 和 namespace 能力,建立容器的成本很低;

  • 一定的隔離性;

  • 標準化,通過使用容器映象的方式進行應用程式的打包和分發,可以遮蔽掉因為環境不一致帶來的諸多問題;

  • DevOps 支撐(可以在不同環境,如開發、測試和生產等環境之間輕鬆遷移應用,同時還可保留應用的全部功能);

  • 為基礎架構增添防護,提升可靠性、可擴充套件性和信賴度;

  • DevOps/GitOps 支撐 (可以做到快速有效地持續性發布,管理版本及配置);

  • 團隊成員間可以有效簡化、加速和編排應用的開發與部署;

在瞭解了為什麼要關注 cgroup 和 namespace 等技術之後,那我們就進入到本篇的重點吧,來一起學習下 cgroup 。

什麼是 cgroup

cgroup 是 Linux 核心的一個功能,用來限制、控制與分離一個程序組的資源(如CPU、記憶體、磁碟輸入輸出等)。它是由 Google 的兩位工程師進行開發的,自 2018 年 1 月正式釋出的 Linux 核心 v2.6.24 開始提供此能力。

cgroup 到目前為止,有兩個大版本, cgroup v1 和 v2 。以下內容以 cgroup v2 版本為主,涉及兩個版本差別的地方會在下文詳細介紹。

cgroup 主要限制的資源是:

  • CPU

  • 記憶體

  • 網路

  • 磁碟 I/O

當我們將可用系統資源按特定百分比分配給 cgroup 時,剩餘的資源可供系統上的其他 cgroup 或其他程序使用。

img

圖 4 ,cgroup 資源分配及剩餘可用資源示例

cgroup 的組成

cgroup 代表“控制組”,並且不會使用大寫。cgroup 是一種分層組織程序的機制, 沿層次結構以受控的方式分配系統資源。我們通常使用單數形式用於指定整個特徵,也用作限定符如 “cgroup controller” 。

cgroup 主要有兩個組成部分:

  • core - 負責分層組織過程;

  • controller - 通常負責沿層次結構分配特定型別的系統資源。每個 cgroup 都有一個 cgroup.controllers 檔案,其中列出了所有可供 cgroup 啟用的控制器。當在 cgroup.subtree_control 中指定多個控制器時,要麼全部成功,要麼全部失敗。在同一個控制器上指定多項操作,那麼只有最後一個生效。每個 cgroup 的控制器銷燬是非同步的,在引用時同樣也有著延遲引用的問題;

所有 cgroup 核心介面檔案都以 cgroup 為字首。每個控制器的介面檔案都以控制器名稱和一個點為字首。控制器的名稱由小寫字母和“ ”組成,但永遠不會以“ ”開頭。

cgroup 的核心檔案

  • cgroup.type - (單值)存在於非根 cgroup 上的可讀寫檔案。通過將“threaded”寫入該檔案,可以將 cgroup 轉換為執行緒 cgroup,可選擇 4 種取值,如下:

  1. domain - 一個正常的有效域 cgroup

  2. domain threaded - 執行緒子樹根的執行緒域 cgroup

  3. domain invalid - 無效的 cgroup

  4. threaded - 執行緒 cgroup,執行緒子樹

  • cgroup.procs - (換行分隔)所有 cgroup 都有的可讀寫檔案。每行列出屬於 cgroup 的程序的 PID。PID 不是有序的,如果程序移動到另一個 cgroup ,相同的 PID 可能會出現不止一次;

  • cgroup.controllers - (空格分隔)所有 cgroup 都有的只讀檔案。顯示 cgroup 可用的所有控制器;

  • cgroup.subtree_control - (空格分隔)所有 cgroup 都有的可讀寫檔案,初始為空。如果一個控制器在列表中出現不止一次,最後一個有效。當指定多個啟用和禁用操作時,要麼全部成功,要麼全部失敗。

    1. 以“+”為字首的控制器名稱表示啟用控制器

    2. 以“-”為字首的控制器名稱表示禁用控制器

  • cgroup.events - 存在於非根 cgroup 上的只讀檔案。

    1. populated - cgroup 及其子節點中包含活動程序,值為1;無活動程序,值為0.

    2. frozen - cgroup 是否被凍結,凍結值為1;未凍結值為0.

  • cgroup.threads - (換行分隔)所有 cgroup 都有的可讀寫檔案。每行列出屬於 cgroup 的執行緒的 TID。TID 不是有序的,如果執行緒移動到另一個 cgroup ,相同的 TID 可能會出現不止一次。

  • cgroup.max.descendants - (單值)可讀寫檔案。最大允許的 cgroup 子節點數量。

  • cgroup.max.depth - (單值)可讀寫檔案。低於當前節點最大允許的樹深度。

  • cgroup.stat - 只讀檔案。

    1. nr_descendants - 可見後代的 cgroup 數量。

    2. nr_dying_descendants - 被使用者刪除即將被系統銷燬的 cgroup 數量。

  • cgroup.freeze - (單值)存在於非根 cgroup 上的可讀寫檔案。預設值為0。當值為1時,會凍結 cgroup 及其所有子節點 cgroup,會將相關的程序關停並且不再執行。凍結 cgroup 需要一定的時間,當動作完成後, cgroup.events 控制檔案中的 “frozen” 值會更新為“1”,併發出相應的通知。cgroup 的凍結狀態不會影響任何 cgroup 樹操作(刪除、建立等);

  • cgroup.kill - (單值)存在於非根 cgroup 上的可讀寫檔案。唯一允許值為1,當值為1時,會將 cgroup 及其所有子節點中的 cgroup 殺死(程序會被 SIGKILL 殺掉)。一般用於將一個 cgroup 樹殺掉,防止葉子節點遷移;

  • cgroup 的歸屬和遷移

    系統中的每個程序都屬於一個 cgroup,一個程序的所有執行緒都屬於同一個 cgroup。一個程序可以從一個 cgroup 遷移到另一個 cgroup 。程序的遷移不會影響現有的後代程序所屬的 cgroup。

    img

    圖 5 ,程序及其子程序的 cgroup 分配;跨 cgroup 遷移示例

    跨 cgroup 遷移程序是一項代價昂貴的操作並且有狀態的資源限制(例如,記憶體)不會動態的應用於遷移。因此,經常跨 cgroup 遷移程序只是作為一種手段。不鼓勵直接應用不同的資源限制。

    如何實現跨 cgroup 遷移

    每個cgroup都有一個可讀寫的介面檔案 “cgroup.procs” 。每行一個 PID 記錄 cgroup 限制管理的所有程序。一個程序可以通過將其 PID 寫入另一 cgroup 的 “cgroup.procs” 檔案來實現遷移。

    但是這種方式,只能遷移一個程序在單個 write(2) 上的呼叫(如果一個程序有多個執行緒,則會同時遷移所有執行緒,但也要參考執行緒子樹,是否有將程序的執行緒放入不同的 cgroup 的記錄)。

    當一個程序 fork 出一個子程序時,該程序就誕生在其父親程序所屬的 cgroup 中。

    一個沒有任何子程序或活動程序的 cgroup 是可以通過刪除目錄進行銷燬的(即使存在關聯的殭屍程序,也被認為是可以被刪除的)。

    什麼是 cgroups

    當明確提到多個單獨的控制組時,才使用複數形式 “cgroups” 。

    cgroups 形成了樹狀結構。(一個給定的 cgroup 可能有多個子 cgroup 形成一棵樹結構體)每個非根 cgroup 都有一個 cgroup.events 檔案,其中包含 populated 欄位指示 cgroup 的子層次結構是否具有實時程序。所有非根的 cgroup.subtree_control 檔案,只能包含在父級中啟用的控制器。

    img

    圖 6 ,cgroups 示例

    如圖所示,cgroup1 中限制了使用 cpu 及 記憶體資源,它將控制子節點的 CPU 週期和記憶體分配(即,限制 cgroup2、cgroup3、cgroup4 中的cpu及記憶體資源分配)。cgroup2 中啟用了記憶體限制,但是沒有啟用cpu的資源限制,這就導致了 cgroup3 和 cgroup4 的記憶體資源受 cgroup2中的 mem 設定內容的限制;cgroup3 和 cgroup4 會自由競爭在 cgroup1 的 cpu 資源限制範圍內的 cpu 資源。

    由此,也可以明顯的看出 cgroup 資源是自上而下分佈約束的。只有當資源已經從上游 cgroup 節點分發給下游時,下游的 cgroup 才能進一步分發約束資源。所有非根的 cgroup.subtree_control 檔案只能包含在父節點的 cgroup.subtree_control 檔案中啟用的控制器內容。

    那麼, 子節點 cgroup 與父節點 cgroup 是否會存在內部程序競爭的情況呢

    當然不會。cgroup v2 中,設定了非根 cgroup 只能在沒有任何程序時才能將域資源分發給子節點的 cgroup。簡而言之,只有不包含任何程序的 cgroup 才能在其 cgroup.subtree_control 檔案中啟用域控制器,這就保證了,程序總在葉子節點上。

    掛載和委派

    cgroup 的掛載方式

    • memory_recursiveprot - 遞迴地將 memory.min 和 memory.low 保護應用於整個子樹,無需顯式向下傳播到葉節點的 cgroup 中,子樹內葉子節點可以自由競爭;

    • memory_localevents - 只能掛載時設定或者通過從 init 名稱空間重新掛載來修改,這是系統範圍的選項。只用當前 cgroup 的資料填充 memory.events,如果沒有這個選項,預設會計數所有子樹;

    • nsdelegate - 只能掛載時設定或者通過從 init 名稱空間重新掛載來修改,這也是系統範圍的選項。它將 cgroup 名稱空間視為委託邊界,這是兩種委派 cgroup 的方式之一;

    cgroup 的委派方式

    • 設定掛載選項 nsdelegate ;

    • cgroup.procs
      cgroup.threads
      cgroup.subtree_control
      

    兩種方式的結果相同。一旦被委派,使用者就可以在目錄下建立子層次結構,所有的資源分配都受父節點的制約。目前,cgroup 對委託子層次結構中的 cgroup 數量或巢狀深度沒有任何限制(之後可能會受到明確限制)。

    前面提到了跨 cgroup 遷移,從委派中,我們可以很明確的得知跨 cgroup 遷移對於普通使用者來講,是有限制條件的。即,是否對目前 cgroup 的 “cgroup.procs” 檔案具有寫訪問許可權以及是否對源 cgroup 和目標 cgroup 的共同祖先的 “cgroup.procs” 檔案具有寫訪問許可權。

    委派和遷移

    img

    圖 7 ,委派許可權示例

    如圖,普通使用者 User0 具有 cgroup[1-5] 的委派許可權。

    為什麼 User0 想將程序 從 cgroup3 遷移至 cgroup5會失敗呢?

    這是由於 User0 的許可權只到 cgroup1 和 cgroup2 層,並不具備 cgroup0 的許可權。而委派中的授權使用者明確指出 需要共同祖先的 “cgroup.procs” 檔案具有寫訪問許可權! (即,需要圖中 cgroup0 的許可權,才可以實現)

    資源分配模型及功能

    以下是 cgroups 的資源分配模型:

    • 權重 - (例如,cpu.weight) 所有權重都在 [1, 10000] 範圍內,預設值為 100。按照權重比率來分配資源。

    • 限制 - [0, max] 範圍內,預設為“max”,即 noop(例如,io.max)。限制可以被過度使用(子節點限制的總和可能超過父節點可用的資源量)。

    • 保護 - [0, max] 範圍內,預設為 0,即 noop(例如,io.low)。保護可以是硬保證或盡力而為的軟邊界,保護也可能被過度使用。

    • 分配 -  [0, max] 範圍內,預設為 0,即沒有資源。分配不能被過度使用(子節點分配的總和不能超過父節點可用的資源量)。

    cgroups 提供瞭如下功能:

    • 資源限制 - 上面 cgroup 部分已經示例,cgroups 可以以樹狀結構來巢狀式限制資源。

    • 優先順序 - 發生資源爭用時,優先保障哪些程序的資源。

    • 審計 - 監控及報告資源限制及使用。

    • 控制 - 控制程序的狀態(起、停、掛起)。

    cgroup v1 與 cgroup v2

    被棄用的核心功能

    cgroup v2 和 cgroup v1 有很大的不同,我們一起來看看在 cgroup v2 中棄用了哪些 cgroup v1 的功能:

    • 不支援包括命名層次在內的多個層次結構;

    • 不支援所有 v1 安裝選項;

    • “tasks” 檔案被刪除,“cgroup.procs” 沒有排序

      • 在 cgroup v1 中執行緒組 ID 的列表。不保證此列表已排序或沒有重複的 TGID,如果需要此屬性,使用者空間應排序/統一列表。將執行緒組 ID 寫入此檔案會將該組中的所有執行緒移動到此 cgroup 中;

    • cgroup.clone_children 被刪除。clone_children 僅影響 cpuset controller。如果在 cgroup 中啟用了 clone_children (設定:1),新的 cpuset cgroup 將在初始化期間從父節點的 cgroup 複製配置;

    • /proc/cgroups 對於 v2 沒有意義。改用根目錄下的“cgroup.controllers”檔案;

    cgroup v1 的問題

    cgroup v2 和 v1 中最顯著的不同就是 cgroup v1 允許任意數量的層次結構, 但這會帶來一些問題的。我們來詳細聊聊。

    掛載 cgroup 層次結構時,你可以指定要掛載的子系統的逗號分隔列表作為檔案系統掛載選項。預設情況下,掛載 cgroup 檔案系統會嘗試掛載包含所有已註冊子系統的層次結構。

    如果已經存在具有完全相同子系統集的活動層次結構,它將被重新用於新安裝。

    如果現有層次結構不匹配,並且任何請求的子系統正在現有層次結構中使用,則掛載將失敗並顯示 -EBUSY。否則,將啟用與請求的子系統相關聯的新層次結構。

    當前無法將新子系統繫結到活動 cgroup 層次結構,或從活動 cgroup 層次結構中取消繫結子系統。當 cgroup 檔案系統被解除安裝時,如果在頂級 cgroup 之下建立了任何子 cgroup,即使解除安裝,該層次結構仍將保持活動狀態;如果沒有子 cgroup,則層次結構將被停用。

    這就是 cgroup v1 中的問題,在 cgroup v2 中就很好的進行了解決。

    cgroup 和容器的聯絡

    這裡我們以 Docker 為例。建立一個容器,並對其可使用的 CPU 和記憶體進行限制:

    ➜  ~ docker run --rm -d  --cpus=2 --memory=2g --name=2c2g redis:alpine 
    e420a97835d9692df5b90b47e7951bc3fad48269eb2c8b1fa782527e0ae91c8e
    ➜ ~ cat /sys/fs/cgroup/system.slice/docker-`docker ps -lq --no-trunc`.scope/cpu.max
    200000 100000
    ➜ ~ cat /sys/fs/cgroup/system.slice/docker-`docker ps -lq --no-trunc`.scope/memory.max
    2147483648
    ➜ ~
    ➜ ~ docker run --rm -d --cpus=0.5 --memory=0.5g --name=0.5c0.5g redis:alpine
    8b82790fe0da9d00ab07aac7d6e4ef2f5871d5f3d7d06a5cdb56daaf9f5bc48e
    ➜ ~ cat /sys/fs/cgroup/system.slice/docker-`docker ps -lq --no-trunc`.scope/cpu.max
    50000 100000
    ➜ ~ cat /sys/fs/cgroup/system.slice/docker-`docker ps -lq --no-trunc`.scope/memory.max
    536870912

    從上面的示例可以看到,當我們使用 Docker 創建出新的容器並且為他指定 CPU 和 記憶體限制後,其對應的 cgroup 配置檔案的 cpu.maxmemory.max 都設定成了相應的值。

    如果你想要對一些已經在執行的容器進行資源配額的檢查的話,也可以直接去檢視其對應的配置檔案中的內容。

    總結

    以上就是關於容器技術的基石之一的 cgroup 的詳細介紹了。接下來我還會寫關於 namespace 以及其他容器技術相關的內容,敬請關注!

    歡迎關注我的文章公眾號【MoeLove】