
1. 引言很多開發(fā)者把容器當(dāng)作輕量虛擬機(jī)但這是一個常見的誤解。虛擬機(jī)里跑的是完整的操作系統(tǒng)內(nèi)核而容器里跑的只是一個加了隔離的進(jìn)程——它和宿主機(jī)共享同一個 Linux 內(nèi)核只是通過 namespace 和 cgroup 這兩大機(jī)制讓這個進(jìn)程看起來像一臺獨(dú)立的小機(jī)器。本文不依賴 Docker而是直接用 Linux 原生的unshare、nsenter、lsns等命令從零手搓一個容器把 namespace、cgroup、鏡像分層這些概念逐一拆開講清楚。讀完你會明白容器不是魔法它只是 Linux 內(nèi)核早已提供的進(jìn)程隔離能力被 Docker 等工具包裝成了易用的產(chǎn)品形態(tài)。本文是系列的第 14 篇前置依賴為第 01 篇Linux 基礎(chǔ)與第 08 篇進(jìn)程與信號。建議先掌握進(jìn)程、PID、掛載等基礎(chǔ)概念再閱讀本文。2. 容器本質(zhì)加了隔離的進(jìn)程2.1 先破除迷你虛擬機(jī)的迷思虛擬機(jī)VM通過 Hypervisor 虛擬化硬件每個 VM 內(nèi)部運(yùn)行一個完整的 Guest OS包括獨(dú)立的內(nèi)核。這意味著每個 VM 都要占用大量內(nèi)存和磁盤內(nèi)核 系統(tǒng)庫 應(yīng)用啟動一個 VM 通常需要幾十秒VM 之間的隔離是硬件級別的安全性較高。而容器完全不同容器與宿主機(jī)共享同一個內(nèi)核容器只是一個普通的用戶態(tài)進(jìn)程只是被 namespace 和 cgroup 包裹了一層啟動一個容器只需毫秒級資源開銷極小。一句話總結(jié)VM 是虛擬的機(jī)器容器是加了隔離的進(jìn)程。2.2 namespace讓進(jìn)程看不見外面的世界namespace 是 Linux 內(nèi)核提供的一種機(jī)制它把系統(tǒng)資源如 PID、網(wǎng)絡(luò)、掛載點(diǎn)、主機(jī)名等做了隔離讓 namespace 內(nèi)的進(jìn)程只能看到屬于自己的那部分資源仿佛自己運(yùn)行在一臺獨(dú)立的機(jī)器上。Linux 目前支持 8 種 namespacenamespace隔離的資源關(guān)鍵作用PID進(jìn)程號容器內(nèi)進(jìn)程 PID 從 1 開始看不到宿主機(jī)其他進(jìn)程N(yùn)etwork網(wǎng)絡(luò)棧獨(dú)立的網(wǎng)卡、IP、路由、防火墻規(guī)則Mount掛載點(diǎn)容器內(nèi)看到的文件系統(tǒng)與宿主機(jī)不同UTS主機(jī)名與域名容器內(nèi)hostname獨(dú)立IPC進(jìn)程間通信隔離 System V IPC 和 POSIX 消息隊(duì)列User用戶 ID 與組 ID容器內(nèi) root 映射為宿主機(jī)普通用戶Cgroupcgroup 根目錄容器內(nèi)看到獨(dú)立的 cgroup 層級Time系統(tǒng)時間容器內(nèi)可擁有獨(dú)立的時鐘偏移較新內(nèi)核其中前 6 種是 Docker 默認(rèn)啟用的核心隔離也是本文重點(diǎn)。2.3 cgroup限制進(jìn)程能用多少資源namespace 解決的是看得見什么的問題cgroup 解決的是能用多少的問題。cgroupControl Groups是 Linux 內(nèi)核提供的資源限制機(jī)制可以對進(jìn)程組做CPU 使用上限與權(quán)重分配內(nèi)存使用上限磁盤 I/O 帶寬限制網(wǎng)絡(luò)帶寬限制進(jìn)程數(shù)量限制pids。Docker 的--cpus、--memory、--pids-limit等參數(shù)底層都是通過 cgroup 實(shí)現(xiàn)的。3. 手搓一個容器unshare 實(shí)戰(zhàn)3.1 準(zhǔn)備工作本文所有命令都在 Linux 環(huán)境執(zhí)行推薦 Ubuntu 22.04 或 CentOS 7內(nèi)核 4.x 以上。先確認(rèn)環(huán)境# 查看內(nèi)核版本uname-r# 確認(rèn) unshare 命令可用whichunshare nsenter lsns如果lsns不存在先安裝 util-linux# Ubuntu / Debiansudoaptinstall-yutil-linux# CentOS / RHELsudoyuminstall-yutil-linux3.2 用 unshare 創(chuàng)建隔離的 PID namespaceunshare命令可以創(chuàng)建一個新的 namespace并在其中運(yùn)行指定命令。先看最簡單的例子——隔離 PID namespacesudounshare--pid--fork--mount-procbash進(jìn)入新 shell 后執(zhí)行# 查看當(dāng)前進(jìn)程 PIDecho$$# 查看進(jìn)程列表psaux你會驚訝地發(fā)現(xiàn)$$輸出的是1ps aux只能看到少數(shù)幾個進(jìn)程而宿主機(jī)上成百上千的進(jìn)程全部消失了。這是因?yàn)?-pid創(chuàng)建了新的 PID namespace新 shell 成為該 namespace 的 PID 1--fork讓 unshare 先 fork 一個子進(jìn)程再進(jìn)入新 namespace否則 PID 1 語義不完整--mount-proc重新掛載/proc讓ps只能看到當(dāng)前 namespace 內(nèi)的進(jìn)程。退出后回到宿主機(jī)再執(zhí)行ps aux一切恢復(fù)原樣。3.3 隔離主機(jī)名UTS namespace再疊加 UTS namespace讓容器擁有獨(dú)立的主機(jī)名sudounshare--pid--fork--mount-proc--utsbash在新 shell 中修改主機(jī)名hostnamemy-containerhostname此時hostname輸出my-container而宿主機(jī)的主機(jī)名完全不受影響。這就是 Docker 里--hostname參數(shù)的底層原理。3.4 隔離網(wǎng)絡(luò)Network namespace網(wǎng)絡(luò)隔離是容器最常用的能力之一。創(chuàng)建獨(dú)立的網(wǎng)絡(luò) namespacesudounshare--netbash在新 shell 中查看網(wǎng)絡(luò)ipaddr你會發(fā)現(xiàn)只有l(wèi)o回環(huán)接口且默認(rèn)是 down 狀態(tài)沒有任何物理網(wǎng)卡。這就是容器有獨(dú)立網(wǎng)絡(luò)棧的直觀體現(xiàn)。啟動回環(huán)接口iplinksetlo upping127.0.0.13.5 組合起來一個偽容器把上面幾種 namespace 組合起來就得到了一個最簡容器sudounshare--pid--fork--mount-proc--uts--net--ipcbash在這個 shell 里PID 從 1 開始主機(jī)名可獨(dú)立修改網(wǎng)絡(luò)棧獨(dú)立IPC 隔離/proc只顯示本 namespace 的進(jìn)程。這就是 Docker 容器最核心的運(yùn)行時骨架。當(dāng)然真正的容器還差最后一塊拼圖——文件系統(tǒng)隔離Mount namespace 鏡像分層我們下一節(jié)講。3.6 用 nsenter 進(jìn)入已有 namespacensenter可以進(jìn)入一個已存在的 namespace這正是docker exec的底層原理。先在一個終端啟動一個容器sudounshare--pid--fork--mount-proc--utsbashecho$$# 記下這個 PID比如 12345然后在另一個終端# 進(jìn)入該進(jìn)程的 PID namespacesudonsenter--target12345--pid--mount--utsbash進(jìn)入后執(zhí)行ps aux你會看到和容器內(nèi)一致的進(jìn)程列表。這就是docker exec的工作方式——它并不是進(jìn)入容器的 PID 1而是創(chuàng)建一個新進(jìn)程并把它加入目標(biāo) namespace。4. 鏡像分層overlayfs 與寫時復(fù)制4.1 為什么容器鏡像能共享Docker 鏡像由多層只讀層組成底層機(jī)制是 overlayfsOverlay Filesystem。overlayfs 把多個目錄層疊加成一個統(tǒng)一視圖lowerdir只讀的鏡像層可被多個容器共享upperdir容器自己的可寫層merged容器內(nèi)看到的最終文件系統(tǒng)。----------------------- | merged容器視角 | ----------------------- | upperdir可寫層 | ----------------------- | lowerdir 層 3 | ----------------------- | lowerdir 層 2 | ----------------------- | lowerdir 層 1 | -----------------------4.2 寫時復(fù)制CoW寫時復(fù)制Copy-on-Write是 overlayfs 的核心優(yōu)化當(dāng)容器要修改一個只讀層中的文件時不會直接改底層文件而是先把該文件復(fù)制到 upperdir再在副本上修改。這樣多個容器共享同一份只讀鏡像層互不影響每個容器只保存自己修改的部分磁盤占用極小鏡像層可以被大量容器并發(fā)共享啟動速度極快。4.3 手動體驗(yàn) overlayfs不依賴 Docker直接用 mount 命令體驗(yàn) overlayfs# 準(zhǔn)備目錄mkdir-p/tmp/overlay/{lower1,lower2,upper,work,merged}# 在只讀層放一些文件echofrom lower1/tmp/overlay/lower1/file1.txtechofrom lower2/tmp/overlay/lower2/file2.txt# 掛載 overlayfssudomount-toverlay overlay\-olowerdir/tmp/overlay/lower1:/tmp/overlay/lower2,\upperdir/tmp/overlay/upper,workdir/tmp/overlay/work\/tmp/overlay/merged# 查看合并視圖ls/tmp/overlay/mergedcat/tmp/overlay/merged/file1.txtcat/tmp/overlay/merged/file2.txt現(xiàn)在修改 merged 中的文件觀察寫時復(fù)制echomodified/tmp/overlay/merged/file1.txt# 底層只讀層不變cat/tmp/overlay/lower1/file1.txt# 仍是 from lower1# 修改被復(fù)制到 upperdircat/tmp/overlay/upper/file1.txt# 輸出 modified這就是 Docker 鏡像分層的全部秘密鏡像層只讀共享容器層寫時復(fù)制。4.4 層共享與緩存Docker 構(gòu)建鏡像時每一層都會生成一個內(nèi)容哈希。如果某層的內(nèi)容與已有層完全一致Docker 會直接復(fù)用緩存不會重新構(gòu)建。這就是為什么多個鏡像共享基礎(chǔ)層時磁盤占用很小修改 Dockerfile 時只有變更層之后的層會重建拉取鏡像時已存在的層會被跳過。5. 用 strace 和 lsns 驗(yàn)證容器不是迷你虛擬機(jī)5.1 lsns查看 namespace 關(guān)系lsns可以列出系統(tǒng)上所有的 namespace 及其關(guān)聯(lián)進(jìn)程sudolsns輸出示例NS TYPE NPROCS PID USER COMMAND 4026531835 pid 123 1 root /sbin/init 4026531836 net 123 1 root /sbin/init 4026531837 mnt 123 1 root /sbin/init 4026531838 uts 123 1 root /sbin/init啟動一個容器后再次執(zhí)行l(wèi)sns你會看到新增的 namespace 條目且其 PID 指向容器內(nèi)的進(jìn)程。這直觀地證明容器進(jìn)程與宿主機(jī)進(jìn)程共享同一個內(nèi)核只是被 namespace 隔離。5.2 strace觀察系統(tǒng)調(diào)用strace可以跟蹤進(jìn)程的系統(tǒng)調(diào)用。用 strace 觀察容器內(nèi)進(jìn)程會發(fā)現(xiàn)它調(diào)用的都是普通的 Linux 系統(tǒng)調(diào)用clone、execve、open、read等與宿主機(jī)進(jìn)程沒有本質(zhì)區(qū)別# 在容器內(nèi)運(yùn)行一個簡單命令并跟蹤其系統(tǒng)調(diào)用sudostrace-f-etraceclone,execve unshare--pid--fork--mount-procbash-cecho hello輸出中可以看到clone調(diào)用攜帶了 namespace 相關(guān)的 flagCLONE_NEWPID、CLONE_NEWNS等這正是容器創(chuàng)建的底層機(jī)制。如果容器是虛擬機(jī)這里應(yīng)該出現(xiàn)的是硬件虛擬化相關(guān)的調(diào)用如KVM_CREATE_VM而實(shí)際并沒有——這從系統(tǒng)調(diào)用層面證明了容器只是加了隔離的進(jìn)程。5.3 對比VM 與容器的系統(tǒng)調(diào)用差異維度虛擬機(jī)容器內(nèi)核獨(dú)立 Guest OS 內(nèi)核共享宿主機(jī)內(nèi)核系統(tǒng)調(diào)用經(jīng)過 Hypervisor 虛擬化直接調(diào)用宿主機(jī)內(nèi)核啟動時間秒級到分鐘級毫秒級資源開銷每個 VM 一套完整 OS僅進(jìn)程級開銷隔離級別硬件級內(nèi)核 namespace/cgroup 級6. 坑位總結(jié)6.1 PID 1 與僵尸進(jìn)程容器內(nèi)的第一個進(jìn)程是 PID 1它承擔(dān)著收養(yǎng)孤兒進(jìn)程、回收僵尸進(jìn)程的職責(zé)。如果 PID 1 進(jìn)程沒有正確處理 SIGCHLD 信號容器內(nèi)就會出現(xiàn)大量僵尸進(jìn)程無法回收。# 在容器內(nèi)觀察僵尸進(jìn)程psaux|grepdefunct解決方案讓 PID 1 進(jìn)程如應(yīng)用主進(jìn)程正確實(shí)現(xiàn)信號處理或使用tini等 init 系統(tǒng)作為容器入口。6.2 docker exec 是新進(jìn)程而非 PID 1docker exec并不是進(jìn)入容器的 PID 1而是在目標(biāo) namespace 中創(chuàng)建一個全新的進(jìn)程。這意味著docker exec啟動的進(jìn)程 PID 不是 1它不繼承 PID 1 的環(huán)境變量除非顯式傳遞它退出后不會影響容器主進(jìn)程。# 在容器內(nèi)查看 exec 進(jìn)程的 PIDdockerexeccontainerecho$$# 輸出通常不是 16.3 容器內(nèi) systemd 不可用由于容器與宿主機(jī)共享內(nèi)核且 PID namespace 隔離容器內(nèi)無法運(yùn)行 systemd 作為 PID 1systemd 需要完整的系統(tǒng)初始化能力。常見表現(xiàn)systemctl start xxx報錯無法管理宿主機(jī)級服務(wù)。解決方案容器內(nèi)使用輕量 init如 tini、s6或直接以應(yīng)用進(jìn)程作為 PID 1。7. 總結(jié)本文從零開始用unshare手搓了一個容器并拆解了容器底層的三大核心機(jī)制namespace隔離進(jìn)程的視野PID、網(wǎng)絡(luò)、掛載、UTS、IPC、Usercgroup限制進(jìn)程的食量CPU、內(nèi)存、I/Ooverlayfs CoW實(shí)現(xiàn)鏡像分層共享與寫時復(fù)制。通過lsns和strace的驗(yàn)證我們從系統(tǒng)調(diào)用層面確認(rèn)容器不是迷你虛擬機(jī)而是加了隔離的進(jìn)程。理解這些底層原理能幫助你在生產(chǎn)環(huán)境中更好地排查容器問題、優(yōu)化鏡像構(gòu)建、設(shè)計更合理的資源限制策略。8. 思考與練習(xí)用unshare組合出包含 PID、UTS、Network、Mount 四種隔離的容器并驗(yàn)證各自效果。在容器內(nèi)啟動一個后臺進(jìn)程觀察它退出后是否變成僵尸進(jìn)程思考 PID 1 的職責(zé)。手動掛載 overlayfs驗(yàn)證寫時復(fù)制行為并對比 Docker 鏡像層的共享機(jī)制。用strace對比容器進(jìn)程與普通進(jìn)程的系統(tǒng)調(diào)用差異總結(jié)兩者的本質(zhì)區(qū)別。思考為什么容器內(nèi)不能運(yùn)行 systemd如果必須運(yùn)行有哪些替代方案