容器化技術 ch02 容器的真相:它其實只是一個被騙的 Process
下一章→
CH 02 核心原理

容器的真相:它其實只是一個被騙的 Process

Namespace 騙它看到什麼Cgroup 限制它用多少pivot_root 換掉它的世界為什麼 Linux 裡沒有「容器」這個東西

這是整門課最重要的一章。讀完之後,你對容器的心智模型會從「一台小小的虛擬機」永久改成「一個被三種核心技術聯手欺騙的普通程式」——之後所有奇怪的行為(為什麼容器裡是 PID 1、為什麼兩個容器可以同時用 8080、為什麼容器會被 OOMKilled)都會變成理所當然的推論結果。

有個事實先講在前面,它會顛覆很多人的直覺:Linux 核心裡根本沒有「容器」這種東西。沒有一個叫 container 的物件、沒有一個叫「建立容器」的系統呼叫。所謂容器,是把幾個本來就存在的核心功能組合起來用之後,浮現出來的「效果」。

先做一個思想實驗(你也可以真的動手做),這個現象會直接摧毀「容器是小虛擬機」的錯覺。

步驟

  1. 在你的 Linux 機器上啟動一個容器,裡面跑 nginx:
    docker run -d --name web nginx
  2. 進容器裡面看看有哪些 process:
    docker exec web ps -ef
    
    UID   PID  PPID  CMD
    root    1     0  nginx: master process
    nginx  29     1  nginx: worker process
    容器裡只有兩個 process,而且 nginx 是 PID 1。
  3. 現在離開容器,在宿主機上找同一個 nginx:
    ps -ef | grep nginx
    
    UID    PID   PPID  CMD
    root  48231  48210  nginx: master process
    nginx 48277  48231  nginx: worker process

你發現了什麼?

那個 nginx 就在宿主機的 process 清單裡,是一個貨真價實、跟其他程式並排的普通 process。它的 PID 是 48231,不是 1。「PID 1」只是它在容器裡「以為」的。

如果容器真的是一台虛擬機,你在宿主機上絕對看不到裡面的 process——就像你看不到 VMware 裡那台 Windows 的 process 一樣。但你看得到。

⭐ 結論:容器不是一台機器,容器就是宿主機上的一個普通 process。它只是被施了法術,看到的世界跟其他 process 不一樣。接下來三張卡,就是拆解這三個法術:Namespace(騙它看到什麼)、Cgroup(限制它能用多少)、pivot_root(換掉它的檔案系統世界)。

Namespace(命名空間)是 Linux 核心的一個功能,作用只有一句話:讓一個 process 對「系統資源」看到的是一份獨立的清單,而不是全機器那份。

💡 楚門的世界類比:楚門住在一個巨大的攝影棚裡,他以為那就是全世界——有街道、有鄰居、有海。他不是被關在另一個星球,他就在地球上,只是他能看到的範圍被佈景切割出來了。Namespace 就是那個攝影棚:process 還在同一台機器上,但它「看得到的東西」被限縮成獨立的一份。

Linux 有幾種 namespace,各自隔離一類東西

  • PID namespace(行程編號):容器內自己編號,第一個 process 就是 PID 1,而且看不到容器外的任何 process。這就是上一張卡的謎底。
    沒有它會怎樣:容器內 ps 會看到宿主機上所有程式,還能 kill 掉別人的服務。
  • Mount namespace(檔案系統掛載):容器有自己的一份掛載表,看到的 / 跟宿主機的 / 完全不同。
    你何時感覺到它:容器裡看不到你 home 目錄的檔案;你要給它檔案必須明講(bind mount,ch06)。
  • Network namespace(網路):每個容器有自己的一整套網路堆疊——自己的網卡、自己的 IP、自己的路由表、自己的一整組 port 號碼(0~65535)。
    這解釋了一個你一定遇過的現象:兩個容器可以「同時」都用 8080,完全不衝突——因為那是兩組不同的 8080。但你要從宿主機連進去就得做 port mapping(ch07)。
  • UTS namespace(主機名稱):容器可以有自己的 hostname。
    你何時感覺到它:進容器後提示字元變成一串亂碼般的 ID,那就是容器自己的 hostname。
  • IPC namespace(行程間通訊):共享記憶體、訊號量這類機制彼此隔離,A 容器不能透過共享記憶體偷看 B 容器。
  • User namespace(使用者):可以做到「容器內是 root(uid 0),對應到宿主機上其實是一個沒權限的普通使用者(uid 100000)」。
    這是重要的安全機制,但 Docker 預設沒有開啟——也就是說預設情況下,容器內的 root 就是宿主機的 root。ch10 會談這件事的嚴重性。
  • Cgroup namespace(隔離資源限制的視角):讓容器內看到的資源限制資訊也是自己那份。

