cgroups 資源限制#

Namespaces 解決的是「看得到什麼」(隔離視圖),但它不管「能用多少」。一個被 namespace 隔離的容器,如果不加限制,仍然可以吃光主機的全部記憶體與 CPU。控制群組(Control Groups,cgroups)就是補上這塊:它對一群行程(Process)施加資源的計量(accounting)與上限(limit)。Namespaces 管隔離、cgroups 管配額,兩者合起來才是完整的容器邊界。

為什麼需要 cgroups#

  • 沒有 cgroups,任何容器都能透過 fork bomb、記憶體洩漏拖垮整台主機
  • 多租戶環境需要把資源「切片」分給不同服務,避免吵鬧鄰居(noisy neighbour)
  • 計費與監控需要「以群組為單位」的精確用量統計,而非單一 PID
  • 核心需要一個明確的對象,在記憶體不足時知道該限制誰、該砍誰

cgroups 把行程組織成樹狀階層,並透過一個個控制器(controller,又稱 subsystem)對每一層套用規則。

v1 vs v2 架構差異#

cgroups 有兩個世代。v1 為每一種資源各自掛載一棵獨立的階層樹;v2 改為「單一統一階層」(unified hierarchy),所有控制器共用同一棵樹。

面向cgroups v1cgroups v2
階層數每個 controller 一棵獨立樹全系統單一統一階層
掛載點/sys/fs/cgroup/<controller>//sys/fs/cgroup/(單一根)
行程歸屬同一行程可在不同 controller 落在不同節點一個行程只屬於一個 cgroup 節點
委派困難,權限模型零散原生支援安全委派(delegation)給非 root
記憶體上限memory.limit_in_bytesmemory.max
CPU 比重cpu.sharescpu.weight
介面風格各 controller 命名不一致統一、一致的 <controller>.<attr> 命名

現代主流發行版(systemd cgroup driver、新版 Docker / Kubernetes)預設使用 cgroups v2。可用 stat -fc %T /sys/fs/cgroup/ 判斷:回傳 cgroup2fs 即為 v2,tmpfs 通常代表 v1 混合掛載。

v2 的設計動機正是 v1「多棵樹各管各的」造成的混亂:同一行程在 memory 樹與 cpu 樹可能落在完全不同的位置,核心難以做跨資源的協調決策(例如記憶體壓力下要不要節流 I/O)。統一階層讓所有控制器看到同一個群組邊界。

/sys/fs/cgroup 介面#

cgroups 透過一個虛擬檔案系統(cgroupfs)暴露,所有操作都是讀寫檔案。建立一個群組就是 mkdir,把行程放進群組就是把 PID 寫進 cgroup.procs

# v2:確認掛載
mount | grep cgroup2
ls /sys/fs/cgroup/

# 建立一個子群組(就是建一個目錄,核心自動產生介面檔)
sudo mkdir /sys/fs/cgroup/demo
ls /sys/fs/cgroup/demo/      # cgroup.procs、memory.max、cpu.weight、pids.max ...

# 把目前 shell 放進這個 cgroup
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs

在 v2,要讓子群組能用某個控制器,必須先在父層的 cgroup.subtree_control 把它「開啟」:

# 在根層啟用 memory / cpu / pids 控制器,下放給子群組使用
echo "+memory +cpu +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

cgroup 層級結構#

cgroups 是樹。根節點代表整台機器的全部資源,往下每一層再切分。容器執行環境(runtime)會在這棵樹下為每個容器建立節點。

graph TD
    Root["/sys/fs/cgroup(root)<br/>全機資源"]
    Sys["system.slice<br/>系統服務"]
    User["user.slice<br/>使用者 session"]
    Docker["docker / kubepods<br/>容器執行環境"]
    C1["容器 A<br/>memory.max=512M<br/>cpu.weight=100"]
    C2["容器 B<br/>memory.max=1G<br/>cpu.weight=200"]

    Root --> Sys
    Root --> User
    Root --> Docker
    Docker --> C1
    Docker --> C2

階層的關鍵性質:

  • 子節點的限制不能超過父節點實際能給的資源
  • 子節點是父節點的「再切分」,計量會逐層往上累加
  • 把行程從一個節點移到另一個節點,只要把 PID 寫進目標的 cgroup.procs

