一篇搞懂容器技術的基石: cgroup

張晉濤發表於2021-11-18

大家好,我是張晉濤。

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

對於容器技術而言,它實現資源層面上的限制和隔離,依賴於 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年開始,國家資訊保安漏洞庫就登出了相關漏洞問題)

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

  • 輕量級,基於 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.procscgroup.threadscgroup.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】

TheMoeLove

相關文章