怎麼做到的?

其實非常樸素——就是 Linux 建立新 process 的系統呼叫 clone() 多帶幾個旗標:

clone(..., CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWUTS | ...)
🚨 請把這件事記牢:這裡沒有「建立一台虛擬機」的動作,只有「建立一個 process,順便給它幾個新的 namespace」。容器的隔離是「視野的隔離」,不是「硬體的隔離」。這也是為什麼容器很輕(沒有模擬任何硬體)、卻也沒有 VM 安全(大家共用同一個核心)。

Namespace 解決了「看到什麼」,但沒解決「用多少」。一個沒有限制的容器,可以把整台機器的 CPU 和記憶體吃光,害死所有鄰居——這叫做吵鬧的鄰居(noisy neighbor)問題。

Cgroups(Control Groups,控制群組)就是核心的計量與收費員:把一組 process 圈起來,統計並限制它們用掉的資源。

能管什麼

  • 記憶體:最多用 512 MB,超過就殺掉。
  • CPU:最多用 1.5 顆核心的算力;或是在搶奪時給予相對權重。
  • 磁碟 I/O:讀寫頻寬上限。
  • 行程數量:最多能開幾個 process(防 fork bomb)。
  • 裝置存取:能不能存取某個硬體裝置。

你早就在用它了(只是不知道)

docker run -m 512m --cpus 1.5 my-app

或是在 compose 裡:

services:
  app:
    image: my-app
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '1.5'

經典現場:Exit Code 137 是什麼意思?

容器莫名其妙死掉,docker ps -a 顯示 Exited (137)——這是最常見的容器死因之一:

  • 137 = 128 + 9,代表這個 process 被 signal 9(SIGKILL)殺死。
  • 誰殺的?核心的 OOM Killer:容器用的記憶體超過 cgroup 上限,核心直接把它砍了。
  • docker inspect <容器> 會看到 OOMKilled: true。
🚨 Java 開發者的頭號陷阱(先預告,ch09 詳解):舊版 JVM 不認得 cgroup 限制,它會去問「這台機器有多少記憶體」,看到宿主機的 32 GB,於是把 heap 上限自訂為 8 GB——但你的容器 cgroup 只給了 512 MB。結果 JVM 開開心心地要記憶體,一超過 512 MB 就被 OOM Killer 砍掉,而且連錯誤訊息都來不及印。這就是無數人遇過的「Spring Boot 在容器裡莫名其妙就死了」。
⭐ 一句話記住分工:Namespace 管「看得到什麼」(隔離),Cgroup 管「能用多少」(限量)。兩者缺一不可——只有 namespace 的容器會互相搶資源,只有 cgroup 的 process 會互相看到彼此。

第三個法術回答這個問題:為什麼容器裡的 /usr/bin、/etc 跟宿主機的完全不一樣?

從老祖宗 chroot 說起

Unix 很早就有一個指令叫 chroot(change root):把某個 process 眼中的「根目錄 /」換成某個子目錄。

  • 假設宿主機上有個目錄 /var/lib/docker/.../merged/,裡面已經放好了 Ubuntu 的完整使用者空間檔案(/bin、/etc、/usr、/lib…)。
  • 對某個 process 執行 chroot 到那個目錄後,它眼中的 / 就是那個目錄。它去讀 /etc/os-release,讀到的是那個目錄下的檔案,於是它「相信」自己在一台 Ubuntu 上。
  • 它也爬不出去——對它來說 / 已經是世界的盡頭,沒有「上一層」了。