三個核心控制器#

memory:記憶體上限與計量#

  • memory.max:硬上限(v1 為 memory.limit_in_bytes)。超過且無法回收時觸發群組內 OOM
  • memory.high:軟上限。超過會被積極回收與節流,但不會立刻被砍
  • memory.current:目前用量(唯讀)
  • memory.swap.max:可用的 swap 上限

cpu:CPU 時間分配#

  • cpu.weight:相對權重(v1 為 cpu.shares),預設 100。只有在 CPU 競爭時才生效,閒置時不限制
  • cpu.max:絕對上限,格式為 <quota> <period>(微秒),例如 50000 100000 代表每 100ms 最多用 50ms,即半顆 CPU
  • cpu.stat:節流統計,可看到被限制(throttled)了多少次與多少時間

pids:行程數上限#

  • pids.max:群組內最多能有多少行程
  • pids.current:目前行程數
  • 這是防 fork bomb 的直接手段,與記憶體無關

實踐:套用限制#

# 假設 demo 群組已建立、控制器已下放

# 記憶體上限 100MB
echo 100M | sudo tee /sys/fs/cgroup/demo/memory.max

# CPU 限制為半顆(每 100ms 最多 50ms)
echo "50000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max

# 行程數上限 64(擋 fork bomb)
echo 64 | sudo tee /sys/fs/cgroup/demo/pids.max

# 把要受限的程式放進來再啟動
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs
stress-ng --vm 1 --vm-bytes 200M   # 會撞到 memory.max
memory.max 被超過時,核心做了什麼?

當群組記憶體用量逼近 memory.max,核心會依序嘗試:

  1. 回收(reclaim):先嘗試釋放可回收的 page,例如 page cache、可換出到 swap 的匿名頁
  2. 節流:若設了 memory.high,會在到達 high 之後對該群組的記憶體分配施加延遲,逼它放慢
  3. 群組內 OOM:若回收後仍無法在 memory.max 之下容納新的分配,核心會在「這個 cgroup 範圍內」觸發 OOM killer,挑一個行程砍掉

關鍵在於:這個 OOM 是侷限在群組內的。它不會去砍主機上其他容器或系統行程,這正是 cgroup 記憶體限制的隔離價值。被砍的行程在 dmesg 會留下 Memory cgroup out of memory 的記錄,而不是全機層級的 OOM。

若容器內被砍的剛好是 PID 1,整個容器會隨之終止 — 這個連鎖效應在下一節詳述。

與容器啟動的綁定#

容器執行環境在啟動容器時,cgroups 的設定是標準流程的一環:

  1. 透過 clone(2)CLONE_NEW* 旗標建立隔離的新行程
  2. 在 cgroup 樹下為這個容器建立專屬節點(如 kubepods/.../<container-id>
  3. memory.maxcpu.maxpids.max 等限制寫進該節點的介面檔
  4. 把容器主行程的 PID 寫進該節點的 cgroup.procs
  5. 執行容器入口程式(entrypoint)

因為 cgroup 的限制會被子孫繼承計量,容器內 entrypoint 之後 fork 出的所有行程,全部一起受同一組上限約束。這就是「對容器整體限制資源」能成立的機制基礎。

與 Kubernetes Request / Limit 的對應#

Kubernetes 的資源宣告最終都會落地成 cgroup 介面檔的數值:

Kubernetes 欄位對應 cgroup(v2)效果
requests.cpu影響 cpu.weight(決定競爭時拿到的比例)與排程落點
limits.cpu寫入 cpu.max(硬性節流,超過就被 throttle)
requests.memory用於排程決策(節點需有足夠可分配記憶體)
limits.memory寫入 memory.max(超過且無法回收即觸發群組內 OOM)
  • CPU limit 的行為是節流:超過配額不會被砍,只會變慢
  • Memory limit 的行為是致命:超過且回收無效,行程會被 OOM kill
  • 這也是為什麼 CPU 超用「只是慢」、記憶體超用「會 OOMKilled」

關於 Kubernetes 端的設定語法與 QoS 分級,見 ../../08-kubernetes/07-resources/

延伸閱讀#