這是整門課最重要的一章。讀完之後,你對容器的心智模型會從「一台小小的虛擬機」永久改成「一個被三種核心技術聯手欺騙的普通程式」——之後所有奇怪的行為(為什麼容器裡是 PID 1、為什麼兩個容器可以同時用 8080、為什麼容器會被 OOMKilled)都會變成理所當然的推論結果。
有個事實先講在前面,它會顛覆很多人的直覺:Linux 核心裡根本沒有「容器」這種東西。沒有一個叫 container 的物件、沒有一個叫「建立容器」的系統呼叫。所謂容器,是把幾個本來就存在的核心功能組合起來用之後,浮現出來的「效果」。
先做一個思想實驗(你也可以真的動手做),這個現象會直接摧毀「容器是小虛擬機」的錯覺。
docker run -d --name web nginxdocker exec web ps -ef
UID PID PPID CMD
root 1 0 nginx: master process
nginx 29 1 nginx: worker process容器裡只有兩個 process,而且 nginx 是 PID 1。ps -ef | grep nginx
UID PID PPID CMD
root 48231 48210 nginx: master process
nginx 48277 48231 nginx: worker process如果容器真的是一台虛擬機,你在宿主機上絕對看不到裡面的 process——就像你看不到 VMware 裡那台 Windows 的 process 一樣。但你看得到。
Namespace(命名空間)是 Linux 核心的一個功能,作用只有一句話:讓一個 process 對「系統資源」看到的是一份獨立的清單,而不是全機器那份。
ps 會看到宿主機上所有程式,還能 kill 掉別人的服務。/ 跟宿主機的 / 完全不同。其實非常樸素——就是 Linux 建立新 process 的系統呼叫 clone() 多帶幾個旗標:
clone(..., CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWUTS | ...)Namespace 解決了「看到什麼」,但沒解決「用多少」。一個沒有限制的容器,可以把整台機器的 CPU 和記憶體吃光,害死所有鄰居——這叫做吵鬧的鄰居(noisy neighbor)問題。
Cgroups(Control Groups,控制群組)就是核心的計量與收費員:把一組 process 圈起來,統計並限制它們用掉的資源。
docker run -m 512m --cpus 1.5 my-app或是在 compose 裡:
services:
app:
image: my-app
deploy:
resources:
limits:
memory: 512M
cpus: '1.5'容器莫名其妙死掉,docker ps -a 顯示 Exited (137)——這是最常見的容器死因之一:
docker inspect <容器> 會看到 OOMKilled: true。第三個法術回答這個問題:為什麼容器裡的 /usr/bin、/etc 跟宿主機的完全不一樣?
Unix 很早就有一個指令叫 chroot(change root):把某個 process 眼中的「根目錄 /」換成某個子目錄。
/var/lib/docker/.../merged/,裡面已經放好了 Ubuntu 的完整使用者空間檔案(/bin、/etc、/usr、/lib…)。/ 就是那個目錄。它去讀 /etc/os-release,讀到的是那個目錄下的檔案,於是它「相信」自己在一台 Ubuntu 上。/ 已經是世界的盡頭,沒有「上一層」了。「容器裡 cat /etc/os-release 顯示 Ubuntu」的真相就是這個:那只是換過根目錄之後讀到的一個文字檔而已。映像檔(image)解壓出來的內容,就是這一整套目錄結構。沒有第二個作業系統在跑,只有一份不同的檔案。
chroot 有已知的逃脫技巧(例如某些情況下可以透過檔案描述符爬出去),它當初的設計目的是方便,不是安全。pivot_root:把整個根掛載點換掉,並把舊的根卸載(unmount)——舊的根不只是看不到,是從掛載表裡整個消失,配合 mount namespace 一起用,隔離更徹底。把三個法術串起來,完整重演 docker run nginx 的內部流程(略過細節,抓主幹):
/var/lib/docker/overlay2/xxx/merged 的目錄。clone(),帶上 CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC——這個新 process 一出生就活在自己的世界裡。eth0,另一端接到宿主機的網橋 docker0 上(ch07 詳解)。pivot_root 到第 1 步準備好的目錄,卸載舊的根。execve("/docker-entrypoint.sh")——這一步之後,這個 process 就變成了 nginx 本身。它睜開眼睛,看到的是一個叫做「容器」的世界。create_container() 這個系統呼叫。容器不是一個東西,是一組核心功能組合起來後浮現的「效果」。理解了原理,就能自己推導出安全性的邊界,不用死背規則。
USER appuser,用非 root 身分跑你的應用。docker run --privileged 會給予容器幾乎全部的 capabilities、放開裝置存取。--privileged——那是把門拆掉來解決鑰匙插不進去的問題。| Namespace | 隔離什麼 | 沒有它會怎樣 | 你在哪裡感覺到它 |
|---|---|---|---|
| PID | 行程編號 | 容器內看得到、殺得掉宿主機的程式 | 容器內 ps 只有自己,主程式是 PID 1 |
| Mount (NS) | 檔案系統掛載表 | 容器直接看到宿主機整個檔案系統 | 容器裡看不到你的檔案,要 mount 才行 |
| Network | 網卡、IP、路由、port 空間 | 所有容器搶同一組 port | 兩個容器可同時用 8080;要 -p 才連得進去 |
| UTS | 主機名稱、網域名稱 | 改容器 hostname 會改到宿主機 | 進容器後提示字元是一串容器 ID |
| IPC | 共享記憶體、訊號量、訊息佇列 | 容器間可透過共享記憶體互相窺探 | 平常無感(除非跑需要大量共享記憶體的 DB) |
| User | 使用者/群組 ID 對應 | 容器內的 root = 宿主機的 root | Docker 預設不啟用——這是重大安全考量 |
| Cgroup (NS) | 資源限制的視角 | 容器看得到宿主機的 cgroup 結構 | 容器內查到的資源上限是自己的 |
| 機制 | 一句話職責 | 類比 | 沒有它的後果 |
|---|---|---|---|
| Namespace | 隔離「看得到什麼」 | 楚門的世界(佈景切割視野) | 容器間互相看見、互相干擾 |
| Cgroup | 限制「能用多少」 | 水電表+斷路器 | 一個容器吃光整台機器(吵鬧的鄰居) |
| pivot_root + 分層檔案系統 | 換掉「檔案系統世界」 | 重新定義家門的位置 | 容器直接用宿主機的檔案,環境不一致 |
| 面向 | 虛擬機(VM) | 容器 |
|---|---|---|
| 隔離靠什麼 | Hypervisor 模擬一整套硬體 | Namespace(視野)+ Cgroup(限量) |
| 跑的是什麼 | 完整的作業系統(含自己的核心) | 宿主機上的一個普通 process |
| 宿主機看得到裡面嗎 | 看不到(只看到一個 VM process) | 看得到——ps 就列出來了 |
| 啟動代價 | 完整開機流程,分鐘級 | clone() + execve(),毫秒~秒級 |
| 安全邊界 | 強(要打穿 hypervisor) | 弱(共用核心,核心漏洞即可逃逸) |
| 適合隔離誰 | 不受信任的對象(不同客戶) | 受信任的對象(自己的多個服務) |
點擊卡片翻面查看答案,共 10 張。