💡 類比:像把一個人的家門重新定義。他推開門以為走到大馬路,其實只是走到攝影棚的另一個佈景。他永遠找不到真正的出口,因為對他而言那個佈景就是全世界。

ch01 那個謎題的完整答案

「容器裡 cat /etc/os-release 顯示 Ubuntu」的真相就是這個:那只是換過根目錄之後讀到的一個文字檔而已。映像檔(image)解壓出來的內容,就是這一整套目錄結構。沒有第二個作業系統在跑,只有一份不同的檔案。

現代容器用的是 pivot_root,不是 chroot

  • chroot 有已知的逃脫技巧(例如某些情況下可以透過檔案描述符爬出去),它當初的設計目的是方便,不是安全。
  • 現代容器執行環境(runc)用 pivot_root:把整個根掛載點換掉,並把舊的根卸載(unmount)——舊的根不只是看不到,是從掛載表裡整個消失,配合 mount namespace 一起用,隔離更徹底。
⭐ 三個法術合體,「容器」就出現了:
① Mount namespace + pivot_root → 它有了自己的檔案系統世界(來自 image)
② 其他 namespace → 它有了自己的 process 編號、網路、hostname
③ Cgroup → 它被限制了能用多少資源
三者疊在一個普通 process 上,效果看起來就像「一台獨立的小機器」。

把三個法術串起來,完整重演 docker run nginx 的內部流程(略過細節,抓主幹):

  1. 準備檔案系統:把 nginx 映像的各層疊起來,組成一個完整的目錄樹,再蓋上一層可寫層(ch03 詳解),得到一個像 /var/lib/docker/overlay2/xxx/merged 的目錄。
  2. 建立 process 並開新 namespace:呼叫 clone(),帶上 CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC——這個新 process 一出生就活在自己的世界裡。
  3. 設定 cgroup:把這個 process 放進一個新的 cgroup,寫入記憶體上限、CPU 配額。
  4. 設定網路:建立一對虛擬網卡(veth pair),一端放進容器的 network namespace 當作它的 eth0,另一端接到宿主機的網橋 docker0 上(ch07 詳解)。
  5. 換根目錄:pivot_root 到第 1 步準備好的目錄,卸載舊的根。
  6. 丟掉多餘權限:裁掉不需要的 Linux capabilities(例如不讓它改系統時間、不讓它載入核心模組)。
  7. 執行你的程式:execve("/docker-entrypoint.sh")——這一步之後,這個 process 就變成了 nginx 本身。它睜開眼睛,看到的是一個叫做「容器」的世界。
整個流程裡,沒有任何一步是「建立容器」。Linux 核心沒有 container 這個物件,也沒有 create_container() 這個系統呼叫。容器不是一個東西,是一組核心功能組合起來後浮現的「效果」。

你可以說:容器是一種「約定」,不是一種「實體」。正因為它只是約定,才會有 Docker、Podman、containerd、CRI-O 等多種實作,而且它們產生的容器彼此相容——大家都是在對同一套核心功能下同樣的指令。
💡 這也解釋了為什麼「Docker ≠ 容器」:Docker 只是一個把上面七步驟包裝得很好用的工具。真正幹活的是 Linux 核心;Docker 是那個幫你唸咒語的人。業界後來把這套咒語標準化成 OCI 規範(映像格式與執行規範),所以你用 Docker 建的映像,可以拿去給 Kubernetes、Podman 跑,完全沒問題。

理解了原理,就能自己推導出安全性的邊界,不用死背規則。

推導一:容器和宿主機共用核心 ⇒ 核心漏洞可以打穿隔離

  • VM 之間隔了一層 hypervisor,要從一台 VM 打到另一台,得先打穿 hypervisor——難度很高。
  • 容器之間只隔了 namespace 這層「視野遮罩」,底下是同一個核心。一旦核心本身有提權漏洞,惡意程式就可能從容器內取得核心權限,進而跳出容器控制整台機器。這就是容器逃逸(container escape)。

