根本沒有第二個作業系統
前三篇導覽建造了一個完整的硬體幻覺。超管理器端出假的 CPU、假的記憶體和假的裝置,好讓一整個客體作業系統——連同核心在內——能在一台虛擬機裡執行,深信自己獨佔了這台機器。那個幻覺很強,卻也很重:每台虛擬機都揹著自己一份完整的作業系統副本,像真機一樣開機,並保留自己那一塊 RAM,不管它用不用得到。現在我們換個問題。如果你不想騙一整個客體核心——你只想騙行程呢?
這就是容器的整個構想。容器不過就是一個或幾個普通的行程,跑在你尋常的核心上,底下根本沒有任何客體作業系統——只是它們被「包裹」起來,讓每一個都看見自己私有的小小世界。對裡頭的程式來說,它彷彿獨自待在一台全新的機器上:自己的行程編號從 1 開始、自己的根檔案系統、自己的網路介面、自己的主機名稱。對底下的核心來說,它不過是另一組行程,與其他一切並排著被排程。這就是作業系統層級的虛擬化:是核心本身發放「每個行程一份」的幻覺,而不是超管理器發放「每台機器一份」的幻覺。
命名空間:改變一個行程看得到什麼
兩個真正的機制中,第一個是命名空間。平常,機器上的每個行程都共用某些由核心管理之物的一份全域視野:有一份行程編號的主清單、一棵被掛載的檔案系統的樹、一組網路介面、一張主機名稱表。命名空間拿起其中一份這樣的全域清單,給一群行程它們自己私有的一份副本。舉例來說,在一個行程編號命名空間之內,那些行程看到的編號是從 1 重新編起的,而且根本無法指名——更別說發訊號或殺掉——任何在它們命名空間之外的行程。並不是其他行程受到了保護;它們只是單純地隱形了。
命名空間不只一種,而是好幾種,各自切開一份不同的全域資源,而容器正是把它們組合起來建成的。掛載命名空間給容器自己的檔案系統樹,於是它對根目錄、對每一個掛載點位於何處的認知,全然是它自己的。網路命名空間給它自己的介面與連接埠。行程編號命名空間替它的行程重新編號。UTS 命名空間給它自己的主機名稱。使用者命名空間甚至能讓一個行程在容器之內身為無所不能的 root,在容器之外卻只是一個無害的、沒有特權的普通使用者。每個命名空間都是獨立的,所以你能精挑細選、混搭出你想讓某個容器擁有「自有副本」的那幾片世界。
關鍵在於,這一切都不是核心的複本——而是同一個核心內部的記帳。當一個行程向核心索取行程清單,核心會查看那個行程屬於哪一個行程編號命名空間,只回答那個命名空間裡的項目。當這個行程開啟一個檔案路徑,核心會依照那個行程的掛載命名空間去解析它。這份工作,就是核心本來就在做、用來管理這些資源的同一份工作;命名空間只是加上一張標籤,標明該從哪一份私有視野去回答每個行程。這就是為什麼建立一個容器幾乎是瞬間的事:沒有機器要開機,只有寥寥幾個命名空間要配置,以及一個行程要啟動。
cgroups:限制一個行程用得到多少
命名空間決定一個容器看得到什麼,卻對它能消耗多少隻字未提。一個只看得到自己私有世界的行程,若放任不管,仍可能霸佔每一顆 CPU 核心、吃光主機上所有的 RAM,餓死它的鄰居。第二個機制修補了這點:控制群組,幾乎總是寫成 cgroups。cgroup 是一群帶標籤的行程,核心會替它們附上資源上限與資源計量。你把一個容器的行程放進一個 cgroup,等於是在說:這一群最多只能用相當於 2 顆 CPU 的時間、最多 512 MB 的記憶體、以及最多這麼多的磁碟頻寬。
這些上限直接掛進你在較早階段已經理解的那些核心子系統。CPU 上限倚靠排程器:排程器本來就在決定下一個該拿到 CPU 的是哪個可執行的行程,所以讓它遵守某個 cgroup 的 CPU 份額,不過是同一個決策裡多加一條規則。記憶體上限倚靠記憶體管理員:當一個 cgroup 裡的行程合計持有超過它們的配額時,核心會拒絕進一步的配置,或回收它們的頁,正是虛擬記憶體那個階段的分頁回收機制,只不過範圍縮限到那一群。cgroups 不是一種新的資源控制;它們是核心既有的排程與記憶體計量,按群組劃分而已。
One host kernel, two containers:
Namespaces (what each can SEE) cgroups (how much each can USE)
-------------------------------- -------------------------------
container A: own PIDs, own mounts, group A: <= 2 CPUs, <= 512 MB
own net, hostname "a"
container B: own PIDs, own mounts, group B: <= 1 CPU, <= 256 MB
own net, hostname "b"
----------------- the single shared kernel does all the work -----------------疊加映像:以層的方式共用檔案
還有最後一塊拼圖。容器需要一個根檔案系統——構成它私有世界的那些檔案、它將要執行的程式與函式庫。給每個容器一份完整的副本,比方說一個好幾百 MB 的基底系統,會把我們剛贏來的輕巧大半都扔掉。答案是疊加檔案系統,它是虛擬記憶體那個階段寫入時複製把戲在儲存上的表親。容器映像是疊成一落唯讀層而建成的:一個基底層(一個極簡的作業系統使用者空間)、然後一層加上某種語言的執行期、再一層放你的應用程式。疊加檔案系統把這一落合併成單一的檔案系統視野,而關鍵在於,那些唯讀層由每一個以它們為基底建出的容器所共用。
在共用的唯讀層之上,每個執行中的容器各自得到一個薄薄的、自有的可寫層。當容器讀一個檔案時,疊加會在各層裡向下搜尋,從它最先出現之處把它端出來——通常是某個共用的唯讀層,不花任何額外空間。當容器寫一個檔案時,疊加會先把那「單獨一個」檔案複製上來、放進容器私有的可寫層,寫入便落在那裡;底下那個共用層永遠不會被動到。這就是以「檔案」為粒度的寫入時複製:一百個出自同一映像的容器,共用基底的同一份實體副本,而只有它們真正改動到的那些位元組,才會被複製出來。
- 啟動一個容器:核心建立它的各個命名空間,把它的行程放進一個帶上限的 cgroup,並把疊加掛載成它的根。
- 容器讀一個函式庫:疊加在某個共用的唯讀層裡找到它,直接端出來,不花額外的磁碟。
- 容器寫一個設定檔:疊加只把那一個檔案複製上來、放進容器私有的可寫層,再在那裡進行寫入。
- 容器結束:丟棄它那個薄薄的可寫層;共用的唯讀層原封不動地留下來,給下一個容器用。
從一個容器到一支艦隊
把這三塊拼在一起,容器就不再神秘了。它是一個普通的行程(或一小群行程),裹在命名空間裡好讓它看見自己的世界,圍在 cgroups 裡好讓它無法超用自己的份額,並以疊加檔案系統為根、好讓它幾乎免費地取得自己的檔案。沒有模擬、沒有陷阱與模擬、沒有第二個核心——只有主機核心做著它尋常的排程、記憶體管理與檔案供應,只不過是從每個容器自己那份帶標籤的視野去回答它。啟動一個只是幾毫秒、幾 MB 的事,這就是為什麼單一一台主機能輕鬆跑上數十甚至數百個容器。
一旦容器便宜到這個地步,你就不再手動管理它們,而讓軟體去做。編排是位於單一主機之上的那一層,它把容器跨整個機器叢集執行:決定哪一台主機還有空間放下一個容器、重啟一個當掉的容器、在負載上升時把一個服務從三份副本擴張到三十份,並把流量導向那些副本碰巧正在執行之處。容器是那個輕量的單元;編排則是那個把成千上萬個這種單元跨許多主機加以排程的管理員——是你的核心在單一機器上替行程所做的那同一套排程與資源管理,在更高層次上的一聲回響。