虛擬記憶體與 OOM Killer#
上一節談 cgroup 的 memory.max 會在超用時觸發 OOM,但要真正理解「OOM 為什麼會砍行程、砍哪一個、為什麼容器會整個掛掉」,得先理解 Linux 的虛擬記憶體(Virtual Memory)模型。容器並沒有自己的記憶體子系統 — 它跑在主機核心的同一套虛擬記憶體機制上,只是被 cgroup 框出了一塊配額。
MMU 與虛擬位址空間#
每個行程(Process)看到的都是一塊連續、私有的虛擬位址空間(virtual address space),它以為自己獨佔整段記憶體。實際上這些虛擬位址要經過記憶體管理單元(Memory Management Unit,MMU)這個硬體,翻譯成真正的實體位址(physical address)。
- 行程操作的永遠是虛擬位址,從不直接碰實體位址
- MMU 依據「頁表(page table)」做即時翻譯,翻譯結果快取在 TLB(Translation Lookaside Buffer)
- 多個行程的虛擬位址可以映射到同一塊實體頁(page),這正是共享函式庫與寫時複製(copy-on-write)的基礎
記憶體以「頁」為單位管理,典型大小 4KB。虛擬位址被切成「頁號 + 頁內偏移」,MMU 用頁號查頁表得到實體頁框(page frame),再拼上偏移。
page table 映射與翻譯流程#
graph TD
CPU["行程存取虛擬位址 VA"]
TLB{"TLB 命中?"}
PT["走 page table 翻譯<br/>VA → PA"]
Present{"頁存在於實體記憶體?"}
OK["MMU 取得實體位址 PA<br/>正常存取"]
Fault["觸發 page fault<br/>進入核心處理"]
CPU --> TLB
TLB -- 命中 --> OK
TLB -- 未命中 --> PT
PT --> Present
Present -- 是 --> OK
Present -- 否 --> Fault頁表通常是多層結構(multi-level page table),用層層索引避免為整段位址空間預先配置巨大的平坦表格。每個行程有自己的一套頁表,切換行程(context switch)時核心會換上對應的頁表根(在 x86 上即 CR3 暫存器)。
demand paging 與 minor / major page fault#
Linux 不會在行程啟動時就把全部記憶體載入實體 RAM,而是「用到才載入」,這叫需求分頁(demand paging)。當行程存取一個尚未對映到實體頁的虛擬位址,MMU 找不到映射,就觸發缺頁中斷(page fault),核心介入補上。
缺頁分兩種,差別在於要不要動到磁碟:
| 類型 | 觸發情境 | 是否讀磁碟 | 成本 |
|---|---|---|---|
| minor page fault | 頁其實已在記憶體(如共享庫已被別人載入、CoW) | 否 | 低 |
| major page fault | 頁不在記憶體,需從磁碟(檔案或 swap)讀進來 | 是 | 高 |
- minor fault:核心只要在頁表補上指向既有實體頁的映射即可,極快
- major fault:必須發起磁碟 I/O 把資料讀進來,行程被阻塞等待,慢上數個數量級
# 觀察一個行程的 minor / major fault 累計次數
ps -o pid,min_flt,maj_flt,cmd -p <pid>
# major fault 大量增加通常代表記憶體吃緊、頻繁從 swap / 磁碟換頁swap 與 thrashing#
當實體記憶體不足,核心會把較少使用的頁「換出(swap out)」到 swap 空間(磁碟),騰出實體頁框給更迫切的需求;需要時再「換入(swap in)」。
- swap 讓系統在記憶體超賣時還能勉強運作,代價是 major fault 變多、變慢
- 當記憶體嚴重不足,系統把時間幾乎全花在換進換出、無暇推進實際工作,這叫抖動(thrashing)
- thrashing 時 CPU 看似有空,但行程一直在等 I/O,整體吞吐崩潰
容器環境通常刻意關閉或限制 swap(Kubernetes 傳統上要求關閉 swap)。原因是:有了 swap,超用記憶體的容器會先進入抖動拖慢一切,而不是乾脆被 OOM 砍掉,反而讓問題更難察覺、更難定位。沒有 swap 時,超用就直接觸發 OOM,行為明確。
OOM Killer 的決策#
當核心無論回收或換出都湊不出記憶體,又有行程在要求分配,它必須做最後手段:選一個行程砍掉,這就是記憶體不足殺手(Out Of Memory Killer,OOM Killer)。
挑選對象的依據是每個行程的 oom_score:
oom_score大致反映「砍掉這個行程能釋放多少記憶體」,吃越多記憶體分數越高、越優先被砍- 管理員可透過
oom_score_adj(範圍 -1000 ~ 1000)手動調整:調高更容易被砍,調低更不容易,-1000等於豁免 - 核心傾向砍「釋放效益最大、且不是關鍵系統行程」的對象
# 查看某行程目前的 OOM 分數與可調整值
cat /proc/<pid>/oom_score
cat /proc/<pid>/oom_score_adj
# 把某行程設為更不容易被砍(需權限)
echo -500 | sudo tee /proc/<pid>/oom_score_adj
# OOM 事件會留在核心訊息
dmesg | grep -i "killed process"在 cgroup v2 下,記憶體限制讓 OOM 變成群組侷限的:撞到 memory.max 時,OOM Killer 只在該 cgroup 內挑對象,不會波及主機其他容器。
容器內 PID 1 被 OOM kill 的致命連鎖#
OOM Killer 砍的是「某個行程」,但在容器情境下,這件事可能引發整個容器終止。
graph TD
A["容器記憶體用量撞到 memory.max"]
B["群組內 OOM Killer 啟動"]
C{"被選中的是哪個行程?"}
D["一般子行程被砍<br/>應用程式可能還能繼續"]
E["PID 1 被砍"]
F["PID namespace 主行程結束<br/>核心終止整個 namespace"]
G["容器整體退出<br/>狀態顯示 OOMKilled"]
A --> B --> C
C -- 子行程 --> D
C -- PID 1 --> E --> F --> G容器內的 PID 1 是該 PID namespace 的根。一旦 PID 1 結束(無論是正常退出還是被 OOM kill),核心會終止整個 PID namespace 內的所有行程,容器隨之整體退出。如果你的主應用就是 PID 1,而它正好是群組裡吃最多記憶體、
oom_score最高的那個,OOM Killer 砍它幾乎是必然 — 結果就是整個容器被OOMKilled,而非只折損一個子行程。
這與 ../05-process-and-signal/05-pid1-init-responsibility/ 描述的 PID 1 特殊地位是同一回事的兩面:PID 1 的不可替代性,在 OOM 情境下變成了風險點。
與 cgroup memory limit 的關係#
把前一節與本節串起來:
memory.max(cgroup)決定「這個容器能用多少實體記憶體」- 容器內所有行程的記憶體計量會累加到該群組
- 累加值逼近
memory.max時,先嘗試回收與(若允許)換出 - 回收無效則在群組內觸發 OOM,依
oom_score選行程砍 - 若被砍的是 PID 1,容器整體
OOMKilled
所以 Kubernetes 看到的 OOMKilled,本質是「cgroup 記憶體上限 + 群組內 OOM Killer + PID 1 連鎖」三者共同作用的結果,而不是什麼神祕的平台行為。
延伸閱讀#
man 5 proc(搜尋oom_score、oom_score_adj)- Linux Kernel 文件:Memory Management ↗
- 上一節:
../07-cgroupscgroups 資源限制 - 下一節:
../09-rootfs-isolationrootfs 與 chroot / pivot_root