推導二:預設沒開 user namespace ⇒ 容器內的 root 就是宿主機的 root

  • 如果你的容器以 root 執行(而這正是很多映像的預設),那個 uid 0 跟宿主機的 uid 0 是同一個。
  • 一旦攻擊者從你的應用漏洞取得容器內 shell,他就是以 root 身分站在你的核心前面——距離接管整台機器只差一個核心漏洞。
  • 對策(ch10 詳談):Dockerfile 裡明確 USER appuser,用非 root 身分跑你的應用。

推導三:--privileged 等於把所有法術通通解除

  • docker run --privileged 會給予容器幾乎全部的 capabilities、放開裝置存取。
  • 此時容器基本上等於直接在宿主機上跑的 root 程式,隔離形同虛設。
  • 網路上很多教學為了「解決權限問題」叫你加 --privileged——那是把門拆掉來解決鑰匙插不進去的問題。
⭐ 正確的心智模型:容器的隔離是為了「防止意外互相干擾」而設計的,不是為了「防禦惡意攻擊」而設計的。要跑不受信任的程式碼(例如讓使用者上傳程式來執行),業界的作法是再加一層 VM 隔離(如 AWS Fargate 底層的 Firecracker 微型 VM、gVisor)。這也是為什麼雲端商用 VM 隔離「不同客戶」、用容器隔離「同一客戶的不同服務」(基礎篇 ch05)。
Namespace 全表:各自隔離什麼、少了會怎樣
Namespace隔離什麼沒有它會怎樣你在哪裡感覺到它
PID行程編號容器內看得到、殺得掉宿主機的程式容器內 ps 只有自己,主程式是 PID 1
Mount (NS)檔案系統掛載表容器直接看到宿主機整個檔案系統容器裡看不到你的檔案,要 mount 才行
Network網卡、IP、路由、port 空間所有容器搶同一組 port兩個容器可同時用 8080;要 -p 才連得進去
UTS主機名稱、網域名稱改容器 hostname 會改到宿主機進容器後提示字元是一串容器 ID
IPC共享記憶體、訊號量、訊息佇列容器間可透過共享記憶體互相窺探平常無感(除非跑需要大量共享記憶體的 DB)
User使用者/群組 ID 對應容器內的 root = 宿主機的 rootDocker 預設不啟用——這是重大安全考量
Cgroup (NS)資源限制的視角容器看得到宿主機的 cgroup 結構容器內查到的資源上限是自己的
三個法術的分工(背這張就夠)
機制一句話職責類比沒有它的後果
Namespace隔離「看得到什麼」楚門的世界(佈景切割視野)容器間互相看見、互相干擾
Cgroup限制「能用多少」水電表+斷路器一個容器吃光整台機器(吵鬧的鄰居)
pivot_root + 分層檔案系統換掉「檔案系統世界」重新定義家門的位置容器直接用宿主機的檔案,環境不一致
VM vs 容器:這次從技術機制比
面向虛擬機(VM)容器
隔離靠什麼Hypervisor 模擬一整套硬體Namespace(視野)+ Cgroup(限量)
跑的是什麼完整的作業系統(含自己的核心)宿主機上的一個普通 process
宿主機看得到裡面嗎看不到(只看到一個 VM process)看得到——ps 就列出來了
啟動代價完整開機流程,分鐘級clone() + execve(),毫秒~秒級
安全邊界強(要打穿 hypervisor)弱(共用核心,核心漏洞即可逃逸)
適合隔離誰不受信任的對象(不同客戶)受信任的對象(自己的多個服務)

練習題 點選選項查看解析

0 / 8
01 / 8
在宿主機上執行 ps -ef,你看到了容器裡那個 nginx 的 process。這說明什麼?
A Docker 設定錯誤,隔離失效了
B 容器本質上就是宿主機上的一個普通 process,只是被 namespace 限制了視野——它以為自己是 PID 1,其實在宿主機是 PID 48231
C 那是宿主機另外裝的 nginx,跟容器無關
D 容器正在逃逸
解析
這是容器最反直覺、也最關鍵的事實。如果容器是虛擬機,你在宿主機上絕對看不到裡面的 process。看得到,正說明它只是一個被施法的普通 process。「PID 1」是 PID namespace 給它的錯覺。
02 / 8
兩個容器可以「同時」都監聽 8080 而不衝突。這是哪個機制的功勞?
A Docker 自動幫忙轉換 port
B Network namespace:每個容器有自己一整套網路堆疊,包含自己獨立的 0~65535 port 空間,所以那是兩組不同的 8080
C Cgroup 限制了網路使用
D 作業系統允許 port 重複使用
解析
Network namespace 給每個容器一套完整獨立的網路堆疊:自己的網卡、IP、路由表、port 空間。容器 A 的 8080 和容器 B 的 8080 是兩個世界的東西。但也正因為它們在自己的世界裡,你從宿主機要連進去就得做 port mapping(-p 8080:8080),把宿主機的 port 接到容器的 port。
03 / 8
Namespace 和 Cgroup 的職責分別是什麼?
A Namespace 管網路,Cgroup 管儲存
B Namespace 隔離「看得到什麼」(視野),Cgroup 限制「能用多少」(資源配額)
C Namespace 負責啟動容器,Cgroup 負責停止容器
D 兩者功能相同,只是新舊版本差異
解析
這是本章最該背下來的一句。只有 namespace 沒有 cgroup:容器彼此看不到,但會搶光 CPU 記憶體(吵鬧的鄰居)。只有 cgroup 沒有 namespace:資源受限,但彼此看得見、能互相 kill。兩者疊加,才浮現出「一台獨立小機器」的效果。
04 / 8
你的容器突然掛掉,docker ps -a 顯示 Exited (137)。最可能的原因是?
A 程式碼有語法錯誤
B 容器使用的記憶體超過 cgroup 限制,被核心的 OOM Killer 以 SIGKILL 殺掉(137 = 128 + 9)
C 網路斷線
D 映像檔損毀
解析
137 = 128 + 9,代表被 signal 9(SIGKILL)殺死,兇手通常是核心的 OOM Killer——容器吃的記憶體超過 cgroup 上限。用 docker inspect 可看到 OOMKilled: true。Java 應用特別容易中招:舊版 JVM 看到的是宿主機的總記憶體而非 cgroup 限制,於是把 heap 設得遠超容器配額(ch09 詳解)。
05 / 8
為什麼容器裡執行 cat /etc/os-release 顯示 Ubuntu,但宿主機是 CentOS?
A 容器裡真的跑著一個 Ubuntu 作業系統
B 容器的根目錄被 pivot_root 換成了映像解壓出來的目錄,那裡面放著 Ubuntu 的使用者空間檔案——它讀到的只是一個文字檔,核心仍然是宿主機的
C Docker 偽造了指令輸出
D 宿主機同時安裝了兩套系統
解析
換根目錄(chroot 的現代版 pivot_root)+ mount namespace,讓 process 眼中的 / 變成映像解壓出的目錄。它讀 /etc/os-release 讀到的是那個目錄下的檔案,於是「相信」自己在 Ubuntu 上。沒有第二個作業系統在跑,只是一份不同的檔案——核心永遠只有宿主機那一個。
06 / 8
關於「Linux 核心裡的容器」,正確的敘述是?
A 核心有一個 container 物件,用 create_container() 建立
B 核心裡根本沒有「容器」這個東西——容器是把 namespace、cgroup、pivot_root 等既有功能組合使用之後浮現的「效果」
C 容器是 Docker 公司加進 Linux 核心的新模組
D 容器由 hypervisor 建立
解析
沒有 create_container() 這個系統呼叫。docker run 做的是:clone() 開新 namespace → 設 cgroup → 建虛擬網卡 → pivot_root → 裁掉多餘權限 → execve() 執行你的程式。容器是「約定」不是「實體」——正因如此,Docker、Podman、containerd 才能各自實作而彼此相容(OCI 規範)。
07 / 8
為什麼說容器的隔離性不如 VM?
A 因為容器啟動比較快
B 容器與宿主機共用同一個核心,只靠 namespace 做視野遮罩;核心一旦有提權漏洞,就可能發生容器逃逸。VM 之間則隔著 hypervisor
C 因為容器沒有防火牆
D 因為容器不能加密資料
解析
共用核心是輕量的代價,也是安全的軟肋。正確的心智模型是:容器隔離是為了「防止意外互相干擾」設計的,不是為了「防禦惡意攻擊」。要跑不受信任的程式碼,業界會再加一層 VM 隔離(Firecracker、gVisor)——AWS Fargate 底下就是這樣做的。
08 / 8
網路教學叫你用 docker run --privileged 解決權限問題。這代表什麼風險?
A 只是效能會下降
B 等於給容器幾乎全部權限與裝置存取,隔離形同虛設,容器內的 root 基本上等同宿主機的 root
C 只影響網路功能
D 沒有風險,這是官方推薦作法
解析
--privileged 是把門拆掉來解決鑰匙插不進去的問題。再加上 Docker 預設不啟用 user namespace(容器內 root = 宿主機 root),攻擊者一旦拿到容器內 shell,就等於站在宿主機的 root 位置上。正確作法是找出真正需要的那一個 capability 單獨授予,並在 Dockerfile 用 USER 指定非 root 身分(ch10)。

點擊卡片翻面查看答案,共 10 張。

QUESTION
容器的一句話真相?
點擊翻面
ANSWER
容器 = 宿主機上一個普通的 process
只是被 namespace 限制視野、被 cgroup 限制資源、被 pivot_root 換掉檔案系統
證據:宿主機 ps -ef 看得到容器裡的程式
點擊翻回
QUESTION
Namespace 的職責與類比?
點擊翻面
ANSWER
隔離「看得到什麼」
類比:楚門的世界(人還在地球上,只是佈景切割了視野)
實作:clone() 帶 CLONE_NEWPID / NEWNET / NEWNS... 旗標
點擊翻回
QUESTION
七種 namespace 分別隔離什麼?
點擊翻面
ANSWER
PID:行程編號(容器內主程式是 PID 1)
Mount:檔案系統掛載表
Network:網卡/IP/路由/port 空間
UTS:hostname
IPC:共享記憶體
User:uid 對應(Docker 預設不開!)
Cgroup:資源限制視角
點擊翻回
QUESTION
為什麼兩個容器可以同時用 8080?
點擊翻面
ANSWER
Network namespace 給每個容器一整套獨立網路堆疊
包含各自完整的 0~65535 port 空間
那是兩組不同的 8080
(也因此要 -p 才連得進去)
點擊翻回
QUESTION
Cgroup 的職責與可管項目?
點擊翻面
ANSWER
限制「能用多少」
記憶體、CPU、磁碟 I/O、行程數、裝置存取
docker run -m 512m --cpus 1.5
點擊翻回
QUESTION
Exit Code 137 代表什麼?
點擊翻面
ANSWER
128 + 9 → 被 SIGKILL 殺死
兇手通常是核心 OOM Killer(超過 cgroup 記憶體上限)
docker inspect 會顯示 OOMKilled: true
點擊翻回
QUESTION
pivot_root 做什麼?跟 chroot 差在哪?
點擊翻面
ANSWER
把 process 眼中的根目錄 / 換成映像解壓出的目錄
chroot:老方法,有已知逃脫技巧
pivot_root:換掉整個根掛載並卸載舊根,隔離更徹底
點擊翻回
QUESTION
docker run 的七個內部步驟?
點擊翻面
ANSWER
① 疊映像層組出檔案系統 ② clone() 開 namespace
③ 設 cgroup ④ 建虛擬網卡接 docker0
⑤ pivot_root 換根 ⑥ 裁掉多餘 capabilities
⑦ execve() 執行你的程式
點擊翻回
QUESTION
「Linux 核心沒有容器這個東西」是什麼意思?
點擊翻面
ANSWER
沒有 container 物件、沒有 create_container() 系統呼叫
容器是既有核心功能組合後浮現的「效果」
是「約定」不是「實體」→ 所以 Docker/Podman/containerd 才能互相相容(OCI)
點擊翻回
QUESTION
容器隔離的正確心智模型?
點擊翻面
ANSWER
為「防止意外互相干擾」設計,不是為「防禦惡意攻擊」設計
共用核心 → 核心漏洞可導致容器逃逸
跑不受信任程式碼要再加 VM 隔離(Firecracker、gVisor)
點擊翻回