一個不同的賭注:共用核心,偽造視野
在這一級裡,你看著一個 hypervisor 蓋起一整台假機器:一個客體拿到自己被虛擬化的 CPU(被 VT-x 磨利的陷入並模擬)、透過巢狀分頁(EPT)拿到自己的記憶體、藉由 virtio 拿到自己的裝置。那台機器裡跑著一個完全分開的核心,它甚至不知道自己是客體。那是刻意的厚重——隔離之所以強,是因為那道牆就是硬體本身。一個容器做的是相反的取捨。沒有第二個核心,也沒有被模擬的機器。每個容器只是一群跑在你早就有的那一個宿主核心上的普通行程,和其他所有東西共用同一個系統呼叫介面。
那麼,如果容器只是一群行程,是什麼讓它感覺起來是分開的?兩個核心功能,而它們值得明白地講出來,因為整個構想都靠它們撐著。第一個,命名空間(namespaces),控制一個行程能看見什麼:哪些行程 id、哪些掛載點、哪些網路介面、哪個主機名稱對它而言存在。第二個,cgroups,控制一個行程能用多少:多少 CPU 時間、多少記憶體、多少 I/O 頻寬。看見與使用——感知與消耗。一個容器不多不少,就是一組被裹進一束嶄新命名空間、並釘在一個 cgroup 底下的行程。核心裡根本沒有一個叫「容器」的物件;它是用這些零件組裝出來的一個模式。
命名空間:一個行程被允許看見什麼
回想行程那一級:每個行程有一個 PID,它們構成一棵以 PID 1 為根的全域樹。一個 PID 命名空間把那個想法分叉:它給一群行程自己的編號,於是命名空間裡的第一個行程對它們而言就是 PID 1,有自己的小樹,即使宿主核心仍以某個更大的全域 PID(像 0x4D2)認得它。在容器裡跑「ps」,你只看見自己的行程;宿主那幾百個根本不在你的視野裡。沒有東西是被一道權限檢查藏起來的——那些別的行程在你命名空間的編號裡並不存在,就像一個不在集合裡的數字不是被拒絕,它只是缺席。
PID 只是其中一種。有一整個家族,每一種切分一項全域資源:掛載命名空間給一棵私有的檔案系統樹,於是一個容器可以有自己的根「/」而不碰到宿主的;網路命名空間給私有的網路介面、路由與連接埠,於是兩個容器可以都綁定 80 埠而不撞在一起;UTS 命名空間給一個私有的主機名稱;使用者命名空間讓一個行程在裡面是 root(uid 0),對應到外面卻是一個沒有特權的 uid。一個容器是藉由把它的行程一次放進一束嶄新的這些東西而建成的。真正的機制是 unshare() 這個系統呼叫——把目前的行程剝離進新的命名空間——以及 setns()——加入一個既有的;都是做著意外地龐大工作的小型系統呼叫。
cgroups:一個行程被允許使用什麼
命名空間阻止一個容器看見機器的其餘部分,但對資源隻字未提。一個你看不見的行程仍然能把你餓死:某個容器裡一支失控的程式可能吃掉每一顆 CPU 和所有記憶體,凍住它那些循規蹈矩的鄰居。那就是控制群組(control groups)、cgroups 的工作。一個 cgroup 是一個你把行程丟進去、貼了標籤的桶子,而核心在那個桶子上強制執行上限與計量。你可以說:這一群最多用 2 顆 CPU 份量的時間、最多持有 512 MiB 記憶體、每秒最多發出這麼多次磁碟 I/O 操作。核心的排程器與記憶體子系統讀取那些上限並守住防線。
記憶體上限值得仔細看,因為它顯示 cgroups 是有牙齒的。當一個 cgroup 的行程加總起來想持有的記憶體超過它的上限時,核心不會客氣地讓一次 malloc() 失敗——它會喚起限定在那個 cgroup 範圍內的 OOM(記憶體耗盡)殺手,殺掉它裡面的一個行程,而讓機器的其餘部分毫髮無傷。這就是容器讓人覺得可以放心密集堆疊的誠實理由:一個容器裡的記憶體臭蟲被框在它自己的預算內。介面本身就是檔案。cgroups 是藉由把純文字寫進 /sys/fs/cgroup 底下一個偽檔案系統來控制的——「一切皆檔案」一路貫徹進了資源控制,是 I/O 那一級同一個 Unix 想法戴上了新帽子。
從基本元件到一個你可以運送的映像
我們有了隔離。那內容呢——容器實際上對著跑的那些檔案?一個容器不會開機一個核心,但它仍然需要一個根檔案系統:它的程式期待的函式庫、執行檔、設定。那個包裹就是容器映像(container image)。一個 容器映像是一棵打包好的、唯讀的檔案系統樹,加上一小撮中繼資料,說明要跑哪個命令、用什麼環境。關鍵在於它是分層建成的——建置過程中每一道指令疊加一個薄薄的層——而那些層是內容定址且共享的。如果十個映像全都建立在同一個基底層之上,那個層只儲存、只下載一次。執行時,唯讀的層被堆疊起來,再在頂上鋪一個薄薄的可寫層,用寫入時複製,於是原本那些層永遠不被改動。你先前在虛擬記憶體裡見過寫入時複製;在這裡,同一個把戲讓檔案系統的層可以便宜地共享。
現在來看編排。拉取一個映像、解開它的層、設好命名空間與 cgroups、最後 exec() 那個程式——這整支舞需要一份共同的合約,好讓一個 Docker 映像能在一個完全不同的工具底下跑。那份合約就是 OCI 執行期(OCI runtime)規格。OCI 執行期標準說:一個映像這樣排版,而一個低階執行期(參考實作是 runc)拿到一份設定加上一個根檔案系統,負責建立命名空間、套用 cgroup 上限、並啟動那個行程。更高階的工具(Docker、containerd、Podman、Kubernetes)全都坐在這條線之上,最終呼叫一個符合 OCI 的執行期來做真正的核心工作。這個標準正是「建置一次,在任何 Linux 上跑」之所以真的成立的原因。
VM stack Container stack -------- --------------- app app guest libs container image libs GUEST KERNEL <-- own kernel (no second kernel) virtual HW <-- emulated namespaces + cgroups <-- kernel features HYPERVISOR ---------------------- host kernel / hardware ONE host kernel / hardware isolation wall = the hardware isolation wall = the syscall boundary
那道薄牆,以及作為解答的 microVM
現在來到這一級一直在鋪陳的誠實部分。因為每個容器共用那唯一的宿主核心,一個容器的攻擊面就是整個系統呼叫介面——數百個系統呼叫,其中任何一個,只要有核心臭蟲,就可能讓一個行程逃出它的命名空間、觸及宿主或某個鄰居。這是真實的,不是理論上的:透過核心漏洞的容器逃逸確實發生過。一份 VM 對容器的比較必須把這點明說——VM 的牆是 CPU 自己的虛擬化硬體加上一個很小的 hypervisor 介面,是一個比完整系統呼叫 API 小得多、也被更仔細審視的攻擊面。容器以接受一道更薄的牆,換來速度與密度。那是一個真正的工程取捨,不是一個要遮掩過去的瑕疵。
那麼,你能同時拿到容器的啟動速度和一台 VM 那道由硬體強制的牆嗎?那正是 microVM 追逐的東西。一個 microVM 是一台精簡到底的虛擬機:一個極小的 hypervisor,只模擬一個 Linux 客體真正需要的那少數幾個裝置——幾乎總是透過 virtio,也就是上一篇那個半虛擬化介面——其餘什麼都不模擬。沒有舊式 BIOS、沒有被模擬的音效卡、沒有一條塞滿假硬體的 PCI 匯流排。把那一切都丟掉之後,一個 microVM 可以在數十毫秒內開機一個客體核心、只用幾 MiB 的額外負擔,卻仍給每個工作負載一個真正分開的核心,藏在硬體虛擬化那道牆後面。AWS 的 Firecracker 是著名的例子,專門打造來以接近容器的速度、用 VM 等級的隔離去跑函式與容器。
退一步,整個這一級就收束成一個形狀。虛擬化是一條關於你把牆放在哪裡、以及你為它付多少的光譜。在一端,一個完整的第一型 hypervisor以最強的隔離跑著厚重的客體。在中間,microVM 保留硬體那道牆,卻把機器削到只剩 virtio 的必需品。在另一端,容器把第二個核心整個丟掉,用命名空間與 cgroups 把行程圍起來,成為最輕、最密、最快的選項——代價是一個共用的核心。沒有單一的「最好」;有的是一個旋鈕,而一個誠實的工程師會選一個與他對那個工作負載的信任程度相稱的點。那份判斷——而不是任何單一機制——才是這一級真正要給的